fix(harness): restore the close-routing guard PR #61 dropped, on a faked clock

Adversarial review of PR #61 found one real coverage loss, and it is the
defect class that PR's own body names: an assertion that survives the deletion
of the thing it was about. `shell.spec.js` lost "a socket that opened and then
dropped reads as disconnected, and stays that way" and both MutationObserver
negative assertions. Reverting PR #21's routing half in `web/app.js` — the
socket `error` and `close` arms calling `setShell` instead of `fail` — left the
node suite 26/26 and Playwright 13/13 green.

`shell.test.js` cannot cover it, and why is the whole boundary argument:
"a loud failure DISARMS the watchdog" tests `createShell` in isolation and
cannot see whether `app.js` ROUTES the close event through `fail()`. That
routing is the half gotcha 48 says keeps getting lost; this was the third time,
after two earlier mis-confirmations that simulated the `opened` flag and not
the routing.

Both tests are back under their original names, so the two `####` headings in
context/test-evidence/clients.md name live tests again. They cost ~75ms each
rather than ~9s, because `page.clock` fakes the PAGE's timers: the page, the
socket, the listeners and the `setTimeout` call are all real and only the
passage of time is simulated. That is not a weakening, and the injections are
the proof in both directions — if `runFor` did not advance the page's timers, a
broken routing could not surface the watchdog's message and they would pass:

  - both arms reverted to `setShell`  -> "…and stays that way" fails
  - close arm only                    -> "…and stays that way" fails
  - close arm drops the `opened` flag -> "…the watchdog stays out of it" fails

Three injections, three outcomes, none of them the unit test.

The helper guards its own premise, because it turned out not to: `runFor`
without `clock.install()` does nothing AND throws nothing, so a test that
forgot the install asserted "the watchdog never spoke" against a watchdog that
never ran — 6/6 green. `outlastTheWatchdog` now requires the page's own
`Date.now()` to have moved, and that omission fails by name.

Also: `render_surface.test.js` matched `model.<field>` over source INCLUDING
comments, so un-painting a field and mentioning it in a comment passed —
demonstrated against the pre-fix version, then fixed and re-demonstrated.

And the PR's speed claim is corrected where it is documented. The eleven
deleted per-widget tests cost 3.3s between them against 3.4s for the three
contract tests replacing them: they were free. Essentially all of 36.9s -> 15.5s
was two real-timer waits, one of them this guard. The wait was the cost, not
the test.

Filed, not fixed: improvise-igxp (the help overlay's rows are asserted as
present, never as content — inherited verbatim from the test contract.spec.js
replaced) and improvise-4ktt (the js job's bundle-freedom is a shell-glob
convention, not a gate). The `####`-vs-`###` blind spot in --check-evidence,
which is why the dangling heading survived the gate, is a comment on
improvise-r0qa.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Edward Langley
2026-09-20 13:56:20 -07:00
parent 584dc3f4cf
commit 9863d96eef
4 changed files with 134 additions and 16 deletions
+8 -1
View File
@@ -8,6 +8,13 @@ import { fileURLToPath } from "node:url";
const read = (rel) => readFileSync(fileURLToPath(new URL(rel, import.meta.url)), "utf8");
/// Comments name model fields routinely, so a field mentioned only in one
/// would read as painted. Strip them before asking what the code touches.
const code = (rel) =>
read(rel)
.replace(/\/\*[\s\S]*?\*\//g, " ")
.replace(/(^|[^:"'`])\/\/.*$/gm, "$1");
/// Fields the page deliberately does not read, each with the reason. A field
/// belongs here only with an argument; the list is the debt, written down.
// improvise-z754 put the title bar's facts in one shared cache and taught
@@ -32,7 +39,7 @@ function renderModelFields() {
test("every field the render model projects is painted, or named as debt", () => {
// Both halves of the page: `app.js` paints, `shell.js` plans the grid.
const page = read("../app.js") + read("../shell.js");
const page = code("../app.js") + code("../shell.js");
const unpainted = renderModelFields().filter((field) => !page.includes(`model.${field}`));
assert.deepEqual(
unpainted.sort(),