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

DSL reference

For the complete documentation index, see llms.txt

Scenarios are plain .js on the scenario 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.55.0 — signatures and descriptions come from the package itself, the same source your editor shows on hover.

Drive the page: load URLs, go back/forward, and wait for state.

  • goto(url, { waitUntil?, timeoutMs? }) — Navigate to a URL. waitUntil: ‘load’ | ‘domcontentloaded’ | ‘networkidle’ | ‘commit’. Relative paths resolve against baseUrl.
  • reload({ waitUntil?, timeoutMs? }) — Reload the current page.
  • goBack({ waitUntil?, timeoutMs? }) — Navigate back in history.
  • goForward({ waitUntil?, timeoutMs? }) — Navigate forward in history.
  • waitForUrl(pattern, { waitUntil?, timeoutMs? }) — Wait until the URL contains pattern (a string, substring match) or matches it (a /regex/, unanchored).
  • waitForNavigation({ timeoutMs? }) — Wait for the next navigation event.
  • waitFor(locator, { state?, timeoutMs? }) — 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, { exact?, state?, timeoutMs? }) — Wait until text appears anywhere on the page. Duplicated text is fine: the wait looks at its first VISIBLE occurrence.
  • waitForCount(locator, count, { exact?, timeoutMs? }) — Poll until the locator matches AT LEAST count elements ({exact: true} for exactly). Default timeout 5000 ms.
  • waitForAnimations(locator, { timeoutMs? }) — Wait until the element and its descendants have no animation still running — a dialog done fading in, a card done sliding. Infinite ones (spinners) and paused ones are not waited for. Default timeout 5000 ms; a timeout names the element and how many were left.
  • waitUntil(() => condition, {timeoutMs, intervalMs}) — Poll the arrow body until it is truthy and return that value (Playwright expect.poll / toPass). Only a falsy result is retried; an error fails at once. State between tries lives in a let inside the step; a let at the file’s top level never runs. Since 0.45.0.
  • pause(ms) — Explicit delay. Discouraged — the linter wants a // reason: comment. Prefer a waitFor*.
  • clockFreeze(iso) — Stop time on the page at an ISO instant: Date reads it and no timer fires until clockTick / clockRun. Any instant, past or future.
  • clockTick(ms) — Move frozen time forward by ms, firing every timer due in it.
  • clockRun() — Let time on the page run again from where it stands.

Locators

Build a locator. Prefer the most stable matcher available — see Stable selectors.

  • getByTestId(id) — Match by data-testid. Most stable — prefer this.
  • getByRole(role, { name?, exact?, checked?, disabled?, expanded?, includeHidden?, level?, pressed?, selected? }) — Match by ARIA role + accessible name. name accepts a string or /regex/; the state options narrow by ARIA state ({disabled: false} skips a disabled button). Any other key is refused.
  • 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 its alt text.
  • getByTitle(text, { exact? }) — Match by title attribute.
  • locator(css, { hasText?, hasNotText?, has?, hasNot? }?) — Match by raw CSS selector. Last resort — the linter warns on deep/brittle CSS. Options narrow the matches exactly as filter() does.

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?, timeoutMs?, position?, modifiers?, button?, clickCount? }) — Click an element.
  • doubleClick(locator, { force?, position?, modifiers?, timeoutMs? }) — Double-click an element.
  • fill(locator, value, { force?, timeoutMs? }) — Clear and type a value into an input.
  • press(locator, key, { delay?, timeoutMs? }) — Press a key (e.g. ‘Enter’, ‘Control+A’).
  • check(locator, { force?, timeoutMs? }) — Check a checkbox/radio/switch: click, then wait until it reads checked (a controlled widget may flip a beat later). Already checked → no click.
  • uncheck(locator, { force?, timeoutMs? }) — Uncheck a checkbox/switch: click, then wait until it reads unchecked. Already unchecked → no click.
  • hover(locator, { force?, position?, timeoutMs? }) — Hover over an element. position is an {x, y} offset inside it — for a menu that only appears at one edge of a tall element.
  • selectOption(locator, value, { force?, timeoutMs? }) — Select option(s) in a <select>. value is the option’s value (a string, {value}, or string[] for a multi-select), its visible text {label: ‘Dark blue’}, or its position {index: 0}. Waits for the option; a {label} or {index} no option has fails naming the labels the element holds.
  • scrollIntoView(locator) — Scroll an element into the viewport.
  • dragAndDrop(from, to, { force?, timeoutMs? }) — Drag one element onto another. Uses synthetic mouse events (not native HTML5 DragEvent).
  • uploadFile(locator, files) — Set files on a file input. files is a path or path[].
  • clipboardPaste(locator, text) — Paste text. Synthetic, not a native ClipboardEvent.
  • clipboardRead() → string — The clipboard’s text as the active page reads it — what a ‘copy’ button left there. Chromium and Firefox, on an https or localhost page; WebKit refuses with an error, never an empty string. No wait: use assertClipboard for a write that lands after the click.
  • assertClipboard(expected, { timeoutMs? }) — Wait until the clipboard holds expected: the whole text for a string, a match for a regex. options: {timeoutMs} (default 5s). Same engines as clipboardRead.

Assertions

Polling assertions (default timeout 5000ms). Assertion failures never retry.

  • assertText(locator, expected, { exact?, not?, timeoutMs? }) — Element’s text equals expected (a string; exact: false — contains it) or matches it (a /regex/). {not: true} waits until it no longer does. Empty: assertText(loc, ”); not empty: assertText(loc, ”, {not: true}).
  • assertVisible(locator, { timeoutMs? }) — Element is visible.
  • assertHidden(locator, { timeoutMs? }) — 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.
  • assertNever(() => condition, {withinMs, intervalMs, label}) — Negative window for a condition: poll the arrow body for the whole window and fail the moment it is truthy, naming the value and the time; pass when the window elapsed clean. withinMs required. An error fails at once.
  • assertValue(locator, expected, { timeoutMs? }) — Input’s value equals expected. On a checkbox/radio/switch: its checked state — pass ‘true’ or ‘false’.
  • assertCount(locator, expected, { timeoutMs? }) — Number of matches equals expected.
  • assertUrl(pattern, { timeoutMs? }) — Current URL contains pattern (a string) or matches it (a /regex/, unanchored). A failure names the pattern as /source/flags.
  • 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.
  • assertScreenshot(name \| locator, name?, { maxDiffRatio?, threshold?, mask?, ignore?, fullPage? }) — The page (or an element) against the PNG baseline unotest/e2e/<scenario>.screenshots/<name>.png. A pixel differs when any channel differs by more than threshold (default 32); fails when the share of differing pixels is MORE than maxDiffRatio (default 0). mask: locators, ignore: rectangles in baseline pixels — both left out of the comparison. No baseline: fails and saves the snapshot as a candidate; accept with e2e —update-screenshots. On failure the run keeps expected / actual / diff.
  • assertTrue(condition, message?) — Assert a boolean condition.
  • assertEqual(actual, expected) — Deep equality as JSON sees it: key order does not count, a missing key differs from a null one. FAILS naming the first differing path (chat.labels[0].send) with both values, plus a line diff of the two sides. The DSL form of toEqual.
  • assertSchema(value, schema) — Check a value against a JSON Schema (draft 2020-12), e.g. one your app generates from its zod schemas: assertSchema(res.body, readJson(‘unotest/fixtures/message.schema.json’)). FAILS naming every broken field by path (/items/0/id) with what is wrong. External $ref is refused; format is not checked.
  • assertTexts(locator, expected, { exact?, timeoutMs? }) — The locator matches exactly expected.length elements, and element i’s text matches expected[i] (string or /regex/, same rules as assertText), in order.
  • assertAttribute(locator, name, expected, { exact?, not?, timeoutMs? }) — Polls the attribute name until it equals expected (exact: false — contains) or matches a /regex/. An absent attribute matches nothing. {not: true} waits until it no longer matches or is gone.
  • skip(reason, condition?) — End the test as skipped when the condition is true (always, without one) — it does not apply here: skip(‘no live API configured’, env(‘LIVE_API’) == null). The run reports skipped with the reason, not passed and not failed; teardown blocks already registered still play. A test guard: at the level of the test_* body, next to its steps, not inside one.

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).
  • texts(locator) → string[] — textContent of every match, in DOM order; [] when nothing matches. No wait — the list to branch on; assertTexts is the waiting check.
  • inputValue(locator) → string — Current input value.
  • isVisible(locator) → boolean — Whether the element is visible.
  • isChecked(locator) → boolean — Checked state of a checkbox/radio/switch (native or ARIA role). Fails on any other element instead of answering false.
  • 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.
  • boundingBox(locator) → {x, y, width, height} \| null — Where the element lies in the viewport, CSS pixels; null when it is not rendered (display:none, detached). Does not scroll.
  • computedStyle(locator, property) → string — The element’s computed CSS value, as the browser resolved it: computedStyle(card, “opacity”) → “0.5”, custom properties too (“—accent”). "" when the property is not set.
  • getImageSize(locator) → {width, height} \| null — Natural size of an <img>, once it has loaded or failed (waits up to the action timeout); null when it has no pixels — broken, or no src. Not an <img> fails.
  • hasAnimation(locator) → bool — Whether the element or a descendant has an animation still running towards an end. Infinite (spinners) and paused ones do not count — the same rule waitForAnimations waits by.
  • animations(locator) → [{ name, kind, playState, currentTime?, infinite }] — Every animation of the element and its descendants: name is the @keyframes name, the transitioned property or a script animation’s id; kind is animation, transition or script; playState is running, paused, finished or idle; currentTime in ms, left out when it has none; infinite for one that repeats forever. Infinite and paused ones are listed too — hasAnimation counts only playState: 'running' with infinite: false.

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, nested frames and a browser extension’s service worker.

  • setPage(index, options?) — Switch the active tab/page by index (0-based) and bring it to front. {activate: false} switches only the tab the steps act on and leaves the visible tab as is.
  • waitForPage(options?) → number — Wait for a tab the scenario has not been handed yet (window.open, target=_blank, an extension), switch to it and return its index. Counts a tab that opened before the call. {timeoutMs} defaults to the navigation timeout.
  • newPage(url?) → number — Open a new tab in the active browser context, make it active and return its index. With a url the tab opens it (relative to the base URL, as goto).
  • closePage(index?) — Close a tab; no index: the active one. Closing the active tab makes the tab that was active before it active again. The only open tab cannot be closed.
  • bringToFront(index?) — Bring a tab to front — the visible one — without changing the tab the steps act on. No index: the active tab.
  • setPageHidden(hidden, index?) — Emulates the tab’s visibility for its scripts: true sends it to the background (document.hidden, visibilityState ‘hidden’, blur + visibilitychange), false brings it back. Stays across navigations. Timers, animation frames and freeze are not emulated. No index: the active tab.
  • setViewport({width, height}) — Resize every tab of the browser, and the ones it opens later, without reloading. The scale (deviceScaleFactor) is set in the config, not here.
  • emulateMedia({ reducedMotion?, colorScheme? }) — Change the media features the current tab reports from now on — what matchMedia and CSS @media see: reducedMotion ‘reduce’ | ‘no-preference’, colorScheme ‘light’ | ‘dark’ | ‘no-preference’. This tab only: a tab or popup opened later starts without it.
  • addVirtualAuthenticator({ protocol?, transport?, hasResidentKey?, hasUserVerification?, isUserVerified?, automaticPresenceSimulation? }) → id — A passkey without a person at a device: a virtual WebAuthn authenticator on the active tab answers navigator.credentials.create/get. Default: a phone or laptop passkey (ctap2, internal, resident key, verified user, no touch needed). Chromium only; the page must be https or localhost. A tab opened later does not see it.
  • virtualCredentials(id?) → [{ credentialId, rpId, userHandle, signCount, resident }] — The credentials a virtual authenticator holds — one, or all of them. Ids are base64url, as the page sees them; signCount grows with each sign-in.
  • removeVirtualAuthenticator(id) — Take a virtual authenticator off; its passkeys go with it. Optional: the context’s close removes them all.
  • grantPermissions(permissions, { origin? }) — Grant browser permissions to the context — [‘notifications’], [‘geolocation’] — for every origin or {origin}. A name the engine does not know fails naming the engine; earlier grants stay.
  • setOffline(offline) — setOffline(true) cuts every page of the context off the network; setOffline(false) connects it again. Undone when the context closes.
  • newContext({ profile?, cdpEndpoint? }) → number — Open another browser context — a separate client with its own cookies, storage and tabs — and make it active. Returns its number (the run’s own is 0). With profile: '<dir>' the context runs on a browser profile of its own in that directory (a relative one starts at the project root; made if missing, kept after the run): two devices side by side, each keeping what it stored. The same directory twice in a run is refused. With cdpEndpoint: 'http://<ip>:9222' the context opens in a Chrome somebody else started with remote debugging (a VM’s browser: vmRun gives the address) and the test only disconnects at the end; it cannot be combined with profile.
  • setContext(index) — Make browser context index active. Each keeps its own tab and frame, so switching back lands where it was.
  • enterFrame(locator) — Scope subsequent calls to an iframe (persists until exitFrame).
  • exitFrame() — Exit the innermost iframe scope.
  • waitForServiceWorker({ timeoutMs?, url? }) → string — Wait for a browser extension’s service worker and return its URL (chrome-extension://<id>/sw.js); a running one comes back at once. url — substring or /regex/ when several extensions run. Extensions load only with launch.userDataDir: without it the call fails at once.
  • evaluateInServiceWorker(js, ...args) → any — Like evaluate, in the extension’s service worker — the way to chrome.storage, chrome.tabs, chrome.runtime. js is a function or a string; same argument, page-code and JSON-return rules; waits for the worker if none runs. Each call finds the worker anew: MV3 stops an idle one.
  • extensionId() → string — The id of the extension whose service worker runs — for goto('chrome-extension://' + extensionId() + '/popup.html').

MCP client

Start a stdio MCP server, call its tools, wait for its notifications — with the same report as browser steps.

  • mcpStart(command, args?, { env?, cwd?, timeoutMs? }) → server — Start a stdio MCP server and finish its handshake; returns the handle the other mcp* functions take. env adds variables to the inherited environment (the journal names them, never their values); cwd is relative to the project. A server the test did not stop is stopped when the test ends, red or green. Default timeout 30 s.
  • mcpCall(server, tool, args?, { timeoutMs?, allowError? }) → { content, structuredContent?, isError? } — Call a tool and return its result. A JSON-RPC error, no answer within timeoutMs (default 30 s), or a result marked isError fails the step — {allowError: true} returns that result instead. The call and the result are notes in the run.
  • mcpWaitNotification(server, method, { match?, timeoutMs? }) → params — Wait for a notification of method and return its params. Notifications are kept from the moment the server started, each handed out once, so call first, then wait. match takes scalars at dot paths of the params ({'data.status': 'done'}). Default timeout 30 s.
  • mcpStop(server) — Stop the server. Optional: whatever the test leaves running is stopped when it ends.

Virtual machines

Clone a ready Tart image, boot it, reach it by IP — a clean machine per test, removed when the test ends.

  • vmAvailable() → bool — Whether VMs can run here: Tart is installed and answers. Guard a VM test with skip(‘no tart’, !vmAvailable()).
  • vmClone(image) → name — A new VM from a ready image (a local Tart VM or an OCI reference); returns its name, unotest-<id>-<n>, unique to the test. Every VM the test cloned is stopped and deleted when it ends, red or green.
  • vmRun(name, { timeoutMs? }) → ip — Boot the VM without a window and wait for its address; returns the IP. Default timeout 180 s. A boot that fails errors at once with Tart’s output.
  • vmIp(name, { timeoutMs? }) → ip — The address of a running VM. Default timeout 60 s.
  • vmStop(name) — Stop the VM. Optional: the test’s VMs are removed when it ends.
  • vmDelete(name) — Delete the VM, stopping it first if it runs. Optional, as vmStop. The vm* steps take only a name vmClone returned in this test — other VMs on the machine are never touched.

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 config database URL (postgres/mysql/sqlite).
  • dbExec(sql, ...params) → number — Parameterized INSERT/UPDATE/DELETE. Returns affected row count.
  • apiCall(method, path, body?, headers?, { base?, timeoutMs? }) → { status, body, headers } — HTTP call. path is relative — base is the config apiBaseUrl, or {base: 'API_BASE_X'} names an env variable holding another. Multipart: pass upload(…) as the body. {timeoutMs} bounds the whole call, reply body included; past it the request is aborted and the step fails naming the call. Without it the call waits as long as the step does.
  • upload(path, options?) → UploadRef — Multipart marker for apiCall’s body (options: field, fields). Path is relative to the project root / config uploadDir.
  • shell(cmd, ...args, options?) → { stdout, stderr, code } — Run a binary (execFile, no shell interpretation). cwd from config shellCwd. 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 config shellCwd) 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.
  • waitForDownload(options?) → { path, name } — The first file the page downloaded that the scenario has not taken yet, saved with the run: path is absolute (read it with waitForFile), name is the name the site suggested. Downloads are recorded from the start, so click first, then call this. options: {timeoutMs} (default: the navigation timeout), {name} (substring or regex of the file name).
  • waitForJsonLine(path, filter, options?) → object — Wait for a LINE of a JSONL file that matches every key of filter (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.
  • readJson(path) → any — A whole JSON file, read once and parsed: app constants a build script exported, fixture objects. Path is relative to config shellCwd and must stay inside it. FAILS at once when the path leaves the project, the file is missing or it is not valid JSON. Walk the result with . and [].
  • env(name, fallback?) → string \| fallback \| null — An environment value the test can do without: the ambient environment, unotest/.env or .secrets, as a bare UPPER_SNAKE name reads it — but the fallback (null without one) when unset instead of failing. A default in one line: env(‘API_URL’, ‘http://localhost:3000’). For deciding on it: if (env(‘LIVE_API’) == null) { … }. A secret is refused, fallback or not; write its bare name where the value is typed.
  • readFile(path, options?) → string — A whole file, read once: its text, or its bytes as base64 with {encoding: ‘base64’}. Path is relative to config shellCwd, or absolute inside the project or to a file waitForDownload saved (its .path). FAILS at once when the path or a symlink leads out, the file is missing or the encoding is unknown.
  • readFileInfo(path) → object — What to assert about a file without reading it: {size} in bytes, plus png: {width, height} for a PNG (from its header). Same path rules and failures as readFile.
  • waitForFileCount(path, pattern, count, options?) → number — Wait until AT LEAST count lines match, then return how many there are. pattern is 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.
  • assertFileHas(path, needle, { timeoutMs? }) — A string or a regex must appear in a file, or anywhere in a directory (searched recursively; symlinks inside are not followed). Polls until found; options: {timeoutMs} (default 20s). A string is searched as bytes, so binary files count; a regex reads the file as UTF-8. A miss FAILS saying how many files and bytes it searched.
  • assertFileLacks(path, needle, { withinMs? }) — A string or a regex must NOT appear in a file or a directory tree — a secret in a log or a DB dump. Searches once; {withinMs} watches that window instead. A hit FAILS naming the file and line, the matched bytes replaced by ‹match, N bytes›. A path that does not exist FAILS too: nothing searched proves nothing.
  • mockRoute(glob, response) — From now on, requests matching glob (a Playwright URL glob: **/api/items*) get a fixed answer: {status?, body?, headers?, contentType?} (an object body goes as JSON), {abort: true, record?} or {continue: true} (to the network). Where several masks match, the one mocked last answers; mocking a mask again replaces its answer. A response to **/* is refused; the site’s own page is never aborted. A mask reaches http(s) requests only — never a WebSocket, which waitForWebSocket waits for — so an extension’s own pages and scripts load under **/*; to mock them, start the mask with their scheme (chrome-extension://<id>/x.js). Mocks last until the scenario ends.
  • unmockRoute(glob?) — Take off the mock at glob, or every mock when called without one.
  • waitForRequest(glob, { timeoutMs? }) → { url, method, headers, postData, postDataJson } — Wait for a request matching glob and return it; postDataJson is the body read as JSON (null when it is not). On a mocked mask it also finds a request sent before the call, each one returned once; on any other mask only requests from the call on. Default timeout 20 s.
  • waitForResponse(glob, { status?, method?, since?, timeoutMs? }) → { url, method, status, ok, headers, json, text, truncated?, mocked? } — Wait for the answer to a request matching glob and return it: headers of the response (names in lower case), json (null when the body is not JSON, was not read or was cut), text (text or JSON bodies of fetch / xhr, at most 64 KB, then truncated). It looks at requests sent from the start of the last call that acted on the page (a click, fill, press, goto, evaluate…); a log, a note or an assertion in between moves nothing, and an answer that came before the call is found. Of those, the one sent first that no earlier waitForResponse returned. {status} skips other statuses, {method} other methods; a request that failed is no answer. {since} (epoch ms from nowMs()) sets the start instead — needed when another action came between the one that sent the request and the wait. A timeout fails the test and names the answers it passed over. The answer is in, not drawn: for “never appears” follow with assertNeverAppears. Default timeout 20 s.
  • waitForWebSocket(glob, { timeoutMs? }) → { url } — Wait until the active page has opened a WebSocket whose URL matches glob (a Playwright URL glob: **/ws, ws://localhost:*/live) and return its URL. A socket opened before the call counts, from the page’s last load on: a new document starts the list again. A socket is no request: waitForRequest and mockRoute never see one. A timeout names the mask and the sockets the page did open. Default timeout 20 s.
  • assertNoRequest(glob, { since? }) — FAIL if the recording abort at glob — mockRoute(glob, {abort: true, record: true}) — stopped any request; with since (epoch ms, from nowMs()) only from then on. A mask without a recording abort is an error, not a pass.
  • networkEntries({ since?, glob? }) → [{ url, method, status, ok, headers, failureText?, mocked?, requestJson, responseJson, responseText, truncated?, at }] — What the active page requested and was answered, oldest first: the status and, for fetch / xhr with a text or JSON content type, the bodies (requestJson as postDataJson of waitForRequest; responseJson is null when the body is not JSON; at most 64 KB each, then truncated). Other resources have a status only; headers are the response’s (names in lower case, empty for a request that failed); a request that failed has status 0 and failureText. Recorded from the moment the page opened, but a response is entered when it finishes, so wait for it. options: {since} (epoch ms of sending, from nowMs()), {glob} (a mask; one starting with / is read on the site’s origin).
  • openTerminal(app?, { host?, leaveOpen?, env?, cwd?, shell?, cols?, rows?, term?, locale?, cleanProfile? }) → number — Open a terminal and make it the one the terminal steps act on; returns its index for setTerminal. No app, or ‘shell’: your shell on a pty of this machine read through a terminal emulator — headless, so it runs on Linux, in CI and in a container (needs the optional node-pty; without it the step names the install command). ‘Terminal’ (Terminal.app) or ‘iTerm2’: a real macOS window, on this Mac by default (it takes the keyboard while the test types); {host: ‘user@address’} opens it on another Mac over ssh, a Tart VM, whose logged-in session runs it — the key comes from your ssh agent or ~/.ssh. Every terminal closes when the test ends: the processes on its tty get HUP, then KILL if they ignore it, and a note lists what was stopped; a process nothing stops fails the test’s end. {leaveOpen: true} leaves the terminal and its processes running, to look at afterwards. The shell terminal also takes {env, cwd, shell, cols, rows, term, locale, cleanProfile} — how its shell starts (cleanProfile: no rc files, a fixed $ prompt, a minimal environment); a GUI window refuses them. openTerminal(‘ssh’, {host: ‘user@address’}) opens a session over ssh -tt on a Linux host with no GUI: a [Y/n] or a password prompt of the host reaches the steps; a password is never passed (key from the ssh agent or ~/.ssh).
  • setTerminal(index) — Make the terminal index (from openTerminal) the one the terminal steps act on.
  • closeTerminal() — Close the active terminal and stop what runs in it: HUP to the processes on its tty and their children, KILL for those that ignore it; a note lists them. Terminals left open close when the test ends anyway.
  • terminalType(text, { echo? }) — Put text into the active terminal as a paste, line by line with Return between the lines, so the keyboard layout does not change it (a window’s clipboard is restored after). Each line must show on the screen within 2 s, or the step fails quoting the screen’s last rows; {echo: false} skips that wait for input a program does not echo (a password). A window gets nothing when another one took the focus — the step fails instead.
  • terminalPress(key) — Press a key in the active terminal, with modifiers held as press() spells them: ‘Enter’, ‘Shift+Enter’, ‘Control+]’, ‘Escape’, ‘ArrowUp’, ‘F5’; a character with a modifier is that key of a US keyboard. A single character alone is pasted as terminalType does, without the echo wait. In a window, a real key event — the app encodes it itself (iTerm2 under the kitty keyboard protocol sends Shift+Enter as CSI 13;2 u); in the ‘shell’ terminal, the bytes an xterm sends (arrows follow the program’s cursor mode, Alt is ESC before the key, Meta is refused).
  • terminalClipboardWrite(text) — Put text on the active terminal’s clipboard: for a window, the clipboard of its machine (this Mac, or the VM of {host}), concealed and transient; for the ‘shell’ terminal, its own — the answer a program gets to an OSC 52 query. The text is passed as an argument, never logged or quoted in an error.
  • terminalClipboardRead() → string — What is on the active terminal’s clipboard (” when empty): for a window, its machine’s; for the ‘shell’ terminal, what a program set with OSC 52. A value the shell copied, such as a token, goes into the test.
  • terminalWaitForText(text, { not?, timeoutMs? }) → match — Wait until text (a substring, or a /regex/) is on the active terminal’s screen; {not: true} waits for it to go. Default timeout 30 s. Returns what it saw: a regex gives the match array ([0] the match, then the groups), a string the first screen row that holds it, {not: true} null. On a timeout the error quotes the last 10 non-empty rows of the screen, secrets masked; a failed test keeps the terminal’s last frame and the whole screen text (terminal.png, terminal.txt).
  • terminalAssertText(text, { not?, timeoutMs? }) — FAIL unless text (a substring, or a /regex/) is on the active terminal’s screen within the timeout (default 5000 ms); {not: true} fails while it is there. The error quotes the last 10 non-empty rows of the screen, secrets masked.
  • terminalWaitForExit({ code?, timeoutMs? }) → { exitCode, signal } — Wait until the shell of the active terminal exits and return how it ended: exitCode, and signal — its name (SIGTERM) when a signal ended it, else null. End the command with exit $?, or start the program with exec. With code, any other exit code fails and the error quotes the end of the screen. Default timeout 30 s. Only the shell terminal can report it: a Terminal / iTerm2 window cannot. After the exit, typing and pressing keys are refused; the last screen is still there.
  • terminalText() → string — The active terminal’s screen as text, rows without their padding — what a failed test keeps as terminal.txt.
  • terminalResize(cols, rows) — Resize the active terminal to cols × rows character cells.
  • terminalScreenshot(name?) → string — A PNG of the active terminal, saved with the run — a window’s own picture, or the ‘shell’ terminal’s screen drawn by the headless browser; returns its project-relative path. A registered secret on the screen saves the masked screen text (.txt) instead.

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 to length characters.
  • 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).
  • textMatches(text, /re/) → false \| string[] — First match of a regex literal: false on a miss, else [whole, group1, …] (a group that took no part is null). The array is truthy, so it also reads as a test: m = textMatches(url, /room=(\d+)/); id = m[1];. For a plain substring use textContains.
  • parseJson(text) → any — Parse a JSON string (an api body, a data attribute, a request’s postData) into a value you walk with . and []. FAILS on invalid JSON, quoting the input; an empty string is not JSON.
  • urlParam(url, key) → string \| null — A query parameter of a URL, decoded; null when the key is absent, ” when it is empty. A path without a host (/room?id=7) works too. The DSL form of new URL(u).searchParams.get(key).
  • toNumber(text, { decimal? }) → number — A number as a page writes it: ‘1 234,5’, ‘1,234.56’, ‘−12’. Spaces (NBSP too) group digits; with both ’,’ and ’.’ the last one is the decimal mark; one mark used twice groups. ‘1,234’ and ‘1.234’ read both ways and FAIL unless {decimal: ’,’ | ’.’} says which. Units and currency (‘440px’, ‘€12’) FAIL, quoting the input — cut the number out with textMatches first. A number passes through.
  • round(n, digits?) → number — Round to digits decimals (0..10, default 0), half away from zero on the decimal as written: round(2.5) → 3, round(-2.5) → -3, round(1.005, 2) → 1.01.
  • formatNumber(n, { locale?, minFractionDigits?, maxFractionDigits? }) → string — A number as text, via Intl.NumberFormat with digit grouping. Without a locale it is en-US on every machine: formatNumber(1234.5) → ‘1,234.5’. Another locale’s output (its spaces and marks) comes from the ICU of the Node that runs it.
  • textJoin(parts) → string — Concatenate an array of parts (numbers coerced). + with a string literal also works; only literal-free a + b string addition is rejected (looks like arithmetic).
  • s.length · s.trim() · s.includes(x) · s.startsWith(x) — Since 0.44.0: the members a string value answers to, in asserts and conditions alike — assertTrue(s.trim().length == 6). Any other member (s.padStart) is a lint error that lists these four.
  • h = {x: 1, y: "z"} · a.b[0].c — Since 0.44.0: an object literal is a value — keep it in a variable and pass it to a helper; an index works anywhere in a chain (res.body.items[0].id, rows()[0]) without a temporary variable.
  • cond ? a : b — Since 0.49.2: the ternary, as in JS — lowest precedence, nested to the right (n == 0 ? 'none' : n == 1 ? 'one' : 'many'), allowed wherever a value goes. Only the picked branch runs, so x == null ? '' : x.name is a safe guard. ?. and ?? stay out — use the ternary or if / else.
  • 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 a test_* function. label is 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) — inside test_* 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; the journal keeps the first 4 KB of a long value and the whole of it goes to a file in the run directory, named in the output. Use it for the question a data-driven case asked and the answer it got.
  • teardown(label, () => { ... }) — Since 0.44.0: registers a rollback block — nothing in it runs where it stands. After the test body, passed or failed, timed out or stopped from the viewer, every registered block runs, newest first, each even if another one fails. Put it before the call that changes the stand, so a failure in that very call is rolled back too; a flow or helper can carry its own. A failed teardown fails a green test; with a failed test the teardown failures are listed next to its error. A killed process, or blocks cut by teardownTimeoutMs (60 s), leave the stand as the test set it — the run result names them.
  • setTestTimeout(ms) — This test’s own time budget, counted from its start (Playwright’s test.setTimeout). Replaces config testTimeoutMs and UNOTEST_MAX_DURATION_MS for this test. A deadline: an action still running when it runs out is cut, the test fails naming the budget, teardown blocks still play. Only in a test_* body.
  • 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.
  • consoleEntries({ level?, since? }) → [{ level, text, url }] — What the active page wrote to its console, oldest first: level is log, info, warn, error or debug. Recorded from the moment the page opened, so act first, then ask. Extension pages (chrome-extension://) too. options: {level} (one level or a list), {since} (epoch ms, from nowMs()).
  • pageErrors({ since? }) → [{ message, stack }] — Errors the active page threw and nothing caught (an uncaught exception, a rejected promise), oldest first; stack is empty when the browser gave none. options: {since} (epoch ms, from nowMs()).
  • assertNoConsoleErrors({ includePageErrors?, since? }) — FAIL when the active page logged console errors, or threw uncaught ones (unless {includePageErrors: false}), quoting the first three. With since (epoch ms, from nowMs()) only from then on.
  • 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]) => …).
  • evaluateOnLocator(locator, js, ...args) → any — Like evaluate, on the matched element: the body is called with the element, then the extra arguments by the same rule ((el, x) => …, two or more → (el, [a, b]) => …). Last resort — prefer typed getters.

Expressions & operators

  • A scenario is JavaScript: let / const, arrows and closures, while, for…of, switch, try / catch, destructuring, spread, template strings with ${…}, ?. and ?? work as in JavaScript. Since 0.52.0 it runs 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 included. Before 0.52.0 unotest’s interpreter runs it, and the notes marked before 0.52.0 below apply.
  • Calls to the page and the system return their result: the runner does the waiting. Don’t write async or await — since 0.52.0 an async function returns at its first await, and what follows runs after the test is over. Before 0.52.0 await is allowed in an async function and changes nothing.
  • Declare a variable before assigning it: let i = 0, not i = 0. Since 0.52.0 a name assigned without a declaration is a lint error, validator:implicit-global (it would be a global shared by every test of the run); lint --fix writes the missing let.
  • Equality and arithmetic are JavaScript’s: == loose ("1" == 1), === strict. &&, || and ?? give an operand. Before 0.52.0, + and < on an object or an array fail the step instead of giving [object Object].
  • DOM reads (textContent, getInputValue, getAttribute) return strings — compare against numbers with ==, or convert with Number(s).
  • keys(), values() and entries() of a Map or a Set, and matchAll, give iterators since 0.52.0, as in JavaScript: for…of, a spread and Array.from read them, array methods and DSL functions need the spread first — [...m.keys()].map(…). Before 0.52.0 they give arrays.
  • Regex literals are JavaScript RegExps, any flag: /…/su, named groups, lookbehind.
  • A test has a time budget: setTestTimeout(ms) in the test, else testTimeoutMs or UNOTEST_MAX_DURATION_MS. Since 0.52.0 it is the only limit on how long a test runs — there is no count of statements, and an endless loop fails on the time budget naming its line; code that never comes back, such as a regular expression that backtracks for minutes, is stopped a second after it. Time held on a pause does not count.
  • The memory a test holds is bounded: at most 512 MB, and never more than half the heap’s limit. Since 0.52.0 that is the heap of the scenario’s own thread; past it the test fails naming the limit and its line, and the runner stays up. Before 0.52.0 it is the growth of the process’s heap over its start, counted after the runner collects garbage.
  • Before 0.52.0, sizes are bounded too: a string of at most 10,000,000 characters, an array or a spread of at most 1,000,000 elements, fail the step with a RangeError before the value is made; one call of a DSL function takes at most 10,000 arguments. For a larger payload, use a file fixture.

Not supported

  • import / export / require — helpers in _helpers/ are visible by name.
  • The APIs of Node and of the browser (process, fetch, window, document, localStorage) — external UPPER_SNAKE variables, apiCall(…), shell(…), evaluate(…). console prints nowhere: log(…), note(…).
  • Timers (setTimeout, setInterval) — pause(ms), waitUntil(() => …); the page’s clock: clockFreeze / clockTick / clockRun.
  • eval and new Function(…) — both are refused when called.
  • A scenario is not a sandbox: it runs with the rights of the user who started it, as a Playwright test does. Run one only if you would run it as code.
  • Before 0.52.0 the library is a white list — Math, JSON, Number, String, Array, Object statics, new Map(…) / new Set(…), the pure string, number, regex, array, Map and Set methods, plus sortBy — and these are lint errors: class, this, getters and setters, generators, Date, Promise, WeakMap / WeakSet / WeakRef, Symbol / Proxy / Reflect, instanceof, with, labels, debugger, BigInt, &&= / ||= / ??=.