# From @playwright/test

> For the complete documentation index, see [llms.txt](/llms.txt)

This page maps `@playwright/test` code to unotest scenarios. Each section
covers one topic and gives a table: the Playwright call on the left and
the unotest equivalent on the right.

A Playwright test and a unotest scenario drive the same browser engine.
The difference is shape: a scenario is JavaScript whose calls to the
browser are awaited for you, and the browser never calls back into it.
What Playwright does with a handler, unotest does with a fixed answer
declared up front.

## Tabs

In Playwright you catch a new tab with an event promise that you set up
before the click. unotest needs no promise. `waitForPage()` also finds a
tab that opened before the call, so the click comes first.

| Playwright | unotest |
| --- | --- |
| `const [tab] = await Promise.all([context.waitForEvent('page'), btn.click()])` | `click(btn); waitForPage();` — switches to the tab, returns its index |
| `context.waitForEvent('page', { timeout: 5000 })` | `waitForPage({timeoutMs: 5000})` |
| `context.pages()[1]` as the page the test drives, another tab in front | `setPage(1, {activate: false})` |
| `context.pages()[1].bringToFront()` | `bringToFront(1)` — the steps stay on their tab |
| `await expect(tab).toHaveURL(/report/)` | `assertUrl('/report')` once the tab is active (substring match) |
| `const tab = await context.newPage(); await tab.goto(url)` | `newPage(url)` — the tab becomes active; returns its index |
| `await tab.close()` | `closePage()` — the steps return to the tab active before it; `closePage(i)` closes another tab |

`waitForPage()` returns each tab once. A second call waits for the next
tab. Tabs that were open at the start, or that `setPage` already switched
to, don't count as new. See [Multi-tab & iframes](/guides/multi-context/).

## Network mocking

Masks are the same URL globs Playwright takes, matched for
every tab and frame of the scenario's browser context.

| Playwright | unotest |
| --- | --- |
| `context.route(glob, r => r.fulfill({ json }))` | `mockRoute(glob, { body: {...} })` |
| `r.fulfill({ status, body, headers, contentType })` | `mockRoute(glob, { status, body, headers, contentType })` |
| `context.route(glob, r => r.abort())` | `mockRoute(glob, { abort: true })` |
| `context.route(glob, r => r.continue())` | `mockRoute(glob, { continue: true })` |
| `context.unroute(glob)` / `unrouteAll()` | `unmockRoute(glob)` / `unmockRoute()` |
| `page.waitForRequest(glob)` + `request.postDataJSON()` | `req = waitForRequest(glob)`, then `req.postDataJson` |
| `page.waitForResponse(glob)` + `response.json()` | `res = waitForResponse(glob, { status: 200 })`, then `res.json` — since 0.55.0 |
| `page.on('response', …)` + `response.json()` after a click | `networkEntries({glob})` → `[{url, status, responseJson, …}]` — recorded from the page's opening, so act first and ask afterwards |
| a handler that aborts and remembers what left the sandbox | `mockRoute('**/*', { abort: true, record: true })` + `assertNoRequest('**/*')` |

- **Order.** As in Playwright, the mask registered last answers first.
  Mocking a mask again replaces its answer and makes it the latest, so an
  allowlist is `mockRoute('**/*', {abort: true})` followed by
  `mockRoute('**/localhost:*/**', {continue: true})`.
- **No handler logic.** A response that depends on the request is out of
  reach — mock the variants you need one after another, or serve them
  from a fixture API.
- **Your page stays reachable.** The page document of the `baseUrl`
  origin is never aborted, and a fixed response to `**/*` is refused:
  either would fail the test on a blank page. Only the document is
  spared. The page's own scripts, styles and API calls need their own
  continue mask, as in the allowlist above; for a site on a domain it is
  `mockRoute('https://app.example.com/**', {continue: true})`.
- **Requests already sent.** `waitForRequest` on a mocked mask finds a
  request sent before the call, so it can come after the click that
  sends it — no `Promise.all` dance.
- **Answers already in.** `waitForResponse` (since 0.55.0) looks at
  requests sent from the start of the last call that acted on the page,
  so it comes after the click too, and an answer faster than the step
  is found. When another action stands between them, pass `{since}`
  from a `nowMs()` taken before the click.

## Polling

`expect.poll` and `toPass` both become `waitUntil`. It runs the body again
and again until the body is truthy, then returns that value. The body is
an expression, or a block that gives its value with `return`.

| Playwright | unotest |
| --- | --- |
| `await expect.poll(() => count()).toBeGreaterThan(4)` | `waitUntil(() => count() > 4)` |
| `expect.poll(fn, { timeout: 30000, intervals: [500] })` | `waitUntil(() => ..., {timeoutMs: 30000, intervalMs: 500})` |
| `expect.poll(fn, { message: 'job done' })` | `waitUntil(() => ..., {label: 'job done'})` |
| `await expect.poll(async () => (await request.get(url)).status()).toBe(200)` | `waitUntil(() => apiCall('GET', url).status == 200)` |
| `await expect(async () => { ...; expect(x).toBe(y) }).toPass()` | `waitUntil(() => { ...; return x == y; })` |
| `let n = 0; await expect.poll(() => ++n).toBeGreaterThan(2)` | `let n = 0; waitUntil(() => { n = n + 1; return n > 2; })` — both lines inside the step |

- **Only a falsy value is retried.** `toPass` retries a thrown assertion;
  `waitUntil` fails the step at once when its body throws. Write the check
  as a condition that returns `false` instead of an assert.
- **Timeout.** Without `timeoutMs` it waits as long as the other waits of
  the run (`defaultTimeoutMs`); `intervalMs` defaults to 250. On timeout
  the step fails naming the label, the number of tries and the last value.
- **State between tries.** A counter or the last value seen lives in a
  `let` inside the `test_` function or the step, and the body writes it.
  A `let` at the top level of the file never runs: the validator refuses
  it, and the body finds no such variable.

## Web servers

`webServer` takes the same fields as Playwright's — one object or a list.

| Playwright | unotest |
| --- | --- |
| `webServer: { command, url, reuseExistingServer, timeout }` | `webServer: { command, url, reuseExistingServer, timeoutMs }` |
| `webServer: [{ command, url }, { command, url }]` | `webServer: [{ command, url }, { command, url }]` — started together, stopped in reverse order |
| `webServer.port` | `url` only: readiness is an HTTP answer at that URL |

If one server of a list does not come up, the ones that did are stopped
and the error names each that failed.

## Schemas

A zod schema can't run in a scenario, but the JSON Schema
generated from it can: the app exports it next to its constants, the
scenario reads it with `readJson` and checks a value with `assertSchema`.
When the contract changes, the check follows it, with no field-by-field
asserts to update by hand.

| Playwright | unotest |
| --- | --- |
| `MessageSchema.parse(body)` | `assertSchema(body, readJson('unotest/fixtures/message.schema.json'))` |
| `z.toJSONSchema(MessageSchema)` | the same call in a build script, written to the file the scenario reads |
| a `ZodError` listing every issue | the step fails naming every broken field by path: `/tags/1: must be string` |

- **Draft 2020-12.** That is what `z.toJSONSchema` writes. A schema that
  declares another draft is refused rather than checked by the wrong rules.
- **One file.** A `$ref` inside the schema (`#/$defs/tag`) is followed;
  one to another file is refused. Put shared parts under `$defs`.
- **`format` is not checked.** `email`, `uuid`, `date-time` are annotations
  here; check such a field with `assertTrue` if it matters.

## Skip

A test skips itself from its own body, on a value it reads
with `env()` or on data it finds: the run reads as skipped, with the
reason.

| Playwright | unotest |
| --- | --- |
| `test.skip(!process.env.LIVE_API, 'no live API')` | `skip('no live API', env('LIVE_API') == null)` at the top of the `test_*` body |
| `test.skip(condition, 'reason')` inside the test, on data | `skip('reason', condition)` anywhere in the `test_*` body, inside the step that reads the data too |
| `process.env.NAME` | `env('NAME')` — `null` when unset; a secret is refused |

- **Inside the test.** `skip()` works at any depth of a `test_*` body:
  next to the steps, inside a step, in an `if` block. Nothing after it
  runs; the step it was called in shows as skipped, the steps before it
  stay passed.
- **Not in a helper or a `flow_*`.** There it would skip every test that
  calls it, so the linter reports it as an error.
- **Teardown still plays.** What the test set up before the skip is
  given back.

## Timeouts

A test's budget is a deadline, as in Playwright: when it
runs out, the action in flight is cut, the test fails and its teardown
blocks still play.

| Playwright | unotest |
| --- | --- |
| `timeout: 30_000` in `playwright.config.ts` | `testTimeoutMs: 30000` in `unotest.config.mjs` (default 300000) |
| `test.setTimeout(90_000)` inside a test | `setTestTimeout(90000)` at the top of the `test_*` body |
| `test.slow()` | `setTestTimeout(…)` with the longer budget, spelled out |
| `npx playwright test --timeout=60000` | `UNOTEST_MAX_DURATION_MS=60000` for one run |

- **Failure, not stop.** A test that runs out of time is red, counted as
  failed, and the message names the budget it spent. Stop in the viewer
  aborts the run instead.
- **Only in the test.** `setTestTimeout()` in a helper or a `flow_*` is
  a lint error.
- **Scenario code is cut between statements.** A browser action or host
  call in flight is cut at the deadline. Plain computation in the
  scenario is not cut in the middle of one call: a regular expression
  that backtracks for seconds runs to its end, then the test fails on
  the budget.

## Helpers & constants

A scenario can't `import`: it runs on the scenario engine, the same one
on your machine, on a box and under an agent. Shared code moves once,
into the DSL. Shared values stay in your app and reach the scenario as
generated JSON.

| Playwright | unotest |
| --- | --- |
| `e2e/helpers/*.ts` (`export async function signIn(page)`) | `unotest/e2e/_helpers/*.js` with `function flow_sign_in() { ... }`, callable from every scenario |
| `import { LABELS } from '../src/shared/labels'` | a script exports it to `unotest/fixtures/shared.json`, the scenario reads `const shared = readJson('unotest/fixtures/shared.json')` |
| `LABELS.chat.send` | `shared.chat.send`, same `.` and `[]` access |
| `schema.parse(message)` (zod) | the app exports the schema as JSON Schema, the scenario checks with `assertSchema` — see [Schemas](#schemas) |
| `test.extend({ ... })` fixtures | `launch.userDataDir` for one shared browser profile, `launch.profile: 'per-test'` for a new one per test, `_helpers` for setup, `teardown(label, () => { ... })` for cleanup |

Regenerate the JSON from the collection's `prepare`, so every run reads
the current source. The recipe is in
[Scenarios](/concepts/scenarios/#app-constants-in-scenarios); helpers in
[Helpers](/concepts/helpers/).

## Files

A scenario has no `fs` and no `Buffer`. It reads files through the DSL,
and only files inside the project or ones the scenario downloaded: a
path relative to the project root, an absolute one inside it, or the
`path` that `waitForDownload()` returns. A path out of them, or a
symlink that leads out, is refused.

| Playwright | unotest |
| --- | --- |
| `fs.readFileSync(path, 'utf8')` | `readFile(path)` |
| `fs.readFileSync(path).toString('base64')` | `readFile(path, {encoding: 'base64'})` |
| `fs.statSync(path).size` | `readFileInfo(path).size` |
| a PNG header parsed by hand, or `sharp(path).metadata()` | `readFileInfo(path).png`: `{width, height}`, absent for a file that is not a PNG |
| `JSON.parse(fs.readFileSync(path, 'utf8'))` | `readJson(path)` |
| `fs.readFileSync(await download.path())` | `readFile(waitForDownload().path)` |

`readFileInfo` reads only the PNG header, so a large image costs one
small read.

## Assertions & values

`assertEqual` compares deeply, by JSON value: key order does not matter,
and a missing key is not the same as `null`. On a mismatch it names the
first path that differs and shows both values.

| Playwright | unotest |
| --- | --- |
| `expect(body).toEqual({ theme: 'dark' })` | `assertEqual(body, {theme: 'dark'})` |
| `expect(a).toBe(b)` | `assertEqual(a, b)` |
| `JSON.parse(raw)` | `JSON.parse(raw)`, or `parseJson(raw)` |
| `new URL(src).searchParams.get('roomId')` | `urlParam(src, 'roomId')` — `null` when the key is absent |
| `text.match(/id=(\d+)/)?.[1]` | the same: `text.match(/id=(\d+)/)?.[1]` |
| `expect(text).toContain('ready')` | `assertTrue(text.includes('ready'), text)` |
| `await expect(loc).toHaveText(/^#\d+$/)` | `assertText(loc, /^#\d+$/)` |
| `await expect(loc).not.toHaveText('Saving…')` | `assertText(loc, 'Saving…', {not: true})` |
| `await expect(loc).toHaveText(['Apple', 'Banana'])` | `assertTexts(loc, ['Apple', 'Banana'])` |
| `await expect(loc).toHaveAttribute('aria-expanded', 'true')` | `assertAttribute(loc, 'aria-expanded', 'true')` |
| `await expect(loc).toBeEmpty()` / `not.toBeEmpty()` | `assertText(loc, '')` / `assertText(loc, '', {not: true})` |

Like Playwright's `expect`, these assertions retry until they hold or
the timeout runs out, and `{not: true}` waits for the opposite. A string
compares whole unless you pass `{exact: false}`.

## Locators

| Playwright | unotest |
| --- | --- |
| `await locator.boundingBox()` | `boundingBox(loc)` — `{x, y, width, height}`, `null` when the element is not rendered |
| `locator.evaluate((el, arg) => ..., arg)` | `evaluateOnLocator(loc, (el, arg) => ..., arg)` |

`evaluateOnLocator` is an escape hatch, like `evaluate`: the linter
flags it, so prefer a typed getter where one exists.

## Downloads

A download is taken after the click that starts it: unotest records
downloads from the moment the browser context opens, so no event promise
is needed.

| Playwright | unotest |
| --- | --- |
| `const [download] = await Promise.all([page.waitForEvent('download'), btn.click()])` | `click(btn); file = waitForDownload();` |
| `page.waitForEvent('download', { timeout: 5000 })` | `waitForDownload({timeoutMs: 5000})` |
| `download.suggestedFilename()` | `file.name` |
| `await download.saveAs(path)` | not needed: `file.path` is the saved file |
| `fs.readFileSync(await download.path(), 'utf8')` | `waitForFile(file.path, 'text')` or `readFile(file.path)` |

`waitForDownload({name})` takes the first download whose name matches a
substring or `/regex/`. In a run the file is saved with the run and
linked under its step in the viewer.

## Extensions

An extension loads only into a browser started on a profile:
`launch.userDataDir` plus the `--load-extension` args in
`unotest.config`. Headless needs `channel: "chromium"`.

| Playwright | unotest |
| --- | --- |
| `context.serviceWorkers()[0] ?? await context.waitForEvent('serviceworker')` | `waitForServiceWorker()` — the worker's URL |
| `new URL(sw.url()).host` | `extensionId()` |
| `serviceWorker.evaluate(fn, arg)` | `evaluateInServiceWorker(js, arg)` |
| `page.goto('chrome-extension://<id>/popup.html')` | `goto('chrome-extension://' + extensionId() + '/popup.html')` |

`mockRoute` answers the worker's own `fetch` only on a profile the
browser opens for the first time. On a profile it opened before, the
worker reaches the network. A scenario that mocks it needs a fresh
`launch.userDataDir`.

## Isolation

A Playwright suite that tests an extension usually opens a persistent
context per test on a `mkdtemp` directory, so that extension storage,
service workers and tabs start clean. In unotest that is one config key.

| Playwright | unotest |
| --- | --- |
| `chromium.launchPersistentContext(mkdtempSync(...), { args })` in a per-test fixture | `launch: { profile: 'per-test', args }` in `unotest.config` |
| `context.close()` + `rm(dir)` in the fixture's teardown | nothing to write: the directory goes after a passing test |
| keeping the directory of a failed test by hand | kept for you in the run folder, and the run log names it |
| one `launchPersistentContext` shared by the whole suite | `launch: { userDataDir }` |

- **Every `test_*` gets its own.** Each test starts its own browser on its
  own empty profile, the tests of one file included: a suite of N tests
  runs on N profiles.
- **One browser per test.** `newContext()` is refused in such a test, as
  on a shared profile.
- **The machine can override it.** `UNOTEST_USER_DATA_DIR` set in CI
  gives one shared profile instead, and the run log says so.

## Console

In Playwright you collect console messages and page errors with
listeners you attach before the page runs. unotest records them for
every page from the moment it opens, so a scenario acts first and asks
afterwards.

| Playwright | unotest |
| --- | --- |
| a `page.on('console', …)` listener, then filtering by `m.type() === 'error'` | `consoleEntries({level: 'error'})` → `[{level, text, url}]` |
| `m.type() === 'warning'` | `level: 'warn'` |
| a `page.on('pageerror', …)` listener | `pageErrors()` → `[{message, stack}]` |
| `expect(errors).toEqual([])` in `afterEach` | `assertNoConsoleErrors()` — console errors and uncaught ones; `{includePageErrors: false}` for console errors only |
| clearing the collected array between phases | `before = nowMs()`, then `{since: before}` on any of the three |
| listeners on an extension page (`chrome-extension://…`) | the same calls once it is the active page |

The calls answer for the active page; switch with `setPage` to ask about
another tab. A service worker's console is not a page's and is not
included.
