@vercel/next-browser 0.1.8 → 0.2.0
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.
- package/README.md +152 -53
- package/dist/browser.js +311 -59
- package/dist/cli.js +100 -11
- package/dist/daemon.js +15 -3
- package/dist/paths.js +3 -1
- package/dist/suspense.js +34 -49
- package/package.json +12 -9
- package/dist/cloud-client.js +0 -72
- package/dist/cloud-daemon.js +0 -87
- package/dist/cloud-paths.js +0 -7
- package/dist/cloud.js +0 -230
package/README.md
CHANGED
|
@@ -1,83 +1,182 @@
|
|
|
1
1
|
# @vercel/next-browser
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
structured text.
|
|
3
|
+
React DevTools and the Next.js dev overlay as shell commands — component
|
|
4
|
+
trees, props, hooks, PPR shells, errors, network, accessibility snapshots —
|
|
5
|
+
structured text that agents can parse and act on.
|
|
7
6
|
|
|
8
|
-
|
|
9
|
-
`next-browser
|
|
7
|
+
An LLM can't click through a DevTools panel, but it can run
|
|
8
|
+
`next-browser snapshot`, read the output, `click e3`, and keep going. Each
|
|
10
9
|
command is a stateless one-shot against a long-lived browser daemon, so an
|
|
11
10
|
agent loop can fire them off without managing browser lifecycle.
|
|
12
11
|
|
|
13
12
|
## Getting started
|
|
14
13
|
|
|
15
|
-
|
|
14
|
+
**As a skill** (recommended) — from your Next.js repo:
|
|
16
15
|
|
|
17
|
-
|
|
16
|
+
```bash
|
|
17
|
+
npx skills add vercel-labs/next-browser
|
|
18
|
+
```
|
|
18
19
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
Works with Claude Code, Cursor, Cline, and [others](https://skills.sh).
|
|
21
|
+
Start your agent in the project and type `/next-browser` to invoke the
|
|
22
|
+
skill. It installs the CLI and Chromium if needed, asks for your dev server
|
|
23
|
+
URL, and from there it's pair programming — tell it what you're debugging
|
|
24
|
+
and it drives the browser for you.
|
|
22
25
|
|
|
23
|
-
|
|
26
|
+
**Manual install:**
|
|
24
27
|
|
|
25
|
-
|
|
28
|
+
```bash
|
|
29
|
+
pnpm add -g @vercel/next-browser # or npm, yarn
|
|
30
|
+
playwright install chromium
|
|
31
|
+
```
|
|
26
32
|
|
|
27
|
-
|
|
33
|
+
Requires Node >= 20.
|
|
28
34
|
|
|
29
|
-
|
|
30
|
-
globally** if it's missing (plus `playwright install chromium`).
|
|
35
|
+
## Commands
|
|
31
36
|
|
|
32
|
-
|
|
33
|
-
browser, and from there it's **pair programming** — tell it what you're
|
|
34
|
-
debugging and it drives the tree, navigates pages, inspects components,
|
|
35
|
-
and reads errors for you.
|
|
37
|
+
### Browser lifecycle
|
|
36
38
|
|
|
37
|
-
|
|
39
|
+
| Command | Description |
|
|
40
|
+
| ------------------------------------ | -------------------------------------------------- |
|
|
41
|
+
| `open <url> [--cookies-json <file>]` | Launch browser and navigate (with optional cookies) |
|
|
42
|
+
| `close` | Close browser and kill daemon |
|
|
38
43
|
|
|
39
|
-
|
|
40
|
-
scripting it yourself.
|
|
44
|
+
### Navigation
|
|
41
45
|
|
|
42
|
-
|
|
46
|
+
| Command | Description |
|
|
47
|
+
| ------------------ | --------------------------------------------------------- |
|
|
48
|
+
| `goto <url>` | Full-page navigation (new document load) |
|
|
49
|
+
| `ssr-goto <url>` | Navigate blocking external scripts (inspect SSR shell) |
|
|
50
|
+
| `push [path]` | Client-side navigation (interactive picker if no path) |
|
|
51
|
+
| `back` | Go back in history |
|
|
52
|
+
| `reload` | Reload current page |
|
|
53
|
+
| `restart-server` | Restart the Next.js dev server (clears caches) |
|
|
43
54
|
|
|
44
|
-
|
|
55
|
+
### Inspection
|
|
56
|
+
|
|
57
|
+
| Command | Description |
|
|
58
|
+
| ----------------- | ------------------------------------------------------------- |
|
|
59
|
+
| `tree` | Full React component tree (hierarchy, IDs, keys) |
|
|
60
|
+
| `tree <id>` | Inspect one component (props, hooks, state, source location) |
|
|
61
|
+
| `snapshot` | Accessibility tree with `[ref=eN]` markers on interactive elements |
|
|
62
|
+
| `errors` | Build and runtime errors for the current page |
|
|
63
|
+
| `logs` | Recent dev server log output |
|
|
64
|
+
| `network [idx]` | List network requests, or inspect one (headers, body) |
|
|
65
|
+
| `screenshot` | Full-page PNG to a temp file |
|
|
66
|
+
|
|
67
|
+
### Interaction
|
|
68
|
+
|
|
69
|
+
| Command | Description |
|
|
70
|
+
| ---------------------------- | ---------------------------------------------------------- |
|
|
71
|
+
| `click <ref\|text\|selector>` | Click via real pointer events (works with Radix, Headless UI) |
|
|
72
|
+
| `fill <ref\|selector> <value>` | Fill a text input or textarea |
|
|
73
|
+
| `eval [ref] <script>` | Run JS in page context (supports `--file` and stdin) |
|
|
74
|
+
| `viewport [WxH]` | Show or set viewport size |
|
|
75
|
+
|
|
76
|
+
### Performance & PPR
|
|
77
|
+
|
|
78
|
+
| Command | Description |
|
|
79
|
+
| -------------- | ------------------------------------------------------------ |
|
|
80
|
+
| `perf [url]` | Core Web Vitals + React hydration timing in one pass |
|
|
81
|
+
| `ppr lock` | Freeze dynamic content to inspect the static shell |
|
|
82
|
+
| `ppr unlock` | Resume dynamic content and print shell analysis |
|
|
83
|
+
|
|
84
|
+
### Next.js MCP
|
|
85
|
+
|
|
86
|
+
| Command | Description |
|
|
87
|
+
| -------------- | ----------------------------------------------- |
|
|
88
|
+
| `page` | Route segments for the current URL |
|
|
89
|
+
| `project` | Project root and dev server URL |
|
|
90
|
+
| `routes` | All app router routes |
|
|
91
|
+
| `action <id>` | Inspect a server action by ID |
|
|
92
|
+
|
|
93
|
+
## Examples
|
|
94
|
+
|
|
95
|
+
**Inspect what's on the page and interact with it:**
|
|
45
96
|
|
|
46
|
-
```
|
|
47
|
-
|
|
97
|
+
```
|
|
98
|
+
$ next-browser open http://localhost:3000
|
|
99
|
+
$ next-browser snapshot
|
|
100
|
+
- navigation "Main"
|
|
101
|
+
- link "Home" [ref=e0]
|
|
102
|
+
- link "Dashboard" [ref=e1]
|
|
103
|
+
- main
|
|
104
|
+
- heading "Settings"
|
|
105
|
+
- tablist
|
|
106
|
+
- tab "General" [ref=e2] (selected)
|
|
107
|
+
- tab "Security" [ref=e3]
|
|
108
|
+
|
|
109
|
+
$ next-browser click e3
|
|
110
|
+
clicked
|
|
111
|
+
|
|
112
|
+
$ next-browser snapshot
|
|
113
|
+
- tablist
|
|
114
|
+
- tab "General" [ref=e0]
|
|
115
|
+
- tab "Security" [ref=e1] (selected)
|
|
48
116
|
```
|
|
49
117
|
|
|
50
|
-
|
|
118
|
+
**Profile page load performance:**
|
|
51
119
|
|
|
52
|
-
|
|
120
|
+
```
|
|
121
|
+
$ next-browser perf http://localhost:3000/dashboard
|
|
122
|
+
# Page Load Profile — http://localhost:3000/dashboard
|
|
123
|
+
|
|
124
|
+
## Core Web Vitals
|
|
125
|
+
TTFB 42ms
|
|
126
|
+
LCP 1205.3ms (img: /_next/image?url=...)
|
|
127
|
+
CLS 0.03
|
|
128
|
+
|
|
129
|
+
## React Hydration — 65.5ms (466.2ms → 531.7ms)
|
|
130
|
+
Hydrated 65.5ms (466.2 → 531.7)
|
|
131
|
+
Commit 2.0ms (531.7 → 533.7)
|
|
132
|
+
...
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
**Debug the PPR shell:**
|
|
53
136
|
|
|
54
137
|
```
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
ppr unlock exit PPR mode and show shell analysis
|
|
68
|
-
|
|
69
|
-
tree show React component tree
|
|
70
|
-
tree <id> inspect component (props, hooks, state, source)
|
|
71
|
-
|
|
72
|
-
viewport [WxH] show or set viewport size (e.g. 1280x720)
|
|
73
|
-
screenshot save full-page screenshot to tmp file
|
|
74
|
-
eval <script> evaluate JS in page context
|
|
75
|
-
|
|
76
|
-
errors show build/runtime errors
|
|
77
|
-
logs show recent dev server log output
|
|
78
|
-
network [idx] list network requests, or inspect one
|
|
138
|
+
$ next-browser ppr lock
|
|
139
|
+
locked
|
|
140
|
+
$ next-browser goto http://localhost:3000/dashboard
|
|
141
|
+
$ next-browser screenshot
|
|
142
|
+
/var/folders/.../next-browser-screenshot.png
|
|
143
|
+
$ next-browser ppr unlock
|
|
144
|
+
# PPR Shell Analysis — 131 boundaries: 3 dynamic holes, 128 static
|
|
145
|
+
|
|
146
|
+
## Quick Reference
|
|
147
|
+
| Boundary | Type | Primary blocker | Source |
|
|
148
|
+
| --- | --- | --- | --- |
|
|
149
|
+
| TrackedSuspense | component | usePathname (client-hook) | tracked-suspense.js |
|
|
79
150
|
```
|
|
80
151
|
|
|
152
|
+
**Inspect a React component:**
|
|
153
|
+
|
|
154
|
+
```
|
|
155
|
+
$ next-browser tree
|
|
156
|
+
0 38167 - Root
|
|
157
|
+
1 38168 38167 HeadManagerContext.Provider
|
|
158
|
+
2 38169 38168 Root
|
|
159
|
+
...
|
|
160
|
+
224 46375 46374 DeploymentsProvider
|
|
161
|
+
|
|
162
|
+
$ next-browser tree 46375
|
|
163
|
+
path: Root > ... > DeploymentsProvider
|
|
164
|
+
DeploymentsProvider #46375
|
|
165
|
+
props:
|
|
166
|
+
children: [<Lazy />, <Lazy />, <span />, <Lazy />, <Lazy />]
|
|
167
|
+
hooks:
|
|
168
|
+
IsMobile: undefined (1 sub)
|
|
169
|
+
Router: undefined (2 sub)
|
|
170
|
+
source: app/.../deployments/_parts/context.tsx:180:10
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
## How it works
|
|
174
|
+
|
|
175
|
+
A daemon process launches Chromium with the React DevTools extension
|
|
176
|
+
pre-loaded and listens on a Unix domain socket (named pipe on Windows).
|
|
177
|
+
CLI commands send JSON-RPC messages to the daemon and print the response.
|
|
178
|
+
The browser stays open across commands — no per-command startup cost.
|
|
179
|
+
|
|
81
180
|
## License
|
|
82
181
|
|
|
83
182
|
MIT
|
package/dist/browser.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*
|
|
11
11
|
* Module-level state: one browser context, one page, one PPR lock.
|
|
12
12
|
*/
|
|
13
|
-
import { readFileSync, mkdirSync
|
|
13
|
+
import { readFileSync, mkdirSync } from "node:fs";
|
|
14
14
|
import { join, resolve } from "node:path";
|
|
15
15
|
import { tmpdir } from "node:os";
|
|
16
16
|
import { chromium } from "playwright";
|
|
@@ -139,7 +139,7 @@ export async function unlock() {
|
|
|
139
139
|
// For goto case: the page auto-reloads. Wait for the new page to load
|
|
140
140
|
// and React/DevTools to reconnect before trying to snapshot boundaries.
|
|
141
141
|
await page.waitForLoadState("load").catch(() => { });
|
|
142
|
-
await
|
|
142
|
+
await waitForDevToolsReconnect(page);
|
|
143
143
|
// Wait for all boundaries to resolve after unlock.
|
|
144
144
|
await waitForSuspenseToSettle(page);
|
|
145
145
|
// Capture the fully-resolved state with rich suspendedBy data.
|
|
@@ -206,6 +206,25 @@ async function waitForSuspenseToSettle(p) {
|
|
|
206
206
|
await new Promise((r) => setTimeout(r, 500));
|
|
207
207
|
}
|
|
208
208
|
}
|
|
209
|
+
/**
|
|
210
|
+
* Wait for React DevTools to reconnect after a page reload.
|
|
211
|
+
*
|
|
212
|
+
* After the goto case unlocks, the page auto-reloads and DevTools loses its
|
|
213
|
+
* renderer connection. Poll until the DevTools hook reports at least one
|
|
214
|
+
* renderer, or bail after 5s. This replaces the old hardcoded 2s sleep.
|
|
215
|
+
*/
|
|
216
|
+
async function waitForDevToolsReconnect(p) {
|
|
217
|
+
const deadline = Date.now() + 5_000;
|
|
218
|
+
while (Date.now() < deadline) {
|
|
219
|
+
const connected = await p.evaluate(() => {
|
|
220
|
+
const hook = window.__REACT_DEVTOOLS_GLOBAL_HOOK__;
|
|
221
|
+
return hook?.rendererInterfaces?.size > 0;
|
|
222
|
+
}).catch(() => false);
|
|
223
|
+
if (connected)
|
|
224
|
+
return;
|
|
225
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
226
|
+
}
|
|
227
|
+
}
|
|
209
228
|
// ── Navigation ───────────────────────────────────────────────────────────────
|
|
210
229
|
/** Hard reload the current page. Returns the URL after reload. */
|
|
211
230
|
export async function reload() {
|
|
@@ -215,66 +234,114 @@ export async function reload() {
|
|
|
215
234
|
return page.url();
|
|
216
235
|
}
|
|
217
236
|
/**
|
|
218
|
-
*
|
|
219
|
-
*
|
|
220
|
-
*
|
|
221
|
-
*
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
* Lock PPR → goto → screenshot the shell → unlock → screenshot frames
|
|
225
|
-
* until the page settles. Just PNGs in a directory — the AI reads them.
|
|
237
|
+
* Profile a page load: reload (or navigate to a URL) and collect Core Web
|
|
238
|
+
* Vitals (LCP, CLS, TTFB) plus React hydration timing.
|
|
239
|
+
*
|
|
240
|
+
* CWVs come from PerformanceObserver and Navigation Timing API.
|
|
241
|
+
* Hydration timing comes from console.timeStamp entries emitted by React's
|
|
242
|
+
* profiling build (see the addInitScript interceptor in launch()).
|
|
226
243
|
*
|
|
227
|
-
*
|
|
228
|
-
* through hydration and data loading. Stops after 3s of no visual change.
|
|
244
|
+
* Returns structured data that the CLI formats into a readable report.
|
|
229
245
|
*/
|
|
230
|
-
export async function
|
|
246
|
+
export async function perf(url) {
|
|
231
247
|
if (!page)
|
|
232
248
|
throw new Error("browser not open");
|
|
233
249
|
const targetUrl = url || page.url();
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
250
|
+
// Install CWV observers before navigation so they capture everything.
|
|
251
|
+
await page.evaluate(() => {
|
|
252
|
+
window.__NEXT_BROWSER_REACT_TIMING__ = [];
|
|
253
|
+
const cwv = { lcp: null, cls: 0, clsEntries: [] };
|
|
254
|
+
window.__NEXT_BROWSER_CWV__ = cwv;
|
|
255
|
+
// Largest Contentful Paint
|
|
256
|
+
new PerformanceObserver((list) => {
|
|
257
|
+
const entries = list.getEntries();
|
|
258
|
+
if (entries.length > 0) {
|
|
259
|
+
const last = entries[entries.length - 1];
|
|
260
|
+
cwv.lcp = {
|
|
261
|
+
startTime: Math.round(last.startTime * 100) / 100,
|
|
262
|
+
size: last.size,
|
|
263
|
+
element: last.element?.tagName?.toLowerCase() ?? null,
|
|
264
|
+
url: last.url || null,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
}).observe({ type: "largest-contentful-paint", buffered: true });
|
|
268
|
+
// Cumulative Layout Shift
|
|
269
|
+
new PerformanceObserver((list) => {
|
|
270
|
+
for (const entry of list.getEntries()) {
|
|
271
|
+
if (!entry.hadRecentInput) {
|
|
272
|
+
cwv.cls += entry.value;
|
|
273
|
+
cwv.clsEntries.push({
|
|
274
|
+
value: Math.round(entry.value * 10000) / 10000,
|
|
275
|
+
startTime: Math.round(entry.startTime * 100) / 100,
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}).observe({ type: "layout-shift", buffered: true });
|
|
280
|
+
});
|
|
281
|
+
// Navigate or reload to trigger a full page load.
|
|
282
|
+
if (url) {
|
|
283
|
+
await page.goto(targetUrl, { waitUntil: "load" });
|
|
243
284
|
}
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
await
|
|
249
|
-
//
|
|
250
|
-
const
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
if (
|
|
271
|
-
|
|
272
|
-
if (lastChangeTime > 0 && Date.now() - lastChangeTime > SETTLE_MS)
|
|
273
|
-
break;
|
|
274
|
-
await new Promise((r) => setTimeout(r, 150));
|
|
285
|
+
else {
|
|
286
|
+
await page.reload({ waitUntil: "load" });
|
|
287
|
+
}
|
|
288
|
+
// Wait for passive effects, late paints, and layout shifts to flush.
|
|
289
|
+
await new Promise((r) => setTimeout(r, 3000));
|
|
290
|
+
// Collect all metrics from the page.
|
|
291
|
+
const metrics = await page.evaluate(() => {
|
|
292
|
+
const cwv = window.__NEXT_BROWSER_CWV__ ?? {};
|
|
293
|
+
const timing = window.__NEXT_BROWSER_REACT_TIMING__ ?? [];
|
|
294
|
+
// TTFB from Navigation Timing API.
|
|
295
|
+
const nav = performance.getEntriesByType("navigation")[0];
|
|
296
|
+
const ttfb = nav
|
|
297
|
+
? Math.round((nav.responseStart - nav.requestStart) * 100) / 100
|
|
298
|
+
: null;
|
|
299
|
+
return { cwv, timing, ttfb };
|
|
300
|
+
});
|
|
301
|
+
// Process React hydration timing.
|
|
302
|
+
const phases = metrics.timing.filter((e) => e.trackGroup === "Scheduler ⚛" && e.endTime > e.startTime);
|
|
303
|
+
const components = metrics.timing.filter((e) => e.track === "Components ⚛" && e.endTime > e.startTime);
|
|
304
|
+
const hydrationPhases = phases.filter((e) => e.label === "Hydrated");
|
|
305
|
+
const hydratedComponents = components.filter((e) => e.color?.startsWith("tertiary"));
|
|
306
|
+
let hydrationStart = Infinity;
|
|
307
|
+
let hydrationEnd = 0;
|
|
308
|
+
for (const p of hydrationPhases) {
|
|
309
|
+
if (p.startTime < hydrationStart)
|
|
310
|
+
hydrationStart = p.startTime;
|
|
311
|
+
if (p.endTime > hydrationEnd)
|
|
312
|
+
hydrationEnd = p.endTime;
|
|
275
313
|
}
|
|
276
|
-
|
|
277
|
-
return {
|
|
314
|
+
const round = (n) => Math.round(n * 100) / 100;
|
|
315
|
+
return {
|
|
316
|
+
url: targetUrl,
|
|
317
|
+
ttfb: metrics.ttfb,
|
|
318
|
+
lcp: metrics.cwv.lcp,
|
|
319
|
+
cls: {
|
|
320
|
+
score: round(metrics.cwv.cls),
|
|
321
|
+
entries: metrics.cwv.clsEntries,
|
|
322
|
+
},
|
|
323
|
+
hydration: hydrationPhases.length > 0
|
|
324
|
+
? {
|
|
325
|
+
startTime: round(hydrationStart),
|
|
326
|
+
endTime: round(hydrationEnd),
|
|
327
|
+
duration: round(hydrationEnd - hydrationStart),
|
|
328
|
+
}
|
|
329
|
+
: null,
|
|
330
|
+
phases: phases.map((p) => ({
|
|
331
|
+
label: p.label,
|
|
332
|
+
startTime: round(p.startTime),
|
|
333
|
+
endTime: round(p.endTime),
|
|
334
|
+
duration: round(p.endTime - p.startTime),
|
|
335
|
+
})),
|
|
336
|
+
hydratedComponents: hydratedComponents
|
|
337
|
+
.map((c) => ({
|
|
338
|
+
name: c.label,
|
|
339
|
+
startTime: round(c.startTime),
|
|
340
|
+
endTime: round(c.endTime),
|
|
341
|
+
duration: round(c.endTime - c.startTime),
|
|
342
|
+
}))
|
|
343
|
+
.sort((a, b) => b.duration - a.duration),
|
|
344
|
+
};
|
|
278
345
|
}
|
|
279
346
|
/**
|
|
280
347
|
* Restart the Next.js dev server via its internal endpoint, then reload.
|
|
@@ -444,10 +511,170 @@ async function hideDevOverlay() {
|
|
|
444
511
|
document.querySelectorAll("[data-nextjs-dev-overlay]").forEach((el) => el.remove());
|
|
445
512
|
}).catch(() => { });
|
|
446
513
|
}
|
|
447
|
-
|
|
448
|
-
|
|
514
|
+
// ── Ref map for interactive elements ──────────────────────────────────
|
|
515
|
+
const INTERACTIVE_ROLES = new Set([
|
|
516
|
+
"button", "link", "textbox", "checkbox", "radio", "combobox", "listbox",
|
|
517
|
+
"menuitem", "menuitemcheckbox", "menuitemradio", "option", "searchbox",
|
|
518
|
+
"slider", "spinbutton", "switch", "tab", "treeitem",
|
|
519
|
+
]);
|
|
520
|
+
let refMap = [];
|
|
521
|
+
/**
|
|
522
|
+
* Snapshot the accessibility tree via CDP and return a text representation
|
|
523
|
+
* with [ref=e0], [ref=e1] … markers on interactive elements.
|
|
524
|
+
* Stores a ref map so that `click("e3")` can resolve back to role+name.
|
|
525
|
+
*/
|
|
526
|
+
export async function snapshot() {
|
|
449
527
|
if (!page)
|
|
450
528
|
throw new Error("browser not open");
|
|
529
|
+
const cdp = await page.context().newCDPSession(page);
|
|
530
|
+
try {
|
|
531
|
+
const { nodes } = (await cdp.send("Accessibility.getFullAXTree"));
|
|
532
|
+
// Index nodes by ID
|
|
533
|
+
const byId = new Map();
|
|
534
|
+
for (const n of nodes)
|
|
535
|
+
byId.set(n.nodeId, n);
|
|
536
|
+
refMap = [];
|
|
537
|
+
const roleNameCount = new Map();
|
|
538
|
+
const lines = [];
|
|
539
|
+
function walk(node, depth) {
|
|
540
|
+
const role = node.role?.value || "unknown";
|
|
541
|
+
const name = (node.name?.value || "").trim().slice(0, 80);
|
|
542
|
+
const isInteractive = INTERACTIVE_ROLES.has(role);
|
|
543
|
+
// Read properties into a map
|
|
544
|
+
const propMap = new Map();
|
|
545
|
+
for (const p of node.properties || [])
|
|
546
|
+
propMap.set(p.name, p.value.value);
|
|
547
|
+
const ignored = propMap.get("hidden") === true;
|
|
548
|
+
if (ignored)
|
|
549
|
+
return;
|
|
550
|
+
// Always skip leaf text nodes — parent already carries the text
|
|
551
|
+
if (role === "InlineTextBox" || role === "StaticText" || role === "LineBreak")
|
|
552
|
+
return;
|
|
553
|
+
// Skip generic/none wrappers with no name — just recurse children
|
|
554
|
+
const SKIP_ROLES = new Set(["none", "generic", "GenericContainer"]);
|
|
555
|
+
if (SKIP_ROLES.has(role) && !name) {
|
|
556
|
+
for (const id of node.childIds || []) {
|
|
557
|
+
const child = byId.get(id);
|
|
558
|
+
if (child)
|
|
559
|
+
walk(child, depth);
|
|
560
|
+
}
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
// Skip root WebArea — just recurse
|
|
564
|
+
if (role === "WebArea" || role === "RootWebArea") {
|
|
565
|
+
for (const id of node.childIds || []) {
|
|
566
|
+
const child = byId.get(id);
|
|
567
|
+
if (child)
|
|
568
|
+
walk(child, depth);
|
|
569
|
+
}
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
const indent = " ".repeat(depth);
|
|
573
|
+
let line = `${indent}- ${role}`;
|
|
574
|
+
if (name)
|
|
575
|
+
line += ` "${name}"`;
|
|
576
|
+
const disabled = propMap.get("disabled") === true;
|
|
577
|
+
if (isInteractive && !disabled) {
|
|
578
|
+
const key = `${role}::${name}`;
|
|
579
|
+
const count = roleNameCount.get(key) || 0;
|
|
580
|
+
roleNameCount.set(key, count + 1);
|
|
581
|
+
const ref = { role, name };
|
|
582
|
+
if (count > 0)
|
|
583
|
+
ref.nth = count;
|
|
584
|
+
const idx = refMap.length;
|
|
585
|
+
refMap.push(ref);
|
|
586
|
+
line += ` [ref=e${idx}]`;
|
|
587
|
+
}
|
|
588
|
+
// Append state properties
|
|
589
|
+
const tags = [];
|
|
590
|
+
if (propMap.get("checked") === "true" || propMap.get("checked") === true)
|
|
591
|
+
tags.push("checked");
|
|
592
|
+
if (propMap.get("checked") === "mixed")
|
|
593
|
+
tags.push("mixed");
|
|
594
|
+
if (disabled)
|
|
595
|
+
tags.push("disabled");
|
|
596
|
+
if (propMap.get("expanded") === true)
|
|
597
|
+
tags.push("expanded");
|
|
598
|
+
if (propMap.get("expanded") === false)
|
|
599
|
+
tags.push("collapsed");
|
|
600
|
+
if (propMap.get("selected") === true)
|
|
601
|
+
tags.push("selected");
|
|
602
|
+
if (tags.length)
|
|
603
|
+
line += ` (${tags.join(", ")})`;
|
|
604
|
+
lines.push(line);
|
|
605
|
+
for (const id of node.childIds || []) {
|
|
606
|
+
const child = byId.get(id);
|
|
607
|
+
if (child)
|
|
608
|
+
walk(child, depth + 1);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
// Start from the root (first node)
|
|
612
|
+
if (nodes.length)
|
|
613
|
+
walk(nodes[0], 0);
|
|
614
|
+
return lines.join("\n");
|
|
615
|
+
}
|
|
616
|
+
finally {
|
|
617
|
+
await cdp.detach();
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
/** Resolve a ref (e.g. "e3") or selector string to a Playwright Locator. */
|
|
621
|
+
function resolveLocator(selectorOrRef) {
|
|
622
|
+
if (!page)
|
|
623
|
+
throw new Error("browser not open");
|
|
624
|
+
const refMatch = selectorOrRef.match(/^e(\d+)$/);
|
|
625
|
+
if (refMatch) {
|
|
626
|
+
const idx = Number(refMatch[1]);
|
|
627
|
+
const ref = refMap[idx];
|
|
628
|
+
if (!ref)
|
|
629
|
+
throw new Error(`ref e${idx} not found — run snapshot first`);
|
|
630
|
+
const locator = page.getByRole(ref.role, {
|
|
631
|
+
name: ref.name,
|
|
632
|
+
exact: true,
|
|
633
|
+
});
|
|
634
|
+
return ref.nth != null ? locator.nth(ref.nth) : locator;
|
|
635
|
+
}
|
|
636
|
+
const hasPrefix = /^(css=|text=|role=|#|\[|\.|\w+\s*>)/.test(selectorOrRef);
|
|
637
|
+
return page.locator(hasPrefix ? selectorOrRef : `text=${selectorOrRef}`);
|
|
638
|
+
}
|
|
639
|
+
/**
|
|
640
|
+
* Click an element using real pointer events.
|
|
641
|
+
* Accepts: "e3" (ref from snapshot), plain text, or Playwright selectors.
|
|
642
|
+
*/
|
|
643
|
+
export async function click(selectorOrRef) {
|
|
644
|
+
if (!page)
|
|
645
|
+
throw new Error("browser not open");
|
|
646
|
+
await resolveLocator(selectorOrRef).click();
|
|
647
|
+
}
|
|
648
|
+
/**
|
|
649
|
+
* Fill a text input/textarea. Clears existing value, then types the new one.
|
|
650
|
+
* Accepts: "e3" (ref from snapshot), or a selector.
|
|
651
|
+
*/
|
|
652
|
+
export async function fill(selectorOrRef, value) {
|
|
653
|
+
if (!page)
|
|
654
|
+
throw new Error("browser not open");
|
|
655
|
+
await resolveLocator(selectorOrRef).fill(value);
|
|
656
|
+
}
|
|
657
|
+
/**
|
|
658
|
+
* Evaluate arbitrary JavaScript in the page context.
|
|
659
|
+
* If ref is provided (e.g. "e3"), the script receives the DOM element as its
|
|
660
|
+
* first argument: `next-browser eval e3 'el => el.textContent'`
|
|
661
|
+
*/
|
|
662
|
+
export async function evaluate(script, ref) {
|
|
663
|
+
if (!page)
|
|
664
|
+
throw new Error("browser not open");
|
|
665
|
+
if (ref) {
|
|
666
|
+
const locator = resolveLocator(ref);
|
|
667
|
+
const handle = await locator.elementHandle();
|
|
668
|
+
if (!handle)
|
|
669
|
+
throw new Error(`ref ${ref} not found in DOM`);
|
|
670
|
+
// The script should be an arrow/function that receives the element.
|
|
671
|
+
// We wrap it so page.evaluate can pass the element handle as an arg.
|
|
672
|
+
return page.evaluate(([fn, el]) => {
|
|
673
|
+
// eslint-disable-next-line no-eval
|
|
674
|
+
const f = (0, eval)(fn);
|
|
675
|
+
return f(el);
|
|
676
|
+
}, [script, handle]);
|
|
677
|
+
}
|
|
451
678
|
return page.evaluate(script);
|
|
452
679
|
}
|
|
453
680
|
/**
|
|
@@ -534,11 +761,36 @@ async function launch() {
|
|
|
534
761
|
profileDirPath = dir;
|
|
535
762
|
const ctx = await chromium.launchPersistentContext(dir, {
|
|
536
763
|
headless,
|
|
537
|
-
viewport:
|
|
764
|
+
viewport: null,
|
|
538
765
|
// --no-sandbox is required when Chrome runs as root (common in containers/cloud sandboxes)
|
|
539
|
-
args:
|
|
766
|
+
args: [
|
|
767
|
+
...(headless ? ["--no-sandbox"] : []),
|
|
768
|
+
"--window-size=1440,900",
|
|
769
|
+
],
|
|
540
770
|
});
|
|
541
771
|
await ctx.addInitScript(installHook);
|
|
772
|
+
// Intercept console.timeStamp to capture React's Performance Track entries.
|
|
773
|
+
// React's profiling build calls console.timeStamp(label, startTime, endTime,
|
|
774
|
+
// track, trackGroup, color) for render phases and per-component timing.
|
|
775
|
+
// startTime/endTime are performance.now() values from the reconciler.
|
|
776
|
+
await ctx.addInitScript(() => {
|
|
777
|
+
const entries = [];
|
|
778
|
+
window.__NEXT_BROWSER_REACT_TIMING__ = entries;
|
|
779
|
+
const orig = console.timeStamp;
|
|
780
|
+
console.timeStamp = function (label, ...args) {
|
|
781
|
+
if (typeof label === "string" && args.length >= 2 && typeof args[0] === "number") {
|
|
782
|
+
entries.push({
|
|
783
|
+
label,
|
|
784
|
+
startTime: args[0],
|
|
785
|
+
endTime: args[1],
|
|
786
|
+
track: args[2] ?? "",
|
|
787
|
+
trackGroup: args[3] ?? "",
|
|
788
|
+
color: args[4] ?? "",
|
|
789
|
+
});
|
|
790
|
+
}
|
|
791
|
+
return orig.apply(console, [label, ...args]);
|
|
792
|
+
};
|
|
793
|
+
});
|
|
542
794
|
// Next.js devtools overlay is removed before each screenshot via hideDevOverlay().
|
|
543
795
|
return ctx;
|
|
544
796
|
}
|