This repository has no description
1import { describe, expect, it, vi } from "vitest";
2import {
3 logFor,
4 repoNameOf,
5 resolveRepoByName,
6 sortTreeEntries,
7 toBranchSummary,
8 toCommitSummary,
9 toTagSummary,
10 toTreeEntrySummary,
11 treeEntryKind,
12 type BranchEntry,
13 type LogCommit,
14 type TagEntry,
15 type TreeEntrySummary
16} from "./repo";
17import { ClientResponseError, createBobbinClient, type BobbinContext } from "./client";
18import type { RecordView, RepoRecord } from "./records";
19
20const jsonResponse = (body: unknown): Response =>
21 new Response(JSON.stringify(body), {
22 status: 200,
23 headers: { "content-type": "application/json" }
24 });
25
26const makeCtx = (fetchMock: typeof globalThis.fetch): BobbinContext =>
27 createBobbinClient({ serviceUrl: "https://bobbin.test", fetch: fetchMock });
28
29describe("repoNameOf", () => {
30 const view = (uri: string, name?: string): RecordView<RepoRecord> => ({
31 uri: uri as RecordView<RepoRecord>["uri"],
32 value: { $type: "sh.tangled.repo", createdAt: "", knot: "knot.test", name }
33 });
34
35 it("prefers the record's cosmetic name over a tid rkey", () => {
36 expect(repoNameOf(view("at://did:plc:o/sh.tangled.repo/3lzg6enurmo22", "infra"))).toBe("infra");
37 });
38
39 it("falls back to the rkey for unnamed records", () => {
40 expect(repoNameOf(view("at://did:plc:o/sh.tangled.repo/infra"))).toBe("infra");
41 });
42});
43
44describe("treeEntryKind", () => {
45 it("maps git file modes to entry kinds", () => {
46 expect(treeEntryKind("0040000")).toBe("directory");
47 expect(treeEntryKind("040000")).toBe("directory");
48 expect(treeEntryKind("0100644")).toBe("file");
49 expect(treeEntryKind("0100755")).toBe("file");
50 expect(treeEntryKind("0120000")).toBe("symlink");
51 expect(treeEntryKind("0160000")).toBe("submodule");
52 });
53});
54
55describe("sortTreeEntries", () => {
56 it("puts directories and submodules before files, then sorts by name", () => {
57 const entry = (name: string, kind: TreeEntrySummary["kind"]): TreeEntrySummary => ({
58 name,
59 kind,
60 size: 0
61 });
62 const sorted = sortTreeEntries([
63 entry("readme.md", "file"),
64 entry("src", "directory"),
65 entry(".gitignore", "file"),
66 entry("vendor", "submodule")
67 ]);
68 expect(sorted.map((item) => item.name)).toEqual(["src", "vendor", ".gitignore", "readme.md"]);
69 });
70});
71
72describe("toCommitSummary", () => {
73 // `this` is where the hex hash lives, `hash` is a byte array on the wire
74 const commit: LogCommit = {
75 this: "0c4d0e9b07940033721395a434b5873f0fb9e6c8",
76 author: { Name: "Ada", Email: "ada@example.com", When: "2026-07-01T10:00:00Z" },
77 committer: { Name: "Ada", Email: "ada@example.com", When: "2026-07-02T10:00:00Z" },
78 message: "web: add repo index\n\nWith a longer body.\n",
79 change_id: "abc123"
80 };
81
82 it("splits the subject from the body and shortens the hash", () => {
83 const summary = toCommitSummary(commit);
84 expect(summary).toMatchObject({
85 hash: "0c4d0e9b07940033721395a434b5873f0fb9e6c8",
86 shortHash: "0c4d0e9b",
87 subject: "web: add repo index",
88 body: "With a longer body.",
89 authorName: "Ada",
90 when: "2026-07-02T10:00:00Z",
91 changeId: "abc123"
92 });
93 });
94
95 it("leaves the body empty for single-line messages", () => {
96 const summary = toCommitSummary({ ...commit, message: "one liner\n" });
97 expect(summary.body).toBe("");
98 expect(summary.subject).toBe("one liner");
99 });
100});
101
102describe("logFor", () => {
103 it("passes the cursor through to the knot", async () => {
104 const fetchMock = vi
105 .fn<typeof globalThis.fetch>()
106 .mockResolvedValue(jsonResponse({ commits: [], total: 42 }));
107 await logFor(makeCtx(fetchMock), "at://did:plc:o/sh.tangled.repo/abc", "master", 20, "40");
108 const url = new URL(String(fetchMock.mock.calls[0][0]));
109 expect(url.pathname).toBe("/xrpc/sh.tangled.repo.log");
110 expect(url.searchParams.get("ref")).toBe("master");
111 expect(url.searchParams.get("limit")).toBe("20");
112 expect(url.searchParams.get("cursor")).toBe("40");
113 });
114});
115
116describe("toBranchSummary", () => {
117 it("reads the nested reference and the go-git commit fields", () => {
118 const branch: BranchEntry = {
119 reference: { name: "master", hash: "ff3a3678" },
120 commit: { Committer: { Name: "Ada", Email: "a@b.c", When: "2026-07-02T10:00:00Z" } },
121 is_default: true
122 };
123 expect(toBranchSummary(branch)).toEqual({
124 name: "master",
125 hash: "ff3a3678",
126 when: "2026-07-02T10:00:00Z",
127 isDefault: true
128 });
129 });
130
131 it("treats a missing is_default as not default", () => {
132 expect(toBranchSummary({ reference: { name: "topic", hash: "abc" } }).isDefault).toBe(false);
133 });
134});
135
136describe("toTagSummary", () => {
137 // an annotated tag's own hash is the tag object, the commit is in Target
138 it("reads the inlined reference and follows an annotated tag to its commit", () => {
139 const tag: TagEntry = {
140 name: "v1.0.0",
141 hash: "63fa1d4b",
142 message: "release",
143 tag: {
144 Tagger: { Name: "Ada", Email: "a@b.c", When: "2026-07-01T10:00:00Z" },
145 Target: [75, 78, 254, 37]
146 }
147 };
148 expect(toTagSummary(tag)).toEqual({
149 name: "v1.0.0",
150 hash: "63fa1d4b",
151 commitHash: "4b4efe25",
152 when: "2026-07-01T10:00:00Z",
153 message: "release"
154 });
155 });
156
157 it("a lightweight tag is its own commit", () => {
158 const summary = toTagSummary({ name: "v1.0.0", hash: "eebb477b" });
159 expect(summary.commitHash).toBe("eebb477b");
160 });
161});
162
163describe("toTreeEntrySummary", () => {
164 it("carries the last commit over from snake_case", () => {
165 expect(
166 toTreeEntrySummary({
167 name: "flake.nix",
168 mode: "0100644",
169 size: 213,
170 last_commit: { hash: "f0b11b85", message: "init", when: "2026-07-01T10:00:00Z" }
171 })
172 ).toEqual({
173 name: "flake.nix",
174 kind: "file",
175 size: 213,
176 lastCommitHash: "f0b11b85",
177 lastCommitWhen: "2026-07-01T10:00:00Z",
178 lastCommitMessage: "init"
179 });
180 });
181});
182
183describe("resolveRepoByName", () => {
184 const owner = "did:plc:owner";
185
186 it("asks bobbin for the owner and name", async () => {
187 const fetchMock = vi.fn<typeof globalThis.fetch>().mockResolvedValue(
188 jsonResponse({
189 uri: `at://${owner}/sh.tangled.repo/3lzg6enurmo22`,
190 value: { $type: "sh.tangled.repo", knot: "knot.test", name: "infra" }
191 })
192 );
193 const view = await resolveRepoByName(makeCtx(fetchMock), owner, "infra");
194 expect(view?.uri).toBe(`at://${owner}/sh.tangled.repo/3lzg6enurmo22`);
195 const url = new URL(String(fetchMock.mock.calls[0][0]));
196 expect(url.pathname).toBe("/xrpc/sh.tangled.repo.getRepoByName");
197 expect(url.searchParams.get("owner")).toBe(owner);
198 expect(url.searchParams.get("name")).toBe("infra");
199 });
200
201 it("returns null when the owner has no such repo", async () => {
202 const fetchMock = vi.fn<typeof globalThis.fetch>().mockResolvedValue(
203 new Response(JSON.stringify({ error: "RecordNotFound" }), {
204 status: 404,
205 headers: { "content-type": "application/json" }
206 })
207 );
208 expect(await resolveRepoByName(makeCtx(fetchMock), owner, "missing")).toBeNull();
209 });
210
211 it("propagates anything that is not a miss", async () => {
212 const fetchMock = vi.fn<typeof globalThis.fetch>().mockResolvedValue(
213 new Response(JSON.stringify({ error: "UpstreamFailed" }), {
214 status: 502,
215 headers: { "content-type": "application/json" }
216 })
217 );
218 await expect(resolveRepoByName(makeCtx(fetchMock), owner, "infra")).rejects.toBeInstanceOf(
219 ClientResponseError
220 );
221 });
222});