@agent-scope/playwright
v1.20.0
Published
Playwright integration for Scope — replay traces and generate tests
Downloads
2,181
Readme
@agent-scope/playwright
Playwright fixture + browser-entry bundle for capturing React component trees in end-to-end tests. Extends @playwright/test with a scope.capture() fixture method that serialises the live React fibre tree into a PageReport — no source maps, no instrumented builds required beyond the Babel plugin.
Installation
npm install @agent-scope/playwright
# or
bun add @agent-scope/playwrightPeer dependencies: @playwright/test.
Prerequisites: The package ships a pre-built browser IIFE bundle (browser-bundle.iife.js). Build it first if it doesn't exist:
bun run build # in packages/playwrightWhat it does / when to use
| Use case | What to use |
|---|---|
| Capture React tree in a Playwright test | test + scope.capture() fixture |
| Navigate then capture in one call | scope.captureUrl(url) |
| Capture from a second page object | scope.capture(otherPage) |
| Wait for async data to finish loading | scope.capture({ waitForStable: true }) |
| Inject the browser script manually (Vite/custom setup) | getBrowserEntryScript() |
| Load a PageReport from JSON | loadTrace(rawJson) |
| Generate a Playwright test skeleton from a trace | generateTest(trace) |
Architecture
Browser (page) Node.js (test)
───────────────────────────────── ───────────────────────────
page.addInitScript(bundle) fixture.ts
└── browser-entry.ts (IIFE) ← BrowserPool / SatoriRenderer
1. installHook() scope.capture()
2. Vite react-refresh compat └── evaluateCapture(page)
3. await firstCommit └── page.evaluate(
4. window.__SCOPE_CAPTURE__() __SCOPE_CAPTURE_JSON__()
5. window.__SCOPE_CAPTURE_JSON__() )
→ JSON string
→ JSON.parse()
→ PageReportBrowser-entry injection pattern
The fixture calls page.addInitScript({ path: bundlePath }) before any navigation. This ensures the bundle runs in the browser before React initialises — critical because the DevTools hook (installHook()) must be installed before React evaluates its own bootstrap code.
Vite react-refresh compatibility
Vite's react-refresh preamble (injectIntoGlobalHook) accesses hook.renderers.forEach(...) (a Map<number, RendererObj>). The Scope devtools hook stores renderers in _renderers (with wrapper objects). browser-entry.ts creates a proxy renderers Map that exposes only the raw renderer objects, patching inject() to keep it in sync. Without this, the preamble throws mid-execution and React commits never reach the hook.
Async React 18 first-commit
React 18's concurrent scheduler may call inject() before the first onCommitFiberRoot. The browser entry wraps onCommitFiberRoot to set hasCommitted = true and resolve a firstCommit Promise. window.__SCOPE_CAPTURE__() awaits this promise if hasCommitted is false — preventing "Execution context was destroyed" races in SPAs that navigate before the first render completes.
CDP structured-clone bypass
window.__SCOPE_CAPTURE_JSON__() serialises the PageReport to a JSON string inside the browser, bypassing Playwright's CDP structured-clone limit. evaluateCapture() prefers __SCOPE_CAPTURE_JSON__ and falls back to __SCOPE_CAPTURE__ for older runtime versions.
API Reference
test and expect
Drop-in replacements for @playwright/test's test and expect that add the scope fixture.
import { test, expect } from '@agent-scope/playwright';
test('captures React tree', async ({ scope, page }) => {
await page.goto('http://localhost:5173');
const report = await scope.capture();
expect(report.tree.name).toBeTruthy();
});ScopeFixture
The scope object available inside tests.
interface ScopeFixture {
scope: {
capture(options?: CaptureOptions): Promise<PageReport>;
capture(targetPage: Page, options?: CaptureOptions): Promise<PageReport>;
captureUrl(url: string): Promise<PageReport>;
};
}scope.capture(options?)
Captures the React component tree from the fixture's default page. Safe to call immediately after page.goto() — the browser bundle awaits the first React commit internally.
const report = await scope.capture();
const report = await scope.capture({ waitForStable: true, stableMs: 500 });scope.capture(targetPage, options?)
Captures from an alternative Playwright Page object. The browser bundle is injected into targetPage automatically.
const secondPage = await context.newPage();
await secondPage.goto('http://localhost:5173/modal');
const report = await scope.capture(secondPage);scope.captureUrl(url)
Navigates to url on the fixture page then immediately captures.
const report = await scope.captureUrl('http://localhost:5173');CaptureOptions
interface CaptureOptions {
/**
* Poll until component count is stable for `stableMs` ms.
* Use when the page performs async data loading after initial render.
* Default: false.
*/
waitForStable?: boolean;
/**
* How long (ms) component count must remain unchanged before capture
* is considered stable. Only used with waitForStable: true.
* Default: 1000.
*/
stableMs?: number;
/**
* Maximum time (ms) to spend polling before returning the last
* successful capture. Does NOT throw on timeout.
* Only used with waitForStable: true.
* Default: 15000.
*/
timeoutMs?: number;
/**
* Use lightweight captures (structure only, no props/state/hooks) during
* stability polling. A single full capture is performed once stability
* is confirmed. Reduces CDP payload cost per poll tick.
* Only used with waitForStable: true.
* Default: false.
*/
lightweight?: boolean;
}PageReport
The return type of scope.capture() — defined in @agent-scope/core.
interface PageReport {
url: string;
route: null;
timestamp: number;
capturedIn: number; // ms to capture
tree: ComponentNode; // root of the React component tree
errors: unknown[];
suspenseBoundaries: unknown[];
consoleEntries: unknown[];
}
interface ComponentNode {
id: number;
name: string;
type: 'function' | 'class' | 'other';
source: SourceLocation | null;
props: SerializedValue;
state: SerializedValue[];
context: SerializedValue[];
renderCount: number;
renderDuration: number;
children: ComponentNode[];
}getBrowserEntryScript()
Returns the pre-built browser IIFE bundle as a string. Use this to inject the Scope runtime into a Playwright page manually — useful in Vite-based setups or when you need to inject it outside of the fixture.
import { getBrowserEntryScript } from '@agent-scope/playwright';
// In a Playwright globalSetup or a manual test:
const script = getBrowserEntryScript();
await page.addInitScript({ content: script });The bundle is searched in:
dist/browser-bundle.iife.js(relative to the package's dist directory, when running from an installed package)../dist/browser-bundle.iife.js(when running from source)
Throws with a clear message if the bundle is not found, instructing you to run bun run build.
Why use this instead of { path: bundlePath } directly?
getBrowserEntryScript() returns the bundle content as a string, which is required when:
- You cannot resolve the bundle file path reliably (e.g. in bundled test environments)
- You need to modify or prefix the script before injection
- You want to inject into multiple pages without file-path resolution per page
evaluateCapture(page) / captureUntilStable(page, stableMs, timeoutMs, lightweight?)
Low-level capture utilities (exported from capture-utils.ts).
import { evaluateCapture, captureUntilStable, countNodes } from '@agent-scope/playwright';evaluateCapture(page) — single capture with retry logic.
Calls page.evaluate(() => window.__SCOPE_CAPTURE_JSON__()). Retries up to MAX_RETRIES (3) times on "Execution context was destroyed" errors (caused by navigations racing with evaluate). Each retry waits RETRY_DELAY_MS (500 ms). Non-retriable errors are re-thrown immediately.
// Succeeds after 1 context-destroyed error:
let callCount = 0;
const page = makePage(async () => {
if (++callCount === 1) throw new Error('Execution context was destroyed');
return JSON.stringify(report);
});
const result = await evaluateCapture(page); // callCount === 2captureUntilStable(page, stableMs, timeoutMs, lightweight?) — stability polling.
Polls evaluateCapture every POLL_INTERVAL_MS (300 ms). Returns when the component node count (via countNodes(report.tree)) has been unchanged for stableMs ms. On timeout, returns the last successful capture instead of throwing.
// Tree grows then stabilises at 5 nodes:
const result = await captureUntilStable(page, 500, 10000);
// countNodes(result.tree) === 5When lightweight: true, polls with evaluateLightweightCapture (calls __SCOPE_CAPTURE_JSON__({ lightweight: true })) and performs a single full capture once stability is confirmed.
countNodes(node) — recursive node counter.
countNodes(makeNode(1));
// → 1
countNodes(makeNode(1, [makeNode(2, [makeNode(4), makeNode(5)]), makeNode(3)]));
// → 5loadTrace(rawJson) / generateTest(trace, options?)
import { loadTrace, generateTest } from '@agent-scope/playwright';
// Parse a raw PageReport JSON string into a CaptureTrace:
const trace = loadTrace(fs.readFileSync('capture.json', 'utf-8'));
// trace.report: PageReport
// trace.capturedAt: number (Date.now() at load time)
// Generate a Playwright test skeleton:
const source = generateTest(trace, {
description: 'Button renders in primary variant',
outputPath: 'button.spec.ts',
});
// Returns a TypeScript test file stringReal test payloads
Basic capture (from fixture.test.ts)
test('captures React tree from basic-tree fixture', async ({ scope, page }) => {
await page.goto('http://localhost:5173');
const report = await scope.capture();
expect(report.url).toContain('localhost:5173');
expect(typeof report.timestamp).toBe('number');
expect(report.tree.name).toBeTruthy();
expect(Array.isArray(report.errors)).toBe(true);
expect(report.errors).toHaveLength(0); // no intentional errors in basic-tree fixture
expect(report.route).toBeNull();
});captureUrl shorthand (from fixture.test.ts)
test('captureUrl navigates and captures', async ({ scope }) => {
const report = await scope.captureUrl('http://localhost:5173');
expect(report.url).toContain('localhost:5173');
expect(report.tree.children.length).toBeGreaterThanOrEqual(0);
});Context-destroyed retry (from capture-utils.test.ts)
// evaluateCapture retries once on context-destroyed, then succeeds:
let callCount = 0;
const page = makePage(async () => {
if (++callCount === 1) {
throw new Error('Execution context was destroyed, most likely because of a navigation.');
}
return JSON.stringify(report);
});
const result = await evaluateCapture(page);
// callCount === 2
expect(result.url).toBe(report.url);Stability polling (from capture-utils.test.ts)
// Tree grows across captures then stabilises at 5 nodes:
const reports = [
makeReport(makeNode(1)), // 1 node
makeReport(makeNode(1, [makeNode(2), makeNode(3)])), // 3 nodes
makeReport(makeNode(1, [makeNode(2, [makeNode(4)]),
makeNode(3, [makeNode(5)])])), // 5 nodes
// ... same 5-node tree repeats → stable
];
const result = await captureUntilStable(page, 500, 10000);
// countNodes(result.tree) === 5Timeout: returns last capture without throwing (from capture-utils.test.ts)
// stableMs > timeoutMs: forced timeout
const resultPromise = captureUntilStable(page, 5000, 500);
await vi.advanceTimersByTimeAsync(2000);
// resolves (not rejects) with a PageReport
await expect(resultPromise).resolves.toBeDefined();Configuration examples
Standard Playwright config
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
use: { baseURL: 'http://localhost:5173' },
webServer: { command: 'bun run dev', port: 5173 },
});// tests/scope.spec.ts
import { test, expect } from '@agent-scope/playwright';
test('component tree is stable after data load', async ({ scope, page }) => {
await page.goto('/dashboard');
const report = await scope.capture({ waitForStable: true, stableMs: 1000 });
expect(report.tree.name).toBe('App');
});Manual injection (no fixture)
import { getBrowserEntryScript } from '@agent-scope/playwright';
import { chromium } from 'playwright';
const browser = await chromium.launch();
const page = await browser.newPage();
await page.addInitScript({ content: getBrowserEntryScript() });
await page.goto('http://localhost:5173');
const json = await page.evaluate(() => window.__SCOPE_CAPTURE_JSON__());
const report = JSON.parse(json);
await browser.close();Used by
@agent-scope/cli— usesgetBrowserEntryScript()to inject the browser bundle into Vite dev-server pages duringscope captureruns- CI E2E test suites —
testandexpectreplace the base Playwright exports for all Scope integration tests
