@vercel/next-browser 0.1.7 → 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/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";
@@ -126,14 +126,9 @@ export async function unlock() {
126
126
  // only truly stuck boundaries remain as "holes."
127
127
  await stabilizeSuspenseState(page);
128
128
  // Capture what's suspended right now under the lock.
129
- let locked = await suspenseTree.snapshot(page).catch(() => []);
130
- // For initial-load (goto) under lock, DevTools may not be connected
131
- // the shell uses a production-like renderer. Fall back to counting
132
- // <template id="B:..."> elements in the DOM (PPR's Suspense placeholders).
133
- const hasDevToolsData = locked.some((b) => b.parentID !== 0);
134
- if (!hasDevToolsData) {
135
- locked = await suspenseTree.snapshotFromDom(page);
136
- }
129
+ // Under goto + lock, DevTools may not be connected (shell is static HTML).
130
+ // That's fine we get all the rich data from the unlocked snapshot below.
131
+ const locked = await suspenseTree.snapshot(page).catch(() => []);
137
132
  // Release the lock. instant() clears the cookie.
138
133
  // - push case: dynamic content streams in immediately (no reload)
139
134
  // - goto case: cookieStore change → auto-reload → full page load
@@ -141,14 +136,26 @@ export async function unlock() {
141
136
  release = null;
142
137
  await settled;
143
138
  settled = null;
139
+ // For goto case: the page auto-reloads. Wait for the new page to load
140
+ // and React/DevTools to reconnect before trying to snapshot boundaries.
141
+ await page.waitForLoadState("load").catch(() => { });
142
+ await waitForDevToolsReconnect(page);
144
143
  // Wait for all boundaries to resolve after unlock.
145
- // Polls the DevTools suspense tree (works for both push and goto cases).
146
144
  await waitForSuspenseToSettle(page);
147
145
  // Capture the fully-resolved state with rich suspendedBy data.
148
146
  const unlocked = await suspenseTree.snapshot(page).catch(() => []);
149
- if (locked.length === 0 && unlocked.length === 0)
150
- return null;
151
- return suspenseTree.formatAnalysis(unlocked, locked, origin);
147
+ if (locked.length === 0 && unlocked.length === 0) {
148
+ return { text: "No suspense boundaries detected.", boundaries: unlocked, locked, report: null };
149
+ }
150
+ const report = await suspenseTree.analyzeBoundaries(unlocked, locked, origin);
151
+ const pageMetadata = await nextMcp
152
+ .call(initialOrigin ?? origin, "get_page_metadata")
153
+ .catch(() => null);
154
+ if (pageMetadata) {
155
+ suspenseTree.annotateReportWithPageMetadata(report, pageMetadata);
156
+ }
157
+ const text = suspenseTree.formatReport(report);
158
+ return { text, boundaries: unlocked, locked, report };
152
159
  }
153
160
  /**
154
161
  * Wait for the suspended boundary count to stop changing.
@@ -199,6 +206,25 @@ async function waitForSuspenseToSettle(p) {
199
206
  await new Promise((r) => setTimeout(r, 500));
200
207
  }
201
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
+ }
202
228
  // ── Navigation ───────────────────────────────────────────────────────────────
203
229
  /** Hard reload the current page. Returns the URL after reload. */
204
230
  export async function reload() {
@@ -208,65 +234,114 @@ export async function reload() {
208
234
  return page.url();
209
235
  }
210
236
  /**
211
- * Reload the page while capturing screenshots every ~150ms.
212
- * Stops when: load has fired AND no new layout-shift entries for 2s.
213
- * Hard timeout at 30s. Returns the list of screenshot paths plus any
214
- * LayoutShift entries observed during the reload.
215
- */
216
- /**
217
- * Lock PPR → goto → screenshot the shell → unlock → screenshot frames
218
- * 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.
219
239
  *
220
- * Frame 0 is always the PPR shell. Remaining frames capture the transition
221
- * through hydration and data loading. Stops after 3s of no visual change.
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()).
243
+ *
244
+ * Returns structured data that the CLI formats into a readable report.
222
245
  */
223
- export async function captureGoto(url) {
246
+ export async function perf(url) {
224
247
  if (!page)
225
248
  throw new Error("browser not open");
226
249
  const targetUrl = url || page.url();
227
- const dir = join(tmpdir(), `next-browser-capture-goto-${Date.now()}`);
228
- mkdirSync(dir, { recursive: true });
229
- let frameIdx = 0;
230
- async function snap() {
231
- const path = join(dir, `frame-${String(frameIdx).padStart(4, "0")}.png`);
232
- const buf = await page.screenshot({ path }).catch(() => null);
233
- frameIdx++;
234
- 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" });
235
284
  }
236
- // PPR shell: lock suppresses hydration.
237
- await lock();
238
- await page.goto(targetUrl, { waitUntil: "load" }).catch(() => { });
239
- await new Promise((r) => setTimeout(r, 300));
240
- await snap();
241
- // Unlock → page reloads, hydrates, loads data.
242
- const unlockDone = unlock();
243
- await new Promise((r) => setTimeout(r, 200));
244
- let lastChangeTime = Date.now();
245
- let prevHash = "";
246
- const SETTLE_MS = 3_000;
247
- const HARD_TIMEOUT = 30_000;
248
- const start = Date.now();
249
- while (true) {
250
- const buf = await snap();
251
- let hash = "";
252
- if (buf) {
253
- let h = 0;
254
- for (let i = 0; i < buf.length; i += 200)
255
- h = ((h << 5) - h + buf[i]) | 0;
256
- hash = String(h);
257
- }
258
- if (hash !== prevHash) {
259
- lastChangeTime = Date.now();
260
- prevHash = hash;
261
- }
262
- if (Date.now() - start > HARD_TIMEOUT)
263
- break;
264
- if (lastChangeTime > 0 && Date.now() - lastChangeTime > SETTLE_MS)
265
- break;
266
- await new Promise((r) => setTimeout(r, 150));
285
+ else {
286
+ await page.reload({ waitUntil: "load" });
267
287
  }
268
- await unlockDone.catch(() => { });
269
- return { dir, frames: frameIdx };
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;
313
+ }
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
+ };
270
345
  }
271
346
  /**
272
347
  * Restart the Next.js dev server via its internal endpoint, then reload.
@@ -338,11 +413,32 @@ export async function push(path) {
338
413
  export async function goto(url) {
339
414
  if (!page)
340
415
  throw new Error("browser not open");
416
+ await page.unrouteAll({ behavior: "wait" });
341
417
  const target = new URL(url, page.url()).href;
342
418
  initialOrigin = new URL(target).origin;
343
419
  await page.goto(target, { waitUntil: "domcontentloaded" });
344
420
  return target;
345
421
  }
422
+ /**
423
+ * Navigate like goto but block external script resources.
424
+ * The HTML loads and inline <script> blocks still execute, but external JS
425
+ * bundles (React, hydration, etc.) are aborted. Shows the SSR shell.
426
+ */
427
+ export async function ssrGoto(url) {
428
+ if (!page)
429
+ throw new Error("browser not open");
430
+ const target = new URL(url, page.url()).href;
431
+ initialOrigin = new URL(target).origin;
432
+ // Clear any stale route handlers from previous ssr-goto calls.
433
+ await page.unrouteAll({ behavior: "wait" });
434
+ await page.route("**/*", (route) => {
435
+ if (route.request().resourceType() === "script")
436
+ return route.abort();
437
+ return route.continue();
438
+ });
439
+ await page.goto(target, { waitUntil: "domcontentloaded" });
440
+ return target;
441
+ }
346
442
  /** Go back in browser history. */
347
443
  export async function back() {
348
444
  if (!page)
@@ -396,20 +492,189 @@ async function formatSource([file, line, col]) {
396
492
  return `source: ${file}:${line}:${col}`;
397
493
  }
398
494
  // ── Utilities ────────────────────────────────────────────────────────────────
399
- /** Full-page screenshot saved to a temp file. Returns the file path. */
495
+ /** Viewport screenshot saved to a temp file. Returns the file path. */
400
496
  export async function screenshot() {
401
497
  if (!page)
402
498
  throw new Error("browser not open");
499
+ await hideDevOverlay();
403
500
  const { join } = await import("node:path");
404
501
  const { tmpdir } = await import("node:os");
405
502
  const path = join(tmpdir(), `next-browser-${Date.now()}.png`);
406
- await page.screenshot({ path, fullPage: true });
503
+ await page.screenshot({ path });
407
504
  return path;
408
505
  }
409
- /** Evaluate arbitrary JavaScript in the page context. */
410
- export async function evaluate(script) {
506
+ /** Remove Next.js devtools overlay from the page before screenshots. */
507
+ async function hideDevOverlay() {
508
+ if (!page)
509
+ return;
510
+ await page.evaluate(() => {
511
+ document.querySelectorAll("[data-nextjs-dev-overlay]").forEach((el) => el.remove());
512
+ }).catch(() => { });
513
+ }
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() {
527
+ if (!page)
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) {
411
622
  if (!page)
412
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
+ }
413
678
  return page.evaluate(script);
414
679
  }
415
680
  /**
@@ -496,10 +761,36 @@ async function launch() {
496
761
  profileDirPath = dir;
497
762
  const ctx = await chromium.launchPersistentContext(dir, {
498
763
  headless,
499
- viewport: { width: 1440, height: 900 },
764
+ viewport: null,
500
765
  // --no-sandbox is required when Chrome runs as root (common in containers/cloud sandboxes)
501
- args: headless ? ["--no-sandbox"] : [],
766
+ args: [
767
+ ...(headless ? ["--no-sandbox"] : []),
768
+ "--window-size=1440,900",
769
+ ],
502
770
  });
503
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
+ });
794
+ // Next.js devtools overlay is removed before each screenshot via hideDevOverlay().
504
795
  return ctx;
505
796
  }
package/dist/cli.js CHANGED
@@ -43,20 +43,57 @@ if (cmd === "ppr" && arg === "lock") {
43
43
  }
44
44
  if (cmd === "ppr" && arg === "unlock") {
45
45
  const res = await send("unlock");
46
- exit(res, res.ok && res.data ? `unlocked\n\n${res.data}` : "unlocked");
46
+ const d = res.ok ? res.data : null;
47
+ const text = typeof d === "string" ? d : d?.text ?? "";
48
+ exit(res, res.ok ? `unlocked${text ? `\n\n${text}` : ""}` : "unlocked");
47
49
  }
48
50
  if (cmd === "reload") {
49
51
  const res = await send("reload");
50
52
  exit(res, res.ok ? `reloaded → ${res.data}` : "");
51
53
  }
52
- if (cmd === "capture-goto") {
53
- const res = await send("capture-goto", arg ? { url: arg } : {});
54
+ if (cmd === "perf") {
55
+ const res = await send("perf", arg ? { url: arg } : {});
54
56
  if (!res.ok)
55
57
  exit(res, "");
56
- const data = res.data;
57
- exit(res, `${data.frames} frames ${data.dir}\n` +
58
- "\n" +
59
- "frame-0000.png is the PPR shell. Remaining frames capture hydration → data.");
58
+ const d = res.data;
59
+ const lines = [`# Page Load Profile — ${d.url}`, ""];
60
+ // Core Web Vitals
61
+ lines.push("## Core Web Vitals");
62
+ const ttfbStr = d.ttfb != null ? `${d.ttfb}ms` : "—";
63
+ lines.push(` TTFB ${ttfbStr.padStart(10)}`);
64
+ if (d.lcp) {
65
+ const lcpLabel = d.lcp.element ? ` (${d.lcp.element}${d.lcp.url ? `: ${d.lcp.url.slice(0, 60)}` : ""})` : "";
66
+ lines.push(` LCP ${String(d.lcp.startTime + "ms").padStart(10)}${lcpLabel}`);
67
+ }
68
+ else {
69
+ lines.push(` LCP —`);
70
+ }
71
+ lines.push(` CLS ${String(d.cls.score).padStart(10)}`);
72
+ lines.push("");
73
+ // React Hydration
74
+ if (d.hydration) {
75
+ lines.push(`## React Hydration — ${d.hydration.duration}ms (${d.hydration.startTime}ms → ${d.hydration.endTime}ms)`);
76
+ }
77
+ else {
78
+ lines.push("## React Hydration — no data (requires profiling build)");
79
+ }
80
+ if (d.phases.length > 0) {
81
+ for (const p of d.phases) {
82
+ lines.push(` ${p.label.padEnd(28)} ${String(p.duration + "ms").padStart(10)} (${p.startTime} → ${p.endTime})`);
83
+ }
84
+ lines.push("");
85
+ }
86
+ if (d.hydratedComponents.length > 0) {
87
+ lines.push(`## Hydrated components (${d.hydratedComponents.length} total, sorted by duration)`);
88
+ const top = d.hydratedComponents.slice(0, 30);
89
+ for (const c of top) {
90
+ lines.push(` ${c.name.padEnd(40)} ${String(c.duration + "ms").padStart(10)}`);
91
+ }
92
+ if (d.hydratedComponents.length > 30) {
93
+ lines.push(` ... and ${d.hydratedComponents.length - 30} more`);
94
+ }
95
+ }
96
+ exit(res, lines.join("\n"));
60
97
  }
61
98
  if (cmd === "restart-server") {
62
99
  const res = await send("restart");
@@ -83,6 +120,10 @@ if (cmd === "goto") {
83
120
  const res = await send("goto", { url: arg });
84
121
  exit(res, res.ok ? `→ ${res.data}` : "");
85
122
  }
123
+ if (cmd === "ssr-goto") {
124
+ const res = await send("ssr-goto", { url: arg });
125
+ exit(res, res.ok ? `→ ${res.data} (external scripts blocked)` : "");
126
+ }
86
127
  if (cmd === "back") {
87
128
  const res = await send("back");
88
129
  exit(res, "back");
@@ -91,12 +132,61 @@ if (cmd === "screenshot") {
91
132
  const res = await send("screenshot");
92
133
  exit(res, res.ok ? String(res.data) : "");
93
134
  }
94
- if (cmd === "eval") {
135
+ if (cmd === "snapshot") {
136
+ const res = await send("snapshot");
137
+ exit(res, res.ok ? json(res.data) : "");
138
+ }
139
+ if (cmd === "click") {
95
140
  if (!arg) {
96
- console.error("usage: next-browser eval <script>");
141
+ console.error("usage: next-browser click <ref|text|selector>");
142
+ process.exit(1);
143
+ }
144
+ const res = await send("click", { selector: arg });
145
+ exit(res, "clicked");
146
+ }
147
+ if (cmd === "fill") {
148
+ const value = args[2];
149
+ if (!arg || value === undefined) {
150
+ console.error("usage: next-browser fill <ref|selector> <value>");
151
+ process.exit(1);
152
+ }
153
+ const res = await send("fill", { selector: arg, value });
154
+ exit(res, "filled");
155
+ }
156
+ if (cmd === "eval") {
157
+ // Check if first arg is a ref (e.g. "e3") — if so, second arg is the script
158
+ let ref;
159
+ let scriptArg = arg;
160
+ let fileArgIdx = 2;
161
+ if (arg && /^e\d+$/.test(arg)) {
162
+ ref = arg;
163
+ scriptArg = args[2];
164
+ fileArgIdx = 3;
165
+ }
166
+ let script;
167
+ if (scriptArg === "--file" || scriptArg === "-f") {
168
+ const filePath = args[fileArgIdx];
169
+ if (!filePath) {
170
+ console.error("usage: next-browser eval [ref] --file <path>");
171
+ process.exit(1);
172
+ }
173
+ script = readFileSync(filePath, "utf-8");
174
+ }
175
+ else if (scriptArg === "-") {
176
+ // Read from stdin
177
+ const chunks = [];
178
+ for await (const chunk of process.stdin)
179
+ chunks.push(chunk);
180
+ script = Buffer.concat(chunks).toString("utf-8");
181
+ }
182
+ else {
183
+ script = scriptArg;
184
+ }
185
+ if (!script) {
186
+ console.error("usage: next-browser eval [ref] <script>\n next-browser eval [ref] --file <path>\n echo 'script' | next-browser eval -");
97
187
  process.exit(1);
98
188
  }
99
- const res = await send("eval", { script: arg });
189
+ const res = await send("eval", { script, ...(ref ? { selector: ref } : {}) });
100
190
  exit(res, res.ok ? json(res.data) : "");
101
191
  }
102
192
  if (cmd === "tree") {
@@ -227,10 +317,11 @@ function printUsage() {
227
317
  " close close browser and daemon\n" +
228
318
  "\n" +
229
319
  " goto <url> full-page navigation (new document load)\n" +
320
+ " ssr-goto <url> goto but block external scripts (SSR shell)\n" +
230
321
  " push [path] client-side navigation (interactive picker if no path)\n" +
231
322
  " back go back in history\n" +
232
323
  " reload reload current page\n" +
233
- " capture-goto [url] capture loading sequence (PPR shell hydration → data)\n" +
324
+ " perf [url] profile page load (CWVs + React hydration timing)\n" +
234
325
  " restart-server restart the Next.js dev server (clears fs cache)\n" +
235
326
  "\n" +
236
327
  " ppr lock enter PPR instant-navigation mode\n" +
@@ -241,7 +332,12 @@ function printUsage() {
241
332
  "\n" +
242
333
  " viewport [WxH] show or set viewport size (e.g. 1280x720)\n" +
243
334
  " screenshot save full-page screenshot to tmp file\n" +
244
- " eval <script> evaluate JS in page context\n" +
335
+ " snapshot accessibility tree with interactive refs\n" +
336
+ " click <ref|sel> click an element (real pointer events)\n" +
337
+ " fill <ref|sel> <v> fill a text input\n" +
338
+ " eval [ref] <script> evaluate JS in page context\n" +
339
+ " eval --file <path> evaluate JS from a file\n" +
340
+ " eval - evaluate JS from stdin\n" +
245
341
  "\n" +
246
342
  " errors show build/runtime errors\n" +
247
343
  " logs show recent dev server log output\n" +