> ## Documentation Index
> Fetch the complete documentation index at: https://e2e.tester.army/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# From Playwright

> Run e2e alongside Playwright and migrate tests one file at a time.

export const migrationPrompt = `Migrate this project's Playwright tests to e2e, one spec at a time. Docs: \`https://e2e.tester.army/docs/migrate/playwright.md\`

1. Read the migration guide above. It maps every Playwright config key, API, and CLI flag to e2e and marks what e2e does not have yet.
2. If the project has no e2e config (\`e2e.config.ts\`, \`.mts\`, \`.js\`, or \`.mjs\`), run \`npx e2e init --yes\` (or the pnpm or bun equivalent). It writes a config and an example test, and installs nothing yet.
3. Read the e2e skill (\`npx e2e guide\` prints it, \`npx e2e guide <topic>\` prints a topic) and follow it for every step below.
4. If you ran init, ask me which model to use for agent steps: a ChatGPT, GitHub Copilot, OpenCode Console, or SuperGrok subscription, an API key, or a local model. Configure it with its provider package. If it needs a sign-in, give me the \`npx e2e login\` command and wait until I confirm.
5. Port \`playwright.config.ts\` into the e2e config with the guide's config tables. Keep \`@playwright/test\` installed and the two file patterns apart.
6. Install dependencies.
7. List the Playwright specs and ask me which one to move first. Suggest a flow whose steps change often, such as a wizard or a checkout.
8. Rewrite that spec as a \`.e2e.ts\` file under \`tests/\` (e2e runs \`tests/**/*.e2e.ts\` by default). Map each \`page\` call to \`screen\`, \`app\`, or \`browser\` with the guide. Where a Playwright query matched a fragment, add \`{ exact: false }\` or a RegExp. Where the guide marks a feature missing, use the workaround it gives, or leave that test in Playwright and tell me why.
9. Keep navigation and the final assertions deterministic, with \`app.open()\` and locator assertions. Where a run of scripted interactions breaks often, propose an \`agent.act\` goal for it instead.
10. Run the new file with \`npx e2e run <file>\` and fix it until it passes. Keep the Playwright spec until I confirm it can go.
11. Summarize which tests moved, which stayed in Playwright, and why.`;

e2e can run beside Playwright in one project while you move tests one file
at a time. `@e2e-dev/web` brings its own pinned `playwright-core`, so your
`@playwright/test` version stays yours. e2e reads `e2e.config.ts` and finds
`tests/**/*.e2e.ts` by default, so keep the two file patterns apart.

<SetupPrompt>

<Prompt description={migrationPrompt} children={migrationPrompt} actions={["copy", "cursor"]} />

</SetupPrompt>

For a migration that spans several sessions, the [`migrate-to-e2e` skill](https://e2e.tester.army/docs/coding-agents#migrate-an-existing-suite) records what moved in a progress file and keeps the old suite running until you retire each test.

## Port the config

<Note>Install e2e and configure a model with the [quickstart](https://e2e.tester.army/docs/quickstart).</Note>

A Playwright key such as `testDir`, `baseURL`, `use`, `webServer`, or
`projects` fails with a message that names the e2e key to use. The
[config reference](https://e2e.tester.army/docs/reference/config) lists every key.

### App, targets, and output

| Playwright | e2e | Status |
| --- | --- | --- |
| `use.baseURL` | `app: { url }` on the target | renamed |
| `webServer` | `app: { url, command }` on the target, see [Starting your app](https://e2e.tester.army/docs/web#starting-your-app) | renamed |
| `webServer` array | `app: { url, command }` on each target; targets declaring the same command share one process. Other dependency processes are not started by this version | missing |
| `webServer.reuseExistingServer` | `app.command: { reuseExisting: true }`; CI ignores it | renamed |
| `globalSetup`, `globalTeardown` | `test.setup` for sign-in, hooks and fixtures for setup code | missing |
| `projects` | `targets`, one engine instance each | renamed |
| `testDir`, `testMatch` | `tests`, globs relative to the project root | renamed |
| `testIgnore` | a `tests` entry with a leading `!`: `tests: ['tests/**/*.e2e.ts', '!tests/wip/**']` | renamed |
| `fullyParallel` | a file's tests run in order on one worker, files run in parallel | missing |
| `maxFailures` | `--max-failures <n>`; no config key | renamed |
| `globalTimeout` | `maxDuration`, or `--max-duration <ms>`; the clock starts with the tests, not app startup | renamed |
| `reporter` | `reporters: ['list', 'junit']`; also `json` and `markdown` | renamed |
| `reporter: 'html'` | the `markdown` reporter writes `.e2e/summary.md` | missing |
| `outputDir` | `output`, default `.e2e`, holding `results/` (cleared when a run starts, like `outputDir`) beside the report | renamed |
| `metadata` | not yet | missing |

### Browser, timeouts, and artifacts

| Playwright | e2e | Status |
| --- | --- | --- |
| `use.viewport`, `use.browserName` | `web({ viewport, browser })` | renamed |
| `use.testIdAttribute` | `web({ testIdAttribute })` | renamed |
| `use.httpCredentials` | `web({ basicAuth })` | renamed |
| `use.extraHTTPHeaders` | `web({ headers })`, sent to the app's site only | renamed |
| `chromium.connectOverCDP(url)` | `web({ connect: { cdpEndpoint: () => url } })`, chromium only | renamed |
| `use.connectOptions` (`wsEndpoint`) | `web({ connect })` attaches over CDP only | missing |
| `retries`, `workers`, `timeout` | the same names at the top level; the defaults differ, see [below](#defaults-that-differ) | same |
| `expect.timeout` | `assertionTimeout` | renamed |
| `use.actionTimeout` | `actionTimeout` | renamed |
| `use.navigationTimeout` | `timeout`, the test budget; `browser.goto(url, { timeout })` overrides one call | renamed |
| `use.storageState`, a `setup` project in `projects[].dependencies` | `test.setup(title, { sessions }, fn)` with `session.save(name)`, then `{ session }` on a test, see [Signing in](https://e2e.tester.army/docs/authentication); no file, see [What differs on purpose](#what-differs-on-purpose) | by design |
| `use.storageState: { cookies }` with fixed values | `browser.setCookies(cookies)` in a fixture | renamed |
| `use.trace` | `trace`: `'off'`, `'on'`, `'retain-on-failure'`, `'on-first-retry'`, or `'on-all-retries'`, at the top of the config, per target, per test, or `--trace [mode]`. A trace is a markdown page, `trace.md`, in each test's directory under `.e2e/results/`, not a zip. The default differs, see [below](#defaults-that-differ) | same |
| `use.video` | `video`, with the modes and places of `trace`, and `--video [mode]` | same |
| `use.headless: false` | `--headed` on the CLI | renamed |
| `use.locale`, `use.timezoneId` | `web({ locale, timezoneId })` | renamed |
| `use.colorScheme`, `use.reducedMotion` | `web({ colorScheme, reducedMotion })` | renamed |
| `use.userAgent` | `web({ userAgent })` | renamed |
| `devices['iPhone 15']`, `deviceScaleFactor`, `isMobile`, `hasTouch` | `web({ viewport })` sets the size only | missing |
| `use.permissions` | `app.permissions` on the target, `{ 'clipboard-read': 'grant' }`: `grant` only, held by every page on any origin, see [Granting browser permissions](https://e2e.tester.army/docs/web#granting-browser-permissions) | renamed |
| `geolocation` (the position; the permission is `app.permissions` above), `ignoreHTTPSErrors`, `bypassCSP`, `offline`, `launchOptions`, `proxy` | not yet | missing |

## Rewrite one spec

Start with a flow whose steps change often, such as a wizard or a checkout.
Stable Playwright tests can stay where they are.

```ts title="Playwright"

test('checkout', async ({ page }) => {
  await page.goto('/cart');
  await page.getByRole('button', { name: 'Checkout' }).click();
  await page.getByLabel('Card number').fill('4242424242424242');
  await page.getByLabel('Expiry').fill('12/30');
  await page.getByLabel('CVC').fill('123');
  await page.getByRole('button', { name: 'Pay' }).click();
  await expect(page.getByRole('heading')).toHaveText('Order confirmed');
});
```

```ts title="e2e"

test('checkout', async ({ app, agent, screen }) => {
  await app.open('/cart');
  await agent.act('pay with the test card 4242 4242 4242 4242, expiry 12/30, CVC 123');
  await expect(screen.getByRole('heading')).toHaveText('Order confirmed');
});
```

The agent does the checkout steps and adapts when the form changes. A test
can also stay fully deterministic, with a `screen.getBy*` line for every
`page.getBy*` line.

## Map each API

`Status` is `same`, `renamed`, `web only`, `by design`, or `missing` with what
to do today. See [What differs on purpose](#what-differs-on-purpose) for the
`by design` rows. Calls on `browser` run only in a browser, so a test that
uses it declares `requires: ['browser']` and other targets skip it. Calls on
`screen`, `app`, `agent`, and `expect(locator)` run on every target.

<AccordionGroup>
<Accordion title="Locators">

| Playwright | e2e | Status |
| --- | --- | --- |
| `page.getByRole`, `getByLabel`, `getByText`, `getByTestId`, `getByPlaceholder` | the same on `screen`; `getByRole` also takes the name positionally, `getByRole('button', 'Save')` | same |
| `getByRole(role, { name })`, `getByLabel`, `getByPlaceholder`, `getByText` with a string | an exact, case-sensitive match, see [What differs on purpose](#what-differs-on-purpose) | by design |
| `getByRole('img')` | the same; `img` is an alias of `image` | same |
| `getByAltText(alt)` | `getByRole('image', alt)` | renamed |
| `getByTitle` | the node carries no title | missing |
| `getByRole(role, { includeHidden })` | a role query never matches a hidden node; `{ visible: true }` on the other queries drops hidden twins | by design |
| `locator.filter({ hasText, hasNotText, has, hasNot })`, `first()`, `last()`, `nth(i)`, `count()` | the same | same |
| `locator.or(other)` | the same; web branches need identical literal frame-selector chains. A locator is strict, so add `first()` when both match | same |
| `locator.and(other)` | not yet | missing |
| `locator.all()`, `allTextContents()` | the same; `allInnerTexts()` is `allTextContents()` | same |
| `page.locator('css')`, `page.locator('xpath=...')` | `browser.locator('css')`: the string is a Playwright selector, so `xpath=` (or a leading `//`) and `>>` chains work too | web only |
| `page.waitForSelector(selector)` | `browser.locator(selector).waitFor()`; a locator is strict, see [What differs on purpose](#what-differs-on-purpose) | renamed |
| `page.click(selector)`, `page.fill(selector, value)`, `page.getAttribute(selector, name)`, and the other `page` methods that take a selector | `browser.locator(selector).tap()`, `.fill(value)`, `.getAttribute(name)`. Playwright acts on the first match; a locator is strict, so add `first()` for the old behavior, see [What differs on purpose](#what-differs-on-purpose) | renamed |
| `locator.locator('css')` | a semantic query inside the scope (`cell.getByRole('textbox')` for `cell.locator('input')`), `browser.locator('css').getByRole(...)` the other way round, or one `browser.locator` selector for the whole path | missing |
| `page.frameLocator('#frame')` | `browser.frameLocator('#frame')`, then `screen` queries inside it | web only |

</Accordion>
<Accordion title="Actions">

| Playwright | e2e | Status |
| --- | --- | --- |
| `locator.click()` | `locator.tap()`; `click()` is an alias | same |
| `click({ button: 'right' })` | `secondaryTap()` | renamed |
| `dblclick()` | `doubleTap()`; `dblclick()` is an alias | same |
| `click({ position })` | `tap({ position })`, an offset from the node's top-left corner | renamed |
| `click({ force })` | `tap({ position })` skips every actionability check but the scroll into view | missing |
| `click({ modifiers })`, `dblclick({ modifiers })` | `tap({ modifiers })`, `doubleTap({ modifiers })` or `dblclick({ modifiers })`, `secondaryTap({ modifiers })`; not beside `position` yet | same |
| `click({ clickCount, delay, trial })` | not yet | missing |
| `{ trial }`, `{ force }`, `{ noWaitAfter }` on `check()`, `fill()`, and the other actions | an unknown option is `INVALID_ARGUMENT`, never ignored | missing |
| `fill(value)` | the same; also takes a `Secret` | same |
| `pressSequentially(text, { delay })` | the same; keystrokes through the platform keyboard, a `Secret` is refused | same |
| `press(key)` | the same; modifiers join with `+`, see the [key grammar](https://e2e.tester.army/docs/reference/engine#keys) | same |
| `clear()`, `focus()`, `hover()`, `setInputFiles()`, `dragTo()` | the same | same |
| `check()`, `uncheck()` | the same, except when the state updates late or the click removes the control, see [What differs on purpose](#what-differs-on-purpose) | by design |
| `selectOption('pro')` | `selectOption('Pro')`: a string matches the option's label only, where Playwright matches its value or its label; `selectOption({ value: 'pro' })` for the value | by design |
| `selectOption(['a', 'b'])` | one option per call. An array is `INVALID_ARGUMENT` | missing |
| `setInputFiles({ name, buffer })` | paths only, resolved from the project root | missing |
| `scrollIntoViewIfNeeded()` | `scrollIntoView()` | renamed |
| `locator.innerText()` | `textContent()`, whitespace collapsed to single spaces | renamed |
| `locator.textContent()` | the rendered text, what `innerText()` reads, see [What differs on purpose](#what-differs-on-purpose) | by design |
| `inputValue()`, `getAttribute()`, `isVisible()`, `isHidden()`, `isEnabled()`, `isDisabled()`, `boundingBox()` | the same | same |
| `isChecked()` | the same on a checkbox, radio, or `aria-checked` node; `false` on any other, see [What differs on purpose](#what-differs-on-purpose) | by design |
| `isEditable()` | not yet | missing |
| `waitFor({ state: 'visible' })`, `'hidden'`, `'attached'`, `'detached'` | the same | same |
| `locator.evaluate(fn)`, `evaluateAll(fn)` | `browser.evaluate(fn, selector)` with a CSS selector argument | missing |
| `locator.screenshot()` | `app.screenshot(label)` captures the viewport | missing |
| `innerHTML()`, `dispatchEvent()`, `selectText()`, `blur()`, `highlight()` | `browser.evaluate` | missing |
| `ariaSnapshot()` | not yet | missing |
| `page.keyboard.press`, `keyboard.type` | `browser.keyboard.press`, `browser.keyboard.type`; plain text only, never a `Secret` | web only |
| `keyboard.down`, `keyboard.up` | `browser.keyboard.down`, `browser.keyboard.up`; keys and modifiers stay held until released | web only |
| `keyboard.insertText` | not yet | missing |
| `page.mouse.click(x, y)` | `screen.tapAt({ x, y })`, viewport coordinates | renamed |
| `mouse.move`, `down`, `up` as a drag | `screen.swipe({ from, to })` | renamed |
| `page.dragAndDrop(source, target, { sourcePosition, targetPosition })` | `source.dragTo(target)`, which takes no positions; for `sourcePosition` or `targetPosition`, `screen.swipe({ from, to })` with each `boundingBox()` origin plus its offset | renamed |
| `page.mouse.move`, `wheel`, `down`, `up` | `browser.mouse.*` | web only |

</Accordion>
<Accordion title="Assertions">

| Playwright | e2e | Status |
| --- | --- | --- |
| `expect(locator).toBeVisible()`, `toBeHidden()`, `toBeEnabled()`, `toBeDisabled()`, `toBeFocused()`, `toHaveValue()`, `toHaveAttribute()`, `toHaveCount()`, `toHaveAccessibleName()` | the same | same |
| a Playwright `Locator` (`page.getByRole(...)`) passed to `expect` from `e2e` | a plain value, so `toBeVisible()` and the other locator matchers fail to compile (`TS2339` on `ValueExpectation`). Rewrite the query on `screen` | missing |
| `toBeChecked()` | the same on a checkable node; on any other node `.not.toBeChecked()` passes, see [What differs on purpose](#what-differs-on-purpose) | by design |
| `toHaveText()`, `toContainText()` | compare the rendered text, see [What differs on purpose](#what-differs-on-purpose) | by design |
| `toHaveText(['a', 'b'])`, `toContainText([...])` on several matches | the same: `toHaveText` needs the count to match, `toContainText` an ordered subsequence | same |
| `toBeChecked({ checked: false })`, `toBeEnabled({ enabled })`, `toBeVisible({ visible })`, `toBeAttached({ attached })` | the same | same |
| `{ ignoreCase }` on `toHaveText()`, `toContainText()`, `toHaveAccessibleName()`, `toHaveAttribute(name, value)` | the same | same |
| `toBeChecked({ indeterminate })`, `{ useInnerText }` | `INVALID_ARGUMENT`, so the matcher never passes on its default | missing |
| `toHaveAttribute('aria-selected', 'true')`, `'aria-expanded'` | `toBeSelected()`, `toBeExpanded()` | renamed |
| a positive matcher on several matches | `LOCATOR_AMBIGUOUS` at once, see [What differs on purpose](#what-differs-on-purpose) | by design |
| `toBeAttached()` | the same; passes on a hidden match too | same |
| `toBeEmpty()`, `toBeInViewport()`, `toBeEditable()`, `toHaveRole()`, `toHaveAccessibleDescription()` | not yet | missing |
| `expect(locator).toHaveClass(cls)` | `expect(browser).toHaveClass(locator, cls)`; a DOM fact is asserted through `browser`; reads the `class` attribute, so `''` matches `class=""` and not a missing attribute | web only |
| `toHaveCSS()`, `toHaveId()`, `toHaveJSProperty()`, `toContainClass()`, `toHaveValues()` | `browser.evaluate` plus a value matcher | missing |
| `expect(page).toHaveURL()`, `toHaveTitle()` | `expect(browser).toHaveURL()`, `toHaveTitle()`; `toHaveURL` takes `ignoreCase` | web only |
| `expect(page).toHaveScreenshot()`, `expect(locator).toHaveScreenshot()` | `expect(screen).toHaveScreenshot()`, `expect(locator).toHaveScreenshot()` on every target, a device included, with `threshold`, `maxDiffPixels`, `maxDiffPixelRatio`, `mask`, `maskColor`, and `timeout`. See [Visual comparison](https://e2e.tester.army/docs/visual-testing) | same |
| `toHaveScreenshot({ fullPage, clip, animations, caret, scale, stylePath })` | always the viewport, at CSS scale, animations stopped and the caret hidden | missing |
| `toMatchSnapshot()`, `toMatchAriaSnapshot()` | not yet | missing |
| `expect(value).toBe()`, `toEqual()`, `toMatch()`, `toBeTruthy()`, `toBeCloseTo()`, `toHaveLength()`, `toMatchObject()`, `toHaveProperty()`, and the rest | the same, except `toContain`, below, see [value matchers](https://e2e.tester.army/docs/reference/expect#value-matchers) | same |
| `expect(collection).toContain()` | compares items by deep equality, like Playwright's `toContainEqual`, so `toContain({ id: 1 })` passes on `[{ id: 1 }]`. Containment in a string is the same | by design |
| `toStrictEqual()`, `toThrow()`, `toBeInstanceOf()` | `toEqual` on the shape you need | missing |
| `expect.any()`, `objectContaining()`, `arrayContaining()`, `stringMatching()` | the same, plus `anything()` and `stringContaining()`, see [asymmetric matchers](https://e2e.tester.army/docs/reference/expect#asymmetric-matchers) | same |
| `expect(async () => { ... }).toPass()` | `expect.poll(read).toBe(...)`; poll an array with `toEqual([a, b])` for several values at once | renamed |
| `expect.poll()` | the same | same |
| `expect.soft()` | the same; kept failures fail the attempt once the body settles, see [soft assertions](https://e2e.tester.army/docs/reference/expect#soft-assertions) | same |
| `expect(locator, 'message')` | `expect(locator)`; a locator failure already names the locator, the matcher, and what was observed. Only a value expectation takes a message | by design |
| `expect.extend()` | not yet | missing |
| `expect(promise).resolves`, `.rejects` | `await` first, then `expect(value)` | missing |

</Accordion>
<Accordion title="Navigation and app">

| Playwright | e2e | Status |
| --- | --- | --- |
| `page.goto(path)` | `app.open(path)`; `browser.goto(url, { waitUntil, timeout })` for the browser options | renamed |
| `const response = await page.goto()` | `app.open` and `browser.goto` resolve to `void`; take the response from `browser.waitForResponse` | by design |
| `page.goBack()` | `app.back()`, or `browser.back()` | renamed |
| `page.goForward()`, `page.reload()` | `browser.forward()`, `browser.reload()` | web only |
| `page.url()`, `page.title()` | `browser.url()`, `browser.title()` | web only |
| `page.waitForURL()` | `browser.waitForURL()`, with a string or `RegExp` and `timeout` as the only option. A string is an absolute URL, or a relative one resolved against `app.url`, matched exactly, not a glob: `'**/orders'` never matches, so pass a `RegExp` | web only |
| `page.waitForLoadState()` | `browser.goto(url, { waitUntil })` on the navigation itself | missing |
| `page.waitForFunction(fn)` | `expect.poll(() => browser.evaluate(fn)).toBeTruthy()` | missing |
| `page.waitForTimeout(ms)` | a locator assertion on the state you are waiting for: `toBeVisible()` on what appears, `toBeHidden()` on what goes away | by design |
| `page.setViewportSize()` | `browser.setViewport()`, before or after the first navigation | web only |
| `page.viewportSize()` | `browser.evaluate(() => ({ width: innerWidth, height: innerHeight }))` | missing |
| `page.screenshot()` | `app.screenshot(label)`: the viewport, redacted, attached to the report; no `fullPage` | renamed |
| a fresh `context` per test | every attempt starts in a fresh context; `app.restart()` recreates it, `app.clearState()` also clears storage | same |
| `page.pause()` | `--headed` to watch, [`e2e mcp`](https://e2e.tester.army/docs/mcp-server) to drive the app from a coding agent | missing |

</Accordion>
<Accordion title="Network and browser">

| Playwright | e2e | Status |
| --- | --- | --- |
| `page.route(pattern, handler)` | `browser.route(pattern, handler)`; a string or `RegExp` pattern, no predicate function; the handler decides exactly once | web only |
| `route.fulfill({ json, body, path, status, headers, contentType })` | the same; one of `json`, `body`, or `path`, and `path` is relative to the project root | web only |
| `route.continue({ url, method, headers, postData })`, `route.fallback()` | the same; a `url` goes through the navigation rule, and `fallback()` takes no overrides | web only |
| `route.fetch()` | argument-free: no `timeout`, `headers`, or `maxRedirects` options, and redirects return unfollowed; returns `{ url, status, headers, text(), json() }` without deciding the route | web only |
| `route.abort(code)`, `route.fallback(overrides)` | none yet; `abort()` takes no error code | missing |
| `page.unroute()` | `browser.unroute()` | web only |
| `page.unrouteAll()` | `browser.unrouteAll()` | web only; clears test routes |
| `page.waitForResponse(pattern)` | `browser.waitForResponse(pattern)`: a string or `RegExp`, no predicate function; `url`, `status`, `headers`, `json()`, `text()`. A string is a glob tested against the whole URL, query included, and never resolved against `app.url`: Playwright's `'/api/orders'` is the full URL or `'**/api/orders'`, and `?` matches any one character (write `'\\?'` for a literal one) | web only |
| `response.ok()`, `response.body()`, `response.request()` | `status` and `text()` | missing |
| `page.waitForRequest()`, `request.postDataJSON()` | none yet for `waitForRequest`; `route.request.postData` is a string | missing |
| `routeFromHAR()`, `routeWebSocket()` | not yet | missing |
| `page.on('dialog')` | `browser.onDialog('accept')`, or a handler that calls `accept` or `dismiss` | web only |
| `page.waitForEvent('download')` | `browser.waitForDownload(() => trigger)` | web only |
| `page.waitForEvent('popup')`, `context.on('page')` | a window the app opens is not driven | missing |
| `page.on('console')`, `page.on('pageerror')` | assert on what the page shows | missing |
| `page.evaluate(fn, arg)` | `browser.evaluate(fn, arg)`; JSON in and out, no handles | web only |
| `page.addInitScript(script, arg)`, `context.addInitScript()` | `browser.addInitScript(script, arg)`, or `web({ initScripts })` for every test; pass `{ content }` as a plain string | web only |
| `page.emulateMedia({ colorScheme, reducedMotion })` | `browser.emulateMedia()`, for every open tab and every tab opened after it; `media`, `forcedColors`, and `contrast` are not yet supported | web only |
| `page.exposeFunction()`, `addStyleTag()`, `setExtraHTTPHeaders()`, `clock` | none yet; for `addStyleTag`, `browser.evaluate` can append a `<style>` once the page is open | missing |
| `context.addCookies()`, `context.cookies()` | `browser.setCookies()`, `browser.cookies()` | web only |
| `context.clearCookies(options)` | `browser.clearCookies(options)` | web only |
| `request` fixture | Node `fetch`, see [API](https://e2e.tester.army/docs/api-testing) | by design |
| `context.storageState({ path })` | no file, see [What differs on purpose](#what-differs-on-purpose) | by design |

</Accordion>
<Accordion title="Test structure">

| Playwright | e2e | Status |
| --- | --- | --- |
| `import { test, expect } from '@playwright/test'` | `import { test } from '@e2e-dev/web'` and `import { expect } from 'e2e'` | renamed |
| `test(title, fn)`, `test.describe`, `beforeEach`, `afterEach` | the same; `describe` and the hooks are also top-level imports | same |
| `test.beforeAll`, `afterAll` | the same, with `platform` as the only fixture; no `browser` or `request` | same |
| `{ page }` in a test | `{ screen, app, browser }`; no raw `Page` in tests, see [What differs on purpose](#what-differs-on-purpose) | by design |
| `test.skip(title, fn)`, `test.skip(condition, reason)` | the same | same |
| `test.only` | the same; CI rejects it with `ONLY_IN_CI` | same |
| `test.describe.only` | `test.only` on each member | missing |
| `test.describe.skip` | `test.describe(title, { skip: true }, fn)` | renamed |
| `test.describe.serial`, `describe.configure({ mode: 'serial' })` | `test.describe(title, { serial: true }, fn)`: one retry unit with shared app state | renamed |
| `describe.configure({ mode: 'parallel' })` | a file's tests run in order on one worker | missing |
| `describe.configure({ retries, timeout })` | `{ retries, timeout }` on the group | renamed |
| `test.setTimeout(ms)`, `test.slow()` | `test(title, { timeout }, fn)` | renamed |
| `test.fixme()`, `test.fail()` | `{ skip: 'reason' }` | missing |
| `test.step(title, fn)` | every `screen`, `browser`, and `agent` call is already a named step in the report, with its arguments redacted | missing |
| `test.info()`, `attach()`, `annotations` | `app.screenshot(label)` attaches a screenshot | missing |
| `{ tag: '@smoke' }` | `{ tags: ['smoke'] }` on a test or group | renamed |
| `test.use({ ... })` | per target in the config; `browser.setViewport` at runtime | renamed |
| `test.extend({ fixture })` | the same shape; a fixture that needs another goes in a chained `extend`. Not yet on demand: every fixture of the chain sets up for every test on that `test`, named or not. Give each setup its own `test` | missing |
| `{ scope: 'worker' }` fixtures, option fixtures | `beforeAll` for per-file setup | missing |

</Accordion>
<Accordion title="CLI">

| Playwright | e2e | Status |
| --- | --- | --- |
| `npx playwright test` | `npx e2e run` | renamed |
| `npx playwright test tests/login.spec.ts` | `npx e2e run tests/login.e2e.ts`; a directory, a bare name, or a glob also selects | same |
| `--project web` | `--target web` | renamed |
| `-g`, `--grep`, `--grep-invert` | `--grep` and `--grep-invert`, no `-g`; matched against the describe titles and the test title joined by spaces; tags are `--tag` and `--exclude-tag` | renamed |
| `tests/login.spec.ts:12` | `tests/login.e2e.ts:12`, the test whose `test(` call opens on that line | same |
| `--debug` | `--headed` to watch. `e2e run --debug` prints phase timings and the agent step table, not an inspector | missing |
| `--ui` | `--watch` reruns on save with vitest's keys, `--headed` to watch, `e2e mcp` to drive the app from a coding agent | missing |
| `--headed`, `--retries`, `--workers`, `--config`, `--pass-with-no-tests` | the same | same |
| `--reporter` | the same flag; the ids are `list`, `json`, `junit`, `markdown` | same |
| `--output` | `--output`, the results directory; each test's trace page and artifacts are in its `results/` | same |
| `--trace <mode>` | `--trace [mode]`, the same modes; a bare `--trace` is `on` | same |
| `--last-failed`, `--shard`, `--repeat-each`, `--max-failures` | the same names; `--shard` keeps serial groups together | same |
| `--only-changed` | `--changed-since <ref>` with a [test filter](https://e2e.tester.army/docs/filters) in `filters`, such as `decisionFilter({ model })`, which asks a model which tests the change can affect | by design |
| `--quiet` | not yet | missing |
| `-u`, `--update-snapshots` | the same; rewrites the stored screenshots that differ | same |
| `--list` | `npx e2e list`, with the same selection flags | renamed |
| `npx playwright codegen` | `npx e2e explore` walks the app from a goal and reports what it found; `npx e2e mcp` lets a coding agent drive the app while it writes the test, see [MCP server](https://e2e.tester.army/docs/mcp-server) | renamed |
| `npx playwright show-trace` | no viewer; open the `trace.md` under `.e2e/results/` that the terminal names | by design |
| `npx playwright show-report` | `.e2e/summary.md` from the `markdown` reporter | missing |
| `npx playwright install` | `npx @e2e-dev/web install`, for the Playwright version the engine pins; a run also installs a missing browser on its own | renamed |

</Accordion>
</AccordionGroup>

## Port page objects

A page object takes `screen` for queries and actions, and `browser` for
keyboard, mouse, CSS selectors, and `evaluate`. Hand page objects out as
fixtures, so every test gets them without a `beforeEach`:

```ts title="tests/page-models/account-page.ts"

export class AccountPage {
  readonly accountName: Locator;

  constructor(
    readonly screen: Screen,
    readonly browser: Browser,
  ) {
    this.accountName = screen.getByTestId('account-name');
  }

  async filterByNote(note: string) {
    await this.screen.getByRole('button', 'Filter').click();
    await this.screen.getByRole('button', 'Note').click();
    await this.browser.keyboard.type(note);
  }
}
```

```ts title="tests/fixtures.ts"

export const test = base.extend<{ accountPage: AccountPage }>({
  accountPage: async ({ screen, browser }, use) => {
    await use(new AccountPage(screen, browser));
  },
});
```

A fixture that reads `browser` makes every test on that `test` web only. See
[Keep it portable](https://e2e.tester.army/docs/page-object-model#keep-it-portable).

`locator.page()` has no counterpart, so pass `browser` with the locator. Drop
`newPage()` and `page.close()` from the hooks: every attempt starts in a fresh
context.

## What differs on purpose

- A string name is exact. Playwright's `getByRole` names, `getByLabel`,
  `getByPlaceholder`, and `getByText` match a case-insensitive substring, so
  `Save` also hits `Save as draft`. In e2e a string matches the whole text,
  case-sensitive. Pass `{ exact: false }` or a `RegExp` for a fragment. A
  query that misses passes a negated assertion, so check it with a positive
  one first. See [queries](https://e2e.tester.army/docs/reference/screen#queries).

  ```ts
  // <button>Save changes</button>, <label>Email address <input></label>
  screen.getByRole('button', 'Save');                  // no match
  screen.getByRole('button', { name: 'Save', exact: false });    // matches
  screen.getByRole('button', 'save changes');          // no match, case differs
  screen.getByLabel('Email');                                    // no match
  ```
- Text reads are rendered text. On the web, `textContent()` and `toHaveText`
  read what `innerText` returns, like Playwright's `{ useInnerText: true }`.
  Playwright reads the DOM's `textContent` by default. Queries still match
  the source text: `getByText('Draft')` finds the tag below,
  `getByText('DRAFT')` does not. `browser.evaluate`
  reads the raw DOM text. See [locator reads](https://e2e.tester.army/docs/reference/screen#locator-reads).

  ```ts
  // <span data-testid="tag" style="text-transform: uppercase">Draft</span>
  // <p data-testid="total">Total <span style="display: none">debug</span>42</p>
  // <p data-testid="address">Line one<br>Line two</p>
  await expect(screen.getByTestId('tag')).toHaveText('DRAFT');                 // Playwright: 'Draft'
  await expect(screen.getByTestId('total')).toHaveText('Total 42');            // Playwright: 'Total debug42'
  await expect(screen.getByTestId('address')).toHaveText('Line one Line two'); // Playwright: 'Line oneLine two'
  ```
- A node with no checked state is unchecked. `isChecked()` is `false` and
  `.not.toBeChecked()` passes on a button or a heading, where Playwright
  throws. Query a checkable control by role, so the query fails when the
  control changes kind.

  ```ts
  await expect(screen.getByRole('checkbox', 'Subscribe')).not.toBeChecked();
  ```
- `check()` and `uncheck()` read the state back until the action timeout,
  so a controlled checkbox that updates late passes. A click that removes the
  control passes too, where Playwright fails. Assert on what replaced it.
- One match or `LOCATOR_AMBIGUOUS`, `isVisible()` included, where Playwright
  reads the first match silently. Narrow with `filter`, `first()`, or
  `{ visible: true }`, or add a `>> visible=true` step to a
  `browser.locator` selector. See [screen](https://e2e.tester.army/docs/reference/screen).
- `browser.evaluate` takes and returns JSON only, so the report can record
  and redact the result. Pass values in through `arg`.
- A password is a `Secret` with no string value, and the model never sees
  it. See [Signing in](https://e2e.tester.army/docs/authentication).
- Sessions are per run. `test.setup` saves state under a name, one time per
  run and target, and `{ session }` restores it. The file is encrypted and
  deleted at cleanup, so there is no `.auth/user.json` to reuse across runs.
- No raw `Page` in tests. Nothing on a raw page is recorded, redacted, or
  masked. `surfaceOf(engine)` is for
  [custom executors](https://e2e.tester.army/docs/executors#driving-the-page-yourself), not test code.

## Defaults that differ

| Setting | Playwright | e2e |
| --- | --- | --- |
| Test timeout | 30 s | 120 s, `timeout` |
| Action timeout | none | 30 s, `actionTimeout` |
| Expect timeout | 5 s | 5 s, `assertionTimeout` |
| Navigation timeout | none | 120 s, the test budget |
| Retries | 0 | 1 in CI, 0 locally |
| Workers | half the CPU cores | 1 in CI, half the CPU cores locally |
| Trace | `off` (`on-first-retry` in the scaffolded config) | `retain-on-failure` |

## Next

<CardGroup cols={2}>
  <Card title="Core concepts" icon="pen-to-square" href="/core-concepts">
    Goals, assertions, and locators instead of scripted selectors.
  </Card>
  <Card title="Web engine reference" icon="masks-theater" href="/reference/web">
    Every option and every `browser` method.
  </Card>
</CardGroup>
