Configuration lives in unotest/unotest.config.{js,mjs,ts} — inside the suite package, next to your scenarios — and is auto-discovered by the CLI and MCP server. A config file left at the project root is refused with migration instructions rather than silently ignored.
Every field is optional. A partially written section (linter with only rules, failureBundle with only tier3) is merged with the defaults key by key; arrays replace as a whole.
| Field | Type | Default | Description |
|---|---|---|---|
baseUrl | string (URL) | — | Base for relative goto() paths. If unset, scenarios use absolute URLs. |
webServer | { command, url, … } | { command, url, … }[] | — | One app server, or a list — a front and the fake backends it talks to, each with its own readiness url. A list starts all at once, waits until every url answers before the first scenario, and stops them in reverse order. If one does not come up, the ones that did are stopped and the error names each that failed. Two entries on one url are refused. |
generate | string | — | Shell command that writes unotest/generated/ from your application’s own source — constants, label tables, schemas — for the scenarios to read with readJson(). It runs from the project root (the same base as prepare and webServer.command) before every local e2e and collection and before bundle push and the first push of box create (before the box is rented), which carry the folder although init keeps it out of git. A box does not run it: it has your suite, not your application, and the snapshot arrived in the bundle. lint does not run it either — linting executes none of your commands. A failure refuses the run (exit 95); --no-generate skips it once. |
webServer.command | string | — | Shell command that starts the app under test (npm run dev, python manage.py runserver). Unset webServer entirely and the app is expected to be running already. |
webServer.url | string (URL) | — | URL probed for readiness — any HTTP response counts as up. Usually the same as baseUrl, but a dev server may answer on a different port than the tests navigate to. |
webServer.reuseExistingServer | boolean | true | Reuse a server that is already up instead of spawning a second one. false makes an occupied URL an error — CI strictness. |
webServer.timeoutMs | int > 0 | 60000 | How long to wait for the URL to come up after spawning. |
browsers | ("chromium" / "firefox" / "webkit")[] | ["chromium"] | Browser families to run. Single in dev; CI can run all three. |
channel | "chrome" / "msedge" / "chrome-beta" / "chromium" / "bundled" / null | null | For chromium: use a system browser (zero download) or bundled Chromium (null, or the word "bundled" — the same value UNOTEST_BROWSER_CHANNEL takes). "chromium" is Chromium in its new headless mode — what CI needs to load an extension headless. |
viewport | { width, height } | { 1280, 720 } | Browser viewport size. |
viewport.deviceScaleFactor | number > 0 | 1 | Device pixels per CSS pixel. 2 makes a 390×844 page a 780×1688 screenshot, the scale design mockups are usually drawn at. Fixed when the browser opens: setViewport() changes width and height only. Every viewport field is optional and overrides the one device would give. |
device | string | — | A Playwright device preset by its exact name ("iPhone 14", "Pixel 7"): size, scale, touch, mobile mode and user agent at once. An unknown name is refused with the nearest ones; a mobile preset on Firefox is refused (Firefox has no mobile mode). A preset’s viewport is the page area, smaller than the screen — "iPhone 14" is 390×664 — so a 390×844 mockup is matched with an explicit viewport: { width: 390, height: 844, deviceScaleFactor: 2 }. |
profile | 'ios' | — | Browser, device and launch mode in one line. "ios" is WebKit with the "iPhone 15" preset (size, scale, touch, mobile mode, user agent) and displayMode: "standalone" — an app opened from the home screen. browsers or device next to it is refused; viewport still overrides the preset’s size, and displayMode its launch mode. It is Playwright WebKit, not Safari on iOS: no zoom on focus, no on-screen keyboard. |
displayMode | 'browser' | 'standalone' | 'minimal-ui' | 'fullscreen' | — | The launch mode the page’s scripts see, from its first document on: matchMedia('(display-mode: standalone)') answers for it, and in WebKit navigator.standalone is true for standalone. No browser engine emulates the feature itself, so CSS @media (display-mode: …) and a query combining it with other features still see a browser tab. Unset: the browser’s own. |
timezone | string (IANA) | — | Time zone of every page, e.g. "Europe/Madrid": Date#toString, Intl and getTimezoneOffset answer in it. Unset → the machine’s. UNOTEST_TIMEZONE overrides it for one run; an unknown zone is refused when the config loads. |
locale | string (BCP 47) | — | Locale of every page, e.g. "es-ES": navigator.language, Intl defaults, Accept-Language. Unset → the browser’s. UNOTEST_LOCALE overrides it for one run. |
maxContexts | int > 0 | 4 | Browser contexts one test may hold — its own plus every newContext(). The next is refused, naming this key: a guard against a loop that opens a browser per iteration. |
launch.userDataDir | string (path) | — | A browser profile directory: the browser starts persistent, with ONE context shared by every scenario — no isolation between them. UNOTEST_USER_DATA_DIR overrides it. Refused with storageState, with more than one worker, and on a box. |
launch.profile | "per-test" | — | A new browser on a new, empty profile directory for every test_*: the extension loads again, its service worker starts from scratch, storage and tabs do not carry over. The directory is removed after a passing test and kept in the run folder after a failing one. Cannot be combined with launch.userDataDir; UNOTEST_USER_DATA_DIR overrides it. Refused with storageState, with more than one worker, and on a box. |
launch.args | string[] | — | Extra browser command-line arguments, e.g. --load-extension=./ext (extensions: Chromium only). With an extension, a headless run needs channel: "chromium" (the new headless mode) — bundled headless Chromium ignores extensions, so that combination is refused; or run headed. Refused on a box. |
retry.count | int 0–10 | 0 | Retries for transient failures. Off in dev; 1+ in CI. |
retry.on | ("transient" / "network" / "crash")[] | ["transient"] | Which failure classes retry. Assertion failures never retry. |
failureBundle.tier1 | true (locked) | true | Always on: error + console + semantic DOM snapshot + DSL trace. |
failureBundle.tier2 | boolean | true | Screenshots (viewport + element-focused). |
failureBundle.tier3 | { network, video } | { false, false } | HAR + video. Not wired yet — leave off. |
failureBundle.retention | { runs, days } | { 20, 7 } | Keep N recent runs; delete older than D days. |
failureBundle.storageDir | string | .unotest/failures | Where failure bundles are written. |
failureBundle.pageHtml | "redacted" | "full" | "redacted" | What page.html of a failure keeps. redacted: the content of each <script> and the value of each hidden form field are replaced by a label with their length; the page’s structure stays. full keeps both as the page had them. Warning: that includes an app’s bootstrap data (__NEXT_DATA__ and the like) and request-forgery tokens, and page.html lands in the run’s artifacts and in a box’s run history. Password and one-time-code fields are hidden in both modes. |
dialogPolicy | "accept" / "dismiss" / "manual" | "accept" | How native dialogs (alert/confirm) are handled. |
storageState | string (path) | — | Playwright storageState.json for cached login. |
defaultTimeoutMs | int | 3000 | Action / locator-resolution timeout. Override per call. |
defaultNavigationTimeoutMs | int | 15000 | Navigation timeout (goto/reload/waitForUrl). |
teardownTimeoutMs | int > 0 | 60000 | Wall-clock ceiling for all of a run’s teardown(...) blocks together — separate from the run’s own time budget, so a test that timed out still gives the stand back. Blocks cut by it are reported as teardown failures: the stand may be left as the test set it. |
testTimeoutMs | int > 0 | 300000 | Wall-clock budget of one test — a deadline: an action still running when it runs out is cut there, the test fails naming testTimeoutMs, and its teardown(...) blocks still play. 30000 gives every test 30 s. UNOTEST_MAX_DURATION_MS overrides it for a run; setTestTimeout(ms) inside a test overrides both for that test. |
testDir | string | unotest/e2e | Scenario directory. |
helpersDir | string | unotest/e2e/_helpers | Helper functions directory. |
sandbox.shellCwd | string | cwd | Working directory for shell() and waitForFile(). |
sandbox.shellTimeoutMs | number | 120000 | Wall-clock budget per shell() call; on expiry the child is killed and the step fails. Per-call override: shell(..., {timeoutMs}). |
sandbox.exportSecrets | string[] | — | Names of .secrets* variables exported into shell() subprocess env (masked in logs). Secrets stay out of process.env otherwise. |
sandbox.database | string (URL) | — | Connection string for dbQuery/dbExec (postgres://, mysql://, sqlite:). |
sandbox.apiBaseUrl | string (URL) | — | Base URL for apiCall(method, path). |
sandbox.readRoots | string[] | [] | More directories a scenario may read files from, beside the project and the run’s downloads: readFile, readFileInfo, waitForFile and the other file oracles. For a file a background process writes outside the project, such as a listener’s log in /tmp. Relative entries resolve against shellCwd. A path outside every root, or a symlink that leads out of them, is refused. |
sandbox.uploadDir | string | cwd | Root for upload() fixture paths. Relative paths resolve against it and may not escape it. |
env.shared | string[] | [] | Variable names whose base unotest/.env value is meant for every environment. With an --env <name> overlay active, a variable that becomes a HOST — APP_BASE_URL, the name passed as apiCall(..., {base: "NAME"}), a name used as the URL in goto(NAME) — must come from that overlay or from the process environment; otherwise the run, the lint and the MCP tools all refuse before the browser starts, naming both files. Listing the name here is how a project says the base value is deliberate. Names that never become a host are unaffected. |
linter.enabled | boolean | true | Enable the scenario linter. |
linter.rules | Record<ruleId, "off" / "warn" / "error"> | per-rule | Severity overrides per rule — every id and its default is in the linter reference. |
mcp.transport | "stdio" | "stdio" | MCP server transport (stdio in the current release). |
queue.enabled | boolean | true | Serialise runs of this project through the filesystem queue in unotest/.queue/, so a run started from the viewer, a terminal and an agent never drive the same browser at once. false (or UNOTEST_NO_QUEUE=1 for one command) starts every run immediately. |
queue.concurrency | int > 0 | 1 | How many runs may be in flight for one environment. 1 is strict first-come-first-served. A collection counts as ONE run — its workers still run in parallel inside it. |
schedules | { collection | scenario, cron, env?, prepare? }[] | [] | What should run on its own, declared next to the tests it runs. An entry targets either a collection or a single test (scenario). This is the hand-written half: the viewer writes its own entries to unotest/schedules.yaml, and npx @unotest/web schedules prints both merged (the file wins on a duplicate target). Data only — the CLI never executes a schedule: cron is run by the box or CI that hosts your runs. cron is a standard 5-field expression in the executor’s timezone; prepare is a shell command run before the run, inside the same run slot, whose failure is reported as “the environment was not prepared” rather than a red suite. |