Split out from PR #1372 (programmatic JS API stabilization) — a valid Codex review finding that is out of scope for that PR (browser + programmatic watch is an edge combination; #1372 focuses on the Node programmatic API).
Problem
When instance.watch() (from @rstest/core/api) is used for a browser-only project (or when filters/shards leave only browser projects), the returned watcher's close() does not stop the browser dev server / WebSocket / browser process. An embedded host awaiting watcher.close() can hang.
Root cause
runTests()'s browser-only branch (packages/core/src/core/runTests.ts, the hasBrowserProjects && !hasNodeProjects block) returns void in watch mode — it never constructs an RstestWatchHandle.
- The browser watch runtime's real teardown is
cleanupWatchRuntime() (packages/browser/src/hostController.ts), which is currently only wired to process-signal handlers via registerWatchCleanup(). There is no programmatic close handle returned to the caller.
- In watch mode
closeHeadlessRuntime / closeContainerRuntime are gated behind !isWatchMode, so BrowserTestRunResult.close is undefined for watch runs — the node/mixed path's browserResult?.close?.() is a no-op there too.
Fix plan
@rstest/browser hostController.ts: in watch mode, expose cleanupWatchRuntime as the result close (both the headless and container return sites).
@rstest/core runTests.ts: in the browser-only branch, when in watch mode, return { close } that invokes browserResult.close() and performs trace cleanup.
- Consider gating
registerWatchCleanup()'s process-signal install on !context.embedded, mirroring the Node watch path which skips host signal handlers in embedded mode.
Adjacent rough edge (verify while fixing)
In the browser-only watch main path, traceController.shutdown(traceRun) runs after the first run, which would close tracing before watch reruns. Confirm whether trace should stay alive across reruns.
Needs a browser-mode programmatic watch e2e to verify teardown (no leaked dev server / WS after close()).
Split out from PR #1372 (programmatic JS API stabilization) — a valid Codex review finding that is out of scope for that PR (browser + programmatic watch is an edge combination; #1372 focuses on the Node programmatic API).
Problem
When
instance.watch()(from@rstest/core/api) is used for a browser-only project (or when filters/shards leave only browser projects), the returned watcher'sclose()does not stop the browser dev server / WebSocket / browser process. An embedded host awaitingwatcher.close()can hang.Root cause
runTests()'s browser-only branch (packages/core/src/core/runTests.ts, thehasBrowserProjects && !hasNodeProjectsblock) returnsvoidin watch mode — it never constructs anRstestWatchHandle.cleanupWatchRuntime()(packages/browser/src/hostController.ts), which is currently only wired to process-signal handlers viaregisterWatchCleanup(). There is no programmatic close handle returned to the caller.closeHeadlessRuntime/closeContainerRuntimeare gated behind!isWatchMode, soBrowserTestRunResult.closeisundefinedfor watch runs — the node/mixed path'sbrowserResult?.close?.()is a no-op there too.Fix plan
@rstest/browserhostController.ts: in watch mode, exposecleanupWatchRuntimeas the resultclose(both the headless and container return sites).@rstest/corerunTests.ts: in the browser-only branch, when in watch mode, return{ close }that invokesbrowserResult.close()and performs trace cleanup.registerWatchCleanup()'s process-signal install on!context.embedded, mirroring the Node watch path which skips host signal handlers in embedded mode.Adjacent rough edge (verify while fixing)
In the browser-only watch main path,
traceController.shutdown(traceRun)runs after the first run, which would close tracing before watch reruns. Confirm whether trace should stay alive across reruns.Needs a browser-mode programmatic watch e2e to verify teardown (no leaked dev server / WS after
close()).