internal-docs/dev-server-test-harness/implementation.md
The @rolldown/test-dev-server browser suite drives a real Chromium page against
the rolldown dev engine (HMR, lazy compilation, error overlay). The server is
Vite's full bundle mode (experimental.bundledDev), loaded at runtime from
the vendored submodule at packages/test-dev-server/vite with its rolldown
resolution linked to the workspace's packages/rolldown — the harness adds only
test instrumentation on top (see The Vite backend). It runs
in-process: each spec file starts the dev server inside its own vitest worker
on an OS-assigned port, connects to one shared Chromium, and tears it down via the
server's own close(). Playground discovery is derived from each spec's own path,
so there is no central registry — adding a test is a folder plus a spec, with
zero central edits. The companion node fixtures suite (a custom dev server
building to disk, the artifact run as a child process — Vite's bundled dev is
client-environment-only, so that platform cannot run on it) shares the status
helpers but is otherwise separate.
The dev server runs in-process in the test worker, binds port 0, and the test
reads the resolved URL back — no port is ever hand-picked. Discovery is the spec
file's own path: vitest.config.e2e.mts globs playground/**/*.spec.[tj]s, the
playground name is regexed out of each spec path, and global setup copies only
the selected playgrounds into playground-temp/. Adding a test is a folder + a
spec — zero central edits.
The HMR runtime needs the websocket port known up front. On the node
platform the server binds the socket first, reads the bound port, injects it
into experimental.devMode.port, then builds. On the browser platform Vite
manages the client's websocket address itself; the harness only reserves an
OS-assigned port before createServer because Vite treats port: 0 as "use
the default port" rather than "let the OS pick" (see getFreePort in
src/vite-server.ts).
A new top-level playground exists only when the server config must differ (plugins, platform, lazy mode, …) — never because of scenario count. Scenarios that can share a config share one playground. Within a playground there are two ways to host several scenarios:
index.html + entry statically import each
scenario's module; each scenario owns disjoint DOM nodes + files so one
cannot perturb another's assertions. (Vite's hmr; rolldown's
lazy-compilation.)index.html,
reached by URL on the same server. Vite's html pipeline can serve multiple
HTML entries from one root, but every existing playground uses co-tenant, so
prefer it for consistency.Lazy cold-start is compatible with co-tenancy. A lazy chunk is compiled only when its own dynamic import fires, so bundling several lazy scenarios into one project never warms another's chunks. Each spec boots its own per-file (virgin) server and triggers only its scenario, getting a first-fetch as cold as a dedicated server would give. Disjoint lazy chunks + DOM nodes are sufficient isolation.
Most playgrounds take the default path; escalate only for a specific need:
| Need | Mechanism |
|---|---|
| Normal browser dev-server behavior | Default: the harness starts the server and navigates in beforeAll |
| Cold first request (no pre-navigation) | __tests__/serve.ts returns ctx.createServer() without navigating; the spec fires its own page.goto |
| A virgin server mid-file | The spec creates/closes its own server per test (Vite's client-reload pattern) |
Tests in a file share one page and run sequentially; a reload affects
everything after it. Safety is by convention, not isolation:
browser.newPage()) → own server.Two mechanisms. (1) /_dev/status polling — waitForBuildStable, buildSeq,
moduleRegistrationSeq — plus expect.poll on DOM text. (2) Browser-log gates
(untilBrowserLogAfter) for things with no DOM signal (reconnect, full
reload). Only string-only console arguments are matchable (object args render
as a preview, not JSON).
Browser specs assert on Vite's own signals:
| Signal | Where | Marker |
|---|---|---|
| WS connected | browser log | [vite] connected. |
| Patch applied | browser log | [vite] hot updated: … |
| Build error | server log | ✘ Build error: … |
| HMR / reload | server log | hmr update …, hmr invalidate …, page reload |
| Error overlay | DOM (<vite-error-overlay>) | errorOverlay() / errorOverlayText() in ~utils |
The overlay renders in a shadow root, so locator(...).textContent() on
the host element returns nothing — always go through the ~utils helpers. The
node fixtures assert on the rolldown runtime's [hmr]: … markers instead.
The browser platform is Vite's full bundle mode; the harness owns none of the
serving. What the harness does own lives in src/vite-server.ts:
dev.config.mjs → Vite inline config:
experimental.bundledDev: true, the playground copy as root (its
index.html module script is the entry), fixture plugins passed through
(Vite 8 runs rolldown natively), assetsInlineLimit: 0 for asset-request
assertions, treeshake forwarded. Full bundle mode forces
devMode.lazy: true, so lazy compilation is always on in browser runs.vite is not a package dependency. It is dynamic-imported by file URL
from the submodule's built dist (loadVite()), with local structural types
for the API slice the harness touches. Node-platform fixtures and CI jobs
that never run browser tests work without the submodule; a missing dist
fails with a "run just setup-test-dev-server-vite" hint.createHarnessPlugin): the /_dev/status
middleware; buildSeq counts buildStart plus broadcast
update/full-reload payloads and deliberately not error payloads
(the server replays the cached error to every fresh client, and the
conservative-rebuild specs assert a refresh on a broken build does not move
buildSeq); moduleRegistrationSeq counts vite:module-loaded events;
bundle state is read live from bundledDev.devEngine.getBundleState().WORKAROUND in the code
and deletable once fixed in Vite:
error payloads and, on the next successful
generateBundle, clears the cached error and reloads after
ensureLatestBuildOutput().lastBuildError only in
onOutput, and the client hard-reloads when its first update meets an
existing overlay — the reconnect would get a stale error replayed. A
vite:client:connect listener (registered before Vite's own replay
listener) drops the stale error when the tracked build state is healthy.The submodule stays byte-pristine. No tracked-file edits, no patches —
bumping it is a plain pointer update. Everything environment-specific happens
in untracked files, via scripts/setup-vite.mjs
(just setup-test-dev-server-vite, idempotent, vp-only):
vp install --frozen-lockfile (vp delegates to the submodule's pinned
pnpm; this also resets a previous step-4 swap, so the build always uses
Vite's own pinned rolldown),packages/vite by invoking its pinned rolldown CLI directly (vp's
workspace scan trips over Vite's intentionally-broken BOM fixture;
build-types is skipped — the harness has its own types),vite/packages/vite/node_modules/rolldown to a symlink at the
workspace's packages/rolldown, so Vite's dist resolves the local binding
at runtime. Any install inside the submodule resets this — re-run the
script.Repo-wide tools ignore packages/test-dev-server/vite/** (a .gitignore
entry covers gitignore-respecting walkers like oxfmt, plus .typos.toml and
.ls-lint.json entries) — a repo-wide vp fmt --write must never touch
submodule files. On CI, only the dev-server workflow runs the setup step;
every other job needs no submodule.
src/)createDevServer(config, opts?) → { url, port, close } (src/dev-server.ts,
exported from src/index.ts alongside loadDevConfig(dir) and a Logger
type) dispatches on build.platform: browser → the Vite backend above,
anything else → the node transport (DevServer +
FullBundleDevEnvironment). On both paths a resolved promise means the
initial build (or its error) has settled: Vite's listen() fires the build
without awaiting it, so the browser path polls the initialBuildCompleted
flag BundledDev sets once its own waitForInitialBuildFinish() (which
polls memoryFiles) settles and the one-shot ready reload has been
broadcast — ensureCurrentBuildFinish() alone can resolve before the build
starts or before onOutput has stored the files; the node path awaits a
first-output latch (waitForFirstOutput) for the same lag.close(). Browser: Vite's server.close() cascades into
bundledDev.close() → devEngine.close(). Node: stop the ws server,
terminate clients, closeAllConnections(), httpServer.close(),
env.close(). Both release the watcher/tokio threads so a vitest fork
exits, and a second engine can start in the same process after the first
closes — covered by dev-engine-close.test.ts + dev-engine-close-child.mjs.serve() (the CLI/fixtures path) loads the cwd config and dispatches the
same way; the stdin 'r' rebuild trigger is wired on the node path only.Logger. The node transport logs through it directly; the
browser path adapts it to Vite's customLogger (toViteLogger). The
harness passes an in-memory logger so server-side output lands in
serverLogs.DEV_SERVER_PORT is not consulted by createDevServer (it binds
opts.port ?? 0); it stays the fixtures/CLI channel consumed by serve().tests/)tests/
vitest.config.e2e.mts # discovery: include playground/**/*.spec.ts; ~utils alias; setup/globalSetup
vitest.config.fixtures.mts # node fixtures + dev-engine-close smoke test
fixtures.test.ts # status helpers re-keyed to URL
dev-engine-close.test.ts # close path + restart-in-process smoke test
src/
dev-status.ts # URL-keyed /_dev/status helpers (shared by fixtures + browser)
dev-engine-close-child.mjs # bare-node child proving the engine releases the process
utils.ts # fixtures dir helpers
playground/
vitest-global-setup.ts # one chromium.launchServer(); selective copy → playground-temp/
vitest-setup.ts # per-file: derive testName/testDir, connect browser, start server or run serve.ts
test-utils.ts # the ~utils surface (re-exports + editFile + untilBrowserLogAfter + status helpers)
<name>/ # a flat playground: one server config, one page
__tests__/<name>.spec.ts # the spec (lives in source, never copied)
__tests__/serve.ts # optional escape hatch (cold-start)
dev.config.mjs # no dev.port
package.json index.html … # the fixture (copied to playground-temp/<name>/)
lazy-compilation/ # a co-tenant playground: one config, many scenarios
__tests__/
serve.ts # ONE cold-start serve shared by every scenario spec
basic.spec.ts aliased-import.spec.ts shared-module.spec.ts nested-dynamic-import.spec.ts
dev.config.mjs index.html main.js # one union config; main.js imports each scenario
<scenario>/setup.js … # one sub-folder per scenario (sources + lazy modules)
package.json
Notable points:
vitest-setup.ts, vitest-global-setup.ts) —
the repo's ls-lint enforces it. __tests__/ is kept (it is the temp-copy
filter boundary that keeps specs + serve.ts out of the served fixture) via an
.ls-lint.json ignore.tests/src/dev-status.ts (URL-keyed, hook-free) so
the node fixtures.test.ts imports them too; test-utils.ts re-exports thin
wrappers that default the URL to the current spec's serverUrl.testDir is the temp copy; testPath is the source spec. serve.ts is
resolved next to the source spec (dirname(testPath)), since __tests__/ is
excluded from the copy.build.cwd = testDir injection. In-process the worker's cwd is the tests
dir, so the harness pins cwd to the playground copy when loading the config —
otherwise relative input paths and the plugin's index.html lookup resolve
against the wrong directory.node_modules: bare imports from
playground-temp/<name>/ resolve by walk-up to tests/node_modules
(depth-insensitive), so copying pnpm's symlink forest is unnecessary.serve.ts contractA playground's optional __tests__/serve.ts exports
serve(ctx) → Promise<DevServerHandle>. ctx carries { testName, testDir, page, createServer }, where createServer() loads the playground config and
starts the in-process server (logger + cwd wired). The lazy-compilation
playground's four scenario specs share one serve.ts with the body
return ctx.createServer() — it creates the server but skips navigation, so each
spec fires the cold first page.goto(serverUrl) itself. The default path (HMR)
has no serve.ts: the harness starts the server and navigates.
The lazy-compilation regressions were originally four sibling playgrounds, each
with its own server config. They are now one playground with one
dev.config.mjs and one page: main.js statically imports a setup.js from
each scenario sub-folder (basic/, aliased-import/, shared-module/,
nested-dynamic-import/), each scenario owns disjoint DOM nodes
(#<scenario>-btn / -status / -log), and there is one spec file per scenario
in __tests__/. This works because compilation is lazy (see the co-tenancy
principle above): each spec boots its own per-file server and clicks only its
button, getting a virgin first-fetch for that scenario — as cold as a dedicated
server would give. The single config is the union of each scenario's needs:
currently just viteAliasPlugin (aliased-import; inert elsewhere).
Playgrounds are pnpm workspace members via the
packages/test-dev-server/tests/playground/* glob in pnpm-workspace.yaml. The
consolidated lazy-compilation playground is one such member; its single
knip.jsonc entry globs the nested scenario sources (*/*.js) plus the specs
and serve.ts. test-utils.ts + dev-engine-close-child.mjs (referenced via
the ~utils alias and an execa path string, which knip can't trace) are entries
in the tests workspace.
WORKAROUND blocks in src/vite-server.ts.untilBrowserLogAfter(() => page.reload(), [/\[vite\] connected\./]) so an
edit fired after a reload can't be lost to a not-yet-reattached websocket —
the marker already exists, no runtime change needed.