A scenario is a plain .js file in unotest/e2e/. It holds one or more
test_* functions — each one a test of its own — and every executable
step lives inside a step() block.
function test_checkout() { step("Add the first product to the cart", () => { goto("/products"); click(getByRole("button", { name: "Add to cart" })); });
step("Cart shows one item", () => { assertText(getByTestId("cart-count"), "1"); });}Two layers
Each step() carries two layers at once:
- Intent — the string label, plain English. Reads like a checklist, even to a non-engineer.
- Execution — the DSL calls inside the closure. One step can be several calls.
This is why repair is precise: the agent knows what a step is meant to do (its label) and how it does it (the calls), so it rewrites only the broken part — it doesn’t guess. The same duality helps you: collapse a step to see the logic, expand it to see the exact commands.
Tags and soft steps
A data-driven test runs one test_* over many cases. Two additions to
step() keep such a run readable:
function test_control_questions() { step("Control questions", () => { for (let i = 1; i <= 10; i++) { const q = readJsonLine('questions.jsonl', {slot: i}); step.soft("Question", {tag: q.id}, () => { fill(getByRole('textbox', {name: 'Message'}), q.text); assertJudge(getByTestId('last-answer'), q.rubric); }); } });}{tag: <expr>}— the case this iteration is on. Any expression; it shows up asQuestion [q17]in the CLI and as a chip on the step in the viewer.step.soft(...)— a failure inside is recorded and the run goes on with the next case; the test still ends failed, listing every soft failure. Allowed insidetest_*only, at any depth. The outer step is a group, not an assertion: it closes fine and shows “1 of 10 soft failed”.
A worked example with the cases in a table and in a JSONL file: Port a @playwright/test spec.
App constants in scenarios
A scenario cannot import your app’s modules. When a test needs values the app keeps in source (button labels,
a device list, a limit), don’t copy them into the scenario. Export them
to JSON with a small script and read the file with readJson:
// scripts/export-test-constants.ts: writes what the scenarios readimport { mkdirSync, writeFileSync } from 'node:fs';import { CHAT_LABELS } from '../src/shared/chat-labels.ts';import { DEVICES } from '../src/shared/devices.ts';
mkdirSync('unotest/generated', { recursive: true });writeFileSync( 'unotest/generated/shared.json', JSON.stringify({ labels: CHAT_LABELS, devices: DEVICES }, null, 2),);Name the constants themselves. import * as labels would put the whole
module in the file: every export under its own name
(labels.CHAT_LABELS.send), and a zod schema exported next to them as a
dump of its internals.
step("Send uses the app's own label", () => { const shared = readJson('unotest/generated/shared.json'); click(getByRole('button', {name: shared.labels.send}));});Since 0.49.0, name the script in the config’s generate:
export default { generate: 'node --experimental-strip-types scripts/export-test-constants.ts',};It runs from the project root, the same base as a collection’s prepare,
before every local e2e and collection, before bundle push, and in
box create before the box is rented. So an
edit of the source reaches the next run without a manual step, and the
bundle carries a snapshot of the source you push. A box has your suite but
not your application, so it never runs the command: it reads the snapshot
that came in the bundle. init keeps unotest/generated/ out of git, and
bundle push packs it anyway. When the command fails, the run is refused
with exit 95 before anything starts; --no-generate skips it for one run.
On earlier versions, run the script yourself before a run and before
bundle push. Don’t use the collection’s prepare for it: prepare runs
on the box too, and the box has no application source to read.
The file is generated: don’t edit it by hand. The path is relative to the project and cannot leave it.
The DSL in one breath
Navigation (goto, waitForUrl), locators by stability (getByTestId →
getByRole → getByLabel → getByText → locator), actions (click, fill,
press, selectOption…), assertions (assertText, assertVisible…), chaining
(getByRole(...).filter(...).first()), multi-tab and iframes, and sandbox
helpers (dbQuery, apiCall, shell). See the DSL reference.
The language is JavaScript
Inside a step, a helper or a flow, a scenario is plain JavaScript:
let / const, arrows and closures, loops of every kind, switch,
try / catch, destructuring, template strings, ?. and ??. Calls
to the page return their result: the runner does the waiting, so a
scenario has nothing to await. A loop over cases goes inside a step, as
in the example above.
Since 0.52.0, scenarios run on V8, the engine of Node and Chrome, with
the semantics of the language as it is: classes, getters, generators,
Date, Symbol, instanceof and the built-in objects all work. What a
scenario adds is the vocabulary — click, getByRole, apiCall, … —
and what it does not have is the APIs of Node and of the browser:
require,process,fetch— useapiCall(…),shell(…)or an externalUPPER_SNAKEvariable.window,document,localStorage—evaluate(fn, arg)runs a function in the page.setTimeout,setInterval—pause(ms)orwaitUntil(() => …).console.logprints nowhere —log(…)ornote(label, value).import/export— helpers in_helpers/are visible by name.
Don’t write async or await: an async function returns at its first
await, and what follows runs after the test is over. Declare every
variable (let i = 0, not i = 0); lint --fix writes the missing
let. Moving a suite from an earlier version:
Upgrade to scenarios on V8.
Before 0.52.0 the language is a white list — Math, JSON, Object,
Map, Set and the string, number and array methods — and what
JavaScript has beyond it (class, Date, timers, host globals) is a
lint error that names what to write instead. See
DSL → Not supported.
Expected failures
A test that is supposed to fail — the failure is the assertion — carries
// @expect-fail in the // comments directly above its function line,
with no blank line between:
// id-checkout-flow// Checkout survives a declined card// #a13f7c// @expect-failfunction test_checkout() { /* … */ }In a file of several tests the marker marks that test only. A marker in
the file’s leading comments, set apart from the first function by a blank
line, marks every test of the file. An // @expect-fail anywhere else is
a comment about a step. The runner reads the marker: a marked test that
fails is reported as failed on purpose (a collection whose only failures are
on purpose exits 0; e2e of such a test still exits 1), and a marked test
that passes fails with a message saying the failure it expects is gone. A
marked test that breaks on a script error (an unknown function, an unset
variable) is a plain error — not the failure it promised. The viewer shows a
failure on purpose as expected (its own colour, not in the failed
count) and a green run of a marked test as failed — the thing it guarded
stopped being broken without anyone noticing.