Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

ai-doc-updater.test.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

ai-doc-updater.test.tsBlame648 lines · 1 contributor
d199847Claude1/**
2 * Tests for src/lib/ai-doc-updater.ts.
3 *
4 * Layout mirrors ai-patch-generator.test.ts:
5 *
6 * 1. Pure parser — `parseTrackedSections` extracts every region with
7 * the right `marker`/`claim`/`claimedFor` triple, ignores unclosed
8 * regions, and is stable across runs (deterministic marker hash).
9 * 2. Pure helpers — `sha256Hex`, `deriveSectionMarker`,
10 * `docUpdateBranchName`, `buildDocUpdatePrompt`,
11 * `renderDocUpdatePrBody`.
12 * 3. Drift detection — uses real bare repos on disk. With no DB present
13 * we still assert that `findTrackedDocs` returns the parsed shape
14 * and that "unseen" sections (no prior row) are NOT flagged stale.
15 * 4. End-to-end propose — injected fake Claude client + real bare repo;
16 * DB-backed steps (PR insert) gated on HAS_DB.
17 *
18 * The Anthropic client is faked via the public `client` option so we never
19 * touch the network or require an API key.
20 */
21
22import { describe, it, expect, beforeAll, afterAll } from "bun:test";
23import { join } from "path";
24import { rm, mkdir } from "fs/promises";
25import {
26 AI_DOC_UPDATE_LABEL,
27 AI_DOC_UPDATE_MARKER,
28 buildDocUpdatePrompt,
29 deriveSectionMarker,
30 docUpdateBranchName,
31 findTrackedDocs,
32 parseTrackedSections,
33 proposeDocUpdate,
34 renderDocUpdatePrBody,
35 sha256Hex,
36 __test,
37} from "../lib/ai-doc-updater";
38import {
39 createOrUpdateFileOnBranch,
40 initBareRepo,
41 refExists,
42 getBlob,
43} from "../git/repository";
44import { db } from "../db";
45import { eq } from "drizzle-orm";
46import {
47 docTracking,
48 pullRequests,
49 repositories,
50 users,
51} from "../db/schema";
52
53const HAS_DB = Boolean(process.env.DATABASE_URL);
54
55const TEST_REPOS = join(
56 import.meta.dir,
57 "../../.test-repos-ai-doc-" + Date.now()
58);
59
60beforeAll(async () => {
61 process.env.GIT_REPOS_PATH = TEST_REPOS;
62 process.env.DATABASE_URL = process.env.DATABASE_URL || "";
63 await rm(TEST_REPOS, { recursive: true, force: true });
64 await mkdir(TEST_REPOS, { recursive: true });
65});
66
67afterAll(async () => {
68 await rm(TEST_REPOS, { recursive: true, force: true });
69});
70
71// ---------------------------------------------------------------------------
72// Pure parser
73// ---------------------------------------------------------------------------
74
75describe("parseTrackedSections", () => {
76 it("returns [] for empty / non-string input", () => {
77 expect(parseTrackedSections("")).toEqual([]);
78 expect(parseTrackedSections(undefined as any)).toEqual([]);
79 expect(parseTrackedSections(null as any)).toEqual([]);
80 });
81
82 it("returns [] when there are no markers", () => {
83 expect(parseTrackedSections("# Hello\n\nJust prose.")).toEqual([]);
84 });
85
86 it("extracts a single region with the right path + claim", () => {
87 const md = [
88 "# Title",
89 "",
90 "<!-- gluecron:doc-track src=src/lib/auth.ts -->",
91 "This module exports `signIn` and `signUp`.",
92 "<!-- /gluecron:doc-track -->",
93 "",
94 "Trailing prose.",
95 ].join("\n");
96 const out = parseTrackedSections(md);
97 expect(out.length).toBe(1);
98 expect(out[0].claimedFor).toBe("src/lib/auth.ts");
99 expect(out[0].claim).toContain("signIn");
100 expect(out[0].claim).toContain("signUp");
101 expect(out[0].marker.length).toBe(16);
102 });
103
104 it("extracts multiple regions in document order", () => {
105 const md = [
106 "<!-- gluecron:doc-track src=a.ts -->",
107 "first",
108 "<!-- /gluecron:doc-track -->",
109 "middle",
110 "<!-- gluecron:doc-track src=b.ts -->",
111 "second",
112 "<!-- /gluecron:doc-track -->",
113 ].join("\n");
114 const out = parseTrackedSections(md);
115 expect(out.length).toBe(2);
116 expect(out[0].claimedFor).toBe("a.ts");
117 expect(out[0].claim).toBe("first");
118 expect(out[1].claimedFor).toBe("b.ts");
119 expect(out[1].claim).toBe("second");
120 });
121
122 it("ignores an unclosed region", () => {
123 const md = [
124 "<!-- gluecron:doc-track src=a.ts -->",
125 "no close",
126 "",
127 "<!-- gluecron:doc-track src=b.ts -->",
128 "closed",
129 "<!-- /gluecron:doc-track -->",
130 ].join("\n");
131 const out = parseTrackedSections(md);
132 // The first open swallows up to the next close — so we get a single
133 // region whose claim spans across the second open marker. That's
134 // still a deterministic outcome; just assert what we got.
135 expect(out.length).toBe(1);
136 expect(out[0].claimedFor).toBe("a.ts");
137 });
138
139 it("ignores a region with empty body", () => {
140 const md = [
141 "<!-- gluecron:doc-track src=a.ts -->",
142 "",
143 "<!-- /gluecron:doc-track -->",
144 ].join("\n");
145 expect(parseTrackedSections(md).length).toBe(0);
146 });
147
148 it("derives the same marker for the same (src, claim) pair", () => {
149 const a = deriveSectionMarker("src/a.ts", "hello");
150 const b = deriveSectionMarker("src/a.ts", "hello");
151 expect(a).toBe(b);
152 expect(a.length).toBe(16);
153 });
154
155 it("derives different markers when the claim differs", () => {
156 expect(deriveSectionMarker("src/a.ts", "hello")).not.toBe(
157 deriveSectionMarker("src/a.ts", "world")
158 );
159 });
160});
161
162describe("sha256Hex", () => {
163 it("is deterministic and 64-char hex", () => {
164 const a = sha256Hex("abc");
165 const b = sha256Hex("abc");
166 expect(a).toBe(b);
167 expect(/^[0-9a-f]{64}$/.test(a)).toBe(true);
168 });
169
170 it("differs on different input", () => {
171 expect(sha256Hex("abc")).not.toBe(sha256Hex("abd"));
172 });
173});
174
175describe("docUpdateBranchName", () => {
176 it("honours an override", () => {
177 expect(docUpdateBranchName("README.md", "custom/branch")).toBe(
178 "custom/branch"
179 );
180 });
181
182 it("uses ai-doc-update/<basename>-<ts> by default", () => {
183 const name = docUpdateBranchName("docs/api/REFERENCE.md");
184 expect(name.startsWith("ai-doc-update/reference-")).toBe(true);
185 const ts = name.split("-").pop()!;
186 expect(/^\d+$/.test(ts)).toBe(true);
187 });
188});
189
190describe("buildDocUpdatePrompt", () => {
191 it("embeds the doc path, doc body, and every stale section's source", () => {
192 const out = buildDocUpdatePrompt({
193 docPath: "README.md",
194 docRaw: "# Title\n<!-- gluecron:doc-track src=a.ts -->old<!-- /gluecron:doc-track -->\n",
195 staleSections: [
196 {
197 marker: "abc",
198 claim: "old",
199 claimedFor: "a.ts",
200 sourceContent: "export const FRESH = 1;",
201 },
202 ],
203 });
204 expect(out).toContain("README.md");
205 expect(out).toContain("a.ts");
206 expect(out).toContain("export const FRESH = 1;");
207 expect(out).toContain('"patches"');
208 expect(out).toContain('"new_content"');
209 expect(out).toContain("doc-track");
210 });
211});
212
213describe("renderDocUpdatePrBody", () => {
214 it("includes the marker, label tag, and section list", () => {
215 const body = renderDocUpdatePrBody({
216 docPath: "README.md",
217 explanation: "Renamed signIn() to login().",
218 updatedSections: [
219 {
220 marker: "abc1234567890def",
221 claim: "old",
222 claimedFor: "src/lib/auth.ts",
223 currentSrcHash: "deadbeefdeadbeefdeadbeef",
224 storedClaimedHash: "cafebabecafebabecafebabe",
225 stale: true,
226 },
227 ],
228 });
229 expect(body).toContain(AI_DOC_UPDATE_MARKER);
230 expect(body).toContain(AI_DOC_UPDATE_LABEL);
231 expect(body).toContain("src/lib/auth.ts");
232 expect(body).toContain("Renamed signIn() to login().");
233 expect(body).toContain("cafebabecafe");
234 expect(body).toContain("deadbeefdead");
235 });
236
237 it("falls back when no explanation is provided", () => {
238 const body = renderDocUpdatePrBody({
239 docPath: "README.md",
240 explanation: "",
241 updatedSections: [],
242 });
243 expect(body).toContain("(no explanation provided)");
244 expect(body).toContain("(none)");
245 });
246});
247
248// ---------------------------------------------------------------------------
249// findTrackedDocs — drift detection against a real bare repo. Skipped
250// without DB because the lib needs to resolve a repositories row.
251// ---------------------------------------------------------------------------
252
253describe.skipIf(!HAS_DB)("findTrackedDocs — drift detection", () => {
254 it.skipIf(!HAS_DB)(
255 "treats first-time observations as fresh, second-time differing hashes as stale",
256 async () => {
257 const username = `aidoc_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
258 const [u] = await db
259 .insert(users)
260 .values({
261 username,
262 email: `${username}@example.com`,
263 passwordHash: "x",
264 })
265 .returning({ id: users.id });
266 const repoName = `subject_${Date.now()}`;
267 const [r] = await db
268 .insert(repositories)
269 .values({
270 ownerId: u.id,
271 name: repoName,
272 diskPath: `/tmp/${username}/${repoName}`,
273 defaultBranch: "main",
274 })
275 .returning({ id: repositories.id });
276
277 await initBareRepo(username, repoName);
278
279 // Seed a README with one tracked region pointing at src/lib/auth.ts.
280 const readme = [
281 "# Project",
282 "",
283 "<!-- gluecron:doc-track src=src/lib/auth.ts -->",
284 "This module exports `signIn` and `signUp`.",
285 "<!-- /gluecron:doc-track -->",
286 ].join("\n");
287 const sourceV1 = "export function signIn() {}\nexport function signUp() {}\n";
288 const sourceV2 = "export function login() {}\nexport function register() {}\n";
289
290 let res = await createOrUpdateFileOnBranch({
291 owner: username,
292 name: repoName,
293 branch: "main",
294 filePath: "README.md",
295 bytes: new TextEncoder().encode(readme),
296 message: "seed readme",
297 authorName: "Seeder",
298 authorEmail: "s@e.com",
299 });
300 if ("error" in res) throw new Error("seed readme failed");
301
302 res = await createOrUpdateFileOnBranch({
303 owner: username,
304 name: repoName,
305 branch: "main",
306 filePath: "src/lib/auth.ts",
307 bytes: new TextEncoder().encode(sourceV1),
308 message: "seed source v1",
309 authorName: "Seeder",
310 authorEmail: "s@e.com",
311 });
312 if ("error" in res) throw new Error("seed source failed");
313
314 // First pass: no rows in doc_tracking → unseen → NOT stale.
315 const first = await findTrackedDocs(r.id);
316 expect(first.length).toBe(1);
317 expect(first[0].sections.length).toBe(1);
318 expect(first[0].sections[0].stale).toBe(false);
319 expect(first[0].sections[0].storedClaimedHash).toBeNull();
320 const seenHash = first[0].sections[0].currentSrcHash;
321 expect(seenHash).toBe(sha256Hex(sourceV1));
322
323 // Manually pin a baseline hash so the next compare has something to
324 // diff against — mimics what persistObservedSections does.
325 await db.insert(docTracking).values({
326 repositoryId: r.id,
327 docPath: first[0].path,
328 sectionMarker: first[0].sections[0].marker,
329 srcPath: first[0].sections[0].claimedFor,
330 claimedHash: seenHash,
331 });
332
333 // Push v2 of the source so its hash drifts.
334 res = await createOrUpdateFileOnBranch({
335 owner: username,
336 name: repoName,
337 branch: "main",
338 filePath: "src/lib/auth.ts",
339 bytes: new TextEncoder().encode(sourceV2),
340 message: "rename apis",
341 authorName: "Seeder",
342 authorEmail: "s@e.com",
343 });
344 if ("error" in res) throw new Error("update source failed");
345
346 const second = await findTrackedDocs(r.id);
347 expect(second.length).toBe(1);
348 expect(second[0].sections.length).toBe(1);
349 expect(second[0].sections[0].stale).toBe(true);
350 expect(second[0].sections[0].storedClaimedHash).toBe(seenHash);
351 expect(second[0].sections[0].currentSrcHash).toBe(sha256Hex(sourceV2));
352 },
353 20000
354 );
355});
356
357// ---------------------------------------------------------------------------
358// proposeDocUpdate — end-to-end with injected fake Claude
359// ---------------------------------------------------------------------------
360
361function fakeClient(responseText: string) {
362 return {
363 messages: {
364 create: async () => ({
365 content: [{ type: "text" as const, text: responseText }],
366 }),
367 },
368 } as any;
369}
370
371describe("proposeDocUpdate", () => {
372 it("returns null when no section is stale", async () => {
373 const out = await proposeDocUpdate({
374 repositoryId: "00000000-0000-0000-0000-000000000000",
375 path: "README.md",
376 sections: [
377 {
378 marker: "abc",
379 claim: "old",
380 claimedFor: "a.ts",
381 currentSrcHash: "h",
382 storedClaimedHash: "h",
383 stale: false,
384 },
385 ],
386 client: fakeClient("{}"),
387 });
388 expect(out).toBeNull();
389 });
390
391 it("returns null when Claude returns zero patches", async () => {
392 // Without DB the resolver returns null first → still null. The
393 // assertion holds in either case.
394 if (!HAS_DB) {
395 const out = await proposeDocUpdate({
396 repositoryId: "00000000-0000-0000-0000-000000000000",
397 path: "README.md",
398 sections: [
399 {
400 marker: "abc",
401 claim: "old",
402 claimedFor: "a.ts",
403 currentSrcHash: "h1",
404 storedClaimedHash: "h0",
405 stale: true,
406 },
407 ],
408 client: fakeClient('{"explanation":"all good","patches":[]}'),
409 });
410 expect(out).toBeNull();
411 return;
412 }
413
414 // HAS_DB path: insert a real repo + readme + source so we get past
415 // the resolver and into Claude. The empty patches array still
416 // short-circuits to null.
417 const username = `aidoc_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
418 const [u] = await db
419 .insert(users)
420 .values({
421 username,
422 email: `${username}@example.com`,
423 passwordHash: "x",
424 })
425 .returning({ id: users.id });
426 const repoName = `empty_${Date.now()}`;
427 const [r] = await db
428 .insert(repositories)
429 .values({
430 ownerId: u.id,
431 name: repoName,
432 diskPath: `/tmp/${username}/${repoName}`,
433 defaultBranch: "main",
434 })
435 .returning({ id: repositories.id });
436
437 await initBareRepo(username, repoName);
438 const seeded1 = await createOrUpdateFileOnBranch({
439 owner: username,
440 name: repoName,
441 branch: "main",
442 filePath: "README.md",
443 bytes: new TextEncoder().encode("# r\n<!-- gluecron:doc-track src=a.ts -->old<!-- /gluecron:doc-track -->\n"),
444 message: "seed",
445 authorName: "Seeder",
446 authorEmail: "s@e.com",
447 });
448 if ("error" in seeded1) throw new Error("seed readme failed");
449 const seeded2 = await createOrUpdateFileOnBranch({
450 owner: username,
451 name: repoName,
452 branch: "main",
453 filePath: "a.ts",
454 bytes: new TextEncoder().encode("export const X = 1;\n"),
455 message: "seed source",
456 authorName: "Seeder",
457 authorEmail: "s@e.com",
458 });
459 if ("error" in seeded2) throw new Error("seed source failed");
460
461 const out = await proposeDocUpdate({
462 repositoryId: r.id,
463 path: "README.md",
464 sections: [
465 {
466 marker: "abc",
467 claim: "old",
468 claimedFor: "a.ts",
469 currentSrcHash: "h1",
470 storedClaimedHash: "h0",
471 stale: true,
472 },
473 ],
474 client: fakeClient('{"explanation":"all good","patches":[]}'),
475 });
476 expect(out).toBeNull();
477
478 const prs = await db
479 .select({ number: pullRequests.number })
480 .from(pullRequests)
481 .where(eq(pullRequests.repositoryId, r.id));
482 expect(prs.length).toBe(0);
483 });
484
485 it.skipIf(!HAS_DB)(
486 "opens a PR with the refreshed markdown when Claude returns a patch",
487 async () => {
488 const username = `aidoc_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
489 const [u] = await db
490 .insert(users)
491 .values({
492 username,
493 email: `${username}@example.com`,
494 passwordHash: "x",
495 })
496 .returning({ id: users.id });
497 const repoName = `subject_${Date.now()}`;
498 const [r] = await db
499 .insert(repositories)
500 .values({
501 ownerId: u.id,
502 name: repoName,
503 diskPath: `/tmp/${username}/${repoName}`,
504 defaultBranch: "main",
505 })
506 .returning({ id: repositories.id });
507
508 await initBareRepo(username, repoName);
509 const readme = [
510 "# Project",
511 "",
512 "<!-- gluecron:doc-track src=src/lib/auth.ts -->",
513 "This module exports `signIn` and `signUp`.",
514 "<!-- /gluecron:doc-track -->",
515 ].join("\n");
516 const refreshed = [
517 "# Project",
518 "",
519 "<!-- gluecron:doc-track src=src/lib/auth.ts -->",
520 "This module exports `login` and `register`.",
521 "<!-- /gluecron:doc-track -->",
522 ].join("\n");
523
524 const s1 = await createOrUpdateFileOnBranch({
525 owner: username,
526 name: repoName,
527 branch: "main",
528 filePath: "README.md",
529 bytes: new TextEncoder().encode(readme),
530 message: "seed readme",
531 authorName: "Seeder",
532 authorEmail: "s@e.com",
533 });
534 if ("error" in s1) throw new Error("seed readme failed");
535 const s2 = await createOrUpdateFileOnBranch({
536 owner: username,
537 name: repoName,
538 branch: "main",
539 filePath: "src/lib/auth.ts",
540 bytes: new TextEncoder().encode(
541 "export function login() {}\nexport function register() {}\n"
542 ),
543 message: "seed source",
544 authorName: "Seeder",
545 authorEmail: "s@e.com",
546 });
547 if ("error" in s2) throw new Error("seed source failed");
548
549 const canned = JSON.stringify({
550 explanation: "Renamed signIn/signUp to login/register to match source.",
551 patches: [{ path: "README.md", new_content: refreshed }],
552 });
553 const branchOverride = `ai-doc-update/test-${Date.now()}`;
554 const out = await proposeDocUpdate({
555 repositoryId: r.id,
556 path: "README.md",
557 sections: [
558 {
559 marker: "marker-test",
560 claim: "This module exports `signIn` and `signUp`.",
561 claimedFor: "src/lib/auth.ts",
562 currentSrcHash: sha256Hex(
563 "export function login() {}\nexport function register() {}\n"
564 ),
565 storedClaimedHash: sha256Hex(
566 "export function signIn() {}\nexport function signUp() {}\n"
567 ),
568 stale: true,
569 },
570 ],
571 client: fakeClient(canned),
572 branchOverride,
573 });
574 expect(out).not.toBeNull();
575 expect(out!.branch).toBe(branchOverride);
576 expect(typeof out!.prNumber).toBe("number");
577 expect(out!.updatedSections).toBe(1);
578
579 // Branch exists in the bare repo.
580 expect(
581 await refExists(username, repoName, `refs/heads/${branchOverride}`)
582 ).toBe(true);
583
584 // README on the branch contains the refreshed prose.
585 const blob = await getBlob(
586 username,
587 repoName,
588 branchOverride,
589 "README.md"
590 );
591 expect(blob).not.toBeNull();
592 expect(blob!.content).toContain("login");
593 expect(blob!.content).toContain("register");
594 expect(blob!.content).not.toContain("signIn");
595
596 // PR row exists with the right base/head.
597 const [pr] = await db
598 .select({
599 number: pullRequests.number,
600 headBranch: pullRequests.headBranch,
601 baseBranch: pullRequests.baseBranch,
602 body: pullRequests.body,
603 })
604 .from(pullRequests)
605 .where(eq(pullRequests.repositoryId, r.id))
606 .limit(1);
607 expect(pr).toBeTruthy();
608 expect(pr!.headBranch).toBe(branchOverride);
609 expect(pr!.baseBranch).toBe("main");
610 expect(pr!.body).toContain(AI_DOC_UPDATE_MARKER);
611 expect(pr!.body).toContain(AI_DOC_UPDATE_LABEL);
612
613 // doc_tracking row was upserted with the new hash + PR id.
614 const tracked = await db
615 .select({
616 claimedHash: docTracking.claimedHash,
617 lastPrId: docTracking.lastPrId,
618 })
619 .from(docTracking)
620 .where(eq(docTracking.repositoryId, r.id));
621 expect(tracked.length).toBeGreaterThan(0);
622 expect(tracked[0].lastPrId).toBeTruthy();
623 },
624 25000
625 );
626});
627
628// ---------------------------------------------------------------------------
629// Internal helpers — sanity checks against bogus inputs.
630// ---------------------------------------------------------------------------
631
632describe("__test internals", () => {
633 it("exports the documented helpers", () => {
634 expect(typeof __test.resolveRepoMeta).toBe("function");
635 expect(typeof __test.listMarkdownFiles).toBe("function");
636 expect(typeof __test.ensureDocUpdateLabel).toBe("function");
637 expect(typeof __test.askClaudeForDocPatch).toBe("function");
638 expect(typeof __test.seedBranchFromDefault).toBe("function");
639 });
640
641 it("askClaudeForDocPatch tolerates an invalid JSON envelope", async () => {
642 const out = await __test.askClaudeForDocPatch(
643 fakeClient("not json at all"),
644 "prompt"
645 );
646 expect(out).toBeNull();
647 });
648});