Skip to content
Chapter 90Lesson 2

Config, storageState, and the trace viewer

Assemble the Playwright surface for end-to-end testing, the config, the sign-in-once storageState pattern, locators and fixtures, and the trace viewer you debug with.

You’ve already decided Playwright is worth its weight on exactly four paths: sign-in to a paid surface, the Stripe Checkout round-trip, invitation acceptance, and the primary value loop. The discipline that protects those tests is settled: run against a production build, one retry not three, fix flake at the root. What’s left is the wiring. This lesson assembles the kit: a complete playwright.config.ts, an auth.setup.ts that signs each role in once and saves the session, the locator and assertion vocabulary your tests are written in, a fixtures file, and the trace viewer you open when something breaks. By the end you’ll have the whole surface, and the next lesson runs the four real paths across it.

Installing and the file layout it generates

Section titled “Installing and the file layout it generates”

One command scaffolds everything. On a fresh project you run the creator, then pull down the browser binaries Playwright drives:

Terminal window
pnpm create playwright
pnpm dlx playwright install

The first command writes a config file and a sample test. The second downloads the actual Chromium, Firefox, and WebKit builds into pnpm’s store, where they live outside your repo and never get committed. Here’s what lands in the project, and what each path is for.

  • playwright.config.ts the surface this lesson builds
  • Directorytests/
    • Directorye2e/ Playwright specs live here
  • Directoryplaywright-report/ HTML report; gitignored, never committed
  • Directorytest-results/ traces, screenshots, videos; gitignored, never committed
  • .gitignore the creator adds the two dirs above

The one decision worth flagging now: E2E specs get their own tests/e2e/ directory, because Playwright is a separate runner driving the app in a separate process. That’s a deliberate contrast with the integration tests from the “Integration tests at the seams” chapter, which stay colocated next to the code they exercise as src/**/*.int.test.ts. The .int.test.ts suffix is what routes those into Vitest’s integration project. Two runners, two homes: integration lives beside the source under src/, and E2E sits apart in tests/e2e/. The split keeps each suite easy to find as the codebase grows.

We pin to the current 1.x line, Playwright 1.60 as of mid-2026, using @playwright/test, which ships TypeScript support out of the box. You need no extra config to get .ts specs.

This is where the discipline from last lesson becomes concrete. Every rule you accepted in the abstract shows up here as a config key: production build, one retry, structural flake fixes, artifacts only on failure. There’s no new argument to make in this section, just the principles turned into literal lines in a file. Read the top-level config one group at a time.

import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: 'tests/e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0,
workers: process.env.CI ? 2 : undefined,
reporter: process.env.CI ? [['github'], ['html']] : 'list',
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
// webServer and projects covered below
});

Where the specs live, and parallel by default: every spec file runs in its own worker process at the same time. It’s the same parallelism instinct as the Vitest workers you already know, on a new runner.

import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: 'tests/e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0,
workers: process.env.CI ? 2 : undefined,
reporter: process.env.CI ? [['github'], ['html']] : 'list',
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
// webServer and projects covered below
});

forbidOnly makes a stray test.only left in a file fail the CI run instead of silently skipping the rest of the suite. !!process.env.CI reads “true on CI, false locally”: a focused test is fine while you iterate, but fatal once it’s pushed.

import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: 'tests/e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0,
workers: process.env.CI ? 2 : undefined,
reporter: process.env.CI ? [['github'], ['html']] : 'list',
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
// webServer and projects covered below
});

Here is last lesson’s rule in concrete form: one retry on CI, zero locally. The generated scaffold ships retries: 2, and the course overrides that to 1 on purpose. One re-run tells a flake apart from a real failure, while more re-runs start hiding real bugs behind a green check. workers: 2 caps CI parallelism so a shared runner doesn’t thrash.

import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: 'tests/e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0,
workers: process.env.CI ? 2 : undefined,
reporter: process.env.CI ? [['github'], ['html']] : 'list',
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
// webServer and projects covered below
});

GitHub annotations plus an HTML report on CI, and a plain scrolling list locally. The github reporter is what surfaces a failure inline on the pull request.

import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: 'tests/e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0,
workers: process.env.CI ? 2 : undefined,
reporter: process.env.CI ? [['github'], ['html']] : 'list',
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
// webServer and projects covered below
});

baseURL lets every spec write page.goto('/billing') instead of the full origin. The other three say the same thing three ways: spend the bytes on traces, screenshots, and video only when something failed. Note trace: 'on-first-retry' in particular, since it’s the line the whole trace-viewer section depends on, and we’ll come back to it.

1 / 1

That’s the spine. Two of the keys above, webServer and projects, carry enough weight to deserve their own treatment, so they get the next two subsections instead of one more annotated step.

This block carries last lesson’s central rule, test the build users get, not next dev, and makes it executable. There’s no separate ceremony, no manual “remember to build first.” It’s four fields:

playwright.config.ts
webServer: {
command: 'pnpm build && pnpm start',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},

The command is a production build, then start, never next dev. Dev mode is a different code path: no static optimization, dev-only error overlays, unminified hydration. A test that passes against next dev and breaks against the real build is the bug that ships, so the config never lets you run against dev by accident.

The url is a readiness probe. Playwright polls it and won’t start a single test until the server answers, so your first test isn’t racing the server’s boot. reuseExistingServer: !process.env.CI controls whether Playwright reuses a server that’s already up: locally, if you already have pnpm start running, Playwright reuses it for a fast inner loop, while on CI it always builds fresh from a clean tree. Finally, timeout: 120_000 gives the probe two minutes, because a real next build genuinely takes a while and you don’t want the probe giving up before the build finishes.

The last block is projects, and it’s where the chapter’s auth model gets wired in. A project is a named run configuration: a set of tests plus the settings they run under. Read this, then we’ll trace what it does:

playwright.config.ts
projects: [
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
{
name: 'chromium',
use: { ...devices['Desktop Chrome'], storageState: '.auth/owner.json' },
dependencies: ['setup'],
},
],

The setup project grabs every file matching *.setup.ts and nothing else. The chromium project runs your actual specs, and it declares dependencies: ['setup'], which forces the setup project to finish first. So the chain runs in order: setup runs, setup writes a file, then use.storageState loads that file into every chromium test. That dependencies key is what connects the next section’s auth.setup.ts to all your tests. Right now it points at a .auth/owner.json file that doesn’t exist yet, and the setup project’s whole job is to produce it.

That flow is hard to see from the config alone, so here it is as a picture:

setup project runs auth.setup.ts
.auth/owner.json session cookies on disk
chromium project loads storageState
every spec starts already signed in
The setup project signs in once and serializes the session to disk; every chromium spec loads that file and starts authenticated. The `dependencies` key is what orders the two.

One policy note carries over from last lesson. Chromium is the only browser CI runs by default. WebKit and Firefox are added as two more projects, gated behind an environment flag so they’re opt-in:

// only when PLAYWRIGHT_PROJECTS=all
{ name: 'webkit', use: { ...devices['Desktop Safari'], storageState: '.auth/owner.json' }, dependencies: ['setup'] },

Cross-browser coverage is expensive, so it’s reserved for the auth and checkout money paths only, with a single browser everywhere else. That’s the same shape as the money-path filter itself: pay for the broad coverage exactly where failure costs money, and nowhere else.

Signing in once with auth.setup.ts and storageState

Section titled “Signing in once with auth.setup.ts and storageState”

This is the first of the two genuinely new mechanisms in the lesson, so we’ll slow down. Start with the mistake it exists to prevent, since it’s a tempting one.

The instinct, fresh off other test frameworks, is to log in at the top of every test: navigate to /sign-in, fill the form, submit, wait for the dashboard, then test the thing you actually care about. Do that, and every one of your four money-path tests replays the full email-to-password-to-redirect flow before it does any real work. You’ve multiplied the suite’s runtime five or ten times over, and you’re re-testing login inside tests that have nothing to do with login.

The fix is to sign each role in once, capture the resulting browser session (cookies plus storage) into a file, and have every other test start already authenticated by loading that file. That serialized session is storageState . The login flow runs exactly one time, in the setup project, and produces the .auth/owner.json file the config was already pointing at.

Here’s the setup file that does it.

import { test as setup, expect } from './fixtures';
setup('authenticate as owner', async ({ page }) => {
await page.goto('/sign-in');
await page.getByLabel(/email/i).fill('owner@e2e.test');
await page.getByLabel(/password/i).fill('correct-horse');
await page.getByRole('button', { name: /sign in/i }).click();
await expect(
page.getByRole('heading', { name: /dashboard/i }),
).toBeVisible();
await page.context().storageState({ path: '.auth/owner.json' });
});

It’s a test like any other, in the setup project. It imports test, aliased to setup for readability, and expect from the project’s own fixtures file rather than from @playwright/test directly. We unpack that fixtures file a couple of sections from here.

import { test as setup, expect } from './fixtures';
setup('authenticate as owner', async ({ page }) => {
await page.goto('/sign-in');
await page.getByLabel(/email/i).fill('owner@e2e.test');
await page.getByLabel(/password/i).fill('correct-horse');
await page.getByRole('button', { name: /sign in/i }).click();
await expect(
page.getByRole('heading', { name: /dashboard/i }),
).toBeVisible();
await page.context().storageState({ path: '.auth/owner.json' });
});

Drive the real login UI. The role-first locators here, getByLabel and getByRole, are exactly the ladder you learned for React Testing Library, with page. swapped in for screen.. The credentials belong to a seeded test user we’ll create next.

import { test as setup, expect } from './fixtures';
setup('authenticate as owner', async ({ page }) => {
await page.goto('/sign-in');
await page.getByLabel(/email/i).fill('owner@e2e.test');
await page.getByLabel(/password/i).fill('correct-horse');
await page.getByRole('button', { name: /sign in/i }).click();
await expect(
page.getByRole('heading', { name: /dashboard/i }),
).toBeVisible();
await page.context().storageState({ path: '.auth/owner.json' });
});

Wait for the post-login state before saving. This assertion does real work, not decoration: if you serialize the session while the redirect is still in flight, you capture a half-finished login, and every test that loads it fails. The matcher waits for the dashboard heading to actually appear. (More on why this expect waits on its own in a couple of sections.)

import { test as setup, expect } from './fixtures';
setup('authenticate as owner', async ({ page }) => {
await page.goto('/sign-in');
await page.getByLabel(/email/i).fill('owner@e2e.test');
await page.getByLabel(/password/i).fill('correct-horse');
await page.getByRole('button', { name: /sign in/i }).click();
await expect(
page.getByRole('heading', { name: /dashboard/i }),
).toBeVisible();
await page.context().storageState({ path: '.auth/owner.json' });
});

The write. context().storageState({ path }) serializes the now-authenticated session to disk, producing the artifact every spec consumes.

1 / 1

You write one setup test per role the suite needs. The money paths assert role-specific UI, since an owner sees billing controls that a member doesn’t, so you write auth.setup.ts once per role. Each one signs in a different seeded user and writes its own state file: .auth/owner.json, .auth/member.json, and so on.

To make the before/after concrete, here are the two approaches side by side.

test('owner can open billing', async ({ page }) => {
await page.goto('/sign-in');
await page.getByLabel(/email/i).fill('owner@e2e.test');
await page.getByLabel(/password/i).fill('correct-horse');
await page.getByRole('button', { name: /sign in/i }).click();
await expect(page.getByRole('heading', { name: /dashboard/i })).toBeVisible();
await page.goto('/billing');
// the test you actually meant to write starts here
});

Multiplies runtime and re-tests login everywhere. Every spec pays the full email-to-redirect cost, and a single login regression now fails dozens of unrelated tests at once, which buries the signal.

The Playwright-owned test database and its seed

Section titled “The Playwright-owned test database and its seed”

Playwright drives a real server, and a real server needs a real database. That much is familiar, since you already accepted “separate Postgres for tests” in “Integration tests at the seams.” The twist is that you can’t reuse the database setup from there, and it’s worth understanding why.

The integration tests keyed a fresh database per Vitest worker and wrapped each test in a transaction that rolled back its writes, which gave clean per-test isolation for free. That scheme works because the test code and the database connection live in the same process: the test opens the transaction, does its writes, and rolls back, all in one runtime. Playwright’s server is a different process. Your test only talks to it over HTTP: it sends a request, the server reads and writes its own database, and the test reads the response. The test has no handle on the server’s transaction, so it can’t open one before a request and roll it back after. The process boundary that makes E2E realistic is the same boundary that makes per-test rollback impossible.

So the isolation model is different: a deterministic full reset between runs, not a rollback per test. The course uses a separate database, saas_e2e, with a reset script:

Terminal window
pnpm db:e2e:reset

That script runs the migrations and then a deterministic seed. The seed creates the role users (owner@e2e.test, member@e2e.test), their organizations, and a baseline of records every test can assume exists. CI runs it once before the suite, while locally you reuse the seeded database until the schema actually changes. Tests assume the seed and write incrementally on top of it, and the reset between full runs is the isolation seam. Those seeded users are exactly the credentials auth.setup.ts signs in, which is where these two sections meet.

This is the spot students most often trip, so it’s worth drawing the line between the two test databases explicitly.

Layer Database Isolation seam
Integration Integration tests at the seams a fresh database per worker keyed by VITEST_POOL_ID a transaction rolled back after each test withRollback(tx) per test
E2E this chapter one shared database saas_e2e a deterministic full reset between runs db:e2e:reset per run
Two test databases, two isolation seams. The integration suite rolls back inside one process; Playwright's server is a separate process, so it resets the whole database between runs instead.

Keep these two genuinely separate. If you point Playwright at the integration suite’s database, or run both against the same one, their writes collide. Each suite gets its own process, its own database, and its own isolation seam.

Tests are made of two things: ways to find elements (locators) and ways to assert something about them (expect). Both work the way you’d hope, and both wait for you, which removes a whole category of timing bugs. This section is also where the second anti-pattern, page.waitForTimeout, goes away, because once you’ve seen why it’s unnecessary you’ll never reach for it again.

The locator priority ladder is the exact one you learned for React Testing Library: getByRole first, then getByLabel, then getByText, and getByTestId only as a last resort. The single difference is the prefix, page. instead of screen.:

page.getByRole('button', { name: /sign in/i });
page.getByLabel(/email/i);
page.getByText(/invoice sent/i);
page.getByTestId('plan-badge');

There’s no new ranking to learn here; it transfers directly. Query by what a user perceives, such as a button named “Sign in,” not by how the markup happens to be built today. That’s why CSS-class locators don’t belong here: page.locator('.btn-primary') reads fine until someone renames the class in a refactor, and then your test breaks for a reason that has nothing to do with behavior. A role plus an accessible name survives that refactor.

The new part is what happens when you act on a locator. Playwright locators are auto-waiting : await page.getByRole('button', { name: /pay/i }).click() doesn’t fire a click at nothing. It waits until that button is attached to the DOM, visible, not animating, and enabled, and then clicks. You don’t call waitForSelector first, because the wait is built into the action.

The same idea runs through assertions. A web-first matcher like toBeVisible is not a one-shot check:

await expect(page.getByRole('heading', { name: /dashboard/i })).toBeVisible();

This polls. It re-checks the condition over and over until it holds or the default timeout, about five seconds, runs out. So after you click “Sign in,” you don’t sleep to let the redirect happen; you assert the dashboard heading is visible, and the assertion waits for it. The matchers you’ll reach for most are toBeVisible, toHaveURL, toHaveText, toBeEnabled, and toBeChecked. When you need to wait on something that isn’t in the DOM, such as a database row appearing or a webhook landing, expect.poll(fn) is the escape hatch: it re-runs your function until the value matches.

Together, auto-waiting locators and polling matchers are why “sleep, then assert” is obsolete. Here it is as a before/after.

await page.getByRole('button', { name: /sign in/i }).click();
await page.waitForTimeout(2000);
expect(await page.getByRole('heading', { name: /dashboard/i }).isVisible()).toBe(true);

Flaky and slow at the same time. 2000ms is a guess: too short and it fails on a slow run, too long and every test pays the full wait even when the page was ready in 200ms. A fixed sleep can only be wrong in one of two directions.

One more trap worth a flag, because it’s subtle and expensive: an expect on a web-first matcher only works if you await it.

expect(page.getByRole('heading', { name: /dashboard/i })).toBeVisible();

Drop the await and the matcher returns a promise nobody waits on. The test moves past it without ever checking the condition, so it passes silently rather than failing. A test that asserts nothing is worse than one that fails, because it gives you false confidence. The fix is one keyword: put await back.

Now a quick check on the locator instinct.

Your checkout page renders this markup, and a money-path test needs to click the Confirm payment button:

<form>
<label>Card number <input name="cardNumber" /></label>
<button class="btn-pay primary" data-testid="checkout-submit">
Confirm payment
</button>
</form>

Which locator targets that button and survives a CSS-class rename or a markup reshuffle in next month’s refactor?

page.locator('.btn-pay.primary');
page.getByRole('button', { name: /confirm payment/i });
page.locator('form button >> nth=0');
page.getByTestId('checkout-submit');

You’ve now imported test and expect from ./fixtures twice without an explanation of where that file comes from. Here it is. Playwright’s test.extend builds typed, per-test fixtures, and “per-test, isolated” should ring a bell, because it’s the same isolation discipline Vitest gave you, on a new surface.

The fixtures file does two jobs. The smaller one is a convention you’ve already met: it re-exports test and expect so every spec imports from a single local file rather than from @playwright/test directly. That’s the same single-import pattern you used for the React Testing Library render helper and the signedInAs fixture in the chapters before this one. The bigger job is defining custom fixtures. Their shape is worth slowing down on, because the await use(...) call in the middle is unfamiliar if you’re coming from beforeEach.

import { test as base, expect } from '@playwright/test';
import { seedInvoices, deleteInvoices } from './seed-helpers';
export const test = base.extend<{ invoices: Invoice[] }>({
invoices: async ({}, use) => {
const invoices = await seedInvoices(3);
await use(invoices);
await deleteInvoices(invoices);
},
});
export { expect };

base.extend takes a generic describing the fixtures this file adds, here an invoices fixture typed as Invoice[]. The returned test is what specs import; it knows about the new fixture and is fully typed.

import { test as base, expect } from '@playwright/test';
import { seedInvoices, deleteInvoices } from './seed-helpers';
export const test = base.extend<{ invoices: Invoice[] }>({
invoices: async ({}, use) => {
const invoices = await seedInvoices(3);
await use(invoices);
await deleteInvoices(invoices);
},
});
export { expect };

The setup phase. Code above use runs before the test; here it seeds three invoices and holds them.

import { test as base, expect } from '@playwright/test';
import { seedInvoices, deleteInvoices } from './seed-helpers';
export const test = base.extend<{ invoices: Invoice[] }>({
invoices: async ({}, use) => {
const invoices = await seedInvoices(3);
await use(invoices);
await deleteInvoices(invoices);
},
});
export { expect };

await use(invoices) hands the value to the test and pauses here for the duration of that test. This is the line that’s new if you’re used to beforeEach: setup and teardown live in one function, split by the use call.

import { test as base, expect } from '@playwright/test';
import { seedInvoices, deleteInvoices } from './seed-helpers';
export const test = base.extend<{ invoices: Invoice[] }>({
invoices: async ({}, use) => {
const invoices = await seedInvoices(3);
await use(invoices);
await deleteInvoices(invoices);
},
});
export { expect };

The teardown phase. Code after use runs once the test finishes, deleting the rows it seeded. Fixtures are per-test by default, so this runs for every test that asks for invoices.

1 / 1

A spec opts into a fixture just by naming it in the test’s destructured argument, async ({ invoices }) =>, and Playwright runs the setup, injects the value, and runs the teardown around that one test. Fixtures compose, they’re typed, and they’re isolated per test. That’s the same guarantee as the integration layer’s per-test setup, just in its browser-test form.

Remember trace: 'on-first-retry' from the config? Here’s what it buys you. When a test fails and gets its one retry, that retry records a trace.zip. You open it with:

Terminal window
pnpm exec playwright show-trace test-results/<the-failing-test>/trace.zip

What opens is not a log file. It’s the trace viewer , and it lets you step back through the run in time. You get a timeline of every action the test took. Click any one and you see the full DOM snapshot of the page at that exact moment, plus the network requests in flight, the console output, a screenshot, and the source-mapped line of your test that fired it. Instead of sprinkling console.log through a spec to find out what went wrong, you open the trace and scrub to the action that failed.

The fastest way to understand it is to operate one. The trace below is a real, failing Playwright run from the official sample suite. It’s live, so go ahead and use it.

Click an action in the timeline along the top and watch the DOM snapshot on the left swap to the page as it looked at that step. Open the Network tab to see the requests that action triggered. Read the Call log to follow the test line by line. Hover the timeline and you scrub through the run like video. That’s the entire 2026 Playwright debugging loop, and you just ran it without writing a single failing test of your own.

On CI it’s the same loop with one extra hop: the trace.zip uploads as a GitHub Actions artifact, and whoever reviews the pull request downloads it and opens it with show-trace. The failure travels together with the evidence for it.

One thing is worth holding onto: a trace exists only when a test retries. With trace: 'on-first-retry', a test that passes on the first try records nothing, which is what you want, since a trace for every green run would fill up your artifacts. The flip side trips people up. “Why is there no trace?” almost always means “the test didn’t retry,” which means it either passed on the first try or you’re looking at a config that doesn’t capture traces. The exercise below puts that sequence in order.

A money-path spec just went red in CI. Order the steps that produce a trace and put it in front of the reviewer — remember, a trace exists only because the test retried. Drag the items into the correct order, then press Check.

A test fails on its first run in CI
retries: 1 triggers exactly one retry
The retry records a trace.zip
CI uploads the zip as a GitHub Actions artifact
The reviewer downloads it and runs show-trace
The reviewer scrubs to the failing action and reads its DOM snapshot

You don’t have to hand-write the first version of a spec. Point codegen at your running app and click through the flow:

Terminal window
pnpm exec playwright codegen http://localhost:3000

A browser opens and records your clicks and typing as Playwright code, with locators generated as you go, which is a fast way to rough out a new test. The catch, and the one place judgment comes in, is that codegen sometimes emits a CSS selector where a role-based locator would be more robust. So the workflow is codegen for the skeleton, then rewrite the brittle locators before the test goes in for review, applying exactly the role-first call from the locators section. The VS Code extension wraps the same tools into the editor: run or debug a single test, view its trace live, and pick locators interactively.

A few deliberate cuts, so you know where the edges are and don’t go looking for them here:

  • Reading the trace viewer panel by panel. You’ve operated it and know what it shows, and the official docs own the exhaustive tour.
  • Custom reporters. Pinned to GitHub plus HTML, with no others.
  • Sharding. Splitting the suite across parallel CI jobs (--shard=1/4, --shard=2/4, and so on) only pays off once the suite passes roughly 10–15 minutes. A four-money-path suite is nowhere near that, so it’s a seam to know exists, not a thing to set up now.
  • Component testing in Playwright. React Testing Library owns the component layer from “Component tests, off by default,” and the course doesn’t pin two component-test tools.
  • Mobile emulation and visual regression. Out of scope for this chapter.
  • page.clock. Stable, and it controls Date.now() inside the browser, with the same intent as Vitest’s fake timers, for time-based UI like a trial-end banner. It exists, but reach for it only when a money path actually needs to freeze time.

The official docs go deeper on CI setup and the trace viewer than this lesson’s scope allows. The best-practices page is worth a read for its locator and isolation guidance.