Commit graph inspector/scripts/lib
Author SHA1 Message Date
cliffhall
2e4e76aa1a
test(scripts): anchor the render-smoke overlap paint to the parent's clock
The case reproduces an overlap — the render deadline expiring while the
survival window is still open — and it had to buy that overlap with a
startup allowance. `paintAt` is measured from the child's first JS tick
while `renderTimer` is armed in the parent at spawn, so a plain
`setTimeout(paint, N)` lands at `N + startup` and the deadline has to
cover whatever startup turns out to be. #2177 widened that allowance
(150ms to 950ms), which moves the threshold rather than removing it: the
case still fails, as "did not render", on any machine loaded enough to
blow the new number.

The child is now told the wall-clock instant to paint at and sleeps for
whatever is left of it on arrival, so startup is absorbed by the sleep
instead of added to it and the paint lands at ~300ms from spawn however
slow the boot was. `timeoutMs: 600` / `surviveMs: 900` then satisfies
the right-hand side of the overlap for ANY paint time including 0, so a
boot slow enough to swallow the anchor entirely still reproduces the
shape rather than degrading into a different case. The one startup
assumption left is that boot completes before the deadline at all, an
order of magnitude over the 46-59ms measured idle here.

Passing is no longer taken as evidence on its own: the verdict's
first-paint time is read back and the overlap asserted, so a paint
landing early enough to put the deadline outside the survival window
fails loudly instead of passing while testing nothing.

Verified by mutation — removing the `clearTimeout(renderTimer)` this
case guards fails it at 607ms, i.e. the timer still fires inside the
survival window. Verified under load — 8/8 with 16 busy loops pinning
all 8 cores. Runtime drops from ~1.75s to ~1.2s.

Closes #2180

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hzwzS8UvBsr4yZm7o7sev
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-27 23:51:36 -04:00
cliffhall
7083c6c7aa
docs(scripts): correct the attribution in the render-smoke margin comment
Copilot round 1. The comment blamed `script(1)` PTY allocation, but this
test's `stub()` spawns `process.execPath` directly — the PTY wrapper
lives in `smoke-tui.mjs`, the caller — so the fixture never pays that
cost and the 66-114ms figure (measured through the wrapper) was not
this path's.

Re-measured without it: Node boot alone is 46-59ms idle, which fits the
old 150ms budget comfortably. That is *why* it read as a flake rather
than a bug — what actually exhausted the budget was scheduling delay
under a full gate run, on both the child's boot and the parent's timer.
The fix is unchanged; only the reasoning was wrong.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hzwzS8UvBsr4yZm7o7sev
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-27 23:51:35 -04:00
cliffhall
0f845a319a
fix(scripts): give the render-smoke overlap test room for process startup
The test gave itself a 150ms budget that had to cover `script(1)` PTY
allocation plus Node boot: `paintAt` is measured from the child's first
JS tick, while `renderTimer` is armed in the parent at spawn. Measured
idle, that startup is 66-114ms — ~36ms of slack at best — and under the
load of a full `local:gate` run it goes over, so the render timer fires
before the paint and the run is reported as "did not render", which is
precisely the misdiagnosis this test exists to catch. It failed three
gate runs in a row while passing standalone every time.

The constants are widened rather than the assertion changed: the shape
under test is the overlap (paint < timeoutMs < paint + surviveMs), and
1200/1500 keeps it with real margin on both sides. Verified by mutation
— removing the `clearTimeout(renderTimer)` this test guards makes it
fail at 1204ms, i.e. the timer still fires inside the survival window.

Closes #2177

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hzwzS8UvBsr4yZm7o7sev
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-27 23:51:35 -04:00
cliffhall
0c0d5faac6 chore(scripts): cover PowerShell's Env: drive, and correct two stale scope summaries (#2146)
Copilot, eleventh review:

- `Set-Item Env:SMOKE_BROWSER firefox` — PowerShell's Env: DRIVE, the other
  standard way to write the environment there — matched none of the patterns.
  Covered now, positional or via -Path/-Value, for Set-Item/New-Item and their
  -ItemProperty forms. A `Get-Item` READ is still not a finding, which has its
  own negative case: reading the variable is not setting it, the same
  distinction as round 5's `echo "SMOKE_BROWSER: firefox"`.

- Two scope summaries had drifted behind the traversal, both saying the guard
  reads only `run` and `env`/`with`: the module header's "ONLY EXECUTABLE
  POSITIONS" paragraph, and the copilot-instructions mirror. Both list the real
  set now — `run`, the `shell` command template at all three levels, and
  `env`/`container.env`/`with`. The mirror one is the AGENTS.md rule this repo
  states about itself, so it is a rule violation rather than a typo.

Signed-off-by: Cliff Hall <cliff@futurescale.com>
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-27 02:44:41 -04:00
cliffhall
2ead041572 chore(scripts): scan the shell command template, and flag a multi-line env override (#2146)
Copilot, ninth and tenth reviews:

- A custom `shell:` is a COMMAND TEMPLATE that Actions runs around every
  script (`{0}` is the script path), so `shell: npm run local:gate && bash {0}`
  invoked the gate while the `run:` value beside it looked harmless. Step
  `shell`, and `defaults.run.shell` at both workflow and job level, are
  command regions now. An ordinary `bash`/`pwsh`/`cmd` matches no rule, so
  naming them costs nothing.

- GitHub's multiline environment-file syntax (`SMOKE_BROWSER<<EOF`, value,
  delimiter) bypassed the assignment patterns. It is reported as an override
  whose value the guard cannot READ rather than parsed: the value can arrive
  through any number of shapes — a brace group redirected to $GITHUB_ENV, a
  heredoc nested in another — and a parser handling some of them would be a
  coverage claim that holds only for the shapes someone thought of. Nobody
  spells `chromium` that way, and if they did, `SMOKE_BROWSER=chromium` is the
  fix.

Signed-off-by: Cliff Hall <cliff@futurescale.com>
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-27 02:32:23 -04:00
cliffhall
bc7abaea63 chore(scripts): accept a dynamic SetEnvironmentVariable argument as an override (#2146)
Copilot, ninth review: the .NET arm required a QUOTED second argument, so
`SetEnvironmentVariable("SMOKE_BROWSER", $engine)` — or a ${{ }} expression
there — set the environment with the guard silent. An engine the guard cannot
read is forbidden for the same reason `smoke:web:engine` is, so the arm now
accepts an unquoted value too. Bare values stop at `,` or `)`, so a third
argument ("Process") is not swallowed — pinned by an allowed three-argument
case beside the dynamic one.

Signed-off-by: Cliff Hall <cliff@futurescale.com>
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-27 02:18:01 -04:00
cliffhall
53e773d70e chore(scripts): stop the assignment capture at the closing quote (#2146)
Copilot, eighth review: the bare-value pattern ran to whitespace, so a QUOTED
assignment swallowed the closing quote — `echo "SMOKE_BROWSER=chromium" >>
$GITHUB_ENV` captured `chromium"`, which is not `chromium`, and the guard
rejected a PERMITTED assignment. A false positive on the allowed value is the
worse direction to be wrong in: it fails a workflow that is doing the right
thing, and there is no way to satisfy the guard except to stop using the
supported spelling.

The value is now a quoted string or a bare run stopping at a quote or a `;`
(PowerShell's separator), so all three dialects unwrap the same way.
Regression cases for the permitted quoted `chromium` in $GITHUB_ENV, in a
cmd-style `set`, and single-quoted in PowerShell — each paired with the
firefox spelling that must still fail.

Signed-off-by: Cliff Hall <cliff@futurescale.com>
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-27 02:06:59 -04:00
cliffhall
47b3c6fc9c chore(scripts): read a shell-expanded name as unreadable, and env keys case-insensitively (#2146)
Copilot, seventh review:

- The unreadable-name rules only knew GitHub expression syntax, so
  `ENGINE: firefox` plus `npm run smoke:web:$ENGINE` ran a forbidden pass with
  the guard silent — the shell resolves the name, which makes it exactly as
  unreadable as a ${{ }} expression and forbidden for the same reason. The
  pattern keys off a leading $ or %, covering $VAR, ${VAR}, $env:VAR and cmd's
  %VAR% in one, for both the engine and `local:` families.

- The env-key comparison was case-sensitive, but Windows environment variables
  are not: `env: { smoke_browser: firefox }` on a windows-latest job sets the
  very variable Node reads as SMOKE_BROWSER. Compared case-insensitively now,
  as is the POSIX assignment pattern. On a POSIX runner that spelling would be
  a different variable and harmless, so this is stricter there than it needs to
  be — the right direction for a guard, and the alternative is a verdict that
  depends on a `runs-on` which may itself be an expression.

Signed-off-by: Cliff Hall <cliff@futurescale.com>
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-27 01:54:23 -04:00
cliffhall
74aa94fb1f chore(scripts): detect a SMOKE_BROWSER assignment in any shell dialect (#2146)
Copilot, sixth review: the run-script rule matched POSIX `NAME=value` only.
PowerShell is the DEFAULT shell on a Windows runner, so
`$env:SMOKE_BROWSER = "firefox"; npm run smoke` redirected the suite with the
guard silent — on exactly the workflow least likely to be read closely.

Three patterns now, one per dialect: POSIX (which also covers cmd's
`set NAME=…` and the GitHub-native `>> $GITHUB_ENV` append, since both carry
the same substring), PowerShell's `$env:NAME =` and `${env:NAME} =`, and the
.NET SetEnvironmentVariable call reachable from PowerShell. Regression cases
for all five spellings, plus a negative for a PowerShell assignment of
chromium.

Signed-off-by: Cliff Hall <cliff@futurescale.com>
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-27 01:40:27 -04:00
cliffhall
6d2806de75 chore(scripts): make the browser rule kind-aware, cover container.env, pin the name (#2146)
Copilot, fifth review:

- The SMOKE_BROWSER rule was a blanket text match across every region, so a
  script that merely PRINTS the variable — `echo "SMOKE_BROWSER: firefox"` —
  or a `with:` input mentioning it was reported as an override. It is
  kind-aware now: an `env:` entry counts only when SMOKE_BROWSER is literally
  its key (recognized structurally, not by regex), a `run:` script only on a
  shell assignment, and a `with:` value not at all — whatever it names is
  already covered by the script rules. A shell assignment inside an `echo` is
  indistinguishable without parsing the shell, and inside `run:` erring toward
  the finding is the safe direction; that is stated in the header.

- `jobs.<id>.container.env` is job-wide too — every step in the container sees
  it — so an override there redirected `npm run smoke` with the guard silent.
  Now traversed, and a `container:` given as a bare image string is ignored
  rather than tripping over.

- Nothing asserted the FIRST acceptance criterion: reintroducing a `ci` or
  `ci:*` root script left test:scripts green. Asserted directly over the
  manifest's keys now, and checked to actually bite by reviving `ci` and
  watching the suite fail.

Signed-off-by: Cliff Hall <cliff@futurescale.com>
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-27 01:25:51 -04:00
cliffhall
8b02329703 chore(scripts): walk the workflow schema's executable paths, and resolve aliases (#2146)
Copilot, fourth review — four findings, all real:

- Matching pairs by KEY NAME anywhere in the tree scanned things that execute
  nothing. Workflow input ids are user-defined, so an input legitimately named
  `env` put its own description and default in front of the rules — failing
  the gate on documentation OF the gate. The walk is structural now: workflow
  `env`, job `env`/`with`, step `run`/`env`/`with`, and nothing else.

- YAML aliases were read as scalars, which have no `.value` on an alias node —
  so `run: *command`, with the command anchored in a metadata scalar the walk
  never visits, invoked anything at all while the guard saw an empty string.
  Aliases are resolved, and reported at the line where they are USED.

- `file` was accepted for diagnostics and then not forwarded, so a malformed
  real workflow was reported as `<workflow>`.

- One negative fixture was a YAML comment, which the parser strips — so it
  asserted nothing. Inside a `run:` block the comment text really does survive
  into the scalar, so the "a comment invokes nothing" rule is now applied
  there, where it actually has to be, and the fixture is a real block scalar.

The table's fixtures are schema-valid workflows now rather than bare `- run:`
fragments, which a structural walk correctly cannot see.

Signed-off-by: Cliff Hall <cliff@futurescale.com>
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-27 01:07:57 -04:00
cliffhall
b8d3cfdc3d chore(scripts): parse the workflow instead of matching lines (#2146)
Copilot, third review — and the second finding is really a verdict on the
approach, so the fix is the approach:

- `SMOKE_BROWSER:` with no value is valid YAML that sets the variable to the
  empty string, which `resolveBrowserName` rejects as present-but-empty rather
  than treating as unset. The value pattern required \S+, so the guard said
  nothing. It is \S* now, with its own message.

- The hand-rolled opener could not see `run: | # explanation` or the flow
  mapping `env: { SMOKE_BROWSER: firefox }`. That is the third spelling it has
  missed in three rounds, each one a forbidden invocation that would have
  passed while test:scripts stayed green — worse than no guard, because it
  reports a coverage that is not there. YAML has too many spellings to
  recognize by indentation, so extractExecutableRegions now parses with the
  `yaml` package. It is already a root dependency, and this file is not in the
  published files allowlist, so nothing about the tarball changes.

A parse error now throws rather than yielding no regions: a workflow the guard
cannot read must not pass as a workflow with nothing in it.

Verified by injecting a flow-mapping SMOKE_BROWSER override into the real
main.yml — the suite fails, and goes green again when removed.

Signed-off-by: Cliff Hall <cliff@futurescale.com>
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-27 00:52:49 -04:00
cliffhall
398d074ad9 chore(scripts): scan only executable positions, and match on the expression prefix (#2146)
Copilot, second review — both findings were real:

- A `${{ … }}` body can itself contain braces, so the `[^}]*\}\}` arm added in
  the previous round stopped at the first `}` and missed
  `smoke:web:${{ format('{0}', matrix.browser) }}` — the very case it was
  added for. It now keys off the `${{` prefix, which nesting cannot defeat.

- Scanning every non-comment line made ordinary metadata a finding:
  `name: Explain why smoke:web:firefox stays local` executes nothing and was
  reported as an engine invocation, contradicting the header's "what it
  forbids, and nothing more". The scan now walks executable positions only —
  `run:` scalars (inline and block) and `env:`/`with:` values — via an
  exported `extractExecutableRegions`, hand-rolled because `test:scripts` is
  dependency-free by design. The narrowing is a stated limit, not a hidden
  one: an execution vector outside those three positions is not seen.

Verified by injecting a nested-brace SMOKE_BROWSER expression into the real
main.yml — the suite fails, and goes green again when removed.

Signed-off-by: Cliff Hall <cliff@futurescale.com>
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-27 00:35:47 -04:00
cliffhall
fc83d2a6c2 Merge remote-tracking branch 'origin/v2/main' into v2/chore/2146-rename-local-gate
Signed-off-by: cliffhall <cliff@futurescale.com>

# Conflicts:
#	.github/copilot-instructions.md
#	AGENTS.md
#	README.md
2026-08-27 00:16:14 -04:00
cliffhall
b18b975128 chore(scripts): treat an expression-built script name as forbidden too (#2146)
Copilot, first review:

- The guard read only literal names, so `npm run smoke:web:${{ matrix.browser }}`
  and `npm run local:${{ matrix.task }}` walked past it — the one form in which
  a forbidden engine reaches CI without anyone typing its name. It already
  refused an expression-valued SMOKE_BROWSER for that exact reason; this applies
  the reasoning consistently. Its limit is now stated: a fully opaque
  `npm run ${{ matrix.script }}` names no family and is not detectable.
- The module header claimed the gate adds "the non-Chromium engine passes";
  it adds Firefox only, and WebKit belongs to neither tier.
- eslint.config.js:33 still said `npm run ci` — the last stale reference.
- AGENTS.md read "everything from `smoke` rightward is `local:gate` only",
  which is two claims wrong: smoke and Storybook run in GitHub CI too, and
  `pack:verify` is in neither tier.

Signed-off-by: Cliff Hall <cliff@futurescale.com>
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-27 00:15:07 -04:00
cliffhall
0b64460097 chore(scripts): rename the local pre-push gate off ci, and guard it out of CI (#2146)
The root `ci` script was never what runs in GitHub CI, and the name worked
against us three ways: it collides with the built-in `npm ci` (which does
something else entirely and is one keystroke away), it invites the
cross-engine passes into a workflow by looking like the thing a workflow
should call, and it blurs "GitHub CI" and "the local gate" in every doc
that names it.

- Rename `ci` → `local:gate` and `ci:storybook` → `local:storybook`, with
  no back-compat alias: `npm run ci` now fails with npm's missing-script
  error, which prints the available scripts.

- Add `scripts/lib/workflow-gate.mjs` + its `test:scripts` suite, which
  runs over the repo's real `.github/workflows/**` and fails on an
  invocation of a `local:*` script, of a non-Chromium engine pass (derived
  from SUPPORTED_BROWSERS), or of `smoke:web:engine`, or on `SMOKE_BROWSER`
  set to anything but a literal `chromium`. It does not forbid
  `npm run smoke`, `smoke:web:chromium` or `smoke:tui` — those belong in CI
  and are there today. Comments are not scanned, so the workflow can still
  explain itself. This is the durable half; the rest is prose.

- Document the two-tier model once, in the root README ("Two tiers: GitHub
  CI and the local gate"), and point the other docs at it rather than
  restating the split.

- Sweep the ~40 stale references across README, AGENTS.md,
  .github/copilot-instructions.md, the client READMEs, the roadmap doc and
  the script headers. AGENTS.md and copilot-instructions.md change
  together, per the mirror rule.

No gate changes what it actually runs.

Signed-off-by: Cliff Hall <cliff@futurescale.com>
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-26 23:56:34 -04:00
cliffhall
f62410a9b3 chore(web): connected-flow smoke for the Tools, Resources and Prompts tabs
The web client had exactly one connected-flow smoke and it was the Apps
tab. `smoke:web:browser` proves the bundle boots and paints but never
connects; `smoke:web:app` / `smoke:web:elicit` connect but only drive the
Apps surface. So the three highest-traffic tabs were unexercised through
the prod bundle end to end — everything covering them (unit tests,
Storybook, the integration project) mocks or bypasses the prod server, so
a regression living only in the built bundle or in the prod Hono
backend's wiring of a tab passed all of them.

`scripts/smoke-web-tabs.mjs` drives all three against one server on one
browser launch (~5s added wall-clock): Tools runs `list_items` and
asserts its `structuredContent` section rendered (#1908), Resources reads
`events` with the template list asserted separately — `resources/list`
and `resources/templates/list` are different calls and either can fail
alone — and Prompts fetches the argument-less `simple_prompt`.

Every assertion is on a `data-*` attribute, never on copy, mirroring the
`data-app-status` precedent: each screen now carries a readiness contract
(`data-tool-count` / `data-call-status` and its Resources / Prompts
equivalents), tabulated in the web README and pinned by screen unit tests
so a rename fails loudly there rather than as an opaque timeout here.

The deep-link connect step is extracted to `lib/deep-link-connect.mjs`
rather than copied a third time. That matters because `parseDeepLink`
ignores a link it cannot validate rather than reporting one, so a drifted
copy fails as an opaque connect timeout in whichever smoke was not
updated. `smoke:web:elicit`'s hand-rolled copy is retired onto it.

Chromium-only and deliberately not in `ENGINE_SMOKES`: the engine tiers
exist for the MCP Apps sandbox's genuinely divergent primitives, and
these tabs are ordinary React and Mantine. Network, Protocol, Logs and
Tasks remain uncovered — a stated gap, not an oversight.

Closes #2148

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UL9retfvAXRgvi6EWk4SY4
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-26 23:09:44 -04:00
cliffhall
e15dd8abee fix(scripts): make the test stubs drain, and the raw-mode guard testable (#2147)
Copilot review round 5 — four findings, all in the suppressed block.

1+4. The regression stubs did `console.log(MARKER); process.exit(1)`.
     Writing to a PIPE is asynchronous and `process.exit()` tears the
     process down without draining, so the marker can be lost outright —
     and then the stub that pins the paint-then-die path fails as "exited
     before rendering", the neighbouring branch and the wrong diagnostic
     for the thing it guards.

     Worth recording that I met this mechanism last round and did not join
     the dots: probing the drain race, a 4MB write followed by
     `process.exit()` reached the parent with the marker missing
     entirely. That was noted as a reason the repro failed, while the same
     construct stayed in the stubs. Both now set `process.exitCode` and
     let the loop drain — still exiting immediately after the write — with
     a comment, since `process.exit()` is the more obvious spelling and
     will read as a simplification to the next person. Same fix for the
     `console.error("boom")` stub, whose assertion had the same exposure.

2.   DEFAULTS claimed "every one is a hard floor as well as a default",
     which contradicts `normalizeMs` (any finite positive value passes)
     and the suite two files over, which passes 400ms against a 2000ms
     default. It rejects the shape a bad env var produces, not smallness.

3.   The raw-mode forbidden-output check was unreachable from any test:
     the real PTY happy path only exercises the pattern NOT matching, so
     a regression breaking it would stay green forever — an
     acceptance-criterion guard with nothing guarding it, which is the
     recursive form of this PR's own complaint.

     Now a `runRenderSmoke` option (`forbidOutput`), so it runs inside the
     tested state machine and against the fully drained output.
     `smoke:tui` passes its pattern in and drops the post-hoc check. It
     can only turn a pass into a failure, never rewrite an existing
     failure's message: a crash reason is more useful than "and it also
     printed X", which is usually a symptom of that crash. Three tests —
     matching, not matching, and that asymmetry.

Verified: test:scripts 213/213; `smoke:tui OK — rendered "MCP Servers" at
360ms and was still running 2000ms later`; and the no-PTY shim still
fails through the refactored path.

Signed-off-by: Cliff Hall <cliff@futurescale.com>
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-26 22:13:35 -04:00
cliffhall
4af38c719c fix(scripts): don't arm a survival timer after the verdict is settled (#2147)
Copilot review round 4.

`exit` can be observed before the child's last output drains — `done()`
already keeps reading until `close` for exactly that reason — so `onData`
can run after the verdict is settled. Recording `markerAt` there is still
right: the message is a thunk rendered at `finish()`, and a marker still
sitting in the pipe when `exit` fired was painted all the same. Arming a
survival timer is not. It could not change the result (`done` is a no-op
once settled), but it holds the event loop open for the whole window,
which under `node --test` is a suite lingering seconds for nothing.

That implies something the comment did not say, and it had already bitten
this PR: if `markerAt` can be set after the exit was observed, any wording
derived from observation ORDER is unstable. The old message read
"rendered at Nms and then exited Mms later", where M was
`since() - markerAt` evaluated at finish — not the paint-to-exit gap at
all, and incoherent whenever the marker surfaced during the drain. The
test asserted on /survival window/, which the losing side of that race
could drop. It could have flaked, and the flake would have looked like
the bug this PR fixes.

So the message now makes no ordering claim and reports both times as the
observations they are, with `exitAt` captured in the exit handler rather
than recomputed later. Assertions moved to the race-stable phrase, plus a
test that runs the paint-then-die stub 12x asserting verdict AND wording
are identical every time — one pass proves nothing about a race.

The ordering could not be forced on darwin, so the guard is reasoned
rather than reproduced, and the reply says so. Two attempts and what they
actually showed: a 4MB payload with the marker last never gets there,
because `process.exit()` discards pending stdout writes (the "before
rendering" branch is correctly taken); marker on stderr with a graceful
exitCode was observed first in all 30 runs, none lingering near the
window. `done()` exists precisely because exit and close are separate
events, so treating their order as guaranteed was worth removing anyway.

Also (suppressed comment): the README's "Two more do the same" clause now
lists four suites, not two, after this PR added render-smoke and pty.

Signed-off-by: Cliff Hall <cliff@futurescale.com>
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-26 22:01:08 -04:00
cliffhall
747f59892f fix(scripts): disarm the first-paint deadline once the marker is seen (#2147)
Copilot review round 3.

`done()` is first-caller-wins, and the render deadline stayed armed after
first paint. So a marker landing near `timeoutMs` let that timer fire
*inside* the survival window and settle the run as

    child did not render "MCP Servers" within 15000ms

followed by an output tail that visibly contains "MCP Servers". A TUI that
is merely slow fails intermittently, with a diagnostic naming the one
thing that demonstrably did happen — the same wrong-attribution class this
PR exists to remove, and one that reads as flake.

First paint has happened; its deadline no longer has a question to ask, so
`onData` clears it at the moment the marker is observed.

Also hoists `renderTimer`'s declaration beside `surviveTimer`'s. It was a
`const` below both closures that clear it, safe only because `onData` and
`done` run from events rather than the synchronous body. That already held
for `done`; this fix adds a second reader, so the ordering is now stated
rather than relied upon.

The test paints at 250ms with timeoutMs 400 and surviveMs 400, so the
render deadline expires while the survival window is open — the exact
shape. Verified it fails against the module with the clearTimeout removed
and passes with it: a wider timeoutMs would have been green either way,
which is why the numbers are tight.

Signed-off-by: Cliff Hall <cliff@futurescale.com>
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-26 21:46:52 -04:00
cliffhall
b7a73e7da3 fix(scripts): address Copilot review round 2 on #2147
Two findings, both of which quietly undo the PR.

1. `SMOKE_TUI_SURVIVE_MS` was unvalidated. `??` guards only nullish, so
   an env var set to the empty string reaches `Number("")` -> 0 and a typo
   reaches NaN; `setTimeout` fires immediately on both. The survival check
   would then resolve before the child could possibly have died, printing
   "still running 0ms later" and asserting nothing — the exact first-paint
   false green this PR removes, restored by an empty environment variable,
   on a green run.

   Fixed at the module rather than only at the env-parse site, so every
   caller is covered: `normalizeMs` (same shape as child-cleanup.mjs's
   `normalizeGraceMs`, matching an existing policy rather than inventing a
   second one) applied to all four durations before anything reads them,
   which also stops the messages interpolating "NaNms". Defaults moved to
   an exported DEFAULTS so the smoke and the module cannot drift.

   `surviveMs: 0` is deliberately NOT honored as "skip the wait": no
   caller wants that, and it is indistinguishable from the typo.

2. (suppressed comment) `spawnSync` REPORTS ENOENT in `result.error` — it
   does not throw — so `probeScriptVersion`'s try/catch read a missing
   binary as "ran, printed nothing". On linux that empty output fell
   through to the util-linux guess, and the smoke then hard-failed
   spawning a binary that does not exist instead of taking the documented
   skip.

   `probeScriptVersion` now returns `{ available, output }`, and
   `resolvePtyWrapper` returns `{ ok: true, flavor, wrap }` or
   `{ ok: false, reason }` — the two unavailability causes deserve
   different skip messages, since "no script(1) on PATH" and "nobody has
   verified this platform's invocation" send the reader elsewhere. A
   non-zero exit is explicitly not unavailability: BSD `script` has no
   `--version` and answers with its usage, which is the evidence the
   flavor fallback reads.

Tests for both, since each finding lives entirely in a branch no happy
path reaches: the normalizeMs table (empty string, typo, undefined, -1,
Infinity, a numeric string, 0), an end-to-end run proving a
`surviveMs: Number("")` paint-then-die stub still fails, the
`result.error` ENOENT and timeout shapes, and both `resolvePtyWrapper`
failure reasons.

Verified: `SMOKE_TUI_SURVIVE_MS="" npm run smoke:tui` reports "still
running 2000ms later"; with PATH blanked, resolvePtyWrapper returns
`{ok: false, reason: "no `script(1)` on PATH"}` rather than guessing.

Signed-off-by: Cliff Hall <cliff@futurescale.com>
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-26 21:36:40 -04:00
cliffhall
bd7297da88 fix(scripts): address Copilot review round 1 on #2147
- pty.mjs: narrow the BSD argv set to {darwin, freebsd}. Grouping every
  BSD together was wrong — NetBSD's `script` takes the command through
  `-c`, so the argv form fails at `script` startup, which this smoke would
  then report as a TUI crash. Rather than add an unverified `-c` branch
  for netbsd/openbsd, they fall through to `null` and the smoke skips with
  the reason: a guessed invocation turns a clean skip into a false failure
  while looking like support.

- pty.test.mjs: "supported unless Windows" would fail `test:scripts` on
  aix/sunos/netbsd/openbsd, where a null wrapper is correct and the smoke
  skips by design. State the policy once (SUPPORTED_PLATFORMS) and branch
  on it, keeping the real-probe teeth that catch a `script` missing from
  PATH. Pin netbsd/openbsd/aix/sunos to null in scriptFlavorFor's tests
  so "unsupported" reads as the default, not a win32 special case.

- render-smoke.mjs: the @returns contract claimed a close guarantee the
  code does not give — both drain give-up branches resolve after only a
  warning, so output can be truncated and the child may still hold the
  work dir. Document the bounded fallback and name the required cleanup
  shape (removeSafe, not rmSync), since a caller "tightening" cleanup on
  the old wording would reintroduce #1801 exactly where it doesn't hold.

- AGENTS.md, clients/launcher/README.md: drop the pre-#2111 claim that the
  smokes build test-servers "on demand if it is missing". ensureTestServers
  rebuilds unconditionally, once per process — presence is not freshness.

- .github/copilot-instructions.md: mirror the survival rule, per the
  same-PR mirroring requirement. One bullet, generalized past this fix:
  a smoke must assert the thing survives, not that it started, and the
  started-then-died branch belongs in a scripts/lib test against a stub.

Signed-off-by: Cliff Hall <cliff@futurescale.com>
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-26 21:20:52 -04:00
cliffhall
b6baa30d7b fix(scripts): make smoke:tui assert the TUI survives its first frame (#2147)
`smoke:tui` spawned the launcher with `stdio: ["ignore", …]`, waited for
"MCP Servers" in the output, printed OK, and SIGTERMed. It never looked at
the child again.

The child was already dead. Ink mounts `useInput`, `useInput` needs raw
mode, and raw mode is a property of the file descriptor — with stdin on
/dev/null the TUI painted one frame and exited 1 about 40ms later with
"Raw mode is not supported on the current process.stdin". Measured here:
first frame at 396ms, exit 1 at 433ms. The smoke won that race and
reported success, so what it asserted was *first paint*, not survival —
contradicting its own header ("boots and renders without crashing").

This is not environment-specific: `stdio: ["ignore", …]` makes the child's
stdin /dev/null whether or not the developer's terminal is a TTY, so this
smoke has never verified a running TUI on any machine. And the race cut
both ways — an `exit` processed first failed the run, which reads as flake
rather than as the standing defect.

Two pieces:

- `scripts/lib/pty.mjs` gives the child a real terminal via `script(1)`,
  no dependency. The three flavors are not interchangeable (BSD takes
  argv; util-linux takes one shell string plus `-e`; busybox that string
  without `-e`), so the invocation is built and unit-tested rather than
  written inline. A platform with no `script(1)` skips loudly instead of
  running without one, which would be a guaranteed failure rather than a
  weaker check.

- `scripts/lib/render-smoke.mjs` is the fix: the child must still be
  running `surviveMs` (default 2s) after first paint. Without that, a
  future harness change reintroduces the same false green and nothing
  notices — which is why it lives in a module `test:scripts` drives
  against a stub that paints the marker and immediately exits. Today's
  harness passes that stub; this one fails it, with a diagnostic that
  distinguishes "died after painting" from "died before painting".

The smoke additionally fails if the raw-mode error appears in a passing
run, so a future Ink that degrades to a warning instead of throwing can't
ship a TUI with no keyboard input past this gate.

Verified end to end: with a `script` shim that allocates no pty, the smoke
now fails with "it painted one frame, it did not run"; with the real one
the TUI is still running 2s after its first frame and never logs the
raw-mode error.

The `process.env.CI` skip stays, but its comment is corrected: the PTY
removes the technical blocker it used to cite, so the smoke is local-only
by decision now, not by capability. Whether it joins GitHub CI is out of
scope here (#2146 is keeping the other local-only smokes out on purpose).

Signed-off-by: Cliff Hall <cliff@futurescale.com>
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-26 21:00:16 -04:00
cliffhall
93bc7df360 fix(smoke): assert the requested engine is the one launched; fix the remedy
Three findings, all from Copilot's suppressed block.

1. The tests never checked that loadBrowser launches the engine it was
   asked for. The failure-path stand-in rejects identically for every
   engine, so loadBrowser(root, "firefox") could have called
   chromium.launch() and still produced a correctly Firefox-labelled
   error. The smokes could not catch it either: the Firefox gate runs
   after Chromium is already installed, so a mis-dispatched launch would
   SUCCEED and report Firefox coverage that never happened.

   Added a success-path test with distinct per-engine spies asserting the
   requested engine is invoked, that nothing else is, and that its handle
   is what comes back. Mutation-verified: hard-coding playwright.chromium
   fails it.

2. The launch-failure message told the reader to run
   `npx playwright install --with-deps <engine>`, which is wrong now that
   the npm scripts run from the repo root rather than clients/web -- I
   introduced that when I removed the `cd`. Playwright is pinned in
   clients/web, so a bare root-level npx can fetch a different version and
   install a browser revision the pinned one still cannot launch. It now
   points at `npm run smoke:web:<engine>` first, and scopes the raw
   command to clients/web with the reason.

   That message named smoke:web:webkit, which did not exist -- only the
   chromium and firefox scripts did. Added it, and a test asserting every
   supported engine has one, since a remedy nobody can run is worse than
   no remedy. Mutation-verified.

3. AGENTS.md said npm run ci "mirrors" the workflow and then, two
   sentences later, that it is "a superset, not a mirror". It now leads
   with strict superset, which is the topology this PR is documenting.

The README and AGENTS.md remediation wording is corrected the same way as
the runtime message, so a reader gets the same advice wherever they find
it.

Addresses Copilot review 5030668906 on #2133.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-26 09:33:21 -04:00
cliffhall
8a7ade7b08 ci: drop the Firefox smoke job; keep the engines as an on-demand tool
The job was trialled and did not earn its place. It was genuinely cheap —
~2 minutes, running in parallel with the 15-minute build job, so zero
added wall-clock — but across a dozen runs Firefox never once disagreed
with Chromium. It was paying runner time and carrying a real flake
surface (playwright install --with-deps runs apt-get update, which fails
whenever a third-party repo in the runner image breaks; that is what the
one red run was) to re-confirm a result already in hand.

Everything that made cross-engine checking possible stays: SMOKE_BROWSER,
the shared headless-browser helper, the installer, and the tests. So
"SMOKE_BROWSER=firefox npm run smoke:web:engine" is there for the moment
it is worth running — before touching the sandbox, the CSP builder or the
proxy page — without taxing every push to re-derive the same answer.

The workflow is now byte-identical to v2/main again; the publish jobs go
back to needing only build.

Docs say what is true rather than what was intended, and AGENTS.md
records the reasoning so the job is not re-added on the general argument
that cross-engine coverage is good — that argument was already accepted,
and the on-demand path is what serves it. Re-add it on evidence: a real
cross-engine regression a gate would have caught.

Also swept the now-stale "matrix" language out of the smokes and the
resolver, including the empty-SMOKE_BROWSER rationale, which no longer
leans on a matrix key that does not exist.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-25 23:55:19 -04:00
cliffhall
fb9e146043 docs(smoke): drop the #2132 references now that the issue is closed
#2132 was closed without the investigation going anywhere, so every
'pending #2132' pointer in the tree was about to send a reader to a
closed issue whose own diagnosis had already been retracted.

The docs now say what is actually known, and nothing more: the two App
smokes fail under Playwright's WebKit for unidentified reasons; it does
not reproduce in real Safari; an isolated repro of the suspected
mechanism did not reproduce it under Playwright's WebKit either; and
chasing it further was judged not worth the effort. A reader hitting it
locally is told to treat it as unexplained rather than as a defect.

The matrix decision is unchanged and never depended on the cause — the
engine fails, so it is not gated.

No behavior change.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-25 23:35:11 -04:00
cliffhall
32cb4c63a7 fix(smoke): reject an empty SMOKE_BROWSER; test the installer's argument logic
Two findings from Copilot's suppressed block.

1. resolveBrowserName treated a present-but-empty SMOKE_BROWSER as unset
   and returned chromium. The test that covered it even documented WHY
   that is dangerous — GitHub Actions renders an undefined matrix key as
   "" rather than omitting the variable — and then asserted the unsafe
   behavior. A misspelled matrix key would run chromium inside a job named
   for another engine and report green: the same false-coverage outcome
   the unrecognized-value branch exists to prevent, reached by a typo
   instead of a bad value.

   Only an absent variable now selects the default. Empty throws, naming
   the likely cause and the actual remedy ('unset it', which is not the
   same as setting it to nothing). The workflow always renders a real
   value, so nothing in CI is affected.

2. install-smoke-browser.mjs had no tests. Its I/O half runs on every
   smoke and fails loudly; its argument half does not — every caller
   passes nothing or the literal 'chromium', so the precedence rule is
   asserted by nothing at runtime. It matters: pack:verify passes chromium
   BECAUSE it launches chromium, and if the environment won,
   SMOKE_BROWSER=webkit npm run pack:verify would install one engine and
   launch another, surfacing as a misleading "Executable doesn't exist".

   resolveRequestedBrowser is now exported and pure; execution moved
   behind main() with the entrypoint guard verify-typecheck-coverage.mjs
   uses, so importing the module for tests cannot trigger an install.

Both mutation-verified: restoring either behavior fails the tests that
claim to cover it.

Addresses the suppressed comments on Copilot's fifth review of #2133.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-25 22:52:02 -04:00
cliffhall
4ab0139de9 test(smoke): cover loadBrowser's failure branches; fix a stale helper comment
Two findings from Copilot's suppressed block.

1. The launch-failure message is #2086's acceptance criterion — a missing
   browser must name THAT engine and its own install command — and it had
   no test. The smokes cannot supply one: install-smoke-browser fetches
   the binary first, so a passing smoke says nothing about what a failing
   one prints, and that message is the deliverable. loadBrowser now takes
   an injectable loadPlaywright purely to open that seam.

   The test asserts per engine that the message leads with that engine,
   names --with-deps <that engine> and no other, and preserves the
   underlying cause. Verified by mutation: hard-coding 'chromium' in the
   message fails it. It also pins the two branches apart — an unresolvable
   package must say
> @modelcontextprotocol/inspector@2.3.0 postinstall
> node scripts/install-clients.mjs

[install-clients] Installing dependencies for clients/web...

up to date, audited 583 packages in 614ms

198 packages are looking for funding
  run `npm fund` for details

found 0 vulnerabilities
[install-clients] Installing dependencies for clients/cli...

up to date, audited 237 packages in 496ms

69 packages are looking for funding
  run `npm fund` for details

found 0 vulnerabilities
[install-clients] Installing dependencies for clients/tui...

up to date, audited 314 packages in 518ms

102 packages are looking for funding
  run `npm fund` for details

found 0 vulnerabilities
[install-clients] Installing dependencies for clients/launcher...

up to date, audited 160 packages in 449ms

50 packages are looking for funding
  run `npm fund` for details

found 0 vulnerabilities

up to date, audited 281 packages in 3s

108 packages are looking for funding
  run `npm fund` for details

found 0 vulnerabilities and must NOT say ,
   which fetches binaries and cannot fix a missing package.

   The unsupported-engine test now also asserts loadPlaywright was never
   called, pinning the ORDER rather than only the message: validating
   first is what makes the error name the real mistake.

2. prod-web-server.mjs's header still described the  prefix this PR removed. Corrected, keeping why
   deriving paths from import.meta.url is still right, and adding the
   elicitation smoke to the list of callers it had also outgrown.

Addresses the suppressed comments on Copilot's review of #2133.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-25 22:42:06 -04:00
cliffhall
25eb2fb3bf docs(smoke): stop describing the WebKit smoke failure as a Safari bug
#2132 was filed asserting that a Safari user opening an MCP App gets a
permanently 'loading' widget. That was never tested. A manual check in
real Safari renders the App normally, so the claim is unsupported and is
removed from all six places it reached.

The failure under Playwright's WebKit is real and still keeps that engine
out of the CI matrix. What is not established is that any shipping
browser behaves this way — and Playwright ships its own WebKit build
whose networking stack is not Safari's, which is exactly the layer the
failure lives in.

headless-browser.mjs already carried the caveat that a green WebKit run
is not a Safari guarantee. The same sentence forbids the inverse
inference, which is the one that was made; it now says so explicitly, and
AGENTS.md carries the rule: reproduce in the real browser before claiming
user impact in one.

No behavior change.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-25 22:32:47 -04:00
cliffhall
ef4259a544 test(smoke): run the headless web smokes in Firefox and WebKit, not just Chromium (#2086)
Every browser-driven check in this repo launched Chromium and nothing else.
That is fine for most of the web client, whose behavior is React and Mantine.
It is not fine for the MCP Apps sandbox, which is built out of the primitives
that genuinely diverge between engines: a CSP <meta> injected as the first
<head> child of a srcdoc document, a nested sandboxed iframe, a
Permissions-Policy allow attribute, and postMessage origin discipline across
those two frames. Nothing else covers that surface — sandbox-csp.test.ts
asserts which policy STRING is built (environment-independent by
construction), and no Storybook story reaches the sandbox at all, since all
three App stories use a data: placeholder iframe and a mock bridge.

The engine is now a parameter:

  SMOKE_BROWSER=webkit npm run smoke:web:app
  SMOKE_BROWSER=firefox npm run smoke:web:engine   # all three smokes

Unset means chromium, so `npm run ci` and `npm run smoke` are unchanged. An
unrecognized value is an error rather than a fallback — falling back would
report a green Chromium run under a job labelled "webkit", claiming coverage
that never ran — and a launch failure names the engine that failed and its own
`playwright install` invocation.

Selection, launching, and the fatal-vs-benign page-diagnostics split now live
in one shared scripts/lib/headless-browser.mjs. The three smokes had each
hand-rolled the diagnostics split and two had their own copy of loadChromium;
pack:verify goes through the same helper, pinned to Chromium explicitly since
it is a packaging check.

The npm scripts drop their `cd clients/web && npx playwright install chromium`
prefix for scripts/install-smoke-browser.mjs. The engine is a variable now, and
a ${VAR:-default} expansion does not expand under Windows' cmd.exe; npx is also
a .cmd shim there that a shell-free spawn cannot start, so this resolves the
Playwright CLI through resolveNodeBin — the #1939 problem solved the #1939 way.

CI gains a `Sandbox smokes (<engine>)` matrix job rather than matrixing `build`,
which also runs validate, the coverage gate, two verify gates and Storybook —
all engine-independent. Both publish jobs now need it.

WebKit is supported by the tooling but deliberately NOT in the CI matrix yet:
pointing the smokes at it immediately found #2132, a pre-existing Safari
incompatibility in the web client's SSE transport that strands the last message
on /api/mcp/events, so an MCP App never leaves "loading". That is a product bug
affecting real Safari users, with its own diagnosis and a verified fix, and it
is not a matrix change. Adding `webkit` here is a one-word diff once it lands.
An engine is either green in CI or absent from it; a continue-on-error job would
report coverage nobody is held to.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-25 20:44:11 -04:00
cliffhall
3222537e73 docs(scripts): correct the deleted-source caveat — it is silent, not loud
Round 3's suppressed finding, and it corrects a claim of mine that was
simply wrong. The header said a deleted source file's stale `.js` produces
"a loud failure rather than a silent one — an import of a removed module
fails at the consumer". It does not: the stale file is still there, so the
post-build existence checks pass against it and anything still importing
that module keeps silently running the old code. That is the SAME failure
class this PR exists to close, described as though it were the opposite.

Rewritten in the helper header and AGENTS.md to say the unconditional build
does not cover deletion, and to make `rm -rf test-servers/build` after a
delete or rename an instruction rather than an aside — which is now a real
clean, since the tsbuildinfo was pinned inside build/ in 8d19346.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JTHVxSu8AUgHRLvo1ntZ8H
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-25 01:37:40 -04:00
cliffhall
8d193461e3 fix(scripts): pin the tsbuildinfo inside build/, assert on cache hits
Two findings from review round 1, both real.

The default incremental cache path derives from `rootDir`, so it landed at
`test-servers/tsconfig.tsbuildinfo` — beside the config, OUTSIDE `build/`.
That survives the `rm -rf test-servers/build` this project documents as the
remedy for a deleted source file, leaving tsc convinced its outputs are up
to date and emitting nothing. Pinned to `./build/tsconfig.tsbuildinfo`, and
verified that removing the output dir now forces a full re-emit.

The build is memoized per repo root; the existence assertion was memoized
with it. A later caller naming an entry the first did not would be handed a
path tsc never emitted, then fail with exactly the opaque
ERR_MODULE_NOT_FOUND the helper exists to replace. The assertion now runs on
every call.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JTHVxSu8AUgHRLvo1ntZ8H
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-25 01:11:10 -04:00
cliffhall
e59452ea8c chore(scripts): build test-servers unconditionally from one shared helper
Five `scripts/*.mjs` consumers each carried their own `ensureTestServer()`
that returned early when `test-servers/build` was already on disk. Presence
is not freshness: once that directory exists — which it does on any machine
that has run the smokes once — an edit to `test-servers/src` was never
picked up again, and the smoke went on driving the previously-built fixture.

The failure is silent and misattributed. It does not read as "your fixture
is stale"; it reads as a product bug in whatever the smoke is checking.

`scripts/lib/ensure-test-servers.mjs` is now the one place that build is
produced for a script consumer, and it builds unconditionally — memoized
once per process per repo root, so pack-and-verify's two asks stay one tsc
pass while a fresh run always rebuilds. Callers name the entries they
depend on and get one actionable failure message, replacing five that had
drifted. pack-and-verify also stops shelling out to `npx tsc`, which is
what made it the one script unrunnable on Windows (#1939).

`test-servers/tsconfig.json` gains `incremental` so the up-to-date case is
cheap: 3.0s cold, 1.6s incremental here.

Unconditional emit is still not a clean — `--noCheck` leaves the stale .js
of a deleted src file behind — which is unchanged from before and recorded
in the helper's header and AGENTS.md.

Closes #2111

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JTHVxSu8AUgHRLvo1ntZ8H
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-25 00:57:48 -04:00
cliffhall
df938777b1 Merge branch 'v2/main' into v2/chore/2003-pack-verify-app-render
One conflict, scripts/smoke-web-app.mjs: #2056 added a second phase to the
smoke (the same app declaring _meta.ui.domain, asserting it renders from the
dedicated app origin rather than an opaque one) while this branch extracted
the smoke's flow into scripts/lib/mcp-app-flow.mjs.

Resolved by keeping both: phase 2 now runs through the shared helper too,
which grows three knobs it needs and pack:verify does not —

  - expectDeepLink: phase 2 re-navigates the SAME page, so the token gate is
    already proven and a rejected re-navigate would fail at the connect wait
    regardless;
  - what: so a phase-2 ready timeout doesn't read as a phase-1 one;
  - extraDiagnostics: phase 2 appends the frame list, which is what separates
    "never rendered" from "rendered at the wrong origin".

Each is covered by a new unit test, since neither consumer exercises a
failure branch.

pack:verify deliberately drives phase 1 only. The dedicated origin is served
by the same runner the packaging assertions already cover and reads nothing
new from disk (app-origin-controller.ts has no file reads), so a second
browser launch there would buy no packaging signal.

Verified on the merged tree: smoke:web:app passes both phases (dedicated
origin http://127.0.0.1:6278/app-document/<id>), and pack:verify passes end
to end against the real tarball.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-24 09:05:53 -04:00
cliffhall
17c33080f9 chore(ci): render an MCP App from the installed tarball in pack:verify (#2003)
Nothing covered the intersection #1859 fell through. `smoke:web:app` drives
the App path but against the repo build tree; `pack:verify` runs against the
installed tarball but only asserted the sandbox proxy page *exists*. So a
packaging regression that ships the file present-but-unreachable — a rename
with a stale reader, a wrong position relative to clients/web/build, a future
"files"/.npmignore edit — passes both.

Extract the drive-the-App-flow half of the smoke into
scripts/lib/mcp-app-flow.mjs and call it from both, rather than copying it:
the deep-link shape is the part that rots silently, and a drifted link fails
as an opaque timeout rather than a mismatch. pack:verify then drives
connect -> open app -> data-app-status="ready" in headless Chromium against
the installed `--web` boot it already starts.

The client under test comes from the install; the App test server stays a
repo fixture.

Also isolates pack:verify's `--web` catalog (the #1977 treatment the smokes
already have). Untidy while the step only did `GET /`; the App deep link
persists a server row, which would make it destructive and non-deterministic
against the developer's real ~/.mcp-inspector/mcp.json.

Unit tests cover what neither consumer can check itself — both CSRF gates and
the appArgs encoding on the deep link, and driveAppFlow's failure branches
against a stand-in page, all dead code from a happy-path smoke's view.

Verified: pack:verify passes end to end against the real tarball, and fails
at the new step alone when sandbox-controller.ts is pointed at a renamed
proxy page (the tarball and on-disk assertions both still pass — the exact
gap this closes).

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-24 07:44:58 -04:00
cliffhall
6b4bf68b8b fix(scripts): observe the final polling interval before declaring a timeout
Copilot's suppressed round-3 comment is right. The attempt-counted loop read
the output and then slept, so nothing ever observed the last interval: an
announcement arriving at 29.9s of a 30s budget was reported as a timeout, and
a spawn error or early exit landing in that window was misattributed the same
way. Inherited from the original smoke, but wrong either way.

Replace it with a deadline loop that re-reads state after every wait, the last
one included, and clamp the final wait to the deadline so the budget is a real
bound rather than one poll longer.

The regression test sets pollMs === timeoutMs, which makes the distinction
deterministic rather than a race: the attempt-counted loop gets exactly one
check, at t≈0 before the child has finished starting, then throws; the deadline
loop re-reads after the wait and sees the announcement. The child announces
immediately, ~2s inside the window, so a loaded runner can't flip it. Verified
both directions — the test fails against the old loop and passes against the
new one — and `npm run test:scripts` was repeated to confirm it is stable under
the full 116-test parallel run (an earlier margin-based version of this test
was not).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hzwzS8UvBsr4yZm7o7sev
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-23 13:02:38 -04:00
cliffhall
9cade95fd0 test(scripts): kill the probe child on every path; document the helper
Two review findings from round 2, both correct.

1. The regression suite killed its child only after the assertions. If
   `assert.rejects` or either assertion failed, execution never reached the
   kill, leaving a 60-second child attached to the runner — a suite guarding a
   process leak would leak one itself, precisely on the run where the
   regression had come back. Teardown is now registered from `onSpawn` via
   `t.after`, so it runs on every path out of every case. That is the helper's
   own ownership rule applied to the tests; killing an already-exited child is
   a no-op, so the early-exit cases pay nothing for it.

2. Document the new shared helper, per the maintenance rules. AGENTS.md's
   scripts/lib tree entry now carries announced-child.mjs and what its contract
   buys, and the smoke:web:app paragraph records why the spawn/readiness step
   moved out of the smoke (same reason teardownWebServer left prod-web-server's
   stop()). README's test:scripts row names the new suite alongside
   resolve-node-bin's.

Not mirrored into .github/copilot-instructions.md: that file is a distillation
of the rules a reviewer cites against a diff, and deliberately excludes the
project-structure tree and tooling inventory.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hzwzS8UvBsr4yZm7o7sev
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-23 12:48:53 -04:00
cliffhall
09b35489aa test(scripts): cover the readiness-timeout teardown path (#2000 review)
Copilot's review is right that the fix was unproven: smoke:web:app's happy path
always receives the announcement, so `npm run ci` never exercised a child that
stays alive *through* the readiness timeout — exactly the case that orphaned a
live server holding its port. A regression there would be silent in CI and
would surface only as a stray process on a later run.

Extract the spawn/readiness ownership into scripts/lib/announced-child.mjs
(the same move prod-web-server.mjs made for teardownWebServer, and for the same
reason), and add scripts/lib/announced-child.test.mjs under `test:scripts`.
The tests drive real `node -e` children rather than spies, so the assertion is
that a real process is reachable and killable, not that a mock was called: the
timeout case asserts the child was published before the wait, is still alive
when the throw lands, and is actually stopped by kill. The early-exit and
spawn-failure paths are covered alongside it, plus the both-streams scan.

smoke-web-app.mjs keeps its behavior — startMcpServer() now delegates the
spawn and publishes the child via `onSpawn`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hzwzS8UvBsr4yZm7o7sev
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-23 12:22:24 -04:00
cliffhall
c93552139c fix(scripts): keep the resolver local, complete the cmd.exe quoting
Copilot review round 3, all three real:

- `require.resolve.paths()` also appends Node's GLOBAL FOLDERS
  ($HOME/.node_modules, $HOME/.node_libraries, $PREFIX/lib/node) and any
  NODE_PATH entries, so round 2's lookup could satisfy a missing repo
  install from a GLOBALLY installed typescript/vite — measuring the
  programs with the wrong compiler instead of producing the actionable
  "run npm install" this helper promises. Filter to the node_modules
  directories on fromDir's own ancestor chain.
- The quoting predicate missed characters cmd.exe still parses, notably
  parentheses (`C:\Temp(1)`), and quoting cannot suppress `%VAR%` at all
  — expansion happens before quotes are processed. Extracted to
  lib/win-shell-args.mjs with a broad predicate and a hard error on `%`
  rather than a silently different path. `platform` is a parameter so the
  Windows behavior is exercised on the POSIX machines that run the suite.
- AGENTS.md claimed pack-and-verify spawns via `process.execPath`; it does
  not, and cannot — its children are `npm` and the installed `.bin` shim.
  State the exception and why.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01387r3MotGk52hrkfjzHFsN
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-17 00:18:40 -04:00
cliffhall
b399bc64e1 fix(scripts): find the manifest without exports, quote shell args on Windows
Copilot review round 2 (suppressed comments), all three real:

- `require.resolve("<pkg>/package.json")` is governed by the package's
  `exports`, and Node keeps no special case for `./package.json` — so a
  package declaring an `exports` map without it threw
  ERR_PACKAGE_PATH_NOT_EXPORTED even though its bin was installed and
  spawnable, a false "not installed" pointing at `npm install` for a
  package already on disk. Walk `resolve.paths()` instead, which never
  consults `exports`. The docblock claiming the opposite is corrected.
- `shell: true` (needed at all on Windows to start the `.cmd` shims) makes
  Node hand `cmd.exe` one space-joined string, so an argument holding a
  path with a space splits in two. Every generated path in
  pack-and-verify lives under `tmpdir()`, which sits beneath the user
  profile. Quote them via `shellArgs()` at all four spawn sites, the
  command path included — the hazard predates this PR, so fix the class.
- Document the new module: `scripts/lib/resolve-node-bin.mjs` in AGENTS.md's
  scripts/ tree, its tests in the README's `test:scripts` row.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01387r3MotGk52hrkfjzHFsN
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-17 00:01:51 -04:00
cliffhall
4b143ce043 fix(scripts): validate the bin name and that its file exists
Copilot review on #1997:

- A string-form `bin` is npm's shorthand for ONE command, named after the
  package, so ignoring `binName` let a typo silently resolve the package's
  only executable while bypassing the documented "declares no such bin"
  failure. Match it against the manifest's unscoped name instead.
- A declared bin whose file is absent (partial install) was returned
  unchecked. `process.execPath <missing>` still spawns and exits 1 with
  empty stdout, which `rawProjectListing` records as "no diagnostic
  captured" — reproducing the bogus every-file-uncovered report this
  helper exists to eliminate. Throw here, where the remedy is actionable.

Also restores `rawProjectListing`'s docblock, which the new `tscEntry`
block had orphaned above itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01387r3MotGk52hrkfjzHFsN
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-16 23:43:24 -04:00
olaservo
7164806be3 Merge origin/v2/main: move the #1939 npx fix into the shared tsc-program lib
Upstream #1965 moved the tsc --listFilesOnly machinery into
scripts/lib/tsc-program.mjs (shared with the new verify:dep-lockstep),
still spawning `npx` — which is ENOENT on Windows. Resolve the conflict
by taking upstream's factoring and porting the #1939 fix into the lib:
rawProjectListing and verify-typecheck-coverage's --showConfig pass now
spawn process.execPath with the cached, hard-failing tscEntry resolver
(exported from tsc-program.mjs). Fixture copies in both main tests pick
up the new lib import graph. verify:dep-lockstep now runs on Windows
too.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 17:07:50 -07:00
cliffhall
a9b9a917e3 chore(scripts): compare only the copies a program actually met
Two findings from the Copilot review of #1965, both real.

1. The candidates were flattened to package names before the version
   comparison, so a third install holding a different copy failed the guard
   even when no program loads that copy beside another — and the diagnostic
   named an install that never took part. `crossInstallPackages` now returns
   the co-occurrences themselves (name → program → install → entry paths) and
   `findSkew` compares per program, across only the installs that met there.

2. Folding a nested copy onto its outermost install lost the path that
   identifies which copy was loaded, and the version was then read from the
   install's top-level entry — absent or differently versioned, either way
   letting a real pair pass. `classifyModulePath` now also returns the
   `entryPath` npm keys the lockfile by, `topLevelLockVersions` becomes
   `lockVersionsByPath`, and each holder is priced from the entry the program
   resolved. A resolved copy with no lockfile entry is now a hard failure
   rather than a silently dropped holder.

The failure output prints `in <program>` with each holder's install path
beside its version, so a nested copy is distinguishable from a top-level one.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-16 12:35:38 -04:00
cliffhall
7f9be24bf3 chore(scripts): derive dep-lockstep candidates from what enters each tsc program
`verify:dep-lockstep` derived its candidate set from the packages the shared
first-party sources named *directly*, so a package whose declarations reach a
program only through another package's `.d.ts` was invisible to it — it could
skew across installs and recreate the two-copy blow-up #1896 was about while
the guard stayed green. `@modelcontextprotocol/sdk` was the live example: never
written in first-party code, yet 16 of its `.d.ts` files land in clients/web's
test program.

Replace the derivation with the measured one: list every client tsconfig
project with `tsc --listFilesOnly`, map each resolved `node_modules` file to
its owning install and package, and keep the packages that reach ONE program
from TWO installs. That is exactly the set that can put two structurally
distinct copies of a type in front of one checker. The rest of the guard is
unchanged — top-level lockfile comparison, deny-by-default, `TOLERATED_SKEW` as
a name allowlist scoped to within-a-major.

The listing machinery moves to `scripts/lib/tsc-program.mjs`, shared with
`verify:typecheck-coverage`, so the two guards can't disagree about what a
program contains. It is deliberately not cached to disk between the two
processes: a stale fingerprint would make a guard measure a program that no
longer exists and pass on a real miss. The second pass costs ~14s.

`TOLERATED_SKEW` is now empty. `jose` and `@modelcontextprotocol/ext-apps` are
root-only since #1970 and cannot skew; `react` (whose types are `@types/react`,
resolved from one install) and `hono` never reach one program from two
installs. Each would be a rationale for a skew that cannot occur.

Closes #1965

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-16 12:17:15 -04:00
olaservo
e3a026802b fix: resolve node bins via package.json instead of spawning npx .cmd shims so the verify/smoke scripts run on Windows
On Windows npx/npm are .cmd shims a shell-free execFileSync/spawnSync
cannot start (ENOENT since the CVE-2024-27980 hardening), so
verify:typecheck-coverage died at validate's second step — doubly
silently, echoing "(no diagnostic captured)" per project and then
reporting all 918 tracked files as getting no tsc pass — and
verify:build-gate, the smoke test-server bootstraps, and pack:verify's
npm pack failed the same way.

Add scripts/lib/resolve-node-bin.mjs: resolves the JS entry behind a
package's bin from <pkg>/package.json (deep resolution is blocked by
Vite 8's exports map) and spawns it via process.execPath — the same
walk npx --no-install did, cross-platform and shell-free. Switch the
six npx call sites to it; the npm pack call gets the existing
shell-on-win32 idiom instead (npm has no in-tree package to resolve).
An unresolvable tsc is now a hard "cannot measure" error with
actionable stderr, pinned by a new main() regression test.

Closes #1939

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 09:04:31 -07:00
cliffhall
678184ed3e fix(smoke): pin the teardown ordering, and correct a docblock the test refuted
Both round-3 review points, both mine.

1. The docblock claimed deleting `removeSafe` would leave "both the unit suite
   and all three smokes green" — written before the test existed and never
   updated, so it contradicted the test added in the same PR. Only the smokes
   would stay green; the unit tests are precisely what fails on it.

2. `assert.ok(hasExited(child))` after the await proved only that the child was
   dead by the time teardown returned. It passes just as happily if the catalog
   were removed *first*, or while an un-awaited `stopChild` was still pending —
   so it did not lock down the #1801 ordering it claimed to.

   Sample the directory from the child's own `exit` handler instead, registered
   before teardown so it runs ahead of stopChild's listener and observes the
   world at the instant the child dies. `removeSafe` cannot have run by then: it
   is sequenced after the promise stopChild resolves from that very event.

Mutation-checked against both defects the old assertion missed — removing before
stopping, and dropping the await. Each now fails the test; neither did before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-11 15:17:56 -04:00
cliffhall
3d541388db test(smoke): exercise the real teardown path, not just removeSafe
Copilot was right that the previous test proved only that `removeSafe` can
delete a directory — it never drove `stop()`, so deleting the cleanup call
would have left the unit suite AND all three smokes green. That is exactly the
regression these tests exist to catch, and they did not catch it.

Extract the teardown into an exported `teardownWebServer({ child, catalogDir,
label })`, which `stop()` now delegates to, and drive it against a stand-in
child process (a real `node -e setInterval` that stays alive until signalled)
rather than a mock. Three cases:

  - it removes the catalog dir it was given (asserted on the dir itself, not a
    spy, so the assertion cannot drift from the behavior);
  - it awaits the child's exit before removing — the #1801 race in one
    assertion, since losing the await is otherwise invisible;
  - an already-dead child is not an error, which is the failure path a smoke
    takes when it calls fail() after the launcher has crashed.

Mutation-checked: with the `removeSafe` call deleted, 2 of the 3 now fail
(previously: 0). AGENTS.md updated to match.

Signed-off-by is now on every commit (#1978 DCO); the requirement itself is
undocumented and tracked in #1979.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-11 14:51:12 -04:00
cliffhall
4a993fc45a docs(smoke): document the throwaway-catalog invariant and fix the label JSDoc
Addresses the two review comments that still applied to HEAD (the third, the
stop() race, was already fixed in a8022138).

- AGENTS.md still described the prod-web-server helper as shared by only
  smoke:web and smoke:web:browser, and said nothing about catalog isolation.
  It is the source of truth for these smoke mechanics, so record the invariant
  there: the temp catalog per run and why, that only the catalog (not HOME) is
  redirected, that teardown needs both halves of child-cleanup.mjs and is
  therefore async for every caller, and where the contract is unit-tested.

- The `label` JSDoc claimed the option prefixes the temp dir. It does not —
  that prefix is fixed at `smoke-web-catalog-` (deliberately, so every run's
  dir is greppable); `label` only prefixes teardown warnings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-11 14:32:20 -04:00
cliffhall
16c96d44c3 fix(smoke): await the server's exit before removing its catalog, and test it
Addresses both Copilot review comments on #1978.

1. `stop()` removed the catalog dir immediately after a bare `child.kill()`,
   which only *delivers* SIGTERM. That is precisely the #1801 race
   `child-cleanup.mjs:11-17` documents, and it says both halves are needed:
   `stopChild()` closes the race on the normal path, `removeSafe()` makes the
   residual case harmless. I had used only the second. `smoke-web-app.mjs`
   already used `stopChild` for its MCP test-server child, so the helper was
   inconsistent with its own sibling. `stop()` is now async — it awaits
   `stopChild` and then removes — and all three callers await it
   (`smoke-web.mjs`'s `fail()` became async to match, so every call site had to
   `await fail(...)` or execution would run past it instead of exiting).

2. The isolation contract had no test. The smokes exit the process right after
   teardown, so a regression reintroducing the shared catalog — or dropping
   cleanup — would leave them all green. Split the two pure pieces out
   (`createTempCatalog`, `buildWebServerEnv`) and cover them in a sibling
   `prod-web-server.test.mjs`: a unique dir per run, no pre-created catalog
   file, the dir is removable, and an inherited `MCP_CATALOG_PATH` (or
   `MCP_AUTO_OPEN_ENABLED`) cannot beat the explicit one — the spread-then-assign
   ordering that guard depends on. `startProdWebServer` itself stays untested by
   design; it spawns a real launcher, which is the smokes' job.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-11 14:32:20 -04:00
cliffhall
29e3ce95b8 chore(smoke): run the web smokes against a throwaway catalog
`startProdWebServer` spread `process.env` without setting `MCP_CATALOG_PATH`,
so `smoke:web`, `smoke:web:browser`, and `smoke:web:app` all ran against the
developer's real `~/.mcp-inspector/mcp.json` — unlike `smoke:cli` / `smoke:tui`,
which have always driven a temp `--catalog`.

That made the web smokes both destructive and non-deterministic.
`smoke:web:app`'s deep link persists a `deep-link` server row, so a second run
finds it already on disk, racing hydration: the deep-link effect misses it in
`servers`, POSTs `addServer` anyway, and the backend answers 409. The app
swallows that 409 by design (App.tsx:2842) so the smoke still passed, but it
reported a spurious non-fatal console error that was really just residue from
the previous run.

Give the helper its own temp catalog dir and clean it up in `stop()` via
`removeSafe`, so cleanup can never turn a passing smoke red. Isolation lives in
the shared helper rather than per script, since every web smoke needs it.

Only the catalog is redirected — other per-user state under `~/.mcp-inspector`
stays shared, since isolating it means redirecting HOME wholesale, which would
also move the npx and Playwright caches these smokes depend on.

Verified: `smoke:web:app` twice in a row is now clean both times (previously
the second run reported the 409), the real catalog's mtime is unchanged, and no
temp dirs leak. CI never saw this — a fresh HOME per run made every CI run look
like a first run.

Closes #1977

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-11 14:32:20 -04:00