import { describe, expect, it, vi } from "vitest"; import { createBobbinClient } from "#lib/api/client.ts"; import * as knotmirror from "#lib/api/knotmirror.ts"; import { commitAtRef, diffFileKind, diffFileName, diffFileStat, listCommits, toMirrorCommitDetail, toMirrorCommitSummary, type MirrorCommit, type MirrorFileDiff } from "#lib/api/knotmirror.ts"; import type { Fetch } from "#lib/fetch.ts"; const jsonResponse = (body: unknown): Response => new Response(JSON.stringify(body), { status: 200, headers: { "content-type": "application/json" } }); describe("knotmirror.tree", () => { it("fetches the readme blob", async () => { const fetchMock = vi.fn().mockImplementation(async (input) => { const url = new URL(String(input)); if (url.pathname.endsWith("getTree")) { return jsonResponse({ entries: [ { name: "README.md", mode: "0100644", size: 12, lastCommit: { hash: "abc", message: "docs", author: { name: "alice", email: "alice@tangled.org", when: "2026-07-01T00:00:00Z" }, committer: { name: "alice", email: "alice@tangled.org", when: "2026-07-02T00:00:00Z" } } } ] }); } return new Response("# hello", { status: 200, headers: { "content-type": "text/plain" } }); }); const ctx = createBobbinClient({ serviceUrl: "https://bobbin.test", fetch: fetchMock }); const tree = await knotmirror.tree(ctx, { repo: "did:plc:repo", ref: "main", path: "docs" }); expect(tree.readme).toEqual({ filename: "README.md", contents: "# hello" }); expect(tree.files[0]).toMatchObject({ name: "README.md", mode: "0100644" }); expect(tree.ref).toBe("main"); expect(tree.files[0].lastCommit?.when).toBe("2026-07-02T00:00:00Z"); expect(fetchMock).toHaveBeenCalledTimes(2); const treeUrl = new URL(String(fetchMock.mock.calls[0][0])); expect(treeUrl.origin).toBe("https://bobbin.test"); expect(treeUrl.pathname).toBe("/xrpc/org.tangled.temp.git.getTree"); const blobUrl = new URL(String(fetchMock.mock.calls[1][0])); expect(blobUrl.origin).toBe("https://bobbin.test"); expect(blobUrl.pathname).toBe("/xrpc/org.tangled.temp.git.getBlob"); expect(blobUrl.searchParams.get("path")).toBe("docs/README.md"); }); it("prefers README.md over other readme files", async () => { const fetchMock = vi.fn().mockImplementation(async (input) => { const url = new URL(String(input)); if (url.pathname.endsWith("getTree")) { return jsonResponse({ entries: [ { name: "README.Rmd", mode: "0100644", size: 12 }, { name: "README.md", mode: "0100644", size: 12 } ] }); } return new Response(url.searchParams.get("path"), { status: 200, headers: { "content-type": "text/plain" } }); }); const ctx = createBobbinClient({ serviceUrl: "https://bobbin.test", fetch: fetchMock }); const tree = await knotmirror.tree(ctx, { repo: "did:plc:repo", ref: "main" }); expect(tree.readme).toEqual({ filename: "README.md", contents: "README.md" }); }); it("falls back to the first readme file without a README.md", async () => { const fetchMock = vi.fn().mockImplementation(async (input) => { const url = new URL(String(input)); if (url.pathname.endsWith("getTree")) { return jsonResponse({ entries: [ { name: "README.Rmd", mode: "0100644", size: 12 }, { name: "README.txt", mode: "0100644", size: 12 } ] }); } return new Response(url.searchParams.get("path"), { status: 200, headers: { "content-type": "text/plain" } }); }); const ctx = createBobbinClient({ serviceUrl: "https://bobbin.test", fetch: fetchMock }); const tree = await knotmirror.tree(ctx, { repo: "did:plc:repo", ref: "main" }); expect(tree.readme).toEqual({ filename: "README.Rmd", contents: "README.Rmd" }); }); }); describe("knotmirror.languages", () => { it("uses the languages endpoint", async () => { const fetchMock = vi .fn() .mockResolvedValue( jsonResponse({ ref: "main", languages: [{ name: "TypeScript", size: 10 }] }) ); const ctx = createBobbinClient({ serviceUrl: "https://bobbin.test", fetch: fetchMock }); await knotmirror.languages(ctx, { repo: "did:plc:repo", ref: "main" }); const url = new URL(String(fetchMock.mock.calls[0][0])); expect(url.pathname).toBe("/xrpc/sh.tangled.git.temp.listLanguages"); }); }); describe("knotmirror.blob", () => { const entryBody = { name: "hello.txt", mode: "0100644", size: 5, lastCommit: { hash: "abc", message: "hi", author: { name: "alice", email: "alice@tangled.org", when: "2026-07-01T00:00:00Z" } } }; const blobFetch = (content: Uint8Array, entry: unknown = entryBody) => vi.fn().mockImplementation(async (input) => { const url = new URL(String(input)); return url.pathname.endsWith("getEntry") ? jsonResponse(entry) : new Response(content.slice().buffer, { status: 200 }); }); it("creates the repo blob shape from getEntry plus raw content", async () => { const fetchMock = blobFetch(new TextEncoder().encode("hello")); const ctx = createBobbinClient({ serviceUrl: "https://bobbin.test", fetch: fetchMock }); const output = await knotmirror.blob( ctx, { repo: "did:plc:repo", ref: "main", path: "hello.txt" }, 1024 ); expect(output).toMatchObject({ path: "hello.txt", size: 5, isBinary: false, encoding: "utf-8", content: "hello", lastCommit: { hash: "abc", when: "2026-07-01T00:00:00Z" } }); const entryUrl = new URL(String(fetchMock.mock.calls[0][0])); expect(entryUrl.origin).toBe("https://bobbin.test"); expect(entryUrl.pathname).toBe("/xrpc/org.tangled.temp.git.getEntry"); const blobUrl = new URL(String(fetchMock.mock.calls[1][0])); expect(blobUrl.origin).toBe("https://bobbin.test"); expect(blobUrl.pathname).toBe("/xrpc/org.tangled.temp.git.getBlob"); }); it("skips the content fetch when the entry is over the inline cap", async () => { const fetchMock = blobFetch(new TextEncoder().encode("hello"), { ...entryBody, size: 2048 }); const ctx = createBobbinClient({ serviceUrl: "https://bobbin.test", fetch: fetchMock }); const output = await knotmirror.blob( ctx, { repo: "did:plc:repo", ref: "main", path: "hello.txt" }, 1024 ); expect(output.fileTooLarge).toBe(true); expect(output.content).toBeUndefined(); expect(fetchMock).toHaveBeenCalledTimes(1); }); it("marks content with null bytes as binary", async () => { const fetchMock = blobFetch(new Uint8Array([0x50, 0x4b, 0x00, 0x00])); const ctx = createBobbinClient({ serviceUrl: "https://bobbin.test", fetch: fetchMock }); const output = await knotmirror.blob( ctx, { repo: "did:plc:repo", ref: "main", path: "hello.txt" }, 1024 ); expect(output.isBinary).toBe(true); expect(output.content).toBeUndefined(); }); it("treats a gitlink mode as a submodule", async () => { const fetchMock = blobFetch(new Uint8Array(), { ...entryBody, mode: "0160000" }); const ctx = createBobbinClient({ serviceUrl: "https://bobbin.test", fetch: fetchMock }); const output = await knotmirror.blob( ctx, { repo: "did:plc:repo", ref: "main", path: "hello.txt" }, 1024 ); expect(output.submodule).toEqual({ name: "hello.txt", url: "" }); }); }); describe("knotmirror.formatPatchUrl", () => { it("asks for the range under the org.tangled name", () => { const url = new URL( knotmirror.formatPatchUrl("https://bobbin.test", { repo: "did:plc:repo", from: "abc^", to: "abc" }) ); expect(url.pathname).toBe("/xrpc/org.tangled.temp.git.formatPatch"); expect(Object.fromEntries(url.searchParams)).toEqual({ repo: "did:plc:repo", from: "abc^", to: "abc" }); }); }); const signature = (when: string) => ({ name: "alice", email: "alice@example.com", when }); const commit = (oid: string, extra: Partial = {}): MirrorCommit => ({ oid, parents: [], tree: "tree", author: signature("2025-09-22T10:40:35+09:00"), committer: signature("2025-09-22T10:44:00+09:00"), message: "subject", extraHeaders: [], ...extra }); describe("knotmirror.listCommits", () => { it("queries the listCommits nsid with the repo dids", async () => { const fetchMock = vi.fn().mockResolvedValue( new Response(JSON.stringify({ commits: [commit("abc")] }), { status: 200, headers: { "content-type": "application/json" } }) ); const ctx = createBobbinClient({ serviceUrl: "https://bobbin.test", fetch: fetchMock }); const page = await listCommits(ctx, { repos: ["did:plc:fork", "did:plc:repo"] as never, ranges: ["base..head"], limit: 2 }); const url = new URL(String(fetchMock.mock.calls[0][0])); expect(url.pathname).toBe("/xrpc/org.tangled.temp.git.listCommits"); // array params repeat rather than joining expect(url.searchParams.getAll("repos")).toEqual(["did:plc:fork", "did:plc:repo"]); expect(url.searchParams.getAll("ranges")).toEqual(["base..head"]); expect(url.searchParams.get("limit")).toBe("2"); expect(page.commits).toHaveLength(1); }); }); describe("knotmirror.commitAtRef", () => { it("recovers the exact hash when commit-time ordering returns an ancestor first", async () => { const wanted = "9a925efef6a0e6dfcd2d4317b4a1eee8752928b8"; const fetchMock = vi .fn() .mockResolvedValueOnce( new Response(JSON.stringify({ commits: [commit("1".repeat(40))] }), { headers: { "content-type": "application/json" } }) ) .mockResolvedValueOnce( new Response(JSON.stringify({ commits: [commit(wanted)] }), { headers: { "content-type": "application/json" } }) ); const ctx = createBobbinClient({ serviceUrl: "https://bobbin.test", fetch: fetchMock }); await expect(commitAtRef(ctx, "did:plc:repo" as never, wanted)).resolves.toMatchObject({ oid: wanted }); const retry = new URL(String(fetchMock.mock.calls[1][0])); expect(retry.pathname).toBe("/xrpc/org.tangled.temp.git.listCommitsByRef"); expect(retry.searchParams.getAll("ranges")).toEqual([`${wanted}^..${wanted}`]); expect(retry.searchParams.get("limit")).toBe("100"); }); }); describe("knotmirror.toMirrorCommitSummary", () => { it("splits the message and picks up the change id", () => { const summary = toMirrorCommitSummary( commit("9a925efef6a0e6dfcd2d4317b4a1eee8752928b8", { message: "pulls: log the range\n\nthe lexicon has no range param", extraHeaders: [ { key: "gpgsig", value: "..." }, { key: "change-id", value: "kmzwvxqouwxnvzsnvtwyprxlpuoymqrt" } ] }) ); expect(summary).toEqual({ hash: "9a925efef6a0e6dfcd2d4317b4a1eee8752928b8", shortHash: "9a925efe", subject: "pulls: log the range", body: "the lexicon has no range param", authorName: "alice", authorEmail: "alice@example.com", committerEmail: "alice@example.com", // the committer date wins, like the other commit converters when: "2025-09-22T10:44:00+09:00", changeId: "kmzwvxqouwxnvzsnvtwyprxlpuoymqrt" }); }); }); describe("knotmirror.toMirrorCommitDetail", () => { it("maps signatures and commit trailers for the commit header", () => { const detail = toMirrorCommitDetail( commit("9a925efef6a0e6dfcd2d4317b4a1eee8752928b8", { author: { name: "alice", email: "did:plc:alice", when: "2025-09-22T10:40:35+09:00" }, message: "subject\n\nbody\n\nCo-authored-by: Bob " }) ); expect(detail).toMatchObject({ hash: "9a925efef6a0e6dfcd2d4317b4a1eee8752928b8", subject: "subject", body: "body\n\nCo-authored-by: Bob ", authorDid: "did:plc:alice", authorWhen: "2025-09-22T10:40:35+09:00", committerName: "alice", committerWhen: "2025-09-22T10:44:00+09:00", coAuthors: [{ name: "Bob", email: "bob@example.com" }] }); }); }); const src = (path: string, extra: Partial = {}) => ({ path, oid: "oid", size: 10, isBinary: false, isSubmodule: false, ...extra }); const fileDiff = ( lhs: string, rhs: string, hunks: MirrorFileDiff["hunks"] = [], extra: Partial = {} ): MirrorFileDiff => ({ lhsSrc: src(lhs), rhsSrc: src(rhs), hunks, hasSyntacticChanges: false, ...extra }); describe("knotmirror diff mappers", () => { it("names a file by its surviving side", () => { expect(diffFileName(fileDiff("a.ts", "a.ts"))).toBe("a.ts"); expect(diffFileName(fileDiff("gone.ts", ""))).toBe("gone.ts"); expect(diffFileName(fileDiff("", "added.ts"))).toBe("added.ts"); }); it("reads the change kind off the two sides", () => { expect(diffFileKind(fileDiff("a.ts", "a.ts"))).toBe("changed"); expect(diffFileKind(fileDiff("", "a.ts"))).toBe("new"); expect(diffFileKind(fileDiff("a.ts", ""))).toBe("deleted"); expect(diffFileKind(fileDiff("a.ts", "b.ts"))).toBe("renamed"); // what knotmirror actually sends: both sides named, the absent one a null oid const absent = src("a.ts", { oid: "0".repeat(40), size: 0 }); expect(diffFileKind({ ...fileDiff("a.ts", "a.ts"), lhsSrc: absent })).toBe("new"); expect(diffFileKind({ ...fileDiff("a.ts", "a.ts"), rhsSrc: absent })).toBe("deleted"); }); it("counts line pairs: one side is pure, both sides is a replacement", () => { const hunks = [ { novelLhs: [3], novelRhs: [3, 4], // line 3 replaced, line 4 added lines: [{ lhs: 3, rhs: 3 }, { rhs: 4 }] }, { novelLhs: [9], novelRhs: [], // line 9 deleted lines: [{ lhs: 9 }] } ]; expect(diffFileStat(fileDiff("a.ts", "a.ts", hunks))).toEqual({ insertions: 2, deletions: 2 }); expect(diffFileStat(fileDiff("a.ts", "a.ts"))).toEqual({ insertions: 0, deletions: 0 }); }); });