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.
“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
Vite servers spun up
Facade pages served
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
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`.
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.
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.
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 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.
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
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();
});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
The ref flow, end to end
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.
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`).
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.
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)
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-react | Ref-targeted in-context |
|---|---|---|
| How the component is mounted | Vite 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 props | Banned. 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 props | Banned. 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 cookies | Missing unless you re-stub them inside the CT fixture. | Whatever the real app has when the test runs. |
| Network calls on mount | Hit 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 selector | Component-scoped locators (`component.getByRole(...)`) after mount. | `[ref=eN]` from the latest snapshot; re-snapshot when the DOM changes. |
| Status in the official docs | Still 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.
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.
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.
More ways the MCP + accessibility-tree model replaces Playwright ceremony
Adjacent reading
The one regex that is the entire scenario grammar
How a 40-character regex at agent.ts:621 replaces the Playwright API surface for English-first tests.
setOffline and navigator.onLine: the parts Playwright does not flip
context.setOffline(true) flips the page's navigator.onLine but leaves service workers and WebSockets online. Here is the addInitScript patch.
Readable Playwright test code from an English plan
Every MCP tool call the agent dispatches becomes a line of real Playwright. The artefact is code you can paste into a *.spec.ts.
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.