chore: add tests
This commit is contained in:
40
.gitea/workflows/ci.yml
Normal file
40
.gitea/workflows/ci.yml
Normal 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
|
||||
@@ -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
561
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
74
src/components/NoteFreshnessBadge.spec.ts
Normal file
74
src/components/NoteFreshnessBadge.spec.ts
Normal 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/)
|
||||
})
|
||||
})
|
||||
98
src/components/UserPill.spec.ts
Normal file
98
src/components/UserPill.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
76
src/hooks/useEditionMode.spec.ts
Normal file
76
src/hooks/useEditionMode.spec.ts
Normal 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")
|
||||
})
|
||||
})
|
||||
67
src/hooks/useForm.hook.spec.ts
Normal file
67
src/hooks/useForm.hook.spec.ts
Normal 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" }
|
||||
})
|
||||
})
|
||||
})
|
||||
53
src/modules/atproto/getUrl.spec.ts
Normal file
53
src/modules/atproto/getUrl.spec.ts
Normal 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")
|
||||
})
|
||||
})
|
||||
54
src/modules/atproto/parseAtUri.spec.ts
Normal file
54
src/modules/atproto/parseAtUri.spec.ts
Normal 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/)
|
||||
})
|
||||
})
|
||||
41
src/modules/atproto/shortDid.spec.ts
Normal file
41
src/modules/atproto/shortDid.spec.ts
Normal 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)
|
||||
}
|
||||
)
|
||||
})
|
||||
123
src/modules/repo/services/repo.spec.ts
Normal file
123
src/modules/repo/services/repo.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
97
src/modules/repo/services/runWithAuthRetry.spec.ts
Normal file
97
src/modules/repo/services/runWithAuthRetry.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
283
src/modules/repo/store/userRepo.store.spec.ts
Normal file
283
src/modules/repo/store/userRepo.store.spec.ts
Normal 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
8
src/test/setup.ts
Normal 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
|
||||
}
|
||||
25
src/utils/decodeBase64ToUTF8.spec.ts
Normal file
25
src/utils/decodeBase64ToUTF8.spec.ts
Normal 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")
|
||||
})
|
||||
})
|
||||
58
src/utils/fileLanguage.spec.ts
Normal file
58
src/utils/fileLanguage.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
54
src/utils/markdown/markdown-html5-media.spec.ts
Normal file
54
src/utils/markdown/markdown-html5-media.spec.ts
Normal 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("")
|
||||
expect(html).toContain('<video src="movie.mp4"')
|
||||
expect(html).toContain("</video>")
|
||||
})
|
||||
|
||||
it("renders <audio> for .mp3 links using image syntax", () => {
|
||||
const html = renderer().render("")
|
||||
expect(html).toContain('<audio src="song.mp3"')
|
||||
expect(html).toContain("</audio>")
|
||||
})
|
||||
|
||||
it("renders <img> for unrecognized extensions", () => {
|
||||
const html = renderer().render("")
|
||||
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(``)).toContain(
|
||||
`<video src="clip.${ext}"`
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it("recognizes all listed audio extensions", () => {
|
||||
for (const ext of ["aac", "m4a", "mp3", "oga", "ogg", "wav"]) {
|
||||
expect(renderer().render(``)).toContain(
|
||||
`<audio src="sound.${ext}"`
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it("includes a title attribute when provided in image syntax", () => {
|
||||
const html = renderer().render('')
|
||||
expect(html).toContain('title="My title"')
|
||||
})
|
||||
|
||||
it("matches extensions case-insensitively", () => {
|
||||
expect(renderer().render("")).toContain(
|
||||
'<video src="CLIP.MP4"'
|
||||
)
|
||||
})
|
||||
})
|
||||
42
src/utils/markdown/markdown-it-regexp.spec.ts
Normal file
42
src/utils/markdown/markdown-it-regexp.spec.ts
Normal 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")
|
||||
})
|
||||
})
|
||||
31
src/utils/markdown/markdown-it-tabler-icons.spec.ts
Normal file
31
src/utils/markdown/markdown-it-tabler-icons.spec.ts
Normal 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")
|
||||
})
|
||||
})
|
||||
59
src/utils/noteTitle.spec.ts
Normal file
59
src/utils/noteTitle.spec.ts
Normal 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
34
src/utils/slugify.spec.ts
Normal 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
59
src/utils/youtube.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user