This is the contract for writing good scenarios — the same guidance shipped with
the package at node_modules/@unotest/web/guides/agent-integration.md. Point your
agent here.
Structure
- One scenario file per feature, in a subfolder of
unotest/e2e/(not its root). - Export
test_*functions. - Wrap every executable step in
step("intent", () => { … }). The label is the human-readable intent; keep it specific. - Iterating over cases (a fixture file, a table)? Keep one
test_*, loop inside an outerstep, and make each case astep.soft("…", {tag: id}, …): the run reaches every case and the verdict lists each failed one. Use a plainstepwhen a failure must stop the run (setup, login). - Factor repeated journeys into
flow_*helpers; seed data withdbExec/apiCall/shellmocks.
Selectors
Use the most stable matcher available:
getByTestId → getByRole(name) → getByLabel → getByText → locator(css)Refine with filter({ hasText }) before first(). Avoid index-only nth(),
deep CSS, XPath, and hashed class names — the linter flags them.
Waiting
Prefer waitFor / assert* (which poll) over pause(ms). If you must pause,
add a // reason: comment.
Variables
Reference secrets and config by bare UPPER_SNAKE identifiers — never hardcode
credentials. When recording through explore_step, a bare name is a variable
only in a value field (fill value, press key, select_option value,
goto url, assert_value value); in a locator’s text or an assertion’s expected text write
{{NAME}} — a bare name there is the literal it looks like, and the reply
warns when it equals a variable’s name. The saved scenario never contains
{{…}}: the generator writes bare identifiers, and the linter rejects mustache
in DSL source (lint:mustache-in-dsl).
evaluate and its argument
evaluate(js, arg?) is the last resort. The body gets at most one argument:
none → nothing, exactly one → the value itself, two or more → one array,
so write evaluate('([a, b]) => a + b', a, b), not (a, b) =>. The linter
warns (lint:evaluate-discouraged) and says so when it sees two or more.
Dialogs
confirm() / alert() / prompt() are answered by the runner itself
(dialogPolicy, accept by default) — a “Remove” that asks first needs no
special step. An agent attached to a run’s browser (attach_debug_session,
or run_test’s auto-attach) applies the run’s policy too, so such a
scenario behaves the same with or without the agent watching.
Input values
A textarea / input value is not page text: waitForText and assertText
never see it. Check it with assertValue(loc, expected) — it polls until the
timeout — or read it with getInputValue(loc).
Shared stands
Never assert “the first row of the list / audit”: another run writes rows
between your steps. Give your own data a marker (randomWord() in a name or
note), find your row by it (.filter({hasText: marker})), and remove what
you created at the end of the scenario.
When the grounder is unavailable
explore_start answers grounder: {available: true} or
{available: false, reason, hint} — intent locators are off for that
session, and an intent step says the same instead of the backend’s error.
Use find_element({role, name}) and its ref.
Saved locators
save_exploration_as_test writes every locator in the chain form the DSL
reads best — locator('tr[data-row="A"]').getByRole('button', {name: 'Reveal', exact: true}),
getByRole('row').filter({hasText: 'Alice'}).first() — scoped steps
included.
The repair loop
On failure: call inspect_runtime, read the failure bundle, classify the cause
(rewrite-selector / add-waitfor / change-assertion), produce a diff, and
hand it to the human. Do not apply patches silently.
Verify before finishing
Run the scenario (run_test) and confirm it passes. Lint it
(npx @unotest/web lint). The result must be clean, readable .js.