npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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/playwright

Peer 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/playwright

What 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()
                                          → PageReport

Browser-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:

  1. dist/browser-bundle.iife.js (relative to the package's dist directory, when running from an installed package)
  2. ../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 === 2

captureUntilStable(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) === 5

When 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)]));
// → 5

loadTrace(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 string

Real 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) === 5

Timeout: 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 — uses getBrowserEntryScript() to inject the browser bundle into Vite dev-server pages during scope capture runs
  • CI E2E test suites — test and expect replace the base Playwright exports for all Scope integration tests