chore: add tests

This commit is contained in:
Julien Calixte
2026-06-06 22:13:14 +02:00
parent 8a8509a0f3
commit 1a2d8f4ebf
23 changed files with 1943 additions and 3 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": {
"@babel/core": "^7.28.5",
"@pinia/testing": "^1.0.3",
"@tailwindcss/typography": "^0.5.19",
"@types/fontfaceobserver": "^2.1.3",
"@types/markdown-it": "^14.1.2",
@@ -79,6 +80,7 @@
"@vite-pwa/assets-generator": "^1.0.2",
"@vitejs/plugin-vue": "^5.2.4",
"@vue/compiler-sfc": "^3.5.28",
"@vue/test-utils": "^2.4.11",
"autoprefixer": "^10.4.24",
"daisyui": "^5.5.18",
"dotenv": "^17.2.3",
@@ -86,6 +88,7 @@
"eslint-plugin-simple-import-sort": "^12.1.1",
"eslint-plugin-unused-imports": "^4.4.1",
"esno": "^4.8.0",
"jsdom": "^29.1.1",
"oxfmt": "^0.42.0",
"oxlint": "^1.57.0",
"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

@@ -0,0 +1,123 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
vi.mock("./octo", () => ({
getOctokit: 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", () => ({
markdownBuilder: () => ({ render: (s: string) => s })
}))
vi.mock("@/modules/note/cache/prepareNoteCache", () => ({
prepareNoteCache: () => ({
getCachedNote: async () => ({ note: null }),
saveCacheNote: vi.fn()
})
}))
import { getOctokit, runWithAuthRetry } from "./octo"
import { getFiles, queryFileContent } from "./repo"
const makeOctokitWithRequest = (impl: (route: string, params: unknown) => unknown) => ({
request: vi.fn(impl)
})
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,283 @@
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)
}))
vi.mock("@/modules/user/service/signIn", () => ({
refreshToken: vi.fn().mockResolvedValue(null)
}))
import { data } from "@/data/data"
import {
getCachedMainReadme,
getFiles,
getMainReadme,
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)
})
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 path from "path"
import { defineConfig, type UserConfigExport } from "vite"
@@ -8,6 +9,11 @@ export default defineConfig(({ command }) => {
build: {
minify: "esbuild"
},
test: {
environment: "jsdom",
setupFiles: ["./src/test/setup.ts"],
globals: false
},
plugins: [
vue(),
VitePWA({