ci: split Verify into a fast leg + 2 integration shards behind the required gate; pin the browser; retry setup; nightly pools (#576) - #583
Conversation
…RIGHT_CHANNEL and keep traces on CI failures Every Workbench browser suite hard-coded `channel: 'chrome'`, so CI tested whatever Google Chrome the runner image shipped that week while Playwright stayed pinned (#576). All 31 sites now read one shared value: - tests/support/browser-launch-options.ts (new leaf) exports `browserLaunchOptions`, selected by AGENT_BUNDLE_PLAYWRIGHT_CHANNEL: unset/empty or `chrome` -> `{ channel: 'chrome' }` (local default, unchanged); `chromium` -> `{}` (Playwright's bundled Chromium for the installed Playwright version; CI sets this); anything else throws at module load. It is a leaf because capture-runtime-playground.mjs cannot load workbench-e2e.ts (`test.extend` needs a running Rstest worker). - tests/support/workbench-e2e.ts re-exports it, uses it in the shared `e2e` fixture, and adds `browserTrace`: `retain-on-failure` when CI=true, else undefined so RSTEST_PLAYWRIGHT_TRACE keeps working (@rstest/playwright reads that variable only when `trace` is undefined). Failed tests land in `.rstest/playwright-traces/<test-name>-<hash>/{trace.zip,trace-summary.json,debug.md}` relative to the Rstest root (the repo root); `.rstest/` is now gitignored. - The 10 `test.extend` forks swap the literal for `browserLaunchOptions` and add `trace: browserTrace`; the 11 raw `chromium.launch` suites (20 calls) and the nightly capture script pass `browserLaunchOptions`. Note for the `chromium` mode: rstest.worker-isolation.ts points XDG_CACHE_HOME at an empty per-worker directory and Playwright derives its browsers directory from that variable on Linux, so workers only find the bundled build when PLAYWRIGHT_BROWSERS_PATH names the real install (~/.cache/ms-playwright). Branded Chrome is a system install and unaffected.
…e required gate; pin the browser; retry setup; schedule the nightly pools (#576) Verify used to run the four Rstest pools serially per Node version (13.8 min on a PR, 66% of it the integration pool). It is now a `leg` × `node-version` matrix — `fast` (build, typecheck, lint, unit, route-unit, projection) plus `integration-1|2` (`rstest --shard N/2` over rstest.integration.config.ts) — fanned into the unchanged required context "Verify gate", which now fails on a failed, cancelled, or non-docs-only skipped matrix. The fast leg runs scripts/verify-rstest-shards.mjs, which proves the shards are disjoint and cover every file `rstest list` reports for the pool. The shard step passes `--shard` without a `--` separator (rstest's parser drops everything after one, so the whole pool ran on both shards) and fails unless rstest prints its "Running shard i of N" banner. PRs and merge-queue entries run every leg on Node 24 plus the fast leg on Node 26; main pushes and manual dispatches run every leg on 22.19.0/24/26. .github/actions/setup-workspace replaces every pnpm/setup + install block: pnpm/setup@v2 is retried once (its first network call is the registry fetch that returned 504 on eight runs on 2026-09-04), `pnpm install --frozen-lockfile` up to three times, and browser-driving jobs get `pnpm exec playwright install chromium`. Workbench browser suites select the browser through AGENT_BUNDLE_PLAYWRIGHT_CHANNEL (chromium in CI, branded Chrome locally) and keep Playwright traces on failure under CI, uploaded as artifacts by every browser-driving job. rstest.worker-isolation.ts pins PLAYWRIGHT_BROWSERS_PATH to the registry Playwright resolves before it redirects XDG_CACHE_HOME, so the bundled build is found inside workers. nightly.yml (schedule + dispatch) hosts the packed release matrix, the evidence pool, and MCP conformance as independent jobs; the manual-only mcp-conformance.yml is folded into it. examples-check runs examples/mcp-app's browser-app suite. native-host-smoke runs each pool's script with its own files instead of `pnpm test -- <files>`, which filtered nothing.
|
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
commit: |
…oloured shard banner; launch the pinned browser as channel 'chromium' - The shard step's `run:` used the default `bash -e` shell, so `pnpm … | tee` returned tee's status and a failed rstest fell through to the banner check, which then misreported the failure as a dropped --shard flag. The step now sets `pipefail` explicitly and strips ANSI codes before matching the banner rstest prints in colour under GITHUB_ACTIONS. - `AGENT_BUNDLE_PLAYWRIGHT_CHANNEL=chromium` launches `channel: 'chromium'` (the full Chromium build in the new headless mode branded Chrome uses) instead of the chromium_headless_shell a bare headless launch picks: the shell reports the MCP App sandbox's srcdoc frames differently and both mcp-app-real.e2e frame-URL polls time out under it (first CI run of #583). - examples-real.e2e's error ledger now allows the aborted GET /api/artifacts/epochs/<id>: the MCP page inspects the active epoch under an AbortController it aborts on unmount, and Chromium 151 observes that designed cancellation where Chrome 152 never did. - The local gate's Verify legs run test:route-unit and test:projection too, as the hosted fast leg does (self-review finding).
…iting early gave sed SIGPIPE (141) and read as a missing banner
|
Post-merge proofs (squash
|
Part of #576 (Rstest audit) — the test-level P1/P2 items. Out of scope here, as agreed on the issue: the config-level items (worker-root teardown, setup-file loading, timeouts, stale-dist guard, adapter
libId, pool membership — follow-up to #570), thecontract-matrixsplit andmcp-lineageimport (after #569), and CI shape (ci.yml, separate PR).Six lanes, one file set each, integrated on one branch. Every mechanism below was re-derived from the current code, not copied from the issue; two of the issue's diagnoses are corrected (items 3 and 6).
1. P1 —
runtime-client-surface-proxy.test.tswaited the real 15 s twiceMechanism.
upstreamRequestTimeout = 15_000was a module constant (src/dev/runtime-client-surface-proxy.ts:26) armed before every upstream dispatch — the bootstrap entry fetch and every proxied asset.bounds an upstream HTTP request before headers arriveandkeeps a completed 502 response intact when a response body stalls after headersboth drive an upstream that never completes, so each case sat on that deadline: 15.0 s + 15.0 s, 30.6 s of a 33 s file, and the unit pool's wall was bound by this one file regardless of worker count (issue: 46.2 / 43.1 / 45.5 s at 16 / 32 / 95 workers).Fix.
RuntimeClientSurfaceProxy.open(...)takes a trailingoptions: RuntimeClientSurfaceProxyOptions = {}withupstreamRequestTimeoutMs?: number; the default is exported asdefaultRuntimeClientSurfaceUpstreamRequestTimeoutMs = 15_000and validated (positive safe integer ≤ the 2^31−1setTimeoutceiling — Node collapses larger delays to 1 ms, which would silently drop the bound; out-of-range values throwTypeErrorbefore the proxy opens). Production callers (workbench-server.ts) pass four arguments and keep 15 s. The two tests pass{ upstreamRequestTimeoutMs: 500 }, keep their exact assertions (status: 502;{ body: 'Not Found', status: 502 }+ socket closed within 250 ms), and tighten their outer bound fromwithin(pending, 16_000)to2_000, so a proxy that ignored the option now fails instead of passing at 15 s. A new fast test asserts the default once and rejects0, -1, 1.5, NaN, Infinity, 2^31.Proof (shared 96-core host, 1-min load noted; same command shape before/after, unmodified vs. patched scratch worktree):
The two cases: 15.0 s → 0.21 s each; file host span 30.6 s → 1.1 s. The pool's wall is now bound by the next file (
eval-native-mount.test.ts, 25.8 s), as the issue predicted. 10× file loop: 10/10, 3.1–3.8 s per run.2. P1 —
dev-host-install.test.tsre-sync test raced a realProjectWatcherMechanism. The test built its
DevCoordinatorwithoutcreateWatcher, so the coordinator installed the realProjectWatcher(coordinator.ts:244,#startEffect:331-338,onInvalidation → this.rebuild): chokidar over the fixture root, 100 ms debounce (watcher.ts:90-93). The test's ownwriteFiles therefore scheduled an unrequested rebuild C, queued behind the explicit rebuild B (coordinator.ts:288-295) and started from B'sonExit(:421-424). Every successful build mints a fresh epoch id and publishesartifact.available; the install manager turns each into async()that rewritesDEV_INSTALL_MARKER, andsettled()(host-install-manager.ts:391-393) only awaits the syncs pending at call time. Normally C fails or is rejected assameInputsbecause the test's broken write lands while C compiles; when host sync of B is slow relative to C (a loaded runner), C completes first, its sync moves the marker, and:370seesepochId: C≠markerBeforeFailure— the exact CI failure. Reproduced deterministically with an instrumented harness outside the repo: with a 2.5 s delayed epoch acquisition the marker moves (FAIL (marker b306cf8d -> 75fa7946)); with a stubbed watcher it does not.Fix. Four lines:
createWatcher: () => ({ close: async () => undefined })— theDevelopmentWatchershapedev-coordinator.test.tsalready uses (:917, 955, 1052, 1094). The coordinator now rebuilds only on the test's explicitrebuild()calls; assertions unchanged. Not "drain": there is no idle signal on the coordinator, so a drain would be a wall-clock wait around inotify + debounce.Proof. Single test ×20 before: 0 failures (does not reproduce natively on this host — fast disk); ×20 after: 20/20, 11.8–16.8 s per run (fixture build dominates), load 27–58. Whole file under the integration config: 7/7 (Claude and Codex variants ran), 106 s; under the root config the CI
host-install-proofsjob uses: 7/7, 105 s. Integrator's 10× loop on the merged branch: see gate table.3. P1 —
scaffold-packed-matrix.e2easserted"failedTests": 0, a key the default reporter never printsMechanism (corrects the issue). The key does exist — in Rstest's
mdreporter, whichinitCliselects automatically when it detects an AI-agent environment (CURSOR_AGENT,CLAUDECODE,CODEX_*, …) and no reporter is configured. The scaffolded project inherits the developer's environment, so under an agent the template'snpm run test:routesprinted themdsummary block and the assertion passed for its author; in CI (GITHUB_ACTIONS, no agent variables) the reporters are['default', 'github-actions']and the key never appears — red every nightly, andpnpm test:evidence(the next step) has never run in CI. Second latent failure the issue did not list: the default reporter expands per-test lines only for single-file runs, so the cli-tool projection pool (two files) failstoContain('greets through the routed CLI shell …')at line 96 before it reaches line 98. Real default-reporter output of a scaffolded pool, for the record:Fix. Test-only; the templates are untouched.
tests/support/scaffold-fixture.tsgainsexpectPassedPool(projectRoot, script, testNames): runsnpm run <script> -- --reporter=json(npm appends the flag to the template'srstest --config …script; an explicit CLI reporter overrides both the agentmdswap and the CI default), locates the report on stdout (the only thing there that opens a line with{), reads a non-zero exit rather than throwing (the report is already written and names the failing test), and asserts in order: failing tests[], failing files[],tests[].name ⊇ testNames(a dropped or empty pool reportsstatus: 'fail',tests: 0— andfailedTests: 0, so the names are what catch it), then{ status: 'pass', summary: { failedTests: 0 } }. Three call sites replace the sixtoContains; the two hunks stay clear of #569's edits in the same file.Proof. CI-faithful (
env -u CURSOR_AGENT CI=true GITHUB_ACTIONS=true)pnpm test:packed:release packages/create-agent-bundle/tests/scaffold-packed-matrix.e2e.test.ts: before → exit 1 (expected ' Rstest v0.11.10\n\n ✓ tests/route-u…' to contain '"failedTests": 0'at line 50; line 96 on the cli-tool test); after → exit 0,Test Files 1 passed, Tests 2 passed, 1m15s wall (load 41→49); again under the agent environment: exit 0, 1m11s. Negative proofs against sabotaged scaffolded copies: an appended failingit→ the failing-tests assertion prints the entry with itserrors[]; a deletedtests/projection/→expected [] to deeply equal ArrayContaining [...]. Note for the next person:pnpm test:packed:release -- <file>forwards--topnpm exec rstest, where cac treats it as end-of-options and drops the filter — pass the file without--.What the evidence artifact looks like now.
pnpm test:evidence(the step that has never run in CI) passes: 1 file / 1 test, 51 s wall includingpnpm build(34 s of test).runtime-playground-capture.test.tsrunspackages/workbench/scripts/capture-runtime-playground.mjsintomkdtemp(join(tmpdir(), 'agent-bundle-runtime-capture-'))under the worker's isolated tmp root and removes it infinally— nothing survives the run and nothing is uploaded. Reproduced by invoking the script directly: five 1440×900 PNGs (desktop.png— populated Runtime playground with "All outputs are from the current runtime generation (generation-3). No stale views.";hmr-before.png;hmr-after.png;compile-error.png— Diagnostics tab showingAB8206 RSC runtime source build failedbeside the last-good generation-2 banner;recovered.png) andevidence.json(1.4 kB):appMarkerVisible: true,appRefreshPreservedDocument: true,appVisibleBefore/After/Recovered: true,compactRunGeneration: "generation-2",compactRunId(==compileErrorRunId),compileErrorDiagnosticsVisible: true,compileErrorGeneration: "generation-2",compileErrorHistoryUnchanged: true,compileErrorLastGoodVisible: true,compileErrorLayout: { diagnostics: { top, bottom, viewportHeight: 900 }, lastGood: { … } },documentTimeOriginBefore === documentTimeOriginAfter(HMR without reload),desktopControlColumns: 4,generationBefore/After/Recovered: "generation-1"/"generation-2"/"generation-3",lastGoodGenerationDuringError: "generation-2",lastGoodPreserved: true,providerSessionId,runBefore/runAfter(distinct UUIDs),recovered: true,sandboxOpaqueOrigin: true,viewports: { desktop: { width: 1440, height: 900 } }.ci.ymlstill needstest:evidenceas its own step withif: ${{ !cancelled() }}— that is the CI-shape PR.4. P1 —
mcp-app-real.e2einferred readiness from a notification count, then waited 120 s for a/closethat could never comeMechanism. The gate is
McpAppFrameRelay.#resourceProvided(packages/workbench/src/mcp/mcp-app-frame.tsx): it flips only when the sandbox proxy postsui/notifications/sandbox-proxy-ready, andclose()branches on it — unset →#forceClose()→DELETE /api/mcp/apps/<id>; set →POST …/close. The test countedui/notifications/initialized/messagesrequests across both previews (toBe(2)), a different channel from the relay flag and not tied to the reopened binding, thenwaitForRequest('/close', { timeout: 30_000 * timeScale })— 120 s in CI. When the relay it closed had not flipped, only aDELETEwas ever sent and the wait consumed the whole budget (2m16s failures on ~15 % of Node 22.19 main pushes).Fix. Option (2) from the issue, the one that observes the actual gating state, plus (3) and (4). The relay publishes its lifecycle on the outer iframe as
data-mcp-app-relay-state∈loading | ready | closing | closed(#publishFrameState(), called on exactly the events that change#state/#resourceProvided:start(), the proxy-ready flip,close(),#completeClose(); no React state, timers, or polling — the same imperative-attribute patternapplyMcpAppFramePolicyalready uses on this iframe).McpAppFrameIframe.setAttributeis optional so the unit-test fakes publish nothing. The test: captures the reopened/appsPOST response to derive the binding path (per-binding instead of a count);await expect(outerFrame).toHaveAttribute('data-mcp-app-relay-state', 'ready', { timeout: browserTimeout })before clicking; then waits for that binding's owninitialized; after the click, polls the recorded requests for the first close-path request for that binding and throws immediately withExpected the reopened App preview to close gracefully (POST …/close); the relay sent DELETE /api/mcp/apps/<id> instead.if it was the force path; asserts the teardown acknowledgement; every wait isbrowserTimeout(32 s in CI) instead of 120 s.AppRouteRequestrecordsmethod(moved up fromRuntimeAppRouteRequest, which already had it).Proof. Workbench rebuilt (
data-mcp-app-relay-statepresent inpackages/workbench/dist/static/js/index.js); e2e file 10×: 10/10 (40/40 tests), 34.6–47.8 s per run, close-step test 4.0–5.5 s, load 34–50; a passingtoHaveAttribute(..., 'ready')in every run is direct evidence the relay publishes on the real production path.mcp-app-frame.test.ts(read-only, includes the two force-delete cases and the markup test): 19/19. Integrator's 10× loop on the merged branch: see gate table.5. P1 —
mcp-app-preview-browser.test.tsread a Node-side request log synchronously after an in-page signalMechanism.
bootstrapRequestsis appended by the bootstrap server'srequesthandler (:21-25) — i.e. after Chrome has opened the connection and Node's parser has emitted'request'. Every in-page signal the test waited on fires before that:create:*is pushed inside the mockedfetchbefore the create response is even parsed, two React commits and a network round-trip ahead of the GET;factoryis pushed in the passive effect of the commit that arms the iframesrc, so the GET is at best concurrently in flight.waitForFunction(raf polling) sees the event on the next frame, replies over CDP, the test reads the array — and on a 2-worker 4-core runner the CDP replies occasionally win:expected [ '/runtime-bootstrap' ] to deeply equal [ '/runtime-bootstrap', …(1) ].Fix. One local helper next to the assertions,
expectBootstrapRequests(requests, expected)=await expect.poll(() => requests.length, { timeout: 5_000 }).toBe(expected.length)then the exacttoEqual(an over-fetch still fails with the array diff). Applied at the four sites that observe a freshly issued bootstrap GET (old lines 247, 332 — the CI flake — 446, 476); the four "still N" sites after a settled state stay synchronous (polling there would turn a genuine over-fetch into a 5 s wait with a worse message). Nothing in the fixture/setup region changed, so this merges cleanly with the shared Rsbuild-fixture extraction in flight.Proof. 40 runs: 39/40 — the one failure was a mid-edit of
mcp-app-frame.tsxby lane 4 landing in the fixture bundle (SyntaxError at fixture init, before any assertion); the following 36 runs against the completed edit all passed, 5.7–6.7 s typical. A scratch Playwright script mirroring thecreate:shape lagged the Node log 2/300 unpinned and 1/300 pinned to two CPUs — consistent with CI's twice-in-36 h. Integrator's 10× loop on the merged branch: see gate table.6. P2 —
packed-outage-ledgerDate.now()fencesMechanism (corrects the issue's second shape). Shape 1 (
unexpected pre-outage failures: GET /api/logs/replay?after=0 … net::ERR_ABORTED): the Logs page issues the replay from its mount effect and aborts it from the effect cleanup; the e2e left Logs on the heading alone, so on a loaded runner the navigation aborted the replay 22 ms in, before any headers, and the allowlist accepted a replay abort only with a 2xx status (ledger:149-156). Shape 2 (unknown post-recovery failure: […after=0…, …after=5…, /api/logs/stream?after=36]): not theafter=0→5pair — the code shows that message can only fire after the fresh-B filter and its per-failure assertion passed, and the two-reader shape (transportstream?after=0right after the POST; session controllerstream?after=Nafter the trace refresh; both aborted before theDELETE) was already accepted. The unclaimed entry was the Logs navigation stream: itsrequestandresponseevents arrived in one Playwright batch, the awaited continuation ran synchronously into the next iteration'sopenedAt = Date.now()— which is the previous record'sleftAt— soleftAt === atand the exclusive windowrequest.at < navigation.leftAtexcluded it; the message then dumped every post-recovery failure, making the recognised ones look guilty. Both shapes reproduce deterministically by replaying the exact CI ledger entries through the old validator.Fix. Exactly two shapes are newly accepted, each documented in the ledger: (a) a pre-outage
GET /api/logs/replaynet::ERR_ABORTEDwith no response (pre-header navigation abort; a non-2xx answer is still rejected); (b) a navigation-owned request observed in the same millisecond as the departure stamp (request.at <= navigation.leftAt). The fresh-B close window is derived from wire entries — opens at the completion of the session's lastPOST …/operations, closes at the completion of itsDELETE(both awaited viaexpect.poll) — instead ofDate.now()around the click; the e2ewaitForResponses the Logs replay (and requires.ok()) before navigating away; recognised failures are aSetand the assertion reports only unclaimed entries. Everything else still fails: replay abort after non-2xx, replay abort via POST, non-abort replay errors, unknown-path pre-header aborts, fresh-B stream abort without headers / before the last operation completed / after the DELETE completed / a third abort / non-canonical cursor, navigation abort delivered before departure, navigation-shaped request after departure, navigation stream abort without headers, any unclaimed post-recovery abort — 13 guard shapes exercised against the new validator.Proof. Old validator vs. CI entries: shape 1 throws
unexpected pre-outage failures, shape 2 throws the same three-entry dump as CI; controls (200 replay;leftAt + 1 ms; theafter=0/after=5pair alone) pass. New validator: both shapes pass, all 13 guards still throw.packed-release.e2e10× against pre-packed tarballs: 10/10, 39.4–45.1 s per run, load 49→18.packed-outage-ledger.test.tsuntouched and green. Integrator's 10× loop on the merged branch: see gate table.Gate (merged branch @@GATE_SHA@@, after
git merge origin/mainand a freshpnpm build)@@GATE_TABLE@@
Changeset
patchforagent-bundle— production source changed in item 1 (RuntimeClientSurfaceProxy.openoptions).RuntimeClientSurfaceProxyis exported only from the internalsrc/dev/index.tsbarrel, not fromsrc/index.tsor anyexportsentry, so no docsite page describes it; no docs change.packages/workbench(item 4) is private.Self-review
Reviewer:
change-risk-revieweron gpt-5.6-sol-medium, two passes againstorigin/main.Pass 1 (48d19b7):
pnpm … | teeran under the defaultbash -eshell (nopipefail), so the pipeline returned tee's status; the following bannergreppassed and the gate could go green on red shards. Fixed:set -euo pipefailin the step (88d470b). CI run 2 then correctly failed the step — for a second reason:sed | grep -qunderpipefaildies with SIGPIPE (141) when grep exits on the match; fixed with onegrep -qPthat tolerates rstest's colour codes (8d27683). Run 3 green.scripts/local-ci.mjsrantest:unit+test:integrationonly. Fixed: Verify legs now runtest:route-unitandtest:projectionas well;docs/local-ci.mdtable updated (88d470b).fast+integration-1|2cover unit/route-unit/projection/integration; the shard guard proves exact partitioning; the Node 26includeadds exactly one job on PRs and nothing on main (9 combinations).failure/cancelled/non-docsskippedall fail; only a docs-onlypull_requestmay pass onskipped. (The pipefail hole above was the only path, now closed.)PLAYWRIGHT_BROWSERS_PATH=0is preserved. Dismissed:??=semantics; covered by reading the one-line implementation.skip-changesetappropriate.Pass 2 (8d27683, after the fixes): no findings. Confirmed: the shard step fails on a failed rstest and passes only with the exact banner (
set -euo pipefail, singlegrep -P, 20-min job timeout bounds hangs); no remaining path letsVerify gatepass without every shard passing;channel: 'chromium'is the documented full-build/new-headless selection andplaywright install chromiuminstalls that build; the/api/artifacts/epochs/<id>allowlist entry is justified (same-origin GETERR_ABORTEDonly, the MCP page's ownAbortControlleron unmount — server/HTTP failures surface as other errors);--pool.maxWorkersapplies to the route-unit/projection configs and the new local-ci step ids collide with nothing.Codex (chatgpt-codex-connector) reviewed 48d19b7 with no comments. Nightly proof:
nightly.ymlcannot be dispatched until it exists onmain(gh workflow run→ 404); it will be dispatched right after merge and itsevidence/mcp-conformanceresults linked in a follow-up comment.@@SELF_REVIEW@@