How it works
Terms
| Term | Meaning |
|---|---|
| Recipe | Optional prepare, required start, optional ready / share / hashInputs |
| Driver | Factory that returns a Recipe (staticDir, nuxt, host, …) |
| Running | { kind: 'url', url, stop } and/or { kind: 'dir', dir } |
| HarnessHandle | url?, dir?, $fetch, files.* |
| Identity | Hash of recipe id + inputs → cache key under .untestutils |
Lifecycle
- Register —
defineRecipes({ … }, import.meta.url) - Prepare (optional) — build into an artifact dir; skipped when cache is warm
- Start — local server, dir, or remote URL
- Ready — HTTP (or custom) gate
- Teardown — stop processes; clear registry
Local servers bind to 127.0.0.1.
run: 'dev' is not the fast path
Framework drivers default to a built mode (nuxt → server, Vite → preview, …) so prepare can be shared. run: 'dev' skips prepare, sets share: 'never', and exists only for niche HMR assertions. See Drivers.
Shared prepare (speed)
Prepare outputs live under .untestutils/builds, keyed by identity. Parallel workers take a file lock so they do not corrupt the same build; a waiter keeps waiting while the holder's heartbeat is fresh (long builds are fine) and only fails — naming the holder pid — once the heartbeat stops. Running URLs are registered for Playwright/Vitest (UNTESTUTILS_HOST_<ID>).
Per-run state — registry and locks/ — lives under the artifacts root's run/ directory (default .untestutils/run/), apart from builds/. Each test run (vitest/playwright plugin) gets its own registry in run/runs/<runId>/ (targets.json + owner.json, the run's main pid). So two vitest runs in one checkout never stop each other's servers. Each run starts its own servers from the shared builds/ cache, and a cold build waits on the other run's build lock. Of other runs' servers, global setup stops only those whose run's owner process is gone (a run dir with no owner.json counts after 60 s). It also stops everything in the shared no-run-id registry (run/targets.json, used by code that calls core without a plugin) and the pre-0.8 <root>/targets.json; neither has an owner check. A nested runner (a vitest run spawned by a test) gets its own run id. Teardown stops its own run's servers and removes the run dir. Registry entries record the server's OS start time when available (Linux /proc, macOS ps); teardown uses it to skip a pid the OS recycled after a crash. On Windows (or without ps) it falls back to a plain pid check. A server start that hits EADDRINUSE is retried on a fresh port.
Global teardown prunes builds/: per recipe id it keeps the 2 most recently used identities plus everything the run used, and never removes a build whose lock is held (gc option / UNTESTUTILS_GC).
| Env | Effect |
|---|---|
UNTESTUTILS_SHARE=0 | Disable sharing |
UNTESTUTILS_DEBUG=1 | Verbose logs |
UNTESTUTILS_ARTIFACTS_DIR | Custom artifacts root |
UNTESTUTILS_SESSION | Playwright session namespace (see Playwright) |
UNTESTUTILS_PREWARM_CONCURRENCY | Parallel prewarm limit |
UNTESTUTILS_GC | 0 disables build GC, a number sets keep-per-recipe |
CI: cache the artifacts root's builds/ dir (default .untestutils/builds; <root>/sessions/<session>/builds with a session; not run/) when inputs are stable; use prewarm; always tear down.
Imports
| Import | Use in |
|---|---|
untestutils | defineRecipes, drivers |
untestutils/vitest/plugin | Vitest config only |
untestutils/vitest | Specs |
untestutils/playwright | Playwright config + specs |
untestutils/nuxt | nuxt({ run }) |
untestutils/utils | Cookies, SEO, poll, … |