This documentation is available as Markdown. For the complete index, see llms.txt. Skip to content

Authoring guide

For the complete documentation index, see llms.txt

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 outer step, and make each case a step.soft("…", {tag: id}, …): the run reaches every case and the verdict lists each failed one. Use a plain step when a failure must stop the run (setup, login).
  • Factor repeated journeys into flow_* helpers; seed data with dbExec / apiCall / shell mocks.

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.