@agent-scope/playwright 1.17.0 → 1.17.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/README.md +416 -0
  2. package/package.json +5 -4
package/README.md ADDED
@@ -0,0 +1,416 @@
1
+ # `@agent-scope/playwright`
2
+
3
+ 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.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install @agent-scope/playwright
9
+ # or
10
+ bun add @agent-scope/playwright
11
+ ```
12
+
13
+ Peer dependencies: `@playwright/test`.
14
+
15
+ **Prerequisites**: The package ships a pre-built browser IIFE bundle (`browser-bundle.iife.js`). Build it first if it doesn't exist:
16
+
17
+ ```bash
18
+ bun run build # in packages/playwright
19
+ ```
20
+
21
+ ---
22
+
23
+ ## What it does / when to use
24
+
25
+ | Use case | What to use |
26
+ |---|---|
27
+ | Capture React tree in a Playwright test | `test` + `scope.capture()` fixture |
28
+ | Navigate then capture in one call | `scope.captureUrl(url)` |
29
+ | Capture from a second page object | `scope.capture(otherPage)` |
30
+ | Wait for async data to finish loading | `scope.capture({ waitForStable: true })` |
31
+ | Inject the browser script manually (Vite/custom setup) | `getBrowserEntryScript()` |
32
+ | Load a `PageReport` from JSON | `loadTrace(rawJson)` |
33
+ | Generate a Playwright test skeleton from a trace | `generateTest(trace)` |
34
+
35
+ ---
36
+
37
+ ## Architecture
38
+
39
+ ```
40
+ Browser (page) Node.js (test)
41
+ ───────────────────────────────── ───────────────────────────
42
+ page.addInitScript(bundle) fixture.ts
43
+ └── browser-entry.ts (IIFE) ← BrowserPool / SatoriRenderer
44
+ 1. installHook() scope.capture()
45
+ 2. Vite react-refresh compat └── evaluateCapture(page)
46
+ 3. await firstCommit └── page.evaluate(
47
+ 4. window.__SCOPE_CAPTURE__() __SCOPE_CAPTURE_JSON__()
48
+ 5. window.__SCOPE_CAPTURE_JSON__() )
49
+ → JSON string
50
+ → JSON.parse()
51
+ → PageReport
52
+ ```
53
+
54
+ ### Browser-entry injection pattern
55
+
56
+ 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.
57
+
58
+ ### Vite react-refresh compatibility
59
+
60
+ 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.
61
+
62
+ ### Async React 18 first-commit
63
+
64
+ 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.
65
+
66
+ ### CDP structured-clone bypass
67
+
68
+ `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.
69
+
70
+ ---
71
+
72
+ ## API Reference
73
+
74
+ ### `test` and `expect`
75
+
76
+ Drop-in replacements for `@playwright/test`'s `test` and `expect` that add the `scope` fixture.
77
+
78
+ ```typescript
79
+ import { test, expect } from '@agent-scope/playwright';
80
+
81
+ test('captures React tree', async ({ scope, page }) => {
82
+ await page.goto('http://localhost:5173');
83
+ const report = await scope.capture();
84
+ expect(report.tree.name).toBeTruthy();
85
+ });
86
+ ```
87
+
88
+ ---
89
+
90
+ ### `ScopeFixture`
91
+
92
+ The `scope` object available inside tests.
93
+
94
+ ```typescript
95
+ interface ScopeFixture {
96
+ scope: {
97
+ capture(options?: CaptureOptions): Promise<PageReport>;
98
+ capture(targetPage: Page, options?: CaptureOptions): Promise<PageReport>;
99
+ captureUrl(url: string): Promise<PageReport>;
100
+ };
101
+ }
102
+ ```
103
+
104
+ #### `scope.capture(options?)`
105
+
106
+ 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.
107
+
108
+ ```typescript
109
+ const report = await scope.capture();
110
+ const report = await scope.capture({ waitForStable: true, stableMs: 500 });
111
+ ```
112
+
113
+ #### `scope.capture(targetPage, options?)`
114
+
115
+ Captures from an alternative Playwright `Page` object. The browser bundle is injected into `targetPage` automatically.
116
+
117
+ ```typescript
118
+ const secondPage = await context.newPage();
119
+ await secondPage.goto('http://localhost:5173/modal');
120
+ const report = await scope.capture(secondPage);
121
+ ```
122
+
123
+ #### `scope.captureUrl(url)`
124
+
125
+ Navigates to `url` on the fixture page then immediately captures.
126
+
127
+ ```typescript
128
+ const report = await scope.captureUrl('http://localhost:5173');
129
+ ```
130
+
131
+ ---
132
+
133
+ ### `CaptureOptions`
134
+
135
+ ```typescript
136
+ interface CaptureOptions {
137
+ /**
138
+ * Poll until component count is stable for `stableMs` ms.
139
+ * Use when the page performs async data loading after initial render.
140
+ * Default: false.
141
+ */
142
+ waitForStable?: boolean;
143
+
144
+ /**
145
+ * How long (ms) component count must remain unchanged before capture
146
+ * is considered stable. Only used with waitForStable: true.
147
+ * Default: 1000.
148
+ */
149
+ stableMs?: number;
150
+
151
+ /**
152
+ * Maximum time (ms) to spend polling before returning the last
153
+ * successful capture. Does NOT throw on timeout.
154
+ * Only used with waitForStable: true.
155
+ * Default: 15000.
156
+ */
157
+ timeoutMs?: number;
158
+
159
+ /**
160
+ * Use lightweight captures (structure only, no props/state/hooks) during
161
+ * stability polling. A single full capture is performed once stability
162
+ * is confirmed. Reduces CDP payload cost per poll tick.
163
+ * Only used with waitForStable: true.
164
+ * Default: false.
165
+ */
166
+ lightweight?: boolean;
167
+ }
168
+ ```
169
+
170
+ ---
171
+
172
+ ### `PageReport`
173
+
174
+ The return type of `scope.capture()` — defined in `@agent-scope/core`.
175
+
176
+ ```typescript
177
+ interface PageReport {
178
+ url: string;
179
+ route: null;
180
+ timestamp: number;
181
+ capturedIn: number; // ms to capture
182
+ tree: ComponentNode; // root of the React component tree
183
+ errors: unknown[];
184
+ suspenseBoundaries: unknown[];
185
+ consoleEntries: unknown[];
186
+ }
187
+
188
+ interface ComponentNode {
189
+ id: number;
190
+ name: string;
191
+ type: 'function' | 'class' | 'other';
192
+ source: SourceLocation | null;
193
+ props: SerializedValue;
194
+ state: SerializedValue[];
195
+ context: SerializedValue[];
196
+ renderCount: number;
197
+ renderDuration: number;
198
+ children: ComponentNode[];
199
+ }
200
+ ```
201
+
202
+ ---
203
+
204
+ ### `getBrowserEntryScript()`
205
+
206
+ 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.
207
+
208
+ ```typescript
209
+ import { getBrowserEntryScript } from '@agent-scope/playwright';
210
+
211
+ // In a Playwright globalSetup or a manual test:
212
+ const script = getBrowserEntryScript();
213
+ await page.addInitScript({ content: script });
214
+ ```
215
+
216
+ The bundle is searched in:
217
+ 1. `dist/browser-bundle.iife.js` (relative to the package's dist directory, when running from an installed package)
218
+ 2. `../dist/browser-bundle.iife.js` (when running from source)
219
+
220
+ Throws with a clear message if the bundle is not found, instructing you to run `bun run build`.
221
+
222
+ **Why use this instead of `{ path: bundlePath }` directly?**
223
+
224
+ `getBrowserEntryScript()` returns the bundle content as a string, which is required when:
225
+ - You cannot resolve the bundle file path reliably (e.g. in bundled test environments)
226
+ - You need to modify or prefix the script before injection
227
+ - You want to inject into multiple pages without file-path resolution per page
228
+
229
+ ---
230
+
231
+ ### `evaluateCapture(page)` / `captureUntilStable(page, stableMs, timeoutMs, lightweight?)`
232
+
233
+ Low-level capture utilities (exported from `capture-utils.ts`).
234
+
235
+ ```typescript
236
+ import { evaluateCapture, captureUntilStable, countNodes } from '@agent-scope/playwright';
237
+ ```
238
+
239
+ **`evaluateCapture(page)`** — single capture with retry logic.
240
+
241
+ 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.
242
+
243
+ ```typescript
244
+ // Succeeds after 1 context-destroyed error:
245
+ let callCount = 0;
246
+ const page = makePage(async () => {
247
+ if (++callCount === 1) throw new Error('Execution context was destroyed');
248
+ return JSON.stringify(report);
249
+ });
250
+ const result = await evaluateCapture(page); // callCount === 2
251
+ ```
252
+
253
+ **`captureUntilStable(page, stableMs, timeoutMs, lightweight?)`** — stability polling.
254
+
255
+ 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.
256
+
257
+ ```typescript
258
+ // Tree grows then stabilises at 5 nodes:
259
+ const result = await captureUntilStable(page, 500, 10000);
260
+ // countNodes(result.tree) === 5
261
+ ```
262
+
263
+ When `lightweight: true`, polls with `evaluateLightweightCapture` (calls `__SCOPE_CAPTURE_JSON__({ lightweight: true })`) and performs a single full capture once stability is confirmed.
264
+
265
+ **`countNodes(node)`** — recursive node counter.
266
+
267
+ ```typescript
268
+ countNodes(makeNode(1));
269
+ // → 1
270
+
271
+ countNodes(makeNode(1, [makeNode(2, [makeNode(4), makeNode(5)]), makeNode(3)]));
272
+ // → 5
273
+ ```
274
+
275
+ ---
276
+
277
+ ### `loadTrace(rawJson)` / `generateTest(trace, options?)`
278
+
279
+ ```typescript
280
+ import { loadTrace, generateTest } from '@agent-scope/playwright';
281
+
282
+ // Parse a raw PageReport JSON string into a CaptureTrace:
283
+ const trace = loadTrace(fs.readFileSync('capture.json', 'utf-8'));
284
+ // trace.report: PageReport
285
+ // trace.capturedAt: number (Date.now() at load time)
286
+
287
+ // Generate a Playwright test skeleton:
288
+ const source = generateTest(trace, {
289
+ description: 'Button renders in primary variant',
290
+ outputPath: 'button.spec.ts',
291
+ });
292
+ // Returns a TypeScript test file string
293
+ ```
294
+
295
+ ---
296
+
297
+ ## Real test payloads
298
+
299
+ ### Basic capture (from `fixture.test.ts`)
300
+
301
+ ```typescript
302
+ test('captures React tree from basic-tree fixture', async ({ scope, page }) => {
303
+ await page.goto('http://localhost:5173');
304
+ const report = await scope.capture();
305
+
306
+ expect(report.url).toContain('localhost:5173');
307
+ expect(typeof report.timestamp).toBe('number');
308
+ expect(report.tree.name).toBeTruthy();
309
+ expect(Array.isArray(report.errors)).toBe(true);
310
+ expect(report.errors).toHaveLength(0); // no intentional errors in basic-tree fixture
311
+ expect(report.route).toBeNull();
312
+ });
313
+ ```
314
+
315
+ ### `captureUrl` shorthand (from `fixture.test.ts`)
316
+
317
+ ```typescript
318
+ test('captureUrl navigates and captures', async ({ scope }) => {
319
+ const report = await scope.captureUrl('http://localhost:5173');
320
+ expect(report.url).toContain('localhost:5173');
321
+ expect(report.tree.children.length).toBeGreaterThanOrEqual(0);
322
+ });
323
+ ```
324
+
325
+ ### Context-destroyed retry (from `capture-utils.test.ts`)
326
+
327
+ ```typescript
328
+ // evaluateCapture retries once on context-destroyed, then succeeds:
329
+ let callCount = 0;
330
+ const page = makePage(async () => {
331
+ if (++callCount === 1) {
332
+ throw new Error('Execution context was destroyed, most likely because of a navigation.');
333
+ }
334
+ return JSON.stringify(report);
335
+ });
336
+
337
+ const result = await evaluateCapture(page);
338
+ // callCount === 2
339
+ expect(result.url).toBe(report.url);
340
+ ```
341
+
342
+ ### Stability polling (from `capture-utils.test.ts`)
343
+
344
+ ```typescript
345
+ // Tree grows across captures then stabilises at 5 nodes:
346
+ const reports = [
347
+ makeReport(makeNode(1)), // 1 node
348
+ makeReport(makeNode(1, [makeNode(2), makeNode(3)])), // 3 nodes
349
+ makeReport(makeNode(1, [makeNode(2, [makeNode(4)]),
350
+ makeNode(3, [makeNode(5)])])), // 5 nodes
351
+ // ... same 5-node tree repeats → stable
352
+ ];
353
+
354
+ const result = await captureUntilStable(page, 500, 10000);
355
+ // countNodes(result.tree) === 5
356
+ ```
357
+
358
+ ### Timeout: returns last capture without throwing (from `capture-utils.test.ts`)
359
+
360
+ ```typescript
361
+ // stableMs > timeoutMs: forced timeout
362
+ const resultPromise = captureUntilStable(page, 5000, 500);
363
+ await vi.advanceTimersByTimeAsync(2000);
364
+ // resolves (not rejects) with a PageReport
365
+ await expect(resultPromise).resolves.toBeDefined();
366
+ ```
367
+
368
+ ---
369
+
370
+ ## Configuration examples
371
+
372
+ ### Standard Playwright config
373
+
374
+ ```typescript
375
+ // playwright.config.ts
376
+ import { defineConfig } from '@playwright/test';
377
+
378
+ export default defineConfig({
379
+ use: { baseURL: 'http://localhost:5173' },
380
+ webServer: { command: 'bun run dev', port: 5173 },
381
+ });
382
+ ```
383
+
384
+ ```typescript
385
+ // tests/scope.spec.ts
386
+ import { test, expect } from '@agent-scope/playwright';
387
+
388
+ test('component tree is stable after data load', async ({ scope, page }) => {
389
+ await page.goto('/dashboard');
390
+ const report = await scope.capture({ waitForStable: true, stableMs: 1000 });
391
+ expect(report.tree.name).toBe('App');
392
+ });
393
+ ```
394
+
395
+ ### Manual injection (no fixture)
396
+
397
+ ```typescript
398
+ import { getBrowserEntryScript } from '@agent-scope/playwright';
399
+ import { chromium } from 'playwright';
400
+
401
+ const browser = await chromium.launch();
402
+ const page = await browser.newPage();
403
+ await page.addInitScript({ content: getBrowserEntryScript() });
404
+ await page.goto('http://localhost:5173');
405
+
406
+ const json = await page.evaluate(() => window.__SCOPE_CAPTURE_JSON__());
407
+ const report = JSON.parse(json);
408
+ await browser.close();
409
+ ```
410
+
411
+ ---
412
+
413
+ ## Used by
414
+
415
+ - `@agent-scope/cli` — uses `getBrowserEntryScript()` to inject the browser bundle into Vite dev-server pages during `scope capture` runs
416
+ - CI E2E test suites — `test` and `expect` replace the base Playwright exports for all Scope integration tests
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-scope/playwright",
3
- "version": "1.17.0",
3
+ "version": "1.17.2",
4
4
  "description": "Playwright integration for Scope — replay traces and generate tests",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -20,7 +20,8 @@
20
20
  "module": "./dist/index.js",
21
21
  "types": "./dist/index.d.ts",
22
22
  "files": [
23
- "dist"
23
+ "dist",
24
+ "README.md"
24
25
  ],
25
26
  "scripts": {
26
27
  "build": "tsup",
@@ -31,8 +32,8 @@
31
32
  },
32
33
  "dependencies": {
33
34
  "@playwright/test": "^1.58.2",
34
- "@agent-scope/core": "1.17.0",
35
- "@agent-scope/runtime": "1.17.0"
35
+ "@agent-scope/core": "1.17.2",
36
+ "@agent-scope/runtime": "1.17.2"
36
37
  },
37
38
  "devDependencies": {
38
39
  "@types/node": "*",