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<Fetch>().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<Fetch>().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<Fetch>().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<Fetch>()
.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<Fetch>().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> = {}): 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<Fetch>().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<Fetch>()
.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 <bob@example.com>"
})
);
expect(detail).toMatchObject({
hash: "9a925efef6a0e6dfcd2d4317b4a1eee8752928b8",
subject: "subject",
body: "body\n\nCo-authored-by: Bob <bob@example.com>",
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<MirrorFileDiff["lhsSrc"]> = {}) => ({
path,
oid: "oid",
size: 10,
isBinary: false,
isSubmodule: false,
...extra
});
const fileDiff = (
lhs: string,
rhs: string,
hunks: MirrorFileDiff["hunks"] = [],
extra: Partial<MirrorFileDiff> = {}
): 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 });
});
});