Scenarios are plain .js on a sandboxed engine. The vocabulary mirrors Playwright, so it reads the way you expect. Every executable step lives inside step("intent", () => { ... }).
This page lists the vocabulary one function at a time. For what a few of them build together — one test over a table of cases, each row tagged and allowed to fail on its own (readJsonLine + step.soft + {tag}) — see Scenarios, which walks a whole data-driven gate.
Documented for @unotest/web@0.33.0 — signatures and descriptions come from the package itself, the same source your editor shows on hover.
Navigation
Drive the page: load URLs, go back/forward, and wait for state.
goto(url, { waitUntil?, timeout? })— Navigate to a URL.waitUntil: ‘load’ | ‘domcontentloaded’ | ‘networkidle’. Relative paths resolve againstbaseUrl.reload({ waitUntil?, timeout? })— Reload the current page.goBack({ timeout? })— Navigate back in history.goForward({ timeout? })— Navigate forward in history.waitForUrl(pattern, { timeout? })— Wait until the URL containspattern(substring match).waitForNavigation({ timeout? })— Wait for the next navigation event.waitFor(locator, { state?, timeout? })— Wait for an element state: DOM presence (‘visible’ default, ‘hidden’, ‘attached’, ‘detached’) or interaction (‘enabled’ / ‘disabled’ — same probe as isEnabled, so aria-disabled counts).waitForText(text, { first?, timeout? })— Wait untiltextappears anywhere on the page.first: truewaits for the first VISIBLE occurrence when the text is duplicated (otherwise duplicates fail strict mode).waitForCount(locator, count, { exact?, timeout? })— Poll until the locator matches AT LEASTcountelements ({exact: true}for exactly). Default timeout 5000 ms.pause(ms)— Explicit delay. Discouraged — the linter wants a// reason:comment. Prefer awaitFor*.
Locators
Build a locator. Prefer the most stable matcher available — see Stable selectors.
getByTestId(id)— Match bydata-testid. Most stable — prefer this.getByRole(role, { name?, exact? })— Match by ARIA role + accessible name.nameaccepts a string or/regex/.getByLabel(text, { exact? })— Match a form control by its associated label.getByText(text, { exact? })— Match by visible text content.getByPlaceholder(text, { exact? })— Match an input by its placeholder.getByAltText(text, { exact? })— Match an image by itsalttext.getByTitle(text, { exact? })— Match bytitleattribute.locator(css)— Match by raw CSS selector. Last resort — the linter warns on deep/brittle CSS.
Chain refiners
Narrow a locator. The chain form and the free-call form are the same call — getByRole(...).filter({...}).first() ≡ first(filter(getByRole(...), {...})); the recorder saves the chain form.
filter(locator, { hasText?, hasNotText?, has?, hasNot? })— Keep matches by contained text or a nested child locator.first(locator)— First match.last(locator)— Last match.nth(locator, index)— The N-th match (0-indexed). Index-only refinement is fragile (linter info).randomNth(locator)— A random one of the matches — for flows where any item will do.contentFrame(locator)— Resolve an<iframe>element to its content frame.loc.getByRole(...) / loc.locator(css) / …— Every builder also works as a chain step — the receiver scopes the query to its subtree (Playwright semantics).
Actions
Interact with elements. Every action takes an options object; e.g. { force: true }.
click(locator, { force?, timeout?, noWaitAfter? })— Click an element.doubleClick(locator, { force?, timeout? })— Double-click an element.fill(locator, value, { timeout?, noWaitAfter? })— Clear and type a value into an input.press(locator, key, { delay?, timeout? })— Press a key (e.g. ‘Enter’, ‘Control+A’).check(locator, { force?, timeout? })— Check a checkbox/radio.uncheck(locator, { force?, timeout? })— Uncheck a checkbox.hover(locator, { force?, position?, timeout? })— Hover over an element.positionis an {x, y} offset inside it — for a menu that only appears at one edge of a tall element.selectOption(locator, value, { timeout? })— Select option(s) in a<select>.valueis a string or string[].scrollIntoView(locator)— Scroll an element into the viewport.dragAndDrop(from, to, { force?, timeout? })— Drag one element onto another. Uses synthetic mouse events (not native HTML5 DragEvent).uploadFile(locator, files)— Set files on a file input.filesis a path or path[].clipboardPaste(locator, text)— Paste text. Synthetic, not a native ClipboardEvent.
Assertions
Polling assertions (default timeout 5000ms). Assertion failures never retry.
assertText(locator, expected, { exact?, timeout? })— Element’s text equals/containsexpected.assertVisible(locator, { timeout? })— Element is visible.assertHidden(locator, { timeout? })— Element is hidden or detached.assertNeverAppears(locator, { withinMs })— Negative window assert: FAIL the moment the locator becomes visible; pass when the whole window elapsed clean. withinMs required; the step always costs that long.assertValue(locator, expected, { timeout? })— Input’s value equalsexpected. On a checkbox/radio/switch: its checked state — pass ‘true’ or ‘false’.assertCount(locator, expected, { timeout? })— Number of matches equalsexpected.assertUrl(pattern, { timeout? })— Current URL containspattern.assertJudge(locator, rubric)— LLM-judge assert for free-form text (chat replies, generated content): the element’s rendered text + a natural-language rubric go to the configured judge (UNOTEST_JUDGE_MODE); a fail verdict fails the step with the judge’s reasoning. Use where substring/regex asserts are too brittle.assertTrue(condition, message?)— Assert a boolean condition.
Queries
Read values (non-polling). Use to branch logic in plain JS.
count(locator) → number— Number of matching elements.textContent(locator) → string— Text content ("" if none).inputValue(locator) → string— Current input value.isVisible(locator) → boolean— Whether the element is visible.isDisabled(locator) → boolean— Whether the element is disabled (native :disabled incl. fieldset, or aria-disabled).isEnabled(locator) → boolean— Negation of isDisabled.getAttribute(locator, name) → string— Attribute value ("" if missing). Boolean attributes (disabled, readonly) read "" whether present or absent — use isDisabled / hasAttribute for those.hasAttribute(locator, name) → boolean— Whether the attribute is present at all — the honest probe for boolean attributes.getInnerText(locator) → string— Rendered inner text.getInputValue(locator) → string— Input value (alias of inputValue).getTitle() → string— Document title of the active page.getUrl() → string— URL of the active page.
Storage & cookies
Read/write localStorage and cookies for setup and assertions.
setLocalStorage(key, value)— Set a localStorage item.getLocalStorage(key) → string— Read a localStorage item ("" if missing).setCookie(name, value, options?)— Set a cookie (options: path, domain, expires, httpOnly, secure, sameSite).getCookie(name) → string— Read a cookie value ("" if missing).
Multi-tab & iframes
Work across tabs and nested frames.
setPage(index)— Switch the active tab/page by index (0-based).enterFrame(locator)— Scope subsequent calls to an iframe (persists until exitFrame).exitFrame()— Exit the innermost iframe scope.
Setup & data (sandbox)
Seed and verify state. Connection details are pinned in config — scenarios cannot redirect them.
dbQuery(sql, ...params) → rows[]— Parameterized SELECT. Dialect from the configdatabaseURL (postgres/mysql/sqlite).dbExec(sql, ...params) → number— Parameterized INSERT/UPDATE/DELETE. Returns affected row count.apiCall(method, path, body?, headers?) → { status, body, headers }— HTTP call.pathis relative — base is the configapiBaseUrl. Multipart: pass upload(…) as the body.upload(path, options?) → UploadRef— Multipart marker for apiCall’s body (options: field, fields). Path is relative to the project root / configuploadDir.shell(cmd, ...args, options?) → { stdout, stderr, code }— Run a binary (execFile, no shell interpretation). cwd from configshellCwd. Non-zero exit FAILS the step unless {allowNonZero: true}; wall-clock budget {timeoutMs} (default 120s).waitForFile(path, pattern?, options?) → string— Poll a file (relative to configshellCwd) until it exists / contains a substring or regex, then return its content. options: {timeoutMs} (default 20s). The waitForText of background processes.assertNoFile(path, pattern?, { withinMs })— Mirror of waitForFile with inverted polarity: FAIL the moment the file exists (and matches pattern); pass when the window elapsed clean. withinMs required.waitForJsonLine(path, filter, options?) → object— Wait for a LINE of a JSONL file that matches every key offilter(strict equality, dot paths for nesting), then return it parsed. What a substring cannot express: JSON key order is not guaranteed. options: {timeoutMs} (default 20s).assertNoJsonLine(path, filter, { withinMs })— Negative twin of waitForJsonLine: FAIL the moment a matching line appears; pass when the window elapsed clean. withinMs required.readJsonLine(path, filter) → object— First matching line of a JSONL file that already exists, no wait: one read, same key filter as waitForJsonLine. FAILS at once when the file is missing or no line matches — use it for fixtures and finished exports, waitForJsonLine for a file still being written.waitForFileCount(path, pattern, count, options?) → number— Wait until AT LEASTcountlines match, then return how many there are.patternis a substring, a regex or a JSON key filter. options: {timeoutMs} (default 20s).assertFileCount(path, pattern, count)— Snapshot count of matching lines, strict equality — ‘exactly N happened’. Pair it with waitForFileCount, which awaits the window.
Generators & text helpers
Random fixture data, time values and text probes — no imports, no Date/Math in scenarios.
randomFirstName() → string— Random first name for fixture data.randomLastName() → string— Random last name for fixture data.randomEmail() → string— Random unique email address for fixture data.randomText(length) → string— Lorem-style prose cut tolengthcharacters.randomWord(length?) → string— Random lowercase letters (default 8) — digit-free unique markers.json(value) → string— Serialize any value to a JSON string — readable assert messages for API/data steps: assertTrue(res.status == 202, json(res.body)).textContains(haystack, needle) → boolean— Substring probe for strings (shell stdout, api bodies): assertTrue(textContains(out.stdout, ‘ready’), out.stderr).textJoin(parts) → string— Concatenate an array of parts (numbers coerced).+with a string literal also works; only literal-freea + bstring addition is rejected (looks like arithmetic).nowMs() → number— Current epoch milliseconds.today() → string— Local date as YYYY-MM-DD.daysFromNow(n) → string— Local date n days ahead (negative = past) as YYYY-MM-DD.
Structure & escape hatch
The required step wrapper, diagnostics, and the raw-JS escape hatch.
step(label, () => { ... })— Required around every executable step in atest_*function.labelis the human-readable intent the agent reads to repair the step. Forms since 0.31.0:step(label, {tag: expr}, () => { ... })names the case a data-driven step is on;step.soft(label, [{tag}], () => { ... })records a failure inside and lets the run continue (the test still ends failed) — insidetest_*only.log(...args)— Write a line to the runner’s output AND to the run journal, under the current step — visible in the viewer’s System pane after the run too.note(label, value)— Attach a labelled value to the current step: shown in the viewer under the step (also after the run) and kept in the run journal. Any value — a string, a number, an object (recorded as JSON). Secrets are masked; long values are cut at 4 KB. Use it for the question a data-driven case asked and the answer it got.screenshot(name?, { fullPage? }) → string— Capture the active page into the run’s artifact dir; returns the project-relative path. For a PNG after EVERY step without calling it, set UNOTEST_STEP_SCREENSHOTS=1 in the run’s environment.evaluate(js, arg?) → any— Run raw JS in the page context. Last resort — prefer typed helpers. Returns JSON-serializable values only. Extra arguments reach the body as: none → nothing, exactly one → the value itself, two or more → ONE array (destructure([a, b]) => …).
Expressions & operators
- Comparison:
==/!=(loose — numeric strings coerce,"1" == 1is true),===/!==(strict — type AND value,1 === "1"is false),<<=>>=. - Logical:
&&/||. - DOM reads (
textContent,getInputValue,getAttribute) return strings — compare against numbers with==, not===.
Not supported
- Control-flow keywords are plain JS around steps, not DSL primitives.
- Regex literals are allowed in matcher args, ES5 flags only (
g i m);s u y d, named groups and lookbehind are rejected at parse time. - waitForPage() is not shipped yet — use pause(ms) with a
// reason:comment for tab races.