@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 CHANGED
@@ -1,83 +1,182 @@
1
1
  # @vercel/next-browser
2
2
 
3
- Programmatic access to React DevTools and the Next.js dev server. Everything
4
- you'd click through in a GUI — component trees, props, hooks, PPR shells,
5
- build errors, Suspense boundaries exposed as shell commands that return
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
- Built for agents. An LLM can't read a DevTools panel, but it can run
9
- `next-browser tree`, parse the output, and decide what to inspect next. Each
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
- You don't install or run this directly. Your agent does.
14
+ **As a skill** (recommended) from your Next.js repo:
16
15
 
17
- 1. **Add the skill to your project.** From your Next.js repo:
16
+ ```bash
17
+ npx skills add vercel-labs/next-browser
18
+ ```
18
19
 
19
- ```bash
20
- npx skills add vercel-labs/next-browser
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
- Works with Claude Code, Cursor, Cline, and [others](https://skills.sh).
26
+ **Manual install:**
24
27
 
25
- 2. **Start your agent** in that project.
28
+ ```bash
29
+ pnpm add -g @vercel/next-browser # or npm, yarn
30
+ playwright install chromium
31
+ ```
26
32
 
27
- 3. **Type `/next-browser`** to invoke the skill.
33
+ Requires Node >= 20.
28
34
 
29
- 4. The skill checks for the CLI and **installs `@vercel/next-browser`
30
- globally** if it's missing (plus `playwright install chromium`).
35
+ ## Commands
31
36
 
32
- 5. It asks for your dev server URL and any cookies it needs, opens the
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
- That's the whole flow. Run `npx skills upgrade` later to pull updates.
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
- The rest of this README documents the raw CLI for the rare case where you're
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
- ## Manual install
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
- ```bash
47
- pnpm add -g @vercel/next-browser
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
- Requires Node `>=20`.
118
+ **Profile page load performance:**
51
119
 
52
- ## Commands
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
- open <url> [--cookies-json <file>] launch browser and navigate
56
- close close browser and daemon
57
-
58
- goto <url> full-page navigation (new document load)
59
- ssr-goto <url> goto but block external scripts (SSR shell)
60
- push [path] client-side navigation (interactive picker if no path)
61
- back go back in history
62
- reload reload current page
63
- capture-goto [url] capture loading sequence (PPR shell → hydration → data)
64
- restart-server restart the Next.js dev server (clears fs cache)
65
-
66
- ppr lock enter PPR instant-navigation mode (requires cacheComponents)
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, writeFileSync } from "node:fs";
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 new Promise((r) => setTimeout(r, 2000));
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
- * Reload the page while capturing screenshots every ~150ms.
219
- * Stops when: load has fired AND no new layout-shift entries for 2s.
220
- * Hard timeout at 30s. Returns the list of screenshot paths plus any
221
- * LayoutShift entries observed during the reload.
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
- * Frame 0 is always the PPR shell. Remaining frames capture the transition
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 captureGoto(url) {
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
- const dir = join(tmpdir(), `next-browser-capture-goto-${Date.now()}`);
235
- mkdirSync(dir, { recursive: true });
236
- let frameIdx = 0;
237
- async function snap() {
238
- await hideDevOverlay();
239
- const path = join(dir, `frame-${String(frameIdx).padStart(4, "0")}.png`);
240
- const buf = await page.screenshot({ path }).catch(() => null);
241
- frameIdx++;
242
- return buf;
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
- // PPR shell: lock suppresses hydration.
245
- await lock();
246
- await page.goto(targetUrl, { waitUntil: "load" }).catch(() => { });
247
- await new Promise((r) => setTimeout(r, 300));
248
- await snap();
249
- // Unlock page reloads, hydrates, loads data.
250
- const unlockDone = unlock();
251
- await new Promise((r) => setTimeout(r, 200));
252
- let lastChangeTime = Date.now();
253
- let prevHash = "";
254
- const SETTLE_MS = 3_000;
255
- const HARD_TIMEOUT = 30_000;
256
- const start = Date.now();
257
- while (true) {
258
- const buf = await snap();
259
- let hash = "";
260
- if (buf) {
261
- let h = 0;
262
- for (let i = 0; i < buf.length; i += 200)
263
- h = ((h << 5) - h + buf[i]) | 0;
264
- hash = String(h);
265
- }
266
- if (hash !== prevHash) {
267
- lastChangeTime = Date.now();
268
- prevHash = hash;
269
- }
270
- if (Date.now() - start > HARD_TIMEOUT)
271
- break;
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
- await unlockDone.catch(() => { });
277
- return { dir, frames: frameIdx };
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
- /** Evaluate arbitrary JavaScript in the page context. */
448
- export async function evaluate(script) {
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: { width: 1440, height: 900 },
764
+ viewport: null,
538
765
  // --no-sandbox is required when Chrome runs as root (common in containers/cloud sandboxes)
539
- args: headless ? ["--no-sandbox"] : [],
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
  }