Control UI, TUI, and extension lanes

Real-Gateway Control UI fixture lifetimes

Use pairControlUiPage from ui/src/test-helpers/control-ui-browser-pairing.ts with the isolated Gateway's CLI runner to authenticate each new page. It obtains and consumes a fresh dashboard --json browser handoff and waits for the Gateway handshake. Dashboard pairing links are single-use; do not reuse a captured URL for another tab or browser context. Pass this operation as preparePage to withControlUiRunInspector so collection authenticates its own page while leaving the caller's Chat and draft in place. Reload uses the browser's paired credential.

Use createControlUiE2eSuite from ui/src/e2e/control-ui-e2e-suite.test-support.ts for real-Gateway browser fixtures. suite.define(...) owns the native hooks. Each native it passes its test context to suite.runScenario(context, ...), which owns acquisition, the test body, and finalization before another case starts. Acquire and close browser contexts through suite.newBrowserContext and suite.closeBrowserContext so late acquisitions and pending closes remain owned.

Retain test state immediately after createApusTestState resolves, including when later config writes, imports, or startup fail. Hold original startup promises, not just their timeout wrappers. Close required producers before releasing state. For producers shared across cases, as in the MCP and auth suites, use the suite's resources.run, resources.close, and resources.release callbacks instead of independent beforeAll/afterAll cleanup. Resource acquisition follows shared server/browser acquisition; teardown joins cases and browser cleanup, closes required producers and servers, and releases state only after those closes succeed.

Failed or unjoined cleanup retains selectors and state, blocks later cases using this suite owner, and leaves native Vitest to terminate and join the isolated fork. Do not swallow close failures or restore the environment beneath unfinished work. The lifetime owner preserves existing hook, test, and action budgets.

Gateway close joins received WebSocket work and asynchronous connection cleanup, including cooperating background refreshes registered at their producer with trackAsyncWork. Connection-dependent worker sidecars must stop successfully before supervisor transports or other dependencies close; failure retains those dependencies and rejects shutdown. Register the actual operation, not just its response or timeout wrapper; cache eviction does not end its lifetime. withApusTestState likewise joins registered callback descendants before releasing state. MCP requests observe both caller cancellation and their closing work owner, so shutdown cancels pending requests before joining handlers and disposing transports. These scopes do not automatically track arbitrary detached work or replace native test-timeout ownership. Other Gateway subsystems can retain documented bounded shutdown behavior, so close is not a guarantee of universal subsystem or descendant-process quiescence.

Retained Control UI proof

For startup ownership changes, exercise authenticated hello before browser recovery migration finishes. Project and environment discovery can start from hello; migration completion must not refetch those catalogs or invalidate an admitted start. Keep changed-owner, process-restart, and late-result fences covered separately. Count storage reads by key around rerenders, typing, and streaming without recording credential values. Compare route payload bytes and loaded module closures separately from timings; CSS ownership changes also need retained screenshots and computed-style or geometry checks across New session and Chat.

Migrated mocked and real-Gateway browser proof uses fresh retained directories. Scenario captures using suite.artifactDir, including Logs and Usage, allocate lazily per test attempt; standalone captures allocate per invocation. MCP conformance and auth transports each allocate one suite-owned directory after the browser-availability check, even when media capture is disabled. Auth transport screenshots wait for meaningful content and the presentation owner's finite entrance or resize animations, while perpetual descendant activity continues. The shared agent-file capture helper allocates once per module evaluation when capture is enabled, sharing that directory across the module's scenarios. The Node-only createControlUiE2eArtifactDir(scope, parentDir?) helper in ui/src/test-helpers/control-ui-e2e-artifacts.ts prints the actual allocated path. An explicit parent wins; otherwise it uses the trimmed existing APUS_UI_E2E_ARTIFACT_DIR, then the repository's .artifacts/control-ui-e2e parent. Existing feature-specific directory controls and script output arguments select parents, with unique children beneath them. Explicit screenshot filename controls preserve the basename and print the relocated path.

Keep capture gates independent from allocation: APUS_CAPTURE_UI_PROOF, APUS_UI_E2E_RECORD, and output-presence gates retain their existing meanings. For per-attempt captures, allocate during scenario execution or beforeEach. Pass the same owner to shared capture helpers so screenshots, reports, and video stay together. Distinguish stage names within an attempt. Close the browser context before finalizing video.

The chat-loading performance real-Gateway suite records browser timestamps in loading-evidence.json for the history request, data publication, committed row model, and visual quiescence. A pane update can still display the old row model while scrolling, so the check verifies that a retained row's index advances before checking for 50 ms without transcript mutations, resizing, or scrolling. It records the start and confirmation of that quiet interval separately; the confirmation delay is not application latency. A nonzero lateChanges count invalidates that quiescence sample.

Use ordinary runs without capture for latency comparisons. With APUS_CAPTURE_UI_PROOF=1, the suite also retains screenshots, video, and history-pagination.cpuprofile. Profiling and capture add overhead, and the CPU profile includes profiler startup before the pagination timer begins. Compare repeated runs of the same fixture and build mode; keep the legacy test-driver wall timings separate from the browser timestamps.

Successful and failed evidence is retained. Cleanup is manual: remove only exact directories that you own and have finished reviewing. Never recursively delete the shared parent before a replay. Disposable build/media fixtures and temporary raw video have their own cleanup. New captures cannot recover overwritten evidence; do not describe a replay as recovery of lost files.

Timeout diagnostics allocate fresh children beneath the existing APUS_UI_E2E_DIAGNOSTIC_DIR or default timeout directory. Each child keeps its raw failure.private.json report and captured failure.private.png screenshot local, alongside an allowlisted failure.public.json summary. Automatic CI uploads match only failure-*/failure.public.json; raw reports and screenshots remain private. Older frozen targets without the public summary produce no matching upload and never fall back to raw captures.

The shared suite captures native test timeouts before draining routes and closing the owned browser context. Cleanup and the pending test body join the same capture, so a later closed-page error does not replace the original timeout evidence.

The shared failure collector gives renderer evaluation and screenshot capture one five-second budget. If the renderer stalls, it records incomplete diagnostics and returns so the caller can rethrow the original failure. A late browser response cannot publish a screenshot after that budget expires; test action deadlines and caller-owned browser cleanup remain unchanged.

Pages from the shared suite's withPage also arm a renderer stall check before the test runs. When the renderer misses that read deadline, the public summary's rendererStall records main-thread busy time by kind and the paused JavaScript stack, then resumes the page, within a further three-second budget. A stall that ended before a responsive read appears in browser.longFrames as frames of at least one second with their script attribution. Both keep only bundle paths, positions, function names, and listener tag and event names; map positions with the same commit's bundled build sourcemaps.

The private JSON report's ci.shardIndex and ci.vitestShardCount fields record VITEST_SHARD_INDEX and VITEST_SHARD_COUNT, respectively, as supplied by normal CI. Missing values remain null; manual and separate release E2E invocations do not infer this metadata from Vitest's --shard argument.

Mantis allocates an invocation directory for setup logs, capture attempts, and its report; the builder preserves each attempt's relative paths and refuses to overwrite an existing report.

Separate output owners remain, including chat-attachment-read-lifecycle. Do not assume unmigrated owners share this retention guarantee.

Screenshots during Chromium recordings

The session-host command-state real-Gateway proof uses page.screenshot({ path }) without clip or fullPage: true, keeping its existing viewport, recording size, waits, and animation options. This path was verified on Linux with Playwright 1.62.1 and full Chrome for Testing 151.0.7922.34.

Other recording owners have not been migrated or certified by this proof; some still use locator or full-page screenshots. This is not a suite-wide capture policy. Verify each owner's screenshot content and finalized video before changing its capture mode. When using the verified viewport path, crop any element-only PNG outside the browser. Cropping cannot recover missing content from an already-corrupted recording.

In a macOS arm64 reproduction with Playwright 1.62.1 and its bundled full Chrome for Testing 151.0.7922.34, locator.screenshot() and page.screenshot({ clip }) caused small screencast frames. The element appears at the video origin with gray elsewhere, even though the PNG, DOM geometry, and functional assertions are correct. fullPage: true is not a general workaround: a document larger than the viewport can instead produce a shrunken page with gray padding. Unclipped viewport captures preserved the recording in the same synthetic reproduction; other browser versions and platforms require their own verification.

This is an upstream capture limitation, not a Gateway or context-cleanup failure. Chromium's screenshot handler temporarily changes the shared view size and restores it after capture; its screencast producer can observe the intermediate surface. Playwright's recorder pads undersized frames with gray. Closing the context finalizes the video but does not repair those frames. Do not filter out bad frames or change UI behavior to conceal this limitation.

Verify finalized video content around every capture, not just its dimensions or the success of locator assertions. For a dependency upgrade, reproduce with a synthetic page containing an offset small element, compare element, clipped, viewport, and oversized full-page screenshots, and inspect every decoded frame. Keep real host/profile footage local; inspect public proof for synthetic-only content before sharing. Correct PNGs remain useful still-image proof, but a corrupted video is not continuous-flow proof.

Gateway and E2E