Playwright deep dive

Playwright component testing without the mount fixture. The [ref=eN] IDs your app already exposes.

Every page about this teaches @playwright/experimental-ct-react, the mount() fixture, and the Vite facade page served at /playwright/index.html. None of them say the quiet part out loud: the serialization boundary that bans live objects and synchronous callbacks is a direct consequence of isolating the component in a separate bundle. Target the same component in the real app using the accessibility-tree refs that @playwright/mcp's browser_snapshot returns, and every single CT limitation disappears.

M
Matthew Diakonov
11 min read
4.8from real Playwright code, no proprietary YAML
Every claim sourced to a file + line in the assrt-mcp repo or the playwright.dev docs
No Vite, no facade page, no mount(), no plain-object constraint
Open source, MIT, self-hostable. Bring your own Anthropic key.
1 tool

name: "snapshot", description: "Returns elements with [ref=eN] references you can use for click/type. ALWAYS call this before interacting with elements."

assrt-mcp/src/core/agent.ts:27-29

What a ref-targeted component test actually looks like

The same component. One navigate(), one snapshot(), one click() against a real [ref=eN].
0

Vite servers spun up

0

Facade pages served

0

Snapshot call, then you act

The mount() limitation list, and where it comes from

Before comparing anything, let us read the official docs back. The Playwright CT overview says three things, in order. First: the mount() fixture navigates to a facade page at /playwright/index.html and tells it to render the component. Second: you can only pass plain JavaScript objects and built-in types as props. Third: you cannot pass data to the component synchronously in a callback, because the callback executes in Node.js, not the browser.

Those three statements are not independent quirks. The second and third statements exist because of the first one. The component runs in a browser-side bundle. Your test runs in Node. Props cross an IPC boundary. Anything non-serializable dies there. That is why the docs themselves use the phrase “experimental” in the package name.

What mount() is really doing

1

Spin up Vite inside Playwright Test

When the test runner starts, playwright-ct boots a Vite dev server against your repo and watches your source. This is why `npm init playwright@latest -- --ct` requires a dedicated `playwright-ct.config.ts`.

2

Build the facade page

Vite compiles a single-page bundle whose entry is `playwright/index.ts`. That bundle is served at `/playwright/index.html`. It is the environment your component will live in for the duration of the test, separate from your real app entrypoint.

3

Serialize the props across the Node boundary

`await mount(<PricingCard price={49} />)` runs in Node. Playwright ships the JSX through an IPC bridge. The JSX arguments must be JSON-serializable. This is the origin of the `can't pass complex live objects` rule and the `can't pass synchronous callbacks` rule.

4

Render and test

The facade page receives the serialized tree, renders the component, and returns a `Locator` scoped to the mounted root. You now drive that locator with `getByRole`, `getByText`, etc. You are testing a component you have conceptually cloned and detached from its production parents.

The CT scaffold

The inversion: refs from the real app's accessibility tree

Instead of dragging the component into a harness, drag your assertion into the running app. The official @playwright/mcp server exposes a browser_snapshottool. It returns a YAML dump of the current page's accessibility tree with a [ref=eN] ID stamped on every interactable node. Those IDs are your component selectors. A region labelled “Pricing card” is the PricingCard component. A switchnamed “Annual” inside it is the BillingToggle sub-component. The accessibility contract of your component is the test selector.

snapshot.yml

That YAML is what the MCP server returned for a real page at /pricing. The ID numbering is not permanent, which is why the agent at agent.ts:214-218 re-calls snapshot after any DOM-mutating action. The numbering is stable within a snapshot, which is all you need.

Every interactable node in the snapshot carries a ref

[ref=e3]
region name="Pricing card"
[ref=e17]
switch name="Annual" pressed=false
[ref=e21]
button name="Upgrade"
[ref=e23]
group name="Billing cycle"
[ref=e20]
heading level=2 "Pro"
[ref=e18]
text "$49/mo"
[ref=e19]

Side by side: the CT test and the ref-targeted test

The two snippets below test the same thing: a pricing card flips its price when the annual toggle is switched on. The left version is a textbook mount() test. The right version is a #Case paragraph that drives the real app. Note which one can accept an onUpgrade callback prop without complaint.

MOUNT FIXTURE vs REF TARGETING

// @playwright/experimental-ct-react
import { test, expect } from '@playwright/experimental-ct-react';
import { PricingCard } from '../src/pricing-card';

test('annual toggle flips the price', async ({ mount }) => {
  // 1. Spin up Vite. Bundle PricingCard into
  //    /playwright/index.html. Navigate there.
  const component = await mount(
    <PricingCard
      monthlyPrice={49}
      annualPrice={39}
      // onUpgrade={() => trackClick()}  <- would fail:
      //   "can't pass data synchronously in a callback"
    />
  );

  await component.getByRole('switch', { name: 'Annual' }).click();
  await expect(component.getByText('$39/mo')).toBeVisible();
});
16% fewer lines

How the ref flow runs under the hood

Five actors. One round trip to snapshot, one to click. The accessibility tree you read is the one your real app renders, so every subsequent MCP call resolves to the real DOM.

ONE #CASE, NO MOUNT FIXTURE

#Case textassrt-mcp agent@playwright/mcpreal Chromiumyour real appmarkdown paragraphcallTool('browser_snapshot')CDP Accessibility.getFullAXTreelive DOM + ARIA treeserialized AX snapshotYAML with [ref=eN] IDs'[ref=e17] = Pricing card region'callTool('browser_click', ref:e21)CDP Input.dispatchMouseEventclick lands on real onChange handlerReact re-renders, state updates

The ref flow, end to end

1

Navigate once to the real page

`navigate("https://your-app.com/pricing")` opens the route where your component is already mounted by your real app. Providers, router, query client, design tokens are all active.

2

Call snapshot

`browser_snapshot` returns a YAML accessibility tree. Each interactable node carries a `[ref=eN]` ID. Look for your component by its ARIA role and accessible name (a `region` with an `<h2>`, a `group` with an `aria-label`).

3

Interact by ref

`click({ element: "Annual switch", ref: "e21" })` dispatches a real pointer event through CDP. Playwright-MCP resolves the ref to a real DOM element using the `aria-ref` attribute stamped during snapshot.

4

Re-snapshot and assert

After any DOM-mutating action the ref numbering can change. The agent re-calls `snapshot` and picks up fresh IDs, then makes its assertion. This is the system-prompt rule at agent.ts:214-218.

Sources in and signals out, from one snapshot

Ref-targeted component test (end to end)

Real Chromium
Running app
ARIA tree
@playwright/mcp
[ref=e17]
click ref
assert

The snapshot is the single source of truth. Everything the agent does next resolves against it.

The feature-by-feature truth table

Feature@playwright/experimental-ct-reactRef-targeted in-context
How the component is mountedVite bundles it into a facade page at /playwright/index.html and mount() navigates there.It is never re-mounted. You target the running component in the real app by its accessibility-tree ref.
Complex live objects as propsBanned. Docs: "Only plain JavaScript objects and built-in types like strings, numbers, dates etc. can be passed."No constraint. The component is already receiving its real props from its real parent.
Synchronous callbacks as propsBanned. Docs: "You can't pass data to your component synchronously in a callback."No constraint. Callbacks execute inside the page, same as they do in production.
Router, providers, auth cookiesMissing unless you re-stub them inside the CT fixture.Whatever the real app has when the test runs.
Network calls on mountHit either MSW stubs or nothing; no real backend by default.Hit the real backend the running app points at. Use a preview URL if you do not want prod.
Stability of the selectorComponent-scoped locators (`component.getByRole(...)`) after mount.`[ref=eN]` from the latest snapshot; re-snapshot when the DOM changes.
Status in the official docsStill labelled experimental. Package name is `@playwright/experimental-ct-react`.Runs on the stable @playwright/mcp server (Microsoft-maintained).

Running it: two terminals, same Chromium binary

Neither path replaces your existing Playwright install. Both paths call out to real Chromium through the same driver. What changes is whether your component lives in a Vite facade or in the real route it is going to ship in.

One-shot: a ref-targeted in-context run

The source, in 12 lines

The anchor for everything on this page is two blocks of TypeScript in assrt-mcp/src/core/agent.ts. The snapshot tool definition at line 27, and the parseScenarios regex at line 621. The whole interaction model fits on one screen.

assrt-mcp/src/core/agent.ts
0 toolsagent-dispatched Playwright MCP tools (agent.ts:16)
L0the single regex scenario parser (agent.ts:621)
0 YAMLproprietary DSL between you and the API
0 filesto self-host: agent.ts, browser.ts, scenario-store.ts

When the mount harness is still the right call

Ref-targeting is not always the right answer. Both tools are real Playwright. The choice is about the question you are asking: “does this component render correctly given these props” is a CT question, “does this component behave correctly in context” is a ref-targeted question. You want both, in the right proportion.

Fast pixel regression on a pure component

If the component renders from props only, you are really doing snapshot testing, and `@playwright/experimental-ct-react` with visual diff is still a fine fit. It is faster to pin a render matrix than to navigate the full app every time.

State-aware component behavior

When the behavior depends on a provider (query client cache, router location, auth user, theme context), ref-targeting in the real app is more honest. The CT harness asks you to re-stub the environment, and the re-stub is where bugs hide.

A component that fires network on mount

MSW inside CT works until it does not. Ref-targeting against a real preview URL is a smaller cognitive load: if the backend breaks, the test breaks, and that is often the regression you want to catch.

Any flow that spans two components

The moment a test needs component A to dispatch something component B consumes, mount() is the wrong tool. You mounted only A. Navigate in the real app and ref-target across both.

Pre-release smoke checks before a merge

`npx @m13v/assrt test https://<preview-deploy-url>` on the preview that just built is a higher-signal gate than a CT shard. It verifies the component in its shipped shape.

Storybook-driven render coverage

Keep it. Storybook + CT-style screenshot tests still belong in the repo for designers who want to pin visual states. The in-context ref test runs alongside, not instead of.

A quick self-check

If you can tick more than two of these boxes for the component you are about to test, the ref approach is probably the higher-signal bet.

Signals that your test belongs in-context, not in a mount harness

  • Component renders against its real providers (router, query, theme, auth).
  • Props include live objects (Date instances, class instances, functions).
  • Props include synchronous callbacks (onChange, onSubmit) that the parent actually passes.
  • The component fires a network call on mount and you want the real backend.
  • You need to verify behavior across two components that talk to each other.
  • The regression you keep shipping is an integration regression, not a render one.

What you actually walk away with

A #Case paragraph that targets one component in the real app, and a Playwright trace you can paste into a .spec.ts whenever you want to pin it.

You keep your existing @playwright/test runner. You keep any CT tests you have already written. What you gain is a second selector strategy that does not need a bundler harness. The agent is just orchestrating the same Playwright APIs you already trust, picking ref IDs out of the same accessibility tree your screen reader users navigate.

Want to see this against your app's real component tree?

Bring a preview URL and one component you keep regressing. We walk the snapshot, pick a ref, and ship a #Case back.

Book a call

Frequently asked questions

What is @playwright/experimental-ct-react actually doing under the hood?

It runs Vite as a dev server inside Playwright Test. During `test.use({})`, it mounts your component onto a facade HTML page served at `/playwright/index.html`. The official docs describe this directly: "The mount() fixture navigates to the facade page /playwright/index.html of this bundle and tells it to render the component." Your test runs in Node.js, your component runs in a real browser, and the mount() call serializes the props across the Node/browser boundary. That serialization boundary is the reason the docs warn: "You can't pass complex live objects to your component. Only plain JavaScript objects and built-in types like strings, numbers, dates etc. can be passed." It is also why "You can't pass data to your component synchronously in a callback". The component is not in your app. It is in a test-only bundle with its own entrypoint.

Why does targeting by accessibility-tree ref eliminate those limitations?

Because the component never leaves the real app. There is no Node-to-browser prop handoff to serialize. `browser_snapshot` (a tool exposed by the official `@playwright/mcp` server and wrapped by Assrt's agent at `assrt-mcp/src/core/agent.ts:27-29`) returns a YAML dump of the live accessibility tree with per-node `[ref=eN]` IDs. You pick the ref of the component you care about (for example, `[ref=e17]` is a `region` labelled "Pricing card"), then drive Playwright against that ref with real `click`, `type`, `press_key`, `select_option` calls. Your providers, state store, design tokens, route, cookies, auth, and network are whatever the running app has. No props cross a bundler boundary because you never crossed one.

How is an accessibility-tree ref different from a CSS selector or a test ID?

A ref is scoped to a snapshot. The agent calls `snapshot`, the MCP server returns a YAML tree with IDs like `[ref=e5]` for each interactable element. Those IDs are derived from the ARIA role + accessible name of the node, so a component that has a stable accessibility contract (a button with `aria-label`, a region with a `<h2>`, a listbox with an option name) gets a stable ref for the duration of the snapshot. Between snapshots the numbering can change, so Assrt's agent is told in its SYSTEM_PROMPT (agent.ts:214-218) to re-snapshot after any action that might have changed the DOM. The net effect: your component selector is its accessibility contract, which is exactly what a user or a screen reader sees.

Does this mean I should never write @playwright/experimental-ct-react tests?

No. It means the two approaches answer different questions. `@playwright/experimental-ct-react` is designed for true unit-style coverage of a component's render output with a bundler harness you control, in isolation from the app's data layer. That is genuinely useful if your component has enough visual states to be worth pinning in a screenshot matrix. Real-app ref-targeting is designed for the majority case most teams actually hit: you want to verify a component behaves correctly in context, wired to the providers and routes it ships with. The in-context test is usually the one that catches the regression, because most component bugs are integration bugs in disguise.

Show me the real line where the snapshot tool is defined.

Open `/Users/matthewdi/assrt-mcp/src/core/agent.ts`. Lines 27-29 read exactly: `name: "snapshot", description: "Get the accessibility tree of the current page. Returns elements with [ref=eN] references you can use for click/type. ALWAYS call this before interacting with elements.", input_schema: { type: "object" as const, properties: {} }`. The source is MIT-licensed on the assrt-ai GitHub and published on npm as `assrt-mcp`, so after `npm install assrt-mcp` you can `grep -n '"snapshot"' node_modules/assrt-mcp/dist/core/agent.js` and see the same tool definition compiled into JS.

What is the scenario format? Is there a hidden DSL?

There is exactly one regex. `agent.ts:621` reads `const scenarioRegex = /(?:#?\s*(?:Scenario|Test|Case))\s*\d*[:.]\s*/gi;`. Line 622 does `text.split(scenarioRegex)`. Lines 624-628 build `{ name, steps }` pairs. That is the entire grammar. You write a markdown heading like `#Case 1: the annual-toggle flips the price` and a paragraph of English underneath. No YAML config, no JSON schema, no compile step. The paragraph becomes the prompt for a Claude Haiku 4.5 instance that calls the 18 Playwright MCP tools on your behalf.

Can I run this in CI the same way I would run `npx playwright test`?

Yes, via the `assrt` CLI. `npx @m13v/assrt test https://your-preview-url --case 'verify the PricingCard in annual mode'` exits non-zero on case failure and writes a JSON report to `/tmp/assrt/results/latest.json`. Because Assrt is running `@playwright/mcp` under the hood, the browser is a real headless Chromium with a real user-data-dir at `~/.assrt/browser-profile` (see `assrt-mcp/src/core/browser.ts:313-344`). That directory survives across CI steps if you cache it, so auth state persists between the build-app step and the component-verification step, which mount() harnesses cannot do.

What about visual regression on a single component?

Same story as in-context targeting. After the agent snapshots, it calls its `screenshot` tool (agent.ts:101-104) which routes through Playwright MCP's `browser_take_screenshot`. You can scope the screenshot to the component's bounding box by first calling evaluate with `document.querySelector('[data-testid="pricing-card"]').getBoundingClientRect()`, then using the resulting rect for a clip. Pixel diffs are yours to keep (snapshots written to your filesystem, not ours), which matters for repos that already have a visual-regression baseline pipeline.

I have existing @playwright/experimental-ct-react tests. Do I rewrite them?

No. They keep working. The two layers coexist: `.spec.ts` files under `tests/` continue to run via `npx playwright test`; `#Case` markdown files under `/tmp/assrt` run via `npx @m13v/assrt test`. Both talk to a real Chromium, both are Playwright. A reasonable migration path is to leave the CT unit-style tests alone and add ref-targeted in-context checks for the integration paths CT can't reach, specifically anything involving routing, auth cookies, or a live network call the component makes on mount.

Is the experimental CT package actually deprecated? What is Playwright's direction?

Not formally deprecated. The package name still carries `experimental` in its import path (`@playwright/experimental-ct-react`), and the official docs still carry the "experimental" banner along with the complex-object and synchronous-callback caveats verbatim. Playwright's public roadmap has not promoted CT to stable at the time of writing. Treat it as a useful experimental helper for isolated render tests, not as the default strategy for verifying component behavior in shipped code.

assrtOpen-source AI testing framework
© 2026 Assrt. MIT License.

How did this page land for you?

React to reveal totals

Comments ()

Leave a comment to see what others are saying.

Public and anonymous. No signup.