Fork Horse repository, listed by its owner. Publisher snapshot.

packages/forkhorse-dashboard/test/review.test.ts

Snapshot at 34e3175

Containing directory

30,574 bytes · typescriptView raw
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