Compare commits

...

2 Commits

Author SHA1 Message Date
Julien Calixte
9f21cb7882 chore: merge origin/main with local test additions
All checks were successful
CI / verify (push) Successful in 10m1s
Resolves conflict in repo.spec.ts by combining getRepoPermission
tests from main with the getFiles and queryFileContent tests from
the local test pass. Adds getRepoPermission to the userRepo.store
spec mocks so the new permission probe added upstream doesn't break
the store tests.
2026-06-06 22:17:10 +02:00
Julien Calixte
1a2d8f4ebf chore: add tests 2026-06-06 22:13:14 +02:00
23 changed files with 1939 additions and 8 deletions

40
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,40 @@
name: CI
on:
push:
branches: [main]
pull_request:
concurrency:
group: ci-${{ gitea.ref }}
cancel-in-progress: true
jobs:
verify:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 11.0.9
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Type-check
run: pnpm types
- name: Lint
run: pnpm lint
- name: Test
run: pnpm test --run

View File

@@ -70,6 +70,7 @@
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.28.5", "@babel/core": "^7.28.5",
"@pinia/testing": "^1.0.3",
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
"@types/fontfaceobserver": "^2.1.3", "@types/fontfaceobserver": "^2.1.3",
"@types/markdown-it": "^14.1.2", "@types/markdown-it": "^14.1.2",
@@ -79,6 +80,7 @@
"@vite-pwa/assets-generator": "^1.0.2", "@vite-pwa/assets-generator": "^1.0.2",
"@vitejs/plugin-vue": "^5.2.4", "@vitejs/plugin-vue": "^5.2.4",
"@vue/compiler-sfc": "^3.5.28", "@vue/compiler-sfc": "^3.5.28",
"@vue/test-utils": "^2.4.11",
"autoprefixer": "^10.4.24", "autoprefixer": "^10.4.24",
"daisyui": "^5.5.18", "daisyui": "^5.5.18",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
@@ -86,6 +88,7 @@
"eslint-plugin-simple-import-sort": "^12.1.1", "eslint-plugin-simple-import-sort": "^12.1.1",
"eslint-plugin-unused-imports": "^4.4.1", "eslint-plugin-unused-imports": "^4.4.1",
"esno": "^4.8.0", "esno": "^4.8.0",
"jsdom": "^29.1.1",
"oxfmt": "^0.42.0", "oxfmt": "^0.42.0",
"oxlint": "^1.57.0", "oxlint": "^1.57.0",
"prettier": "^3.8.1", "prettier": "^3.8.1",

561
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,74 @@
import { mount } from "@vue/test-utils"
import { describe, expect, it } from "vitest"
import NoteFreshnessBadge from "./NoteFreshnessBadge.vue"
const factory = (props: { status: string; lastCheckedAt?: Date | null }) =>
mount(NoteFreshnessBadge, {
props: { lastCheckedAt: null, ...props } as never
})
describe("NoteFreshnessBadge", () => {
it("renders the verified state with the cloud-check icon", () => {
const wrapper = factory({ status: "verified" })
expect(wrapper.classes()).toContain("state-verified")
expect(wrapper.find(".icon-tabler-cloud-check").exists()).toBe(true)
})
it("renders the outdated state with the download icon", () => {
const wrapper = factory({ status: "outdated" })
expect(wrapper.classes()).toContain("state-outdated")
expect(wrapper.find(".icon-tabler-cloud-download").exists()).toBe(true)
})
it("renders the offline state with the cloud-off icon", () => {
const wrapper = factory({ status: "offline" })
expect(wrapper.classes()).toContain("state-offline")
expect(wrapper.find(".icon-tabler-cloud-off").exists()).toBe(true)
})
it("renders the unauthorized state with the lock icon", () => {
const wrapper = factory({ status: "unauthorized" })
expect(wrapper.classes()).toContain("state-unauthorized")
expect(wrapper.find(".icon-tabler-cloud-lock").exists()).toBe(true)
})
it("disables the button while checking", () => {
const wrapper = factory({ status: "checking" })
expect(wrapper.attributes("disabled")).toBeDefined()
expect(wrapper.find(".icon-tabler-loader-2").exists()).toBe(true)
})
it("emits click when not busy", async () => {
const wrapper = factory({ status: "verified" })
await wrapper.trigger("click")
expect(wrapper.emitted("click")).toHaveLength(1)
})
it("includes the last-checked time in the verified tooltip", () => {
const wrapper = factory({
status: "verified",
lastCheckedAt: new Date("2026-01-01T10:30:00")
})
const tooltip = wrapper.attributes("title") as string
expect(tooltip).toMatch(/Verified at/)
})
it("renders the unknown state as default", () => {
const wrapper = factory({ status: "unknown" })
expect(wrapper.classes()).toContain("state-unknown")
expect(wrapper.find(".icon-tabler-cloud-question").exists()).toBe(true)
})
it("offline tooltip prompts a retry", () => {
const wrapper = factory({ status: "offline" })
expect(wrapper.attributes("title")).toMatch(/retry/i)
})
it("unauthorized tooltip prompts a sign-in", () => {
const wrapper = factory({ status: "unauthorized" })
expect(wrapper.attributes("title")).toMatch(/[Ss]ign in/)
})
})

View File

@@ -0,0 +1,98 @@
import { mount } from "@vue/test-utils"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import { ref } from "vue"
const ghAccessToken = ref<string | null>(null)
const ghUsername = ref<string | null>(null)
const atIsLoggedIn = ref(false)
const atHandle = ref<string | null>(null)
const atAvatarUrl = ref<string | null>(null)
vi.mock("@/hooks/useGitHubLogin.hook", () => ({
useGitHubLogin: () => ({
username: ghUsername,
accessToken: ghAccessToken
})
}))
vi.mock("@/hooks/useATProtoLogin.hook", () => ({
useATProtoLogin: () => ({
isLoggedIn: atIsLoggedIn,
handle: atHandle,
avatarUrl: atAvatarUrl
})
}))
import UserPill from "./UserPill.vue"
describe("UserPill", () => {
beforeEach(() => {
ghAccessToken.value = null
ghUsername.value = null
atIsLoggedIn.value = false
atHandle.value = null
atAvatarUrl.value = null
})
afterEach(() => {
vi.clearAllMocks()
})
it("shows the ghost 'Sign in' pill when nobody is logged in", () => {
const wrapper = mount(UserPill)
expect(wrapper.text()).toContain("Sign in")
expect(wrapper.classes()).toContain("profile-chip--ghost")
})
it("shows the GitHub username and initial when logged in via GitHub only", () => {
ghAccessToken.value = "tok"
ghUsername.value = "alice"
const wrapper = mount(UserPill)
expect(wrapper.text()).toContain("alice")
expect(wrapper.find(".profile-avatar-initial").text()).toBe("A")
expect(wrapper.find("img").exists()).toBe(false)
expect(wrapper.classes()).not.toContain("profile-chip--ghost")
})
it("shows the ATProto handle and avatar when logged in via ATProto", () => {
atIsLoggedIn.value = true
atHandle.value = "alice.bsky.social"
atAvatarUrl.value = "https://cdn.example.com/avatar.jpg"
const wrapper = mount(UserPill)
expect(wrapper.text()).toContain("alice.bsky.social")
const img = wrapper.find("img")
expect(img.exists()).toBe(true)
expect(img.attributes("src")).toBe("https://cdn.example.com/avatar.jpg")
})
it("prefers the ATProto handle over the GitHub username when both are present", () => {
ghAccessToken.value = "tok"
ghUsername.value = "alice-gh"
atIsLoggedIn.value = true
atHandle.value = "alice.bsky.social"
const wrapper = mount(UserPill)
expect(wrapper.text()).toContain("alice.bsky.social")
expect(wrapper.text()).not.toContain("alice-gh")
})
it("falls back to '?' initial when no name is available but a user is logged in", () => {
atIsLoggedIn.value = true
atHandle.value = ""
const wrapper = mount(UserPill)
expect(wrapper.find(".profile-avatar-initial").text()).toBe("?")
})
it("emits click when clicked", async () => {
const wrapper = mount(UserPill)
await wrapper.trigger("click")
expect(wrapper.emitted("click")).toHaveLength(1)
})
})

View File

@@ -0,0 +1,76 @@
import { mount } from "@vue/test-utils"
import { afterEach, describe, expect, it, vi } from "vitest"
import { defineComponent, ref } from "vue"
const escape = ref(false)
vi.mock("@vueuse/core", () => ({
useMagicKeys: () => ({ escape })
}))
import { useEditionMode } from "./useEditionMode"
const host = (slot: (api: ReturnType<typeof useEditionMode>) => void) =>
mount(
defineComponent({
template: "<div/>",
setup() {
const api = useEditionMode()
slot(api)
return api
}
})
)
describe("useEditionMode", () => {
afterEach(() => {
escape.value = false
})
it("starts in read mode", () => {
let mode: unknown
host((api) => {
mode = api.mode.value
})
expect(mode).toBe("read")
})
it("toggleMode flips read ↔ edit", () => {
let api: ReturnType<typeof useEditionMode> | undefined
host((a) => {
api = a
})
api!.toggleMode()
expect(api!.mode.value).toBe("edit")
api!.toggleMode()
expect(api!.mode.value).toBe("read")
})
it("escape key exits edit mode", async () => {
let api: ReturnType<typeof useEditionMode> | undefined
host((a) => {
a.toggleMode()
api = a
})
expect(api!.mode.value).toBe("edit")
escape.value = true
await new Promise((r) => setTimeout(r, 0))
expect(api!.mode.value).toBe("read")
})
it("escape key is a no-op when already in read mode", async () => {
let api: ReturnType<typeof useEditionMode> | undefined
host((a) => {
api = a
})
escape.value = true
await new Promise((r) => setTimeout(r, 0))
expect(api!.mode.value).toBe("read")
})
})

View File

@@ -0,0 +1,67 @@
import { mount } from "@vue/test-utils"
import { beforeEach, describe, expect, it, vi } from "vitest"
import { defineComponent } from "vue"
const push = vi.fn()
vi.mock("vue-router", () => ({
useRouter: () => ({ push })
}))
import { useForm } from "./useForm.hook"
const host = () => {
let api!: ReturnType<typeof useForm>
mount(
defineComponent({
template: "<div/>",
setup() {
api = useForm()
return api
}
})
)
return api
}
describe("useForm", () => {
beforeEach(() => {
push.mockReset()
})
it("starts with empty user and repo inputs", () => {
const api = host()
expect(api.userInput.value).toBe("")
expect(api.repoInput.value).toBe("")
})
it("submit is a no-op when userInput is empty", () => {
const api = host()
api.repoInput.value = "notes"
api.submit()
expect(push).not.toHaveBeenCalled()
})
it("submit is a no-op when repoInput is empty", () => {
const api = host()
api.userInput.value = "alice"
api.submit()
expect(push).not.toHaveBeenCalled()
})
it("submit pushes the FluxNoteView route with user/repo params", () => {
const api = host()
api.userInput.value = "alice"
api.repoInput.value = "notes"
api.submit()
expect(push).toHaveBeenCalledWith({
name: "FluxNoteView",
params: { user: "alice", repo: "notes" }
})
})
})

View File

@@ -0,0 +1,53 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
vi.mock("@/modules/atproto/getAuthor", () => ({
getAuthor: vi.fn()
}))
import { getAuthor } from "@/modules/atproto/getAuthor"
import { getUrl } from "./getUrl"
describe("getUrl", () => {
beforeEach(() => {
vi.mocked(getAuthor).mockReset()
})
afterEach(() => {
vi.restoreAllMocks()
})
it("returns null when the author cannot be resolved", async () => {
vi.mocked(getAuthor).mockResolvedValue(null)
expect(
await getUrl({ did: "did:plc:abc", rkey: "r1" })
).toBeNull()
})
it("builds a getRecord URL with the right query params on the author's PDS", async () => {
vi.mocked(getAuthor).mockResolvedValue({
handle: "alice.bsky.social",
pds: "https://pds.example.com"
})
const url = await getUrl({ did: "did:plc:abc", rkey: "rkey1" })
const parsed = new URL(url as string)
expect(parsed.origin).toBe("https://pds.example.com")
expect(parsed.pathname).toBe("/xrpc/com.atproto.repo.getRecord")
expect(parsed.searchParams.get("repo")).toBe("did:plc:abc")
expect(parsed.searchParams.get("collection")).toBe("space.remanso.note")
expect(parsed.searchParams.get("rkey")).toBe("rkey1")
})
it("passes the did to getAuthor", async () => {
vi.mocked(getAuthor).mockResolvedValue({
handle: "h",
pds: "https://pds.example.com"
})
await getUrl({ did: "did:web:example.com", rkey: "r2" })
expect(getAuthor).toHaveBeenCalledWith("did:web:example.com")
})
})

View File

@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest"
import { parseAtUri } from "./parseAtUri"
describe("parseAtUri", () => {
it("parses a did:plc AT URI", () => {
expect(
parseAtUri("at://did:plc:abc123/app.bsky.feed.post/rkey-xyz")
).toEqual({
did: "did:plc:abc123",
rkey: "rkey-xyz"
})
})
it("parses a did:web AT URI", () => {
expect(
parseAtUri("at://did:web:example.com/space.remanso.note/note-1")
).toEqual({
did: "did:web:example.com",
rkey: "note-1"
})
})
it("treats rkeys with slashes as a single trailing segment", () => {
expect(
parseAtUri("at://did:plc:abc/space.remanso.note/multi/segment")
).toEqual({
did: "did:plc:abc",
rkey: "multi/segment"
})
})
it("throws when the URI does not start with at://", () => {
expect(() =>
parseAtUri("https://did:plc:abc/collection/rkey")
).toThrow(/Invalid AT URI/)
})
it("throws when the DID prefix is missing", () => {
expect(() => parseAtUri("at://abc/collection/rkey")).toThrow(
/Invalid AT URI/
)
})
it("throws when the collection or rkey is missing", () => {
expect(() => parseAtUri("at://did:plc:abc/onlycollection")).toThrow(
/Invalid AT URI/
)
})
it("throws on empty input", () => {
expect(() => parseAtUri("")).toThrow(/Invalid AT URI/)
})
})

View File

@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest"
import { fromShortDid, toShortDid } from "./shortDid"
describe("toShortDid", () => {
it("strips did:plc: prefix", () => {
expect(toShortDid("did:plc:abc123")).toBe("abc123")
})
it("strips did: prefix but keeps the method when non-plc", () => {
expect(toShortDid("did:web:example.com")).toBe("web:example.com")
})
it("returns input unchanged when there is no did: prefix", () => {
expect(toShortDid("abc123")).toBe("abc123")
})
})
describe("fromShortDid", () => {
it("adds did:plc: prefix to bare identifiers", () => {
expect(fromShortDid("abc123")).toBe("did:plc:abc123")
})
it("adds did: prefix when method is already present", () => {
expect(fromShortDid("web:example.com")).toBe("did:web:example.com")
})
it("passes through fully-qualified DIDs unchanged", () => {
expect(fromShortDid("did:plc:abc123")).toBe("did:plc:abc123")
expect(fromShortDid("did:web:example.com")).toBe("did:web:example.com")
})
})
describe("round-trip toShortDid → fromShortDid", () => {
it.each(["did:plc:abc123", "did:web:example.com", "did:key:zXyZ"])(
"is identity for %s",
(did) => {
expect(fromShortDid(toShortDid(did))).toBe(did)
}
)
})

View File

@@ -1,30 +1,43 @@
import { beforeEach, describe, expect, it, vi } from "vitest" import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
const { reposGet } = vi.hoisted(() => ({ reposGet: vi.fn() })) const { reposGet } = vi.hoisted(() => ({ reposGet: vi.fn() }))
vi.mock("@/modules/repo/services/octo", () => ({ vi.mock("./octo", () => ({
getOctokit: vi.fn().mockResolvedValue({ getOctokit: vi.fn().mockResolvedValue({
repos: { get: reposGet } repos: { get: reposGet }
}), }),
runWithAuthRetry: vi.fn() runWithAuthRetry: vi.fn()
})) }))
// Stub heavyweight transitive imports so loading repo.ts doesn't spin up the
// data Web Worker or the full markdown rendering stack.
vi.mock("@/hooks/useMarkdown.hook", () => ({ vi.mock("@/hooks/useMarkdown.hook", () => ({
markdownBuilder: () => ({ render: (s: string) => s }) markdownBuilder: () => ({ render: (s: string) => s })
})) }))
vi.mock("@/modules/note/cache/prepareNoteCache", () => ({ vi.mock("@/modules/note/cache/prepareNoteCache", () => ({
prepareNoteCache: () => ({ prepareNoteCache: () => ({
getCachedNote: vi.fn().mockResolvedValue({ note: null }), getCachedNote: async () => ({ note: null }),
saveCacheNote: vi.fn() saveCacheNote: vi.fn()
}) })
})) }))
import { getRepoPermission } from "./repo" import { getOctokit, runWithAuthRetry } from "./octo"
import {
getFiles,
getRepoPermission,
queryFileContent
} from "./repo"
const makeOctokitWithRequest = (impl: (route: string, params: unknown) => unknown) => ({
request: vi.fn(impl)
})
describe("getRepoPermission", () => { describe("getRepoPermission", () => {
beforeEach(() => { beforeEach(() => {
reposGet.mockReset() reposGet.mockReset()
vi.mocked(getOctokit).mockResolvedValue({
repos: { get: reposGet }
} as never)
}) })
it("returns true when permissions.push is true", async () => { it("returns true when permissions.push is true", async () => {
@@ -54,3 +67,101 @@ describe("getRepoPermission", () => {
expect(reposGet).not.toHaveBeenCalled() expect(reposGet).not.toHaveBeenCalled()
}) })
}) })
describe("getFiles", () => {
beforeEach(() => {
vi.mocked(getOctokit).mockReset()
})
afterEach(() => {
vi.restoreAllMocks()
})
it("returns empty array when owner is missing", async () => {
expect(await getFiles("", "repo")).toEqual([])
expect(getOctokit).not.toHaveBeenCalled()
})
it("returns empty array when repo is missing", async () => {
expect(await getFiles("owner", "")).toEqual([])
expect(getOctokit).not.toHaveBeenCalled()
})
it("returns empty array when there are no commits", async () => {
const octokit = makeOctokitWithRequest((route) => {
if (route === "GET /repos/{owner}/{repo}/commits") {
return { data: [] }
}
throw new Error("unexpected route " + route)
})
vi.mocked(getOctokit).mockResolvedValue(octokit as never)
expect(await getFiles("owner", "repo")).toEqual([])
})
it("fetches the tree from the latest commit and filters out non-blob entries", async () => {
const octokit = makeOctokitWithRequest((route, params) => {
if (route === "GET /repos/{owner}/{repo}/commits") {
return {
data: [{ commit: { tree: { sha: "TREE_SHA" } } }]
}
}
if (route === "GET /repos/{owner}/{repo}/git/trees/{tree_sha}") {
expect((params as { tree_sha: string }).tree_sha).toBe("TREE_SHA")
expect((params as { recursive: string }).recursive).toBe("true")
return {
data: {
tree: [
{ path: "README.md", type: "blob", sha: "a" },
{ path: "src", type: "tree", sha: "b" },
{ path: "src/note.md", type: "blob", sha: "c" }
]
}
}
}
throw new Error("unexpected route " + route)
})
vi.mocked(getOctokit).mockResolvedValue(octokit as never)
const files = await getFiles("owner", "repo")
expect(files.map((f) => f.path)).toEqual(["README.md", "src/note.md"])
})
})
describe("queryFileContent", () => {
beforeEach(() => {
vi.mocked(runWithAuthRetry).mockReset()
})
afterEach(() => {
vi.restoreAllMocks()
})
it("returns null when owner or repo is missing", async () => {
expect(await queryFileContent("", "repo", "sha")).toBeNull()
expect(await queryFileContent("owner", "", "sha")).toBeNull()
expect(runWithAuthRetry).not.toHaveBeenCalled()
})
it("returns the blob content via runWithAuthRetry", async () => {
vi.mocked(runWithAuthRetry).mockImplementation(async (call) => {
const octokit = {
request: vi.fn().mockResolvedValue({
data: { content: "BASE64" }
})
}
return call(octokit as never)
})
expect(await queryFileContent("owner", "repo", "SHA")).toBe("BASE64")
})
it("returns null and swallows errors when the call fails", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {})
vi.mocked(runWithAuthRetry).mockRejectedValue(new Error("boom"))
expect(await queryFileContent("owner", "repo", "SHA")).toBeNull()
expect(warn).toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,97 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
vi.mock("@/modules/user/service/signIn", () => ({
getAccessToken: vi.fn(),
refreshToken: vi.fn()
}))
import {
getAccessToken,
refreshToken
} from "@/modules/user/service/signIn"
import { runWithAuthRetry } from "./octo"
const unauthorized = () => Object.assign(new Error("Bad credentials"), { status: 401 })
const notFound = () => Object.assign(new Error("Not found"), { status: 404 })
describe("runWithAuthRetry", () => {
beforeEach(() => {
vi.mocked(getAccessToken).mockResolvedValue({
token: "t1"
} as Awaited<ReturnType<typeof getAccessToken>>)
vi.mocked(refreshToken).mockReset()
})
afterEach(() => {
vi.restoreAllMocks()
})
it("returns the call result on success without refreshing", async () => {
const call = vi.fn().mockResolvedValue("ok")
const result = await runWithAuthRetry(call)
expect(result).toBe("ok")
expect(call).toHaveBeenCalledTimes(1)
expect(refreshToken).not.toHaveBeenCalled()
})
it("rethrows non-401 errors immediately without refreshing", async () => {
const err = notFound()
const call = vi.fn().mockRejectedValue(err)
await expect(runWithAuthRetry(call)).rejects.toBe(err)
expect(refreshToken).not.toHaveBeenCalled()
expect(call).toHaveBeenCalledTimes(1)
})
it("refreshes and retries once on 401, returning the retry result", async () => {
vi.mocked(refreshToken).mockResolvedValue({
token: "t2"
} as Awaited<ReturnType<typeof refreshToken>>)
const call = vi
.fn()
.mockRejectedValueOnce(unauthorized())
.mockResolvedValueOnce("after-refresh")
const result = await runWithAuthRetry(call)
expect(result).toBe("after-refresh")
expect(refreshToken).toHaveBeenCalledWith({ force: true })
expect(call).toHaveBeenCalledTimes(2)
})
it("rethrows the original 401 when refresh returns null", async () => {
vi.mocked(refreshToken).mockResolvedValue(null)
const err = unauthorized()
const call = vi.fn().mockRejectedValue(err)
await expect(runWithAuthRetry(call)).rejects.toBe(err)
expect(call).toHaveBeenCalledTimes(1)
})
it("rethrows the original 401 when refresh itself throws", async () => {
vi.mocked(refreshToken).mockRejectedValue(new Error("network down"))
const err = unauthorized()
const call = vi.fn().mockRejectedValue(err)
await expect(runWithAuthRetry(call)).rejects.toBe(err)
})
it("propagates a retry-time error after a successful refresh", async () => {
vi.mocked(refreshToken).mockResolvedValue({
token: "t2"
} as Awaited<ReturnType<typeof refreshToken>>)
const retryErr = unauthorized()
const call = vi
.fn()
.mockRejectedValueOnce(unauthorized())
.mockRejectedValueOnce(retryErr)
await expect(runWithAuthRetry(call)).rejects.toBe(retryErr)
expect(call).toHaveBeenCalledTimes(2)
})
})

View File

@@ -0,0 +1,286 @@
import { createPinia, setActivePinia } from "pinia"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
vi.mock("@/data/data", () => ({
data: {
get: vi.fn().mockResolvedValue(null),
update: vi.fn().mockResolvedValue(undefined),
add: vi.fn().mockResolvedValue(undefined)
},
generateId: (type: string, id: string) => `${type}-${id}`
}))
vi.mock("@/modules/repo/services/repo", () => ({
getFiles: vi.fn().mockResolvedValue([]),
getMainReadme: vi.fn().mockResolvedValue(null),
getCachedMainReadme: vi.fn().mockResolvedValue(null),
getUserSettingsContent: vi.fn().mockResolvedValue(null),
getRepoPermission: vi.fn().mockResolvedValue(false)
}))
vi.mock("@/modules/user/service/signIn", () => ({
refreshToken: vi.fn().mockResolvedValue(null)
}))
import { data } from "@/data/data"
import {
getCachedMainReadme,
getFiles,
getMainReadme,
getRepoPermission,
getUserSettingsContent
} from "@/modules/repo/services/repo"
import { useUserRepoStore } from "./userRepo.store"
const flushAsync = () => new Promise((r) => setTimeout(r, 0))
describe("userRepo store — synchronous mutations", () => {
beforeEach(() => {
setActivePinia(createPinia())
localStorage.clear()
})
afterEach(() => {
vi.clearAllMocks()
})
it("resetUserRepo clears user, repo, files, and settings", () => {
const store = useUserRepoStore()
store.user = "alice"
store.repo = "notes"
store.files = [{ sha: "x", path: "a.md", type: "blob" }] as never
store.userSettings = { $type: 1 } as never
store.resetUserRepo()
expect(store.user).toBe("")
expect(store.repo).toBe("")
expect(store.files).toEqual([])
expect(store.userSettings).toBeUndefined()
})
it("resetFiles clears files and sets readme to null", () => {
const store = useUserRepoStore()
store.files = [{ sha: "x", path: "a.md", type: "blob" }] as never
store.readme = "<p>hi</p>"
store.resetFiles()
expect(store.files).toEqual([])
expect(store.readme).toBeNull()
})
it("addFile appends a new file with a unique sha", () => {
const store = useUserRepoStore()
store.user = "alice"
store.repo = "notes"
store.files = [{ sha: "old", path: "a.md", type: "blob" }] as never
store.addFile({ sha: "new", path: "b.md", type: "blob" } as never)
expect(store.files.map((f) => f.sha)).toEqual(["old", "new"])
expect(vi.mocked(data.update)).toHaveBeenCalled()
})
it("addFile is a no-op when the sha already exists", () => {
const store = useUserRepoStore()
store.user = "alice"
store.repo = "notes"
store.files = [{ sha: "x", path: "a.md", type: "blob" }] as never
store.addFile({ sha: "x", path: "duplicate.md", type: "blob" } as never)
expect(store.files).toHaveLength(1)
expect(vi.mocked(data.update)).not.toHaveBeenCalled()
})
it("addFile is a no-op when sha is missing", () => {
const store = useUserRepoStore()
store.files = []
store.addFile({ path: "no-sha.md", type: "blob" } as never)
expect(store.files).toHaveLength(0)
expect(vi.mocked(data.update)).not.toHaveBeenCalled()
})
it("setFontFamily initializes userSettings when absent and persists to localStorage", () => {
const store = useUserRepoStore()
store.user = "alice"
store.repo = "notes"
store.setFontFamily("Inter")
expect(store.userSettings?.chosenFontFamily).toBe("Inter")
const persisted = JSON.parse(
localStorage.getItem("remanso:layout:alice:notes") as string
)
expect(persisted.chosenFontFamily).toBe("Inter")
})
it("setFontSize, setTitleFont, setBodyFont each persist their respective field", () => {
const store = useUserRepoStore()
store.user = "alice"
store.repo = "notes"
store.setFontSize("18px")
store.setTitleFont("Serif")
store.setBodyFont("Sans")
const persisted = JSON.parse(
localStorage.getItem("remanso:layout:alice:notes") as string
)
expect(persisted.chosenFontSize).toBe("18px")
expect(persisted.chosenTitleFont).toBe("Serif")
expect(persisted.chosenBodyFont).toBe("Sans")
})
})
describe("userRepo store — setUserRepo", () => {
beforeEach(() => {
setActivePinia(createPinia())
localStorage.clear()
vi.clearAllMocks()
vi.mocked(data.get).mockResolvedValue(null as never)
vi.mocked(getFiles).mockResolvedValue([])
vi.mocked(getMainReadme).mockResolvedValue(null)
vi.mocked(getCachedMainReadme).mockResolvedValue(null)
vi.mocked(getUserSettingsContent).mockResolvedValue(null)
vi.mocked(getRepoPermission).mockResolvedValue(false)
})
it("sets user and repo immediately and clears loadError", async () => {
const store = useUserRepoStore()
store.loadError = "auth"
await store.setUserRepo("alice", "notes")
expect(store.user).toBe("alice")
expect(store.repo).toBe("notes")
expect(store.loadError).toBeNull()
})
it("populates files from getFiles on success", async () => {
vi.mocked(getFiles).mockResolvedValue([
{ sha: "a", path: "x.md", type: "blob" } as never
])
const store = useUserRepoStore()
await store.setUserRepo("alice", "notes")
await flushAsync()
await flushAsync()
expect(store.files.map((f) => f.sha)).toEqual(["a"])
})
it("sets readme from getMainReadme on success", async () => {
vi.mocked(getMainReadme).mockResolvedValue("<p>hi</p>")
const store = useUserRepoStore()
await store.setUserRepo("alice", "notes")
await flushAsync()
await flushAsync()
expect(store.readme).toBe("<p>hi</p>")
})
it("classifies 401 errors from getFiles as auth", async () => {
vi.mocked(getFiles).mockRejectedValue(
Object.assign(new Error("Unauthorized"), { status: 401 })
)
vi.spyOn(console, "warn").mockImplementation(() => {})
const store = useUserRepoStore()
await store.setUserRepo("alice", "notes")
await flushAsync()
await flushAsync()
expect(store.loadError).toBe("auth")
})
it("classifies TimeoutError as network", async () => {
vi.mocked(getFiles).mockRejectedValue(
Object.assign(new Error("Timed out"), { name: "TimeoutError" })
)
vi.spyOn(console, "warn").mockImplementation(() => {})
const store = useUserRepoStore()
await store.setUserRepo("alice", "notes")
await flushAsync()
await flushAsync()
expect(store.loadError).toBe("network")
})
it("classifies 500-range errors as network", async () => {
vi.mocked(getFiles).mockRejectedValue(
Object.assign(new Error("Server error"), { status: 503 })
)
vi.spyOn(console, "warn").mockImplementation(() => {})
const store = useUserRepoStore()
await store.setUserRepo("alice", "notes")
await flushAsync()
await flushAsync()
expect(store.loadError).toBe("network")
})
it("does NOT surface loadError from getMainReadme when a cached readme is present", async () => {
vi.mocked(getCachedMainReadme).mockResolvedValue("<p>cached</p>")
vi.mocked(getMainReadme).mockRejectedValue(
Object.assign(new Error("Server error"), { status: 503 })
)
vi.spyOn(console, "warn").mockImplementation(() => {})
const store = useUserRepoStore()
await store.setUserRepo("alice", "notes")
await flushAsync()
await flushAsync()
expect(store.readme).toBe("<p>cached</p>")
expect(store.loadError).toBeNull()
})
it("surfaces loadError from getMainReadme when no cached readme is present", async () => {
vi.mocked(getCachedMainReadme).mockResolvedValue(null)
vi.mocked(getMainReadme).mockRejectedValue(
Object.assign(new Error("Unauthorized"), { status: 401 })
)
vi.spyOn(console, "warn").mockImplementation(() => {})
const store = useUserRepoStore()
await store.setUserRepo("alice", "notes")
await flushAsync()
await flushAsync()
expect(store.readme).toBeNull()
expect(store.loadError).toBe("auth")
})
it("ignores stale getFiles results when a newer setUserRepo has been called (race guard)", async () => {
let resolveStale: (files: never[]) => void = () => {}
vi.mocked(getFiles).mockImplementationOnce(
() =>
new Promise<never[]>((r) => {
resolveStale = r
})
)
vi.mocked(getFiles).mockImplementationOnce(async () => [
{ sha: "fresh", path: "fresh.md", type: "blob" } as never
])
const store = useUserRepoStore()
await store.setUserRepo("alice", "stale-repo")
await store.setUserRepo("alice", "fresh-repo")
resolveStale([{ sha: "stale", path: "stale.md", type: "blob" } as never])
await flushAsync()
await flushAsync()
expect(store.repo).toBe("fresh-repo")
expect(store.files.map((f) => f.sha)).toEqual(["fresh"])
})
})

8
src/test/setup.ts Normal file
View File

@@ -0,0 +1,8 @@
import { config } from "@vue/test-utils"
config.global.stubs = {
"router-link": true,
"router-view": true,
transition: false,
"transition-group": false
}

View File

@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest"
import { decodeBase64ToUTF8, encodeUTF8ToBase64 } from "./decodeBase64ToUTF8"
describe("base64 ↔ UTF-8 round-trip", () => {
it.each([
["ASCII", "Hello, world!"],
["multi-byte UTF-8", "Café résumé naïve"],
["CJK characters", "こんにちは世界"],
["emoji", "👋 🌍 🎉"],
["empty string", ""],
["newlines and whitespace", "line1\nline2\tend"],
["markdown content", "# Title\n\n- [[link]]\n- **bold**"]
])("round-trips %s", (_label, input) => {
expect(decodeBase64ToUTF8(encodeUTF8ToBase64(input))).toBe(input)
})
it("encodes ASCII to standard base64", () => {
expect(encodeUTF8ToBase64("hi")).toBe("aGk=")
})
it("decodes standard base64 to ASCII", () => {
expect(decodeBase64ToUTF8("aGk=")).toBe("hi")
})
})

View File

@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest"
import { getFileLanguage, isMarkdownPath } from "./fileLanguage"
describe("isMarkdownPath", () => {
it.each(["note.md", "dir/note.md", "note.mdx", "DIR/NOTE.MD"])(
"returns true for %s",
(path) => {
expect(isMarkdownPath(path)).toBe(true)
}
)
it.each(["note.txt", "script.ts", "no-extension", "", "image.png"])(
"returns false for %s",
(path) => {
expect(isMarkdownPath(path)).toBe(false)
}
)
})
describe("getFileLanguage", () => {
it.each([
["sh", "bash"],
["bash", "bash"],
["js", "javascript"],
["mjs", "javascript"],
["cjs", "javascript"],
["ts", "typescript"],
["mts", "typescript"],
["md", "markdown"],
["mdx", "markdown"],
["html", "html"],
["htm", "html"],
["css", "css"],
["scss", "css"],
["json", "json"],
["jsonc", "json"],
["als", "alloy"]
])("maps .%s to %s", (ext, lang) => {
expect(getFileLanguage(`file.${ext}`)).toBe(lang)
})
it("matches case-insensitively", () => {
expect(getFileLanguage("File.TS")).toBe("typescript")
})
it("returns null for unknown extensions", () => {
expect(getFileLanguage("file.xyz")).toBeNull()
})
it("returns null for files without an extension", () => {
expect(getFileLanguage("Makefile")).toBeNull()
})
it("returns null for empty input", () => {
expect(getFileLanguage("")).toBeNull()
})
})

View File

@@ -0,0 +1,54 @@
import MarkdownIt from "markdown-it"
import { describe, expect, it } from "vitest"
import { html5Media } from "./markdown-html5-media"
const renderer = () => MarkdownIt().use(html5Media)
describe("html5Media plugin", () => {
it("renders <video> for .mp4 links using image syntax", () => {
const html = renderer().render("![demo](movie.mp4)")
expect(html).toContain('<video src="movie.mp4"')
expect(html).toContain("</video>")
})
it("renders <audio> for .mp3 links using image syntax", () => {
const html = renderer().render("![demo](song.mp3)")
expect(html).toContain('<audio src="song.mp3"')
expect(html).toContain("</audio>")
})
it("renders <img> for unrecognized extensions", () => {
const html = renderer().render("![alt](pic.png)")
expect(html).toContain('<img src="pic.png"')
expect(html).not.toContain("<video")
expect(html).not.toContain("<audio")
})
it("recognizes all listed video extensions", () => {
for (const ext of ["mp4", "m4v", "ogv", "webm", "mpg", "mpeg"]) {
expect(renderer().render(`![v](clip.${ext})`)).toContain(
`<video src="clip.${ext}"`
)
}
})
it("recognizes all listed audio extensions", () => {
for (const ext of ["aac", "m4a", "mp3", "oga", "ogg", "wav"]) {
expect(renderer().render(`![a](sound.${ext})`)).toContain(
`<audio src="sound.${ext}"`
)
}
})
it("includes a title attribute when provided in image syntax", () => {
const html = renderer().render('![demo](movie.mp4 "My title")')
expect(html).toContain('title="My title"')
})
it("matches extensions case-insensitively", () => {
expect(renderer().render("![v](CLIP.MP4)")).toContain(
'<video src="CLIP.MP4"'
)
})
})

View File

@@ -0,0 +1,42 @@
import MarkdownIt from "markdown-it"
import { describe, expect, it } from "vitest"
import { markdownItPlugin } from "./markdown-it-regexp"
describe("markdownItPlugin", () => {
it("calls the replacer when the pattern matches at start of inline content", () => {
const plugin = markdownItPlugin(/@(\w+)/, (match) => {
return `<span class="mention">${match[1]}</span>`
})
const md = MarkdownIt().use(plugin)
expect(md.render("@alice posted a note")).toContain(
'<span class="mention">alice</span>'
)
})
it("leaves non-matching text untouched", () => {
const plugin = markdownItPlugin(/@(\w+)/, (match) => `MATCH:${match[1]}`)
const md = MarkdownIt().use(plugin)
expect(md.render("no mention here")).toBe("<p>no mention here</p>\n")
})
it("supports case-insensitive matching when the input regex has the i flag", () => {
const plugin = markdownItPlugin(/@hello/i, () => "<b>hi</b>")
const md = MarkdownIt().use(plugin)
expect(md.render("@HELLO world")).toContain("<b>hi</b>")
})
it("each plugin instance gets a unique rule id (no collisions)", () => {
const pluginA = markdownItPlugin(/@a/, () => "RULE_A")
const pluginB = markdownItPlugin(/@b/, () => "RULE_B")
const md = MarkdownIt().use(pluginA).use(pluginB)
const result = md.render("@a and @b")
expect(result).toContain("RULE_A")
expect(result).toContain("RULE_B")
})
})

View File

@@ -0,0 +1,31 @@
import MarkdownIt from "markdown-it"
import { describe, expect, it } from "vitest"
import { markdownItTablerIcons } from "./markdown-it-tabler-icons"
describe("markdownItTablerIcons", () => {
const renderer = () => MarkdownIt().use(markdownItTablerIcons)
it("renders a tabler icon for :icon-name: syntax", () => {
expect(renderer().render(":home:")).toContain(
'<i class="ti ti-home"></i>'
)
})
it("supports hyphenated icon names", () => {
expect(renderer().render(":arrow-right:")).toContain(
'<i class="ti ti-arrow-right"></i>'
)
})
it("renders icons inline alongside surrounding text", () => {
const html = renderer().render("Click :check: to confirm")
expect(html).toContain("Click")
expect(html).toContain('<i class="ti ti-check"></i>')
expect(html).toContain("to confirm")
})
it("does not fire on text without colon delimiters", () => {
expect(renderer().render("home")).toBe("<p>home</p>\n")
})
})

View File

@@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest"
import {
filenameToNoteTitle,
pathToNotePathTitle,
pathToNoteTitle
} from "./noteTitle"
describe("filenameToNoteTitle", () => {
it("replaces hyphens with spaces", () => {
expect(filenameToNoteTitle("my-cool-note")).toBe("my cool note")
})
it("wraps slashes with spaces", () => {
expect(filenameToNoteTitle("dir/sub/file")).toBe("dir / sub / file")
})
it("returns empty input unchanged", () => {
expect(filenameToNoteTitle("")).toBe("")
})
})
describe("pathToNotePathTitle", () => {
it("strips the file extension", () => {
expect(pathToNotePathTitle("note.md")).toBe("note")
})
it("filters out README segments", () => {
expect(pathToNotePathTitle("folder/README.md")).toBe("folder")
})
it("preserves multi-level paths and replaces hyphens", () => {
expect(pathToNotePathTitle("dir/my-sub-dir/my-note.md")).toBe(
"dir/my sub dir/my note"
)
})
it("handles paths with multiple dots correctly", () => {
expect(pathToNotePathTitle("a/b.c.md")).toBe("a/b.c")
})
})
describe("pathToNoteTitle", () => {
it("returns the last segment of a multi-level path", () => {
expect(pathToNoteTitle("dir/sub/my-note.md")).toBe("my note")
})
it("returns the title for a root-level file", () => {
expect(pathToNoteTitle("my-note.md")).toBe("my note")
})
it("returns empty string for README files at root", () => {
expect(pathToNoteTitle("README.md")).toBe("")
})
it("returns the parent dir name when the file is a README", () => {
expect(pathToNoteTitle("folder/README.md")).toBe("folder")
})
})

34
src/utils/slugify.spec.ts Normal file
View File

@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest"
import { slugify } from "./slugify"
describe("slugify", () => {
it("lowercases and replaces spaces with hyphens", () => {
expect(slugify("Hello World")).toBe("hello-world")
})
it("strips diacritics via NFD normalization", () => {
expect(slugify("Café Résumé")).toBe("cafe-resume")
})
it("collapses non-alphanumeric runs into a single hyphen", () => {
expect(slugify("a !! b c__d")).toBe("a-b-c-d")
})
it("trims leading and trailing hyphens", () => {
expect(slugify("---hello---")).toBe("hello")
expect(slugify("!!!hello!!!")).toBe("hello")
})
it("returns empty string for empty input", () => {
expect(slugify("")).toBe("")
})
it("returns empty string when input is only special characters", () => {
expect(slugify("!@#$%^")).toBe("")
})
it("preserves digits", () => {
expect(slugify("Note 42")).toBe("note-42")
})
})

59
src/utils/youtube.spec.ts Normal file
View File

@@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest"
import { extractYouTubeId } from "./youtube"
describe("extractYouTubeId", () => {
it("returns null for empty input", () => {
expect(extractYouTubeId("")).toBeNull()
})
it("returns the trimmed string when input is not a valid URL", () => {
expect(extractYouTubeId(" dQw4w9WgXcQ ")).toBe("dQw4w9WgXcQ")
})
it("extracts id from youtu.be short URLs", () => {
expect(extractYouTubeId("https://youtu.be/dQw4w9WgXcQ")).toBe(
"dQw4w9WgXcQ"
)
})
it("extracts id from youtube.com/watch?v=", () => {
expect(
extractYouTubeId("https://www.youtube.com/watch?v=dQw4w9WgXcQ")
).toBe("dQw4w9WgXcQ")
})
it("extracts id from youtube.com/embed/", () => {
expect(extractYouTubeId("https://www.youtube.com/embed/dQw4w9WgXcQ")).toBe(
"dQw4w9WgXcQ"
)
})
it("extracts id from youtube.com/shorts/", () => {
expect(extractYouTubeId("https://www.youtube.com/shorts/abc123XYZ")).toBe(
"abc123XYZ"
)
})
it("extracts id from youtube.com/live/", () => {
expect(extractYouTubeId("https://www.youtube.com/live/abc123XYZ")).toBe(
"abc123XYZ"
)
})
it("prefers v= param over path segments", () => {
expect(
extractYouTubeId(
"https://www.youtube.com/watch?v=dQw4w9WgXcQ&list=PLxyz"
)
).toBe("dQw4w9WgXcQ")
})
it("returns null for non-YouTube hosts", () => {
expect(extractYouTubeId("https://example.com/watch?v=dQw4w9WgXcQ")).toBeNull()
})
it("returns null for youtube.com root URL with no id", () => {
expect(extractYouTubeId("https://www.youtube.com/")).toBeNull()
})
})

View File

@@ -1,3 +1,4 @@
/// <reference types="vitest" />
import vue from "@vitejs/plugin-vue" import vue from "@vitejs/plugin-vue"
import path from "path" import path from "path"
import { defineConfig, type UserConfigExport } from "vite" import { defineConfig, type UserConfigExport } from "vite"
@@ -8,6 +9,11 @@ export default defineConfig(({ command }) => {
build: { build: {
minify: "esbuild" minify: "esbuild"
}, },
test: {
environment: "jsdom",
setupFiles: ["./src/test/setup.ts"],
globals: false
},
plugins: [ plugins: [
vue(), vue(),
VitePWA({ VitePWA({