Merge branch 'main' of ssh://git.apoena.dev:22222/remanso-space/remanso
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
Julien Calixte
2026-06-09 14:34:31 +02:00
30 changed files with 2690 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": {
"@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,197 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
vi.mock("@/modules/repo/services/octo", () => ({
getOctokit: vi.fn(),
runWithAuthRetry: vi.fn()
}))
vi.mock("@/utils/notif", () => ({
confirmMessage: vi.fn(),
errorMessage: vi.fn()
}))
import { getOctokit, runWithAuthRetry } from "@/modules/repo/services/octo"
import { confirmMessage, errorMessage } from "@/utils/notif"
import { useGitHubContent } from "./useGitHubContent.hook"
const make = () => useGitHubContent({ user: "alice", repo: "notes" })
describe("useGitHubContent.fetchLatestSha", () => {
beforeEach(() => {
vi.mocked(runWithAuthRetry).mockReset()
})
afterEach(() => {
vi.restoreAllMocks()
})
it("returns the sha from a file response", async () => {
vi.mocked(runWithAuthRetry).mockResolvedValue({
data: { sha: "abc123" }
} as never)
expect(await make().fetchLatestSha("a.md")).toEqual({
kind: "ok",
sha: "abc123"
})
})
it("returns sha=null when the path resolves to a directory (array response)", async () => {
vi.mocked(runWithAuthRetry).mockResolvedValue({ data: [] } as never)
expect(await make().fetchLatestSha("dir/")).toEqual({
kind: "ok",
sha: null
})
})
it("returns sha=null when the data object has no sha field", async () => {
vi.mocked(runWithAuthRetry).mockResolvedValue({
data: { type: "submodule" }
} as never)
expect(await make().fetchLatestSha("path")).toEqual({
kind: "ok",
sha: null
})
})
it("returns kind=unauthorized on 401", async () => {
vi.mocked(runWithAuthRetry).mockRejectedValue(
Object.assign(new Error("Unauthorized"), { status: 401 })
)
expect(await make().fetchLatestSha("path")).toEqual({
kind: "unauthorized"
})
})
it("returns kind=offline on non-401 errors (404, network, etc.)", async () => {
vi.mocked(runWithAuthRetry).mockRejectedValue(
Object.assign(new Error("Not found"), { status: 404 })
)
expect(await make().fetchLatestSha("path")).toEqual({ kind: "offline" })
vi.mocked(runWithAuthRetry).mockRejectedValue(new Error("network"))
expect(await make().fetchLatestSha("path")).toEqual({ kind: "offline" })
})
})
describe("useGitHubContent.updateFile / createFile (putFile)", () => {
const mockOctokitRequest = vi.fn()
beforeEach(() => {
mockOctokitRequest.mockReset()
vi.mocked(getOctokit).mockResolvedValue({
request: mockOctokitRequest
} as never)
vi.mocked(confirmMessage).mockReset()
vi.mocked(errorMessage).mockReset()
})
afterEach(() => {
vi.restoreAllMocks()
})
it("returns the new sha and calls confirmMessage on success", async () => {
mockOctokitRequest.mockResolvedValue({
data: { content: { sha: "new-sha" } }
})
const result = await make().updateFile({
content: "# hello",
path: "a.md",
sha: "old-sha"
})
expect(result).toEqual({ sha: "new-sha", conflict: false })
expect(confirmMessage).toHaveBeenCalledWith("✅ Note saved")
})
it("flags conflict=true on 409 and 422 responses", async () => {
vi.spyOn(console, "warn").mockImplementation(() => {})
for (const status of [409, 422]) {
mockOctokitRequest.mockRejectedValueOnce(
Object.assign(new Error("Conflict"), { status })
)
const result = await make().createFile({ content: "x", path: "a.md" })
expect(result).toEqual({ sha: null, conflict: true })
}
expect(errorMessage).toHaveBeenCalledWith(
"⚠ Conflict: this note changed on GitHub"
)
})
it("returns conflict=false and calls errorMessage on non-conflict errors", async () => {
vi.spyOn(console, "warn").mockImplementation(() => {})
mockOctokitRequest.mockRejectedValue(
Object.assign(new Error("Server error"), { status: 500 })
)
const result = await make().updateFile({
content: "x",
path: "a.md",
sha: "old"
})
expect(result).toEqual({ sha: null, conflict: false })
expect(errorMessage).toHaveBeenCalledWith("❌ Note could not be saved")
})
})
describe("useGitHubContent.uploadBinaryFile", () => {
const mockOctokitRequest = vi.fn()
beforeEach(() => {
mockOctokitRequest.mockReset()
vi.mocked(getOctokit).mockResolvedValue({
request: mockOctokitRequest
} as never)
vi.mocked(errorMessage).mockReset()
vi.mocked(confirmMessage).mockReset()
})
afterEach(() => {
vi.restoreAllMocks()
})
it("uploads pre-base64-encoded content without re-encoding", async () => {
mockOctokitRequest.mockResolvedValue({
data: { content: { sha: "img-sha" } }
})
const result = await make().uploadBinaryFile({
base64: "ZGF0YQ==",
path: "img/cover.png"
})
expect(result).toEqual({ sha: "img-sha", conflict: false })
expect(mockOctokitRequest).toHaveBeenCalledWith(
"PUT /repos/{owner}/{repo}/contents/{+path}",
expect.objectContaining({
path: "img/cover.png",
content: "ZGF0YQ=="
})
)
expect(confirmMessage).toHaveBeenCalledWith("✅ Image uploaded")
})
it("surfaces a conflict on existing file (422)", async () => {
vi.spyOn(console, "warn").mockImplementation(() => {})
mockOctokitRequest.mockRejectedValue(
Object.assign(new Error("Exists"), { status: 422 })
)
const result = await make().uploadBinaryFile({
base64: "Zm9v",
path: "img.png"
})
expect(result).toEqual({ sha: null, conflict: true })
expect(errorMessage).toHaveBeenCalledWith(
"⚠ A file already exists at this path on GitHub"
)
})
})

View File

@@ -0,0 +1,164 @@
import { createPinia, setActivePinia } from "pinia"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
const uploadBinaryFile = vi.fn()
const registerUploadedFile = vi.fn()
vi.mock("@/hooks/useGitHubContent.hook", () => ({
useGitHubContent: () => ({ uploadBinaryFile })
}))
vi.mock("@/modules/repo/store/userRepo.store", () => ({
useUserRepoStore: () => ({
files: [{ path: "alice/notes/already-here.png", sha: "x" }],
registerUploadedFile
})
}))
vi.mock("@/utils/notif", () => ({
errorMessage: vi.fn()
}))
import { errorMessage } from "@/utils/notif"
import { useImageUpload } from "./useImageUpload.hook"
const makeFile = (name: string, body = "fake-image-bytes") =>
new File([body], name, { type: "image/png" })
describe("useImageUpload", () => {
beforeEach(() => {
setActivePinia(createPinia())
uploadBinaryFile.mockReset()
registerUploadedFile.mockReset()
vi.mocked(errorMessage).mockReset()
})
afterEach(() => {
vi.restoreAllMocks()
})
it("returns null and shows an error when notePath is missing", async () => {
const { uploadImage } = useImageUpload({
user: "alice",
repo: "notes",
notePath: undefined
})
expect(await uploadImage(makeFile("img.png"))).toBeNull()
expect(errorMessage).toHaveBeenCalledWith("❌ Image upload failed")
expect(uploadBinaryFile).not.toHaveBeenCalled()
})
it("uploads to the note's directory using the note basename and the file extension", async () => {
uploadBinaryFile.mockResolvedValue({ sha: "new-sha", conflict: false })
const { uploadImage } = useImageUpload({
user: "alice",
repo: "notes",
notePath: "alice/notes/my-note.md"
})
const result = await uploadImage(makeFile("photo.PNG"))
expect(result).toEqual({ filename: "my-note.png" })
expect(uploadBinaryFile).toHaveBeenCalledWith(
expect.objectContaining({
path: "alice/notes/my-note.png"
})
)
})
it("deduplicates filenames against existing files in the store", async () => {
uploadBinaryFile.mockResolvedValue({ sha: "new-sha", conflict: false })
const { uploadImage } = useImageUpload({
user: "alice",
repo: "notes",
notePath: "alice/notes/already-here.md"
})
const result = await uploadImage(makeFile("photo.png"))
expect(result?.filename).toBe("already-here-2.png")
})
it("returns null on conflict and does NOT register the file", async () => {
uploadBinaryFile.mockResolvedValue({ sha: null, conflict: true })
const { uploadImage } = useImageUpload({
user: "alice",
repo: "notes",
notePath: "x/note.md"
})
expect(await uploadImage(makeFile("img.png"))).toBeNull()
expect(registerUploadedFile).not.toHaveBeenCalled()
})
it("registers the uploaded file in the store on success", async () => {
uploadBinaryFile.mockResolvedValue({ sha: "abc", conflict: false })
const file = makeFile("img.png", "data")
const { uploadImage } = useImageUpload({
user: "alice",
repo: "notes",
notePath: "x/note.md"
})
await uploadImage(file)
expect(registerUploadedFile).toHaveBeenCalledWith({
path: "x/note.png",
sha: "abc",
type: "blob",
size: file.size
})
})
it("falls back to .png when the file name has no extension", async () => {
uploadBinaryFile.mockResolvedValue({ sha: "abc", conflict: false })
const { uploadImage } = useImageUpload({
user: "alice",
repo: "notes",
notePath: "x/note.md"
})
await uploadImage(makeFile("noextension"))
expect(uploadBinaryFile).toHaveBeenCalledWith(
expect.objectContaining({ path: "x/note.png" })
)
})
it("uses the root path when the note is at the repo root", async () => {
uploadBinaryFile.mockResolvedValue({ sha: "abc", conflict: false })
const { uploadImage } = useImageUpload({
user: "alice",
repo: "notes",
notePath: "root.md"
})
await uploadImage(makeFile("img.png"))
expect(uploadBinaryFile).toHaveBeenCalledWith(
expect.objectContaining({ path: "root.png" })
)
})
it("returns null and notifies on unexpected errors", async () => {
vi.spyOn(console, "warn").mockImplementation(() => {})
uploadBinaryFile.mockRejectedValue(new Error("boom"))
const { uploadImage } = useImageUpload({
user: "alice",
repo: "notes",
notePath: "x/note.md"
})
expect(await uploadImage(makeFile("img.png"))).toBeNull()
expect(errorMessage).toHaveBeenCalledWith("❌ Image upload failed")
})
})

View File

@@ -0,0 +1,234 @@
import { createPinia, setActivePinia } from "pinia"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import { ref } from "vue"
const {
fetchLatestSha,
addFile,
getRawContent,
saveCacheNote,
queryFileContent
} = vi.hoisted(() => ({
fetchLatestSha: vi.fn(),
addFile: vi.fn(),
getRawContent: vi.fn((s: string) => s),
saveCacheNote: vi.fn(),
queryFileContent: vi.fn()
}))
vi.mock("@/hooks/useGitHubContent.hook", () => ({
useGitHubContent: () => ({ fetchLatestSha })
}))
vi.mock("@/hooks/useMarkdown.hook", () => ({
markdownBuilder: () => ({ getRawContent, render: (s: string) => s })
}))
vi.mock("@/modules/note/cache/prepareNoteCache", () => ({
prepareNoteCache: () => ({
getCachedNote: async () => ({ note: null }),
saveCacheNote
})
}))
vi.mock("@/modules/repo/services/repo", () => ({
queryFileContent
}))
vi.mock("@/modules/repo/store/userRepo.store", () => ({
useUserRepoStore: () => ({ addFile })
}))
import { useNoteFreshness } from "./useNoteFreshness.hook"
const setup = (overrides: { sha?: string; path?: string; edited?: string | null } = {}) => {
setActivePinia(createPinia())
const sha = ref(overrides.sha ?? "local-sha")
const path = ref(overrides.path ?? "note.md")
const getEditedSha = vi.fn().mockResolvedValue(overrides.edited ?? null)
return useNoteFreshness({
user: "alice",
repo: "notes",
sha,
path,
getEditedSha
})
}
// Make performance.now() return values where elapsed > MIN_SPINNER_MS so the
// 400ms spinner-min-time wait is skipped.
const skipSpinnerWait = () => {
let call = 0
vi.spyOn(performance, "now").mockImplementation(() =>
call++ === 0 ? 0 : 10_000
)
}
describe("useNoteFreshness.check", () => {
beforeEach(() => {
fetchLatestSha.mockReset()
addFile.mockReset()
saveCacheNote.mockReset()
queryFileContent.mockReset()
skipSpinnerWait()
})
afterEach(() => {
vi.restoreAllMocks()
})
it("does nothing when path is empty", async () => {
const { check, status } = setup({ path: "" })
await check()
expect(status.value).toBe("unknown")
expect(fetchLatestSha).not.toHaveBeenCalled()
})
it("sets status to 'verified' when remote sha matches local sha", async () => {
fetchLatestSha.mockResolvedValue({ kind: "ok", sha: "local-sha" })
const { check, status, latestSha, lastCheckedAt } = setup()
await check()
expect(status.value).toBe("verified")
expect(latestSha.value).toBe("local-sha")
expect(lastCheckedAt.value).toBeInstanceOf(Date)
})
it("sets status to 'outdated' when remote sha differs from local sha", async () => {
fetchLatestSha.mockResolvedValue({ kind: "ok", sha: "remote-sha" })
const { check, status } = setup()
await check()
expect(status.value).toBe("outdated")
})
it("prefers the edited sha over the live sha when comparing", async () => {
fetchLatestSha.mockResolvedValue({ kind: "ok", sha: "edited-sha" })
const { check, status } = setup({ sha: "live-sha", edited: "edited-sha" })
await check()
expect(status.value).toBe("verified")
})
it("sets status to 'unauthorized' on auth failure", async () => {
fetchLatestSha.mockResolvedValue({ kind: "unauthorized" })
const { check, status } = setup()
await check()
expect(status.value).toBe("unauthorized")
})
it("sets status to 'offline' on network failure", async () => {
fetchLatestSha.mockResolvedValue({ kind: "offline" })
const { check, status } = setup()
await check()
expect(status.value).toBe("offline")
})
it("sets status to 'offline' when remote returns sha=null (e.g. directory)", async () => {
fetchLatestSha.mockResolvedValue({ kind: "ok", sha: null })
const { check, status } = setup()
await check()
expect(status.value).toBe("offline")
})
})
describe("useNoteFreshness.pullLatest", () => {
beforeEach(() => {
fetchLatestSha.mockReset()
addFile.mockReset()
saveCacheNote.mockReset()
queryFileContent.mockReset()
getRawContent.mockClear()
skipSpinnerWait()
})
afterEach(() => {
vi.restoreAllMocks()
})
it("returns raw=null with no failureStatus when path is empty", async () => {
const { pullLatest } = setup({ path: "" })
expect(await pullLatest()).toEqual({ raw: null, failureStatus: null })
expect(fetchLatestSha).not.toHaveBeenCalled()
})
it("returns the fetched content and updates state on success", async () => {
fetchLatestSha.mockResolvedValue({ kind: "ok", sha: "remote-sha" })
queryFileContent.mockResolvedValue("BASE64")
getRawContent.mockReturnValue("# raw")
const { pullLatest, status, latestSha, lastCheckedAt } = setup()
const result = await pullLatest()
expect(result).toEqual({ raw: "# raw", failureStatus: null })
expect(status.value).toBe("verified")
expect(latestSha.value).toBe("remote-sha")
expect(lastCheckedAt.value).toBeInstanceOf(Date)
expect(addFile).toHaveBeenCalledWith({ path: "note.md", sha: "remote-sha" })
expect(saveCacheNote).toHaveBeenCalled()
})
it("surfaces 'unauthorized' failureStatus when remote resolve is unauthorized", async () => {
vi.spyOn(console, "warn").mockImplementation(() => {})
fetchLatestSha.mockResolvedValue({ kind: "unauthorized" })
const { pullLatest, status } = setup()
expect(await pullLatest()).toEqual({
raw: null,
failureStatus: "unauthorized"
})
expect(status.value).toBe("unauthorized")
})
it("surfaces 'offline' failureStatus when remote resolve is offline", async () => {
vi.spyOn(console, "warn").mockImplementation(() => {})
fetchLatestSha.mockResolvedValue({ kind: "offline" })
const { pullLatest, status } = setup()
expect(await pullLatest()).toEqual({
raw: null,
failureStatus: "offline"
})
expect(status.value).toBe("offline")
})
it("surfaces 'offline' failureStatus when blob content fetch fails", async () => {
vi.spyOn(console, "warn").mockImplementation(() => {})
fetchLatestSha.mockResolvedValue({ kind: "ok", sha: "remote-sha" })
queryFileContent.mockResolvedValue(null)
const { pullLatest, status } = setup()
expect(await pullLatest()).toEqual({
raw: null,
failureStatus: "offline"
})
expect(status.value).toBe("offline")
})
it("uses the cached latestSha to skip a redundant fetchLatestSha call", async () => {
fetchLatestSha.mockResolvedValueOnce({ kind: "ok", sha: "cached-sha" })
queryFileContent.mockResolvedValue("BASE64")
const api = setup()
await api.check() // populates latestSha
expect(fetchLatestSha).toHaveBeenCalledTimes(1)
fetchLatestSha.mockClear()
await api.pullLatest()
expect(fetchLatestSha).not.toHaveBeenCalled()
expect(queryFileContent).toHaveBeenCalledWith("alice", "notes", "cached-sha")
})
})

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,55 @@
import { describe, expect, it } from "vitest"
import { withATProtoImages } from "./withATProtoImages"
const PDS = "https://pds.example.com"
const DID = "did:plc:abc123"
describe("withATProtoImages", () => {
it("rewrites a bafkrei CID image to a getBlob URL", () => {
const out = withATProtoImages(
"![cover](bafkreigh2akiscaildc7r4apx2t6q4t6n6kxjpw3xhqxkfvvbprdaezz4i)",
{ pds: PDS, did: DID }
)
expect(out).toContain(
`${PDS}/xrpc/com.atproto.sync.getBlob`
)
expect(out).toContain("did=did%3Aplc%3Aabc123")
expect(out).toContain(
"cid=bafkreigh2akiscaildc7r4apx2t6q4t6n6kxjpw3xhqxkfvvbprdaezz4i"
)
})
it("preserves the alt text", () => {
const out = withATProtoImages("![my cover](bafkreiabc)", {
pds: PDS,
did: DID
})
expect(out).toMatch(/!\[my cover\]/)
})
it("rewrites multiple images in one pass", () => {
const md = "![a](bafkreiaaa) and ![b](bafkreibbb)"
const out = withATProtoImages(md, { pds: PDS, did: DID })
expect(out.match(/com\.atproto\.sync\.getBlob/g)).toHaveLength(2)
expect(out).toContain("cid=bafkreiaaa")
expect(out).toContain("cid=bafkreibbb")
})
it("leaves regular image URLs untouched", () => {
const md = "![pic](https://example.com/a.png)"
expect(withATProtoImages(md, { pds: PDS, did: DID })).toBe(md)
})
it("leaves CIDs that don't match the bafkrei prefix untouched", () => {
const md = "![x](sha256-abc123)"
expect(withATProtoImages(md, { pds: PDS, did: DID })).toBe(md)
})
it("preserves an empty alt text", () => {
const out = withATProtoImages("![](bafkreiabc)", { pds: PDS, did: DID })
expect(out).toMatch(/^!\[\]/)
})
})

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() }))
vi.mock("@/modules/repo/services/octo", () => ({
vi.mock("./octo", () => ({
getOctokit: vi.fn().mockResolvedValue({
repos: { get: reposGet }
}),
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: vi.fn().mockResolvedValue({ note: null }),
getCachedNote: async () => ({ note: null }),
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", () => {
beforeEach(() => {
reposGet.mockReset()
vi.mocked(getOctokit).mockResolvedValue({
repos: { get: reposGet }
} as never)
})
it("returns true when permissions.push is true", async () => {
@@ -54,3 +67,101 @@ describe("getRepoPermission", () => {
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"])
})
})

View File

@@ -0,0 +1,30 @@
import { beforeEach, describe, expect, it } from "vitest"
import {
consumeGithubOAuthReturnPath,
GITHUB_OAUTH_RETURN_PATH_KEY
} from "./oauthReturnPath"
describe("consumeGithubOAuthReturnPath", () => {
beforeEach(() => {
sessionStorage.clear()
})
it("returns the stored path and clears the key", () => {
sessionStorage.setItem(GITHUB_OAUTH_RETURN_PATH_KEY, "/alice/notes")
expect(consumeGithubOAuthReturnPath()).toBe("/alice/notes")
expect(sessionStorage.getItem(GITHUB_OAUTH_RETURN_PATH_KEY)).toBeNull()
})
it("returns null when nothing is stored", () => {
expect(consumeGithubOAuthReturnPath()).toBeNull()
})
it("returns null on the second call (consume-once semantics)", () => {
sessionStorage.setItem(GITHUB_OAUTH_RETURN_PATH_KEY, "/x")
expect(consumeGithubOAuthReturnPath()).toBe("/x")
expect(consumeGithubOAuthReturnPath()).toBeNull()
})
})

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,38 @@
import { afterEach, describe, expect, it, vi } from "vitest"
import { displayLanguage } from "./displayLanguage"
describe("displayLanguage", () => {
afterEach(() => {
vi.restoreAllMocks()
})
it("returns null when no language code is given", () => {
expect(displayLanguage()).toBeNull()
expect(displayLanguage("")).toBeNull()
})
it("returns a human-readable name for a known language code", () => {
expect(displayLanguage("en")).toMatch(/^English/i)
})
it("works for French", () => {
expect(displayLanguage("fr")).toBeTruthy()
})
it("returns null and logs a warning when Intl.DisplayNames throws", () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {})
const original = Intl.DisplayNames
// Force the constructor to throw
;(Intl as unknown as { DisplayNames: unknown }).DisplayNames = function () {
throw new Error("boom")
}
try {
expect(displayLanguage("en")).toBeNull()
expect(warn).toHaveBeenCalled()
} finally {
;(Intl as unknown as { DisplayNames: unknown }).DisplayNames = original
}
})
})

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()
})
})

33
src/utils/link.spec.ts Normal file
View File

@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest"
import { isExternalLink } from "./link"
describe("isExternalLink", () => {
const ORIGIN = window.location.origin
it("returns false for same-origin absolute URLs", () => {
expect(isExternalLink(`${ORIGIN}/alice/notes`)).toBe(false)
})
it("returns true for an https URL on a different origin", () => {
expect(isExternalLink("https://github.com/anywhere")).toBe(true)
})
it("returns true for an http URL on a different origin", () => {
expect(isExternalLink("http://example.com")).toBe(true)
})
it("returns false for relative paths (no http/https prefix)", () => {
expect(isExternalLink("/alice/notes")).toBe(false)
expect(isExternalLink("./neighbor.md")).toBe(false)
})
it("returns false for mailto and other non-http schemes", () => {
expect(isExternalLink("mailto:user@example.com")).toBe(false)
expect(isExternalLink("ftp://files.example.com")).toBe(false)
})
it("returns false for an https URL that happens to start with the origin", () => {
expect(isExternalLink(`${ORIGIN}/deep/path`)).toBe(false)
})
})

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({