Fork Horse repository, listed by its owner. Publisher snapshot.
packages/forkhorse-dashboard/test/review.test.ts
Snapshot at 34e3175
1import { installDom } from "./dom.ts";
2import { createRequire } from "node:module";
3import assert from "node:assert/strict";
4import { test } from "node:test";
5import { mountDashboard } from "../src/dashboard.ts";
6import { createHttpTransport } from "../src/transport.ts";
7import { reviewGate, stateView } from "../src/model.ts";
8import { reviewFixtureState, reviewFixtureDetail, reviewFixtureDiff } from "./review-fixtures.ts";
9const { JSDOM } = createRequire(import.meta.url)("jsdom");
10const origin = "https://fixture.idiot.tax";
11async function until(fn: () => unknown) {
12 for (let i = 0; i < 250; i++) {
13 if (fn()) return;
14 await new Promise((r) => setTimeout(r, 5));
15 }
16 assert.fail("Review DOM did not settle");
17}
18function setup(
19 scenario = "review-current",
20 owner = true,
21 route = "#review/review-1",
22 overrides: {
23 detail?: () => Promise<Response>;
24 diff?: () => Promise<Response>;
25 returning?: () => Promise<Response>;
26 noDetail?: boolean;
27 noDiff?: boolean;
28 } = {},
29) {
30 const dom = new JSDOM('<div id="dashboard"></div>', { url: `${origin}/${route}` });
31 installDom(dom.window);
32 const root = dom.window.document.getElementById("dashboard")! as HTMLElement;
33 const wire = reviewFixtureState(scenario);
34 const calls: { url: URL; init?: RequestInit }[] = [];
35 const transport = createHttpTransport({
36 origin,
37 fetch: async (input, init) => {
38 const url = new URL(String(input));
39 calls.push({ url, init });
40 if (url.pathname.endsWith("/state")) return Response.json(wire);
41 if (url.pathname.endsWith("/activity")) return Response.json({ activity: [], nextAfter: 0 });
42 if (url.pathname.endsWith("/diff"))
43 return overrides.diff
44 ? overrides.diff()
45 : url.searchParams.get("path") === "missing.txt"
46 ? Response.json({ error: "missing" }, { status: 404 })
47 : Response.json(reviewFixtureDiff(url.searchParams.get("path")!));
48 if (url.pathname.endsWith("/return")) {
49 if (overrides.returning) return overrides.returning();
50 wire.reviews[0].returned = {
51 reason: JSON.parse(String(init!.body)).reason,
52 at: "2026-10-13T08:00:00Z",
53 };
54 return Response.json({ review: wire.reviews[0] });
55 }
56 if (url.pathname.endsWith("/approve")) {
57 wire.reviews[0].approval = {
58 sha: wire.reviews[0].sha,
59 receiptDigest: wire.reviews[0].receiptDigest,
60 };
61 return Response.json({ review: wire.reviews[0] });
62 }
63 if (url.pathname.endsWith("/receipt"))
64 return Response.json({ error: "fixture logs" }, { status: 404 });
65 return overrides.detail
66 ? overrides.detail()
67 : scenario === "review-not-found"
68 ? Response.json({ error: "missing" }, { status: 404 })
69 : Response.json(reviewFixtureDetail(wire));
70 },
71 });
72 if (overrides.noDetail) delete transport.getReview;
73 if (overrides.noDiff) delete transport.getDiff;
74 const app = mountDashboard(root, {
75 brand: { name: "Fixture product", tagline: "Fixture" },
76 fixture: true,
77 owner,
78 agent: "fixture-agent",
79 initialProject: scenario,
80 transport,
81 });
82 return {
83 dom,
84 root,
85 wire,
86 calls,
87 app,
88 transport,
89 close() {
90 app.dispose();
91 dom.window.close();
92 },
93 };
94}
95function button(c: ReturnType<typeof setup>, text: string, dialog = false): HTMLButtonElement {
96 return [...(dialog ? c.dom.window.document : c.root).querySelectorAll("button")].find(
97 (b: HTMLButtonElement) => b.textContent === text,
98 )!;
99}
100function fill(c: ReturnType<typeof setup>, value: string) {
101 const element = c.dom.window.document.querySelector("textarea") as HTMLTextAreaElement;
102 Object.getOwnPropertyDescriptor(c.dom.window.HTMLTextAreaElement.prototype, "value")!.set!.call(
103 element,
104 value,
105 );
106 element.dispatchEvent(new c.dom.window.Event("input", { bubbles: true }));
107}
108
109test("hosts without detail capability keep non-linking queue rows and resolve bookmarked or typed detail hashes to the queue", async () => {
110 for (const route of ["#review", "#review/review-1"]) {
111 const c = setup("review-current", true, route, { noDetail: true });
112 try {
113 await until(() => c.root.querySelector(".review-card"));
114 assert.equal(c.dom.window.location.hash, "#review");
115 assert.equal(!!c.root.querySelector(".review-detail"), false);
116 assert.equal(!!c.root.querySelector(".review-card .claim-link"), false);
117 assert.equal(!!c.root.querySelector('.review-card a[href*="#review/"]'), false);
118 const row = c.root.querySelector<HTMLElement>(".review-card")!;
119 row.click();
120 row.querySelector<HTMLElement>("td span")!.click();
121 assert.equal(c.dom.window.location.hash, "#review");
122 c.dom.window.location.hash = "#review/review-1";
123 await until(() => c.dom.window.location.hash === "#review");
124 assert.equal(!!c.root.querySelector("#reviews"), true);
125 assert.equal(
126 c.calls.some((call) => call.url.pathname.endsWith("/reviews/review-1")),
127 false,
128 );
129 } finally {
130 c.close();
131 }
132 }
133});
134
135test("missing diff capability uses product copy and missing receipt/ledger evidence stays plain", async () => {
136 const c = setup("review-promoted", true, "#review/review-1", {
137 noDiff: true,
138 detail: async () => {
139 const detail = reviewFixtureDetail(c.wire);
140 detail.review.receipt = null;
141 detail.promotion = null;
142 return Response.json(detail);
143 },
144 });
145 try {
146 await until(() => c.root.querySelector(".review-file-toggle"));
147 assert.match(c.root.textContent!, /No receipt/);
148 assert.equal(
149 c.root.querySelectorAll(".review-milestone")[0].textContent!.match(/No receipt/g)?.length,
150 1,
151 );
152 assert.match(c.root.textContent!, /No ledger entry recorded\./);
153 c.root.querySelector<HTMLButtonElement>(".review-file-toggle")!.click();
154 await until(() => c.root.textContent!.includes("Couldn't load this diff."));
155 assert.doesNotMatch(
156 c.root.querySelector(".review-detail")!.textContent!,
157 /host adapter|unavailable/,
158 );
159 assert.equal(
160 c.calls.some((call) => call.url.pathname.endsWith("/diff")),
161 false,
162 );
163 } finally {
164 c.close();
165 }
166});
167
168test("detail route replaces queue, retains activity, three independent evidence cells and request list", async () => {
169 const c = setup();
170 try {
171 await until(() => c.root.querySelector(".review-milestones"));
172 assert.equal(c.root.querySelector("#reviews"), null);
173 assert.equal(c.root.querySelectorAll(".review-milestone").length, 3);
174 assert.match(c.root.textContent!, /Checks passed.*Not approved yet.*Not promoted yet/s);
175 assert.match(
176 c.root.querySelectorAll(".review-milestone")[2].textContent!,
177 /No promotion recorded\./,
178 );
179 assert.ok(c.root.querySelector(".review-acceptance li"));
180 assert.match(
181 c.root.querySelector(".plain-text")!.textContent!,
182 /request and inspect each claim\.\nKeep/,
183 );
184 assert.ok(c.root.querySelector("#activity-panel"));
185 assert.ok(button(c, "Approve"));
186 assert.ok(button(c, "Return to agent"));
187 assert.equal(c.root.querySelector('[data-variant="primary"]'), null);
188 button(c, "← Review queue").click();
189 await until(() => c.root.querySelector("#reviews"));
190 assert.equal(c.dom.window.location.hash, "#review");
191 c.root.querySelector<HTMLButtonElement>(".review-card .claim-link")!.click();
192 await until(() => c.root.querySelector(".review-milestones"));
193 assert.equal(c.dom.window.location.hash, "#review/review-1");
194 c.dom.window.history.back();
195 await until(() => c.root.querySelector("#reviews"));
196 } finally {
197 c.close();
198 }
199});
200
201test("return required labelled reason, counter, escaped multiline owner and agent view, never approve returned", async () => {
202 const c = setup();
203 try {
204 await until(() => button(c, "Return to agent"));
205 button(c, "Return to agent").click();
206 await until(() => c.dom.window.document.querySelector("textarea"));
207 const confirm = c.dom.window.document.querySelector('[data-action="confirm-return"]')!;
208 assert.equal(confirm.disabled, true);
209 assert.equal(
210 c.dom.window.document.querySelector("label[for=return-reason]")!.textContent,
211 "What needs to change?",
212 );
213 assert.equal(
214 c.dom.window.document.querySelector("textarea")!.hasAttribute("placeholder"),
215 false,
216 );
217 await until(() => c.dom.window.document.activeElement?.tagName === "TEXTAREA");
218 fill(c, "Fix <script>plain</script>\nSecond line");
219 await until(() => !confirm.disabled);
220 assert.equal(
221 c.dom.window.document.getElementById("return-counter")!.textContent,
222 `${"Fix <script>plain</script>\nSecond line".length} / 2000`,
223 );
224 confirm.click();
225 confirm.click();
226 await until(() => c.root.querySelector(".returned-reason"));
227 assert.equal(c.calls.filter((r) => r.url.pathname.endsWith("/return")).length, 1);
228 assert.equal(c.root.querySelector(".returned-reason script"), null);
229 assert.equal(
230 c.root.querySelector(".returned-reason .plain-text")!.textContent,
231 "Fix <script>plain</script>\nSecond line",
232 );
233 assert.equal(c.root.querySelector('[data-action="approve"]'), null);
234 assert.equal(c.root.querySelector('[data-action="promote"]'), null);
235 assert.equal(c.root.querySelector('[data-action="return"]'), null);
236 button(c, "← Review queue").click();
237 await until(() => c.root.querySelector(".returned-preview"));
238 assert.match(c.root.querySelector(".review-card")!.textContent!, /Returned/);
239 assert.equal(
240 c.root.querySelector(".returned-preview")!.textContent,
241 "Fix <script>plain</script>",
242 );
243 const state = stateView(c.wire);
244 assert.equal(reviewGate(state.reviews[0], state).approve, false);
245 } finally {
246 c.close();
247 }
248 const agent = setup("review-returned", false);
249 try {
250 await until(() => agent.root.querySelector(".returned-reason"));
251 assert.equal(agent.root.querySelector('[data-action="return"]'), null);
252 assert.equal(agent.root.querySelector('[data-action="approve"]'), null);
253 assert.match(
254 agent.root.querySelector(".returned-reason")!.textContent!,
255 /<script>plain text<\/script>/,
256 );
257 button(agent, "Open receipt").click();
258 await until(() => agent.root.querySelector(".receipt-details"));
259 assert.doesNotMatch(agent.root.querySelector(".receipt-details")!.textContent!, /View logs/);
260 assert.equal(
261 agent.calls.some((r) => r.url.pathname.endsWith("/receipt")),
262 false,
263 );
264 } finally {
265 agent.close();
266 }
267});
268
269test("return 409 focuses Close with a read-only reason and refetches authoritative promotion or return; uncertain writes are never retried", async () => {
270 for (const race of ["promoted", "returned", "network"] as const) {
271 const c = setup("review-current", true, "#review/review-1", {
272 returning: async () => {
273 if (race === "network") throw new Error("connection closed");
274 if (race === "promoted") c.wire.reviews[0].promoted = true;
275 else
276 c.wire.reviews[0].returned = {
277 reason: "Other tab returned this review",
278 at: "2026-10-13T08:00:00Z",
279 };
280 return Response.json({ error: "Review promoted." }, { status: 409 });
281 },
282 });
283 try {
284 await until(() => button(c, "Return to agent"));
285 button(c, "Return to agent").click();
286 await until(() => c.dom.window.document.querySelector("textarea"));
287 fill(c, "Fix");
288 await until(
289 () => !c.dom.window.document.querySelector('[data-action="confirm-return"]')!.disabled,
290 );
291 c.dom.window.document.querySelector('[data-action="confirm-return"]')!.click();
292 await until(() => c.dom.window.document.querySelector(".return-dialog [role=alert]"));
293 assert.equal(c.dom.window.document.querySelectorAll('[role="alert"]').length, 1);
294 assert.ok(c.dom.window.document.querySelector(".return-dialog [role=alert] svg"));
295 assert.equal(c.calls.filter((r) => r.url.pathname.endsWith("/return")).length, 1);
296 assert.match(
297 c.dom.window.document.querySelector(".return-dialog [role=alert]")!.textContent!,
298 /This review was promoted, so it can.t be returned\.|Couldn.t return this review/,
299 );
300 assert.doesNotMatch(c.root.textContent!, /Review promoted\./);
301 if (race !== "network") {
302 await until(() => c.dom.window.document.activeElement?.textContent === "Close");
303 assert.equal(
304 !!c.dom.window.document.querySelector('[data-action="confirm-return"]'),
305 false,
306 );
307 const reason = c.dom.window.document.querySelector("textarea");
308 assert.equal(reason.value, "Fix");
309 assert.equal(reason.readOnly, true);
310 await until(() =>
311 c.root.textContent!.includes(
312 race === "promoted" ? "Promoted to main" : "Other tab returned this review",
313 ),
314 );
315 assert.equal(c.calls.filter((r) => r.url.pathname.endsWith("/state")).length >= 2, true);
316 assert.equal(
317 c.calls.filter((r) => r.url.pathname.endsWith("/reviews/review-1")).length >= 2,
318 true,
319 );
320 }
321 } finally {
322 c.close();
323 }
324 }
325});
326
327test("diffs are lazy, several expand, text escaped, binary and truncated and 404 explicit", async () => {
328 const c = setup("review-changes");
329 try {
330 await until(() => c.root.querySelector(".review-file-toggle"));
331 assert.equal(
332 c.calls.some((r) => r.url.pathname.endsWith("/diff")),
333 false,
334 );
335 assert.equal(c.root.querySelectorAll(".review-file-toggle").length, 20);
336 assert.ok(button(c, "Show 180 more files"));
337 assert.doesNotMatch(c.root.textContent!, /Showing 200 of 400 files\./);
338 const paths = reviewFixtureDetail(c.wire).changes.files.map((f) => f.path);
339 assert.deepEqual(
340 paths,
341 paths.toSorted((a, b) => a.localeCompare(b)),
342 );
343 const rows = c.root.querySelectorAll<HTMLButtonElement>(".review-file-toggle");
344 for (const i of [0, 1, 2, 3]) rows[i].click();
345 await until(
346 () =>
347 c.root.querySelectorAll(".review-diff").length === 4 &&
348 c.root.textContent!.includes("Binary file, not shown.") &&
349 c.root.textContent!.includes("This file isn't part"),
350 );
351 assert.equal(rows[0].getAttribute("aria-expanded"), "true");
352 assert.match(
353 c.root.querySelector(".diff-added")!.textContent!,
354 /<script>plain text only<\/script>/,
355 );
356 assert.equal(c.root.querySelector(".review-diff script"), null);
357 assert.match(c.root.textContent!, /Patch truncated at 64 KB\./);
358 assert.equal(c.calls.filter((r) => r.url.pathname.endsWith("/diff")).length, 4);
359 button(c, "Show 180 more files").click();
360 await until(() => c.root.querySelectorAll(".review-file-toggle").length === 200);
361 assert.equal(
362 c.dom.window.document.activeElement === c.root.querySelectorAll(".review-file-toggle")[20],
363 true,
364 );
365 assert.equal(c.root.querySelectorAll('.review-file-toggle[aria-expanded="true"]').length, 4);
366 assert.equal(c.calls.filter((r) => r.url.pathname.endsWith("/diff")).length, 4);
367 assert.match(c.root.textContent!, /Showing 200 of 400 files\./);
368 rows[0].click();
369 rows[0].click();
370 await new Promise((r) => setTimeout(r, 20));
371 assert.equal(c.calls.filter((r) => r.url.pathname.endsWith("/diff")).length, 4);
372 assert.match(c.root.textContent!, /Packet FH-040.*Agent codex.*No Fixture product trailers/s);
373 } finally {
374 c.close();
375 }
376});
377
378test("approved/promoted/stale/advisory milestones and owner gates reflect independent evidence", async () => {
379 for (const id of ["review-approved", "review-promoted", "review-stale", "review-advisory"]) {
380 const c = setup(id);
381 try {
382 await until(() => c.root.querySelector(".review-milestones"));
383 if (id === "review-approved") assert.ok(button(c, "Promote"));
384 if (id === "review-promoted") {
385 assert.match(c.root.textContent!, /Promoted to main/);
386 assert.equal(c.root.querySelector('[data-action="return"]'), null);
387 button(c, "Promoted to main. Inspect the ledger entry.");
388 c.root
389 .querySelector<HTMLButtonElement>(
390 '[aria-label="Promoted to main. Inspect the ledger entry."]',
391 )!
392 .click();
393 await until(() => c.dom.window.document.body.textContent.includes("Promotion ledger"));
394 assert.equal(
395 c.dom.window.document.querySelectorAll('[aria-label="Copy to clipboard"]').length >= 3,
396 true,
397 );
398 }
399 if (id === "review-stale") {
400 assert.match(c.root.textContent!, /The fork moved after this review/);
401 assert.equal(c.root.querySelector('[data-action="return"]'), null);
402 assert.equal(c.root.querySelector('[data-action="approve"]'), null);
403 }
404 if (id === "review-advisory") {
405 assert.ok(button(c, "Approve"));
406 assert.match(c.root.textContent!, /Checks failed.*Advisory: this didn't block approval/s);
407 }
408 } finally {
409 c.close();
410 }
411 }
412});
413
414test("compact commit copies the full SHA, approval focuses its row, and file names separate status/path", async () => {
415 const c = setup("review-approved");
416 const prior = Object.getOwnPropertyDescriptor(navigator, "clipboard");
417 let copied = "";
418 Object.defineProperty(navigator, "clipboard", {
419 configurable: true,
420 value: {
421 writeText: async (value: string) => {
422 copied = value;
423 },
424 },
425 });
426 try {
427 await until(() => c.root.querySelector(".review-commit-caption"));
428 assert.equal(!!c.root.querySelector("#approved-commit"), false);
429 assert.equal(!!c.root.querySelector('.review-commit [aria-label="Copy to clipboard"]'), false);
430 const sha = c.wire.reviews[0].sha;
431 c.root
432 .querySelector<HTMLButtonElement>(`[aria-label="Copy commit ${sha.slice(0, 7)}"]`)!
433 .click();
434 await until(() => copied === sha);
435 c.root
436 .querySelector<HTMLButtonElement>('[aria-label="Approved. Inspect the approved commit."]')!
437 .click();
438 assert.equal(c.dom.window.document.activeElement.id, `review-commit-${sha}`);
439 const row = [...c.root.querySelectorAll(".review-file-toggle")].find((candidate) =>
440 candidate.textContent!.includes("src/review.tsx"),
441 )!;
442 assert.equal(row.textContent!.trim(), "Modified, src/review.tsx");
443 assert.equal(row.getAttribute("aria-label"), "Modified, src/review.tsx");
444 } finally {
445 if (prior) Object.defineProperty(navigator, "clipboard", prior);
446 else Reflect.deleteProperty(navigator, "clipboard");
447 c.close();
448 }
449});
450
451test("Request preserves full exact glob paths and folds only after twenty", async () => {
452 const c = setup("review-current", true, "#review/review-1", {
453 detail: async () => {
454 const detail = reviewFixtureDetail(c.wire);
455 detail.packet.paths = Array.from(
456 { length: 25 },
457 (_, i) => `packages/very/long/claimed/path-${i}/**`,
458 );
459 return Response.json(detail);
460 },
461 });
462 try {
463 await until(() => c.root.querySelector(".review-claimed-paths"));
464 assert.equal(c.root.querySelectorAll(".review-claimed-paths li").length, 20);
465 assert.equal(
466 c.root.querySelector(".review-claimed-paths code")!.textContent,
467 "packages/very/long/claimed/path-0/**",
468 );
469 button(c, "Show all 25 paths").click();
470 await until(() => c.root.querySelectorAll(".review-claimed-paths li").length === 25);
471 } finally {
472 c.close();
473 }
474});
475
476test("detail loading/error/retry/not-found and late response after queue navigation", async () => {
477 let finish!: (response: Response) => void;
478 const c = setup("review-current", true, "#review/review-1", {
479 detail: () =>
480 new Promise((resolve) => {
481 finish = resolve;
482 }),
483 });
484 try {
485 await until(() => finish);
486 assert.match(c.root.textContent!, /Loading review…/);
487 button(c, "← Review queue").click();
488 finish(Response.json(reviewFixtureDetail(c.wire)));
489 await new Promise((r) => setTimeout(r, 20));
490 assert.equal(c.root.querySelector(".review-milestones"), null);
491 } finally {
492 c.close();
493 }
494 let failed = true;
495 const e = setup("review-current", true, "#review/review-1", {
496 detail: async () =>
497 failed
498 ? Response.json({ error: "bad" }, { status: 503 })
499 : Response.json(reviewFixtureDetail(e.wire)),
500 });
501 try {
502 await until(() => button(e, "Try again"));
503 assert.match(e.root.textContent!, /Couldn't load this review\./);
504 failed = false;
505 button(e, "Try again").click();
506 await until(() => e.root.querySelector(".review-milestones"));
507 } finally {
508 e.close();
509 }
510 const n = setup("review-not-found");
511 try {
512 await until(() => n.root.textContent!.includes("This review doesn't exist anymore."));
513 assert.ok(button(n, "← Review queue"));
514 assert.equal(button(n, "Try again"), undefined, "a missing review has no retry button");
515 } finally {
516 n.close();
517 }
518});
519
520test("FH-039 transport encodes paths, uses same-origin session and never retries owner writes", async () => {
521 const calls: { url: string; init?: RequestInit }[] = [];
522 const transport = createHttpTransport({
523 origin,
524 fetch: async (url, init) => {
525 calls.push({ url: String(url), init });
526 return Response.json({ error: "denied" }, { status: 403 });
527 },
528 });
529 await assert.rejects(transport.getReview!("a/b", "r/id"));
530 await assert.rejects(transport.getDiff!("a/b", "r/id", "a?b&c #.ts"));
531 await assert.rejects(transport.returnReview!("a/b", "r/id", "Fix\nthis"));
532 assert.equal(calls.length, 3);
533 assert.equal(calls[0].url, `${origin}/projects/a%2Fb/reviews/r%2Fid`);
534 assert.equal(new URL(calls[1].url).searchParams.get("path"), "a?b&c #.ts");
535 assert.equal(calls[2].init!.body, '{"reason":"Fix\\nthis"}');
536 for (const { init } of calls) {
537 assert.equal(init!.credentials, "same-origin");
538 assert.equal(init!.redirect, "error");
539 assert.equal(init!.cache, "no-store");
540 assert.equal((init!.headers as Record<string, string>)?.Authorization, undefined);
541 }
542});
543
544test("Return reason accepts exactly 2000 characters, rejects oversized input and Escape cancels without a write", async () => {
545 const c = setup();
546 try {
547 await until(() => button(c, "Return to agent"));
548 button(c, "Return to agent").click();
549 await until(() => c.dom.window.document.querySelector("textarea"));
550 fill(c, "x".repeat(2001));
551 await until(() =>
552 c.dom.window.document.getElementById("return-counter")!.textContent!.startsWith("2001"),
553 );
554 assert.equal(
555 c.dom.window.document.querySelector('[data-action="confirm-return"]')!.disabled,
556 true,
557 );
558 fill(c, "x".repeat(2000));
559 await until(
560 () => !c.dom.window.document.querySelector('[data-action="confirm-return"]')!.disabled,
561 );
562 const textarea = c.dom.window.document.querySelector("textarea")!;
563 textarea.dispatchEvent(
564 new c.dom.window.KeyboardEvent("keydown", { key: "Escape", bubbles: true }),
565 );
566 await until(() => !c.dom.window.document.querySelector(".return-dialog"));
567 assert.equal(
568 c.calls.some((r) => r.url.pathname.endsWith("/return")),
569 false,
570 );
571 } finally {
572 c.close();
573 }
574});
575
576test("detail retains queue SHA/digest gates and rejects a contract for a different review or unbounded patch", async () => {
577 const c = setup("review-approved");
578 try {
579 c.wire.reviews[0].approval!.sha = "9".repeat(40);
580 await until(() => c.root.querySelector(".review-milestones"));
581 assert.equal(c.root.querySelector('[data-action="promote"]'), null);
582 assert.equal(c.root.querySelector('[data-action="return"]'), null);
583 } finally {
584 c.close();
585 }
586 const mismatch = setup("review-current", true, "#review/review-1", {
587 detail: async () => {
588 const value = reviewFixtureDetail(reviewFixtureState("review-current"));
589 value.review.id = "other-review";
590 return Response.json(value);
591 },
592 });
593 try {
594 await until(() => mismatch.root.textContent!.includes("Couldn't load this review."));
595 assert.equal(mismatch.root.querySelector('[data-action="approve"]'), null);
596 } finally {
597 mismatch.close();
598 }
599 const huge = setup("review-current", true, "#review/review-1", {
600 diff: async () =>
601 Response.json({ ...reviewFixtureDiff("src/review.tsx"), patch: "x".repeat(65537) }),
602 });
603 try {
604 await until(() => huge.root.querySelector(".review-file-toggle"));
605 [...huge.root.querySelectorAll<HTMLButtonElement>(".review-file-toggle")]
606 .find((b) => b.textContent!.includes("src/review.tsx"))!
607 .click();
608 await until(() => huge.root.textContent!.includes("Couldn't load this diff."));
609 assert.equal(huge.root.querySelector(".review-diff pre"), null);
610 } finally {
611 huge.close();
612 }
613});
614
615test("detail blockers hide owner decisions even when the earlier snapshot had no blocker", async () => {
616 const c = setup("review-current", true, "#review/review-1", {
617 detail: async () => {
618 const value = reviewFixtureDetail(reviewFixtureState("review-current"));
619 value.blockers = [
620 {
621 id: "detail-blocker",
622 packet: "FH-040",
623 claimingAgent: "fixture-agent",
624 pushingAgent: "fixture-peer",
625 repoName: value.review.repoName,
626 pushingPacket: value.review.packet,
627 sha: "7".repeat(40),
628 paths: ["src/review.tsx"],
629 },
630 ];
631 return Response.json(value);
632 },
633 });
634 try {
635 await until(() => c.root.querySelector(".review-milestones"));
636 assert.match(c.root.textContent!, /fixture-peer pushed 7777777/);
637 assert.equal(c.root.querySelector('[data-action="approve"]'), null);
638 assert.ok(c.root.querySelector('[data-action="return"]'));
639 const titles = [...c.root.querySelectorAll(".review-detail h2")].map((h) => h.textContent);
640 assert.ok(titles.indexOf("Collisions") < titles.indexOf("Request"));
641 } finally {
642 c.close();
643 }
644});
645
646// Synthetic FH-045 state and DOM coverage, including the stale:false shot-7 loser.
647test("stale causes match in detail and queue before a click, with no owner actions", async () => {
648 const cases = [
649 ["review-stale", "Fork moved", "The fork moved after this review was requested."],
650 ["review-main-moved", "Main moved", "main moved to 7777777 after this review was requested."],
651 ["review-main-moved-stale", "Main moved", "main moved to 7777777 after this review was requested."],
652 ["review-out-of-date", "Out of date", "This review no longer applies."],
653 ["review-reconciler-stale", "Out of date", "This review no longer applies."],
654 ["review-pending-promotion-stale", "Out of date", "This review no longer applies."],
655 ];
656 for (const [scenario, label, banner] of cases) {
657 for (const route of ["#review", "#review/review-1"]) {
658 const c = setup(scenario, true, route);
659 try {
660 await until(() => c.root.querySelector(route === "#review" ? ".review-card" : ".review-milestones"));
661 const scope = c.root.querySelector(route === "#review" ? ".review-card" : ".review-detail")!;
662 assert.ok(scope.textContent!.includes(route === "#review" ? label : banner));
663 assert.equal(scope.querySelector('[data-action="approve"], [data-action="promote"], [data-action="return"]'), null);
664 if (label === "Main moved") assert.match(scope.textContent!, /The agent must rebase and request review again\./);
665 if (scenario === "review-main-moved") assert.equal(c.wire.reviews[0].stale, false);
666 if (label === "Out of date") assert.doesNotMatch(scope.textContent!, /Fork moved|The fork moved/);
667 assert.equal(c.calls.some((call) => call.init?.method === "POST"), false);
668 } finally { c.close(); }
669 }
670 }
671});
672
673test("guarded main movement keeps approval, pending promotion hides Promote, and returned suppresses stale banner", async () => {
674 for (const scenario of ["review-reconciler", "review-pending-promotion", "review-returned", "review-promoted"]) {
675 const c = setup(scenario);
676 if (["review-returned", "review-promoted"].includes(scenario)) {
677 c.wire.reviews[0].stale = true;
678 c.wire.forks.find((fork) => fork.repoName === c.wire.reviews[0].repoName)!.head = "9".repeat(40);
679 }
680 try {
681 await until(() => c.root.querySelector(".review-milestones"));
682 const detail = c.root.querySelector(".review-detail")!;
683 assert.doesNotMatch(detail.textContent!, /The fork moved|main moved to|This review no longer applies/);
684 if (scenario === "review-reconciler") assert.ok(button(c, "Promote"));
685 if (scenario === "review-pending-promotion") assert.equal(button(c, "Promote"), undefined);
686 } finally { c.close(); }
687 }
688});
689
690test("main-moved needs ledger, known base, explicit null pending and no current-head alert", async () => {
691 const { reviewState } = await import("../src/model.ts");
692 const wire = reviewFixtureState("review-main-moved");
693 const state = stateView(wire);
694 const r = state.reviews[0];
695 assert.equal(reviewState(r, state).kind, "main-moved");
696 assert.equal(reviewGate(r, state).promote, false);
697 state.reconcilerAlerts = [{ id: "historical-alert", sha: r.sha, message: "Historical fixture" }];
698 assert.equal(reviewState(r, state).kind, "main-moved");
699 // Latest entry by recorded time, even if an adapter gives an unordered ledger.
700 state.ledger.unshift({ review: "later", sha: r.base!, promotedAt: "2026-10-14T08:00:00Z" });
701 assert.equal(reviewState(r, state).kind, "current");
702 state.ledger.shift();
703 for (const mutation of [
704 (s: typeof state) => { s.ledger = []; },
705 (s: typeof state) => { s.reviews[0].base = undefined; },
706 (s: typeof state) => { s.promotionPending = undefined; },
707 (s: typeof state) => { s.reconcilerAlerts.push({ id: "current-head", sha: r.sha, message: "Fixture" }); },
708 ]) {
709 const guarded = structuredClone(state);
710 mutation(guarded);
711 assert.equal(reviewState(guarded.reviews[0], guarded).kind, "current");
712 guarded.reviews[0].stale = true;
713 assert.equal(reviewState(guarded.reviews[0], guarded).kind, "out-of-date");
714 }
715 state.forks.find((f) => f.repoName === r.repoName)!.head = "9".repeat(40);
716 assert.equal(reviewState(r, state).kind, "fork-moved");
717 r.returned = { reason: "Fixture return", at: "2026-10-13T08:00:00Z" };
718 assert.equal(reviewState(r, state).kind, "returned");
719 r.status = "promoted";
720 assert.equal(reviewState(r, state).kind, "promoted");
721});
722