@vercel/next-browser 0.2.0 → 0.4.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
@@ -46,7 +46,8 @@ Requires Node >= 20.
46
46
  | Command | Description |
47
47
  | ------------------ | --------------------------------------------------------- |
48
48
  | `goto <url>` | Full-page navigation (new document load) |
49
- | `ssr-goto <url>` | Navigate blocking external scripts (inspect SSR shell) |
49
+ | `ssr lock` | Block external scripts on all navigations (SSR-only mode) |
50
+ | `ssr unlock` | Re-enable external scripts |
50
51
  | `push [path]` | Client-side navigation (interactive picker if no path) |
51
52
  | `back` | Go back in history |
52
53
  | `reload` | Reload current page |
@@ -62,7 +63,8 @@ Requires Node >= 20.
62
63
  | `errors` | Build and runtime errors for the current page |
63
64
  | `logs` | Recent dev server log output |
64
65
  | `network [idx]` | List network requests, or inspect one (headers, body) |
65
- | `screenshot` | Full-page PNG to a temp file |
66
+ | `preview [caption]` | Screenshot + open in viewer window (accumulates across calls) |
67
+ | `screenshot` | Viewport PNG to a temp file (`--full-page` for entire page) |
66
68
 
67
69
  ### Interaction
68
70
 
@@ -138,8 +140,8 @@ $ next-browser perf http://localhost:3000/dashboard
138
140
  $ next-browser ppr lock
139
141
  locked
140
142
  $ next-browser goto http://localhost:3000/dashboard
141
- $ next-browser screenshot
142
- /var/folders/.../next-browser-screenshot.png
143
+ $ next-browser preview "PPR shell — locked"
144
+ preview → /var/folders/.../next-browser-screenshot.png
143
145
  $ next-browser ppr unlock
144
146
  # PPR Shell Analysis — 131 boundaries: 3 dynamic holes, 128 static
145
147
 
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 } from "node:fs";
13
+ import { readFileSync, mkdirSync, writeFileSync } from "node:fs";
14
14
  import { join, resolve } from "node:path";
15
15
  import { tmpdir } from "node:os";
16
16
  import { chromium } from "playwright";
@@ -29,6 +29,23 @@ let context = null;
29
29
  let page = null;
30
30
  let profileDirPath = null;
31
31
  let initialOrigin = null;
32
+ let ssrLocked = false;
33
+ let screenshotBrowser = null;
34
+ let screenshotPage = null;
35
+ let screenshotEntries = [];
36
+ /** Install or remove the script-blocking route handler based on ssrLocked. */
37
+ async function syncSsrRoutes() {
38
+ if (!page)
39
+ return;
40
+ await page.unrouteAll({ behavior: "wait" });
41
+ if (ssrLocked) {
42
+ await page.route("**/*", (route) => {
43
+ if (route.request().resourceType() === "script")
44
+ return route.abort();
45
+ return route.continue();
46
+ });
47
+ }
48
+ }
32
49
  // ── Browser lifecycle ────────────────────────────────────────────────────────
33
50
  /**
34
51
  * Launch the browser (if not already open) and optionally navigate to a URL.
@@ -36,11 +53,12 @@ let initialOrigin = null;
36
53
  * reuse the existing context.
37
54
  */
38
55
  export async function open(url) {
39
- if (!context) {
40
- context = await launch();
41
- page = context.pages()[0] ?? (await context.newPage());
42
- net.attach(page);
56
+ if (context) {
57
+ await close();
43
58
  }
59
+ context = await launch();
60
+ page = context.pages()[0] ?? (await context.newPage());
61
+ net.attach(page);
44
62
  if (url) {
45
63
  initialOrigin = new URL(url).origin;
46
64
  await page.goto(url, { waitUntil: "domcontentloaded" });
@@ -59,11 +77,16 @@ export async function cookies(cookies, domain) {
59
77
  }
60
78
  /** Close the browser and reset all state. */
61
79
  export async function close() {
80
+ await screenshotBrowser?.close().catch(() => { });
81
+ screenshotBrowser = null;
82
+ screenshotPage = null;
83
+ screenshotEntries = [];
62
84
  await context?.close();
63
85
  context = null;
64
86
  page = null;
65
87
  release = null;
66
88
  settled = null;
89
+ ssrLocked = false;
67
90
  // Clean up temp profile directory.
68
91
  if (profileDirPath) {
69
92
  const { rmSync } = await import("node:fs");
@@ -88,7 +111,7 @@ export function lock() {
88
111
  if (!page)
89
112
  throw new Error("browser not open");
90
113
  if (release)
91
- throw new Error("already locked");
114
+ return Promise.resolve();
92
115
  return new Promise((locked) => {
93
116
  settled = instant(page, () => {
94
117
  locked();
@@ -225,6 +248,29 @@ async function waitForDevToolsReconnect(p) {
225
248
  await new Promise((r) => setTimeout(r, 200));
226
249
  }
227
250
  }
251
+ // ── SSR lock/unlock ──────────────────────────────────────────────────────────
252
+ //
253
+ // While SSR-locked, every navigation blocks external script resources so the
254
+ // page renders only the server-side HTML shell (no React hydration, no client
255
+ // bundles). Useful for inspecting raw SSR output across multiple navigations.
256
+ /** Enter SSR-locked mode. All subsequent navigations block external scripts. */
257
+ export async function ssrLock() {
258
+ if (!page)
259
+ throw new Error("browser not open");
260
+ if (ssrLocked)
261
+ return;
262
+ ssrLocked = true;
263
+ await syncSsrRoutes();
264
+ }
265
+ /** Exit SSR-locked mode. Re-enables external scripts. */
266
+ export async function ssrUnlock() {
267
+ if (!page)
268
+ throw new Error("browser not open");
269
+ if (!ssrLocked)
270
+ return;
271
+ ssrLocked = false;
272
+ await syncSsrRoutes();
273
+ }
228
274
  // ── Navigation ───────────────────────────────────────────────────────────────
229
275
  /** Hard reload the current page. Returns the URL after reload. */
230
276
  export async function reload() {
@@ -412,30 +458,9 @@ export async function push(path) {
412
458
  /** Full-page navigation (new document load). Resolves relative URLs against the current page. */
413
459
  export async function goto(url) {
414
460
  if (!page)
415
- throw new Error("browser not open");
416
- await page.unrouteAll({ behavior: "wait" });
417
- const target = new URL(url, page.url()).href;
418
- initialOrigin = new URL(target).origin;
419
- await page.goto(target, { waitUntil: "domcontentloaded" });
420
- return target;
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");
461
+ await open(undefined);
430
462
  const target = new URL(url, page.url()).href;
431
463
  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
464
  await page.goto(target, { waitUntil: "domcontentloaded" });
440
465
  return target;
441
466
  }
@@ -492,15 +517,71 @@ async function formatSource([file, line, col]) {
492
517
  return `source: ${file}:${line}:${col}`;
493
518
  }
494
519
  // ── Utilities ────────────────────────────────────────────────────────────────
495
- /** Viewport screenshot saved to a temp file. Returns the file path. */
496
- export async function screenshot() {
520
+ /** Render screenshot entries as HTML and refresh (or launch) the log window.
521
+ * No-op in headless mode. */
522
+ async function refreshScreenshotLog() {
523
+ if (process.env.NEXT_BROWSER_HEADLESS)
524
+ return;
525
+ const entriesHtml = screenshotEntries
526
+ .map((e) => {
527
+ const header = `<div style="padding:4px 12px;display:flex;align-items:baseline;gap:8px">` +
528
+ (e.caption
529
+ ? `<span style="font-size:14px">${escapeHtml(e.caption)}</span>`
530
+ : "") +
531
+ `<span style="font-size:11px;opacity:0.5">${escapeHtml(e.timestamp)}</span>` +
532
+ `</div>`;
533
+ return header + `<img src="data:image/png;base64,${e.imgData}" style="display:block;max-width:100%">`;
534
+ })
535
+ .join(`<hr style="border:none;border-top:1px solid #333;margin:12px 0">`);
536
+ const html = `<html><head><title>Screenshot Log</title></head>` +
537
+ `<body style="margin:0;background:#111;color:#fff;font-family:system-ui">` +
538
+ `<div style="padding:8px 12px;font-size:11px;opacity:0.5;text-transform:uppercase;letter-spacing:0.05em">Screenshot Log</div>` +
539
+ `${entriesHtml}` +
540
+ `</body></html>`;
541
+ const htmlPath = join(tmpdir(), `next-browser-screenshots-${process.pid}.html`);
542
+ writeFileSync(htmlPath, html);
543
+ const target = `file://${htmlPath}`;
544
+ // Reuse existing log window, or launch a new one.
545
+ if (screenshotPage && !screenshotPage.isClosed()) {
546
+ try {
547
+ await screenshotPage.goto(target);
548
+ await screenshotPage.bringToFront();
549
+ return;
550
+ }
551
+ catch {
552
+ // Window was closed by user — fall through to launch a new one.
553
+ await screenshotBrowser?.close().catch(() => { });
554
+ }
555
+ }
556
+ const { mkdtempSync } = await import("node:fs");
557
+ const userDataDir = mkdtempSync(join(tmpdir(), "nb-screenshots-"));
558
+ const ctx = await chromium.launchPersistentContext(userDataDir, {
559
+ headless: false,
560
+ args: [`--app=${target}`, "--window-size=820,640"],
561
+ viewport: null,
562
+ });
563
+ screenshotBrowser = ctx;
564
+ screenshotPage = ctx.pages()[0] ?? (await ctx.waitForEvent("page"));
565
+ await screenshotPage.waitForLoadState();
566
+ await screenshotPage.bringToFront();
567
+ }
568
+ function escapeHtml(s) {
569
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
570
+ }
571
+ /** Screenshot saved to a temp file. Opens the Screenshot Log window in headed mode.
572
+ * Returns the file path. */
573
+ export async function screenshot(opts) {
497
574
  if (!page)
498
575
  throw new Error("browser not open");
499
576
  await hideDevOverlay();
500
577
  const { join } = await import("node:path");
501
578
  const { tmpdir } = await import("node:os");
502
579
  const path = join(tmpdir(), `next-browser-${Date.now()}.png`);
503
- await page.screenshot({ path });
580
+ await page.screenshot({ path, fullPage: opts?.fullPage });
581
+ const imgData = readFileSync(path).toString("base64");
582
+ const timestamp = new Date().toLocaleTimeString();
583
+ screenshotEntries.unshift({ caption: opts?.caption, imgData, timestamp });
584
+ await refreshScreenshotLog();
504
585
  return path;
505
586
  }
506
587
  /** Remove Next.js devtools overlay from the page before screenshots. */
@@ -690,6 +771,312 @@ export async function mcp(tool, args) {
690
771
  const origin = initialOrigin ?? new URL(page.url()).origin;
691
772
  return nextMcp.call(origin, tool, args);
692
773
  }
774
+ /** Return browser console output captured by the init-script interceptor. */
775
+ export async function browserLogs() {
776
+ if (!page)
777
+ throw new Error("browser not open");
778
+ return page.evaluate(() => window.__NEXT_BROWSER_CONSOLE_LOGS__ ?? []);
779
+ }
780
+ // ── Render Profiling ────────────────────────────────────────────────────────
781
+ /**
782
+ * The browser-side script that installs the onCommitFiberRoot hook.
783
+ * Extracted so it can be used by both rendersStart (page.evaluate)
784
+ * and rendersAuto (addInitScript).
785
+ */
786
+ const rendersHookScript = `(() => {
787
+ const hook = window.__REACT_DEVTOOLS_GLOBAL_HOOK__;
788
+ if (!hook || window.__NEXT_BROWSER_RENDERS_ACTIVE__) return;
789
+
790
+ const MAX_COMPONENTS = 200;
791
+ const data = {};
792
+ const fps = { frames: [], last: 0, rafId: 0 };
793
+
794
+ window.__NEXT_BROWSER_RENDERS__ = data;
795
+ window.__NEXT_BROWSER_RENDERS_FPS__ = fps;
796
+ window.__NEXT_BROWSER_RENDERS_START__ = performance.now();
797
+ window.__NEXT_BROWSER_RENDERS_ACTIVE__ = true;
798
+
799
+ // FPS tracking via requestAnimationFrame
800
+ function fpsLoop(now) {
801
+ if (fps.last > 0) fps.frames.push(now - fps.last);
802
+ fps.last = now;
803
+ fps.rafId = requestAnimationFrame(fpsLoop);
804
+ }
805
+ fps.rafId = requestAnimationFrame(fpsLoop);
806
+
807
+ const origOnCommit = hook.onCommitFiberRoot;
808
+ window.__NEXT_BROWSER_RENDERS_ORIG_COMMIT__ = origOnCommit;
809
+
810
+ hook.onCommitFiberRoot = function(rendererID, root) {
811
+ try { walkFiber(root.current); } catch {}
812
+ if (typeof origOnCommit === "function") {
813
+ return origOnCommit.apply(hook, arguments);
814
+ }
815
+ };
816
+
817
+ function getName(fiber) {
818
+ if (!fiber.type || typeof fiber.type === "string") return null;
819
+ return fiber.type.displayName || fiber.type.name || null;
820
+ }
821
+
822
+ function brief(val) {
823
+ if (val === undefined) return "undefined";
824
+ if (val === null) return "null";
825
+ if (typeof val === "function") return "fn()";
826
+ if (typeof val === "string") return val.length > 60 ? '"' + val.slice(0, 57) + '..."' : '"' + val + '"';
827
+ if (typeof val === "number" || typeof val === "boolean") return String(val);
828
+ if (Array.isArray(val)) return "Array(" + val.length + ")";
829
+ if (typeof val === "object") {
830
+ try {
831
+ const keys = Object.keys(val);
832
+ return keys.length <= 3 ? "{" + keys.join(", ") + "}" : "{" + keys.slice(0, 3).join(", ") + ", ...}";
833
+ } catch { return "{...}"; }
834
+ }
835
+ return String(val).slice(0, 40);
836
+ }
837
+
838
+ function getChanges(fiber) {
839
+ const changes = [];
840
+ const alt = fiber.alternate;
841
+ if (!alt) { changes.push({ type: "mount" }); return changes; }
842
+
843
+ // Props
844
+ if (fiber.memoizedProps !== alt.memoizedProps) {
845
+ const curr = fiber.memoizedProps || {};
846
+ const prev = alt.memoizedProps || {};
847
+ const allKeys = new Set([...Object.keys(curr), ...Object.keys(prev)]);
848
+ for (const k of allKeys) {
849
+ if (k !== "children" && curr[k] !== prev[k]) {
850
+ changes.push({ type: "props", name: k, prev: brief(prev[k]), next: brief(curr[k]) });
851
+ }
852
+ }
853
+ }
854
+
855
+ // State — walk memoizedState linked list
856
+ if (fiber.memoizedState !== alt.memoizedState) {
857
+ let curr = fiber.memoizedState;
858
+ let prev = alt.memoizedState;
859
+ let hookIdx = 0;
860
+ while (curr || prev) {
861
+ if (curr?.memoizedState !== prev?.memoizedState) {
862
+ changes.push({
863
+ type: "state", name: "hook #" + hookIdx,
864
+ prev: brief(prev?.memoizedState), next: brief(curr?.memoizedState)
865
+ });
866
+ }
867
+ curr = curr?.next;
868
+ prev = prev?.next;
869
+ hookIdx++;
870
+ }
871
+ }
872
+
873
+ // Context — walk dependencies linked list
874
+ if (fiber.dependencies?.firstContext) {
875
+ let ctx = fiber.dependencies.firstContext;
876
+ let altCtx = alt.dependencies?.firstContext;
877
+ while (ctx) {
878
+ if (!altCtx || ctx.memoizedValue !== altCtx?.memoizedValue) {
879
+ const ctxName = ctx.context?.displayName || ctx.context?.Provider?.displayName || "unknown";
880
+ changes.push({
881
+ type: "context", name: ctxName,
882
+ prev: brief(altCtx?.memoizedValue), next: brief(ctx.memoizedValue)
883
+ });
884
+ }
885
+ ctx = ctx.next;
886
+ altCtx = altCtx?.next;
887
+ }
888
+ }
889
+
890
+ if (changes.length === 0) {
891
+ // Find the nearest parent component name
892
+ let parent = fiber.return;
893
+ while (parent) {
894
+ const pName = getName(parent);
895
+ if (pName) {
896
+ const suffix = !parent.alternate ? " (mount)" : "";
897
+ changes.push({ type: "parent", name: pName + suffix });
898
+ break;
899
+ }
900
+ parent = parent.return;
901
+ }
902
+ if (changes.length === 0) changes.push({ type: "parent", name: "unknown" });
903
+ }
904
+
905
+ return changes;
906
+ }
907
+
908
+ function childrenTime(fiber) {
909
+ let t = 0;
910
+ let child = fiber.child;
911
+ while (child) {
912
+ if (typeof child.actualDuration === "number") t += child.actualDuration;
913
+ child = child.sibling;
914
+ }
915
+ return t;
916
+ }
917
+
918
+ function hasDomMutation(fiber) {
919
+ // Check if this fiber or any host (DOM) child has the Mutation flag (4)
920
+ // or Placement flag (2) or Update flag (4) in subtreeFlags/flags
921
+ if (!fiber.alternate) return true; // mount always mutates
922
+ let child = fiber.child;
923
+ while (child) {
924
+ if (typeof child.type === "string" && (child.flags & 6) > 0) return true;
925
+ child = child.sibling;
926
+ }
927
+ return false;
928
+ }
929
+
930
+ function walkFiber(fiber) {
931
+ if (!fiber) return;
932
+
933
+ const tag = fiber.tag;
934
+ if (tag === 0 || tag === 1 || tag === 2 || tag === 11 || tag === 15) {
935
+ const didRender =
936
+ fiber.alternate === null ||
937
+ fiber.flags > 0 ||
938
+ fiber.memoizedProps !== fiber.alternate?.memoizedProps ||
939
+ fiber.memoizedState !== fiber.alternate?.memoizedState;
940
+
941
+ if (didRender) {
942
+ const name = getName(fiber);
943
+ if (name) {
944
+ if (!(name in data) && Object.keys(data).length >= MAX_COMPONENTS) {
945
+ // skip — at cap
946
+ } else {
947
+ if (!data[name]) {
948
+ data[name] = {
949
+ count: 0, mounts: 0, totalTime: 0, selfTime: 0,
950
+ domMutations: 0, changes: [],
951
+ _instances: new Set()
952
+ };
953
+ }
954
+ data[name].count++;
955
+ if (!fiber.alternate) data[name].mounts++;
956
+ if (!data[name]._instances.has(fiber)) {
957
+ data[name]._instances.add(fiber);
958
+ if (fiber.alternate) data[name]._instances.add(fiber.alternate);
959
+ }
960
+ if (typeof fiber.actualDuration === "number") {
961
+ data[name].totalTime += fiber.actualDuration;
962
+ data[name].selfTime += Math.max(0, fiber.actualDuration - childrenTime(fiber));
963
+ }
964
+ if (hasDomMutation(fiber)) data[name].domMutations++;
965
+ const ch = getChanges(fiber);
966
+ // Keep last 50 change entries per component to cap memory
967
+ for (const c of ch) {
968
+ if (data[name].changes.length < 50) data[name].changes.push(c);
969
+ }
970
+ }
971
+ }
972
+ }
973
+ }
974
+
975
+ walkFiber(fiber.child);
976
+ walkFiber(fiber.sibling);
977
+ }
978
+ })()`;
979
+ /**
980
+ * Start recording React re-renders by hooking into onCommitFiberRoot.
981
+ * Installs via both addInitScript (survives navigations, captures mount)
982
+ * and page.evaluate (activates immediately on current page).
983
+ */
984
+ export async function rendersStart() {
985
+ if (!page)
986
+ throw new Error("browser not open");
987
+ const ctx = page.context();
988
+ await ctx.addInitScript(rendersHookScript);
989
+ await page.evaluate(rendersHookScript);
990
+ }
991
+ /**
992
+ * Stop recording and return a per-component render profile with raw data.
993
+ */
994
+ export async function rendersStop() {
995
+ if (!page)
996
+ throw new Error("browser not open");
997
+ return page.evaluate(() => {
998
+ const active = window.__NEXT_BROWSER_RENDERS_ACTIVE__;
999
+ if (!active)
1000
+ throw new Error("renders recording not active — run `renders start` first");
1001
+ const data = window.__NEXT_BROWSER_RENDERS__;
1002
+ const startTime = window.__NEXT_BROWSER_RENDERS_START__;
1003
+ const elapsed = performance.now() - startTime;
1004
+ // Collect FPS data
1005
+ const fpsData = window.__NEXT_BROWSER_RENDERS_FPS__;
1006
+ let fpsStats = { avg: 0, min: 0, max: 0, drops: 0 };
1007
+ if (fpsData) {
1008
+ cancelAnimationFrame(fpsData.rafId);
1009
+ if (fpsData.frames.length > 0) {
1010
+ const fpsSamples = fpsData.frames.map((dt) => dt > 0 ? 1000 / dt : 0);
1011
+ const sum = fpsSamples.reduce((a, b) => a + b, 0);
1012
+ fpsStats = {
1013
+ avg: Math.round(sum / fpsSamples.length),
1014
+ min: Math.round(Math.min(...fpsSamples)),
1015
+ max: Math.round(Math.max(...fpsSamples)),
1016
+ drops: fpsSamples.filter((f) => f < 30).length,
1017
+ };
1018
+ }
1019
+ }
1020
+ // Restore original hook
1021
+ const hook = window.__REACT_DEVTOOLS_GLOBAL_HOOK__;
1022
+ const orig = window.__NEXT_BROWSER_RENDERS_ORIG_COMMIT__;
1023
+ if (hook) {
1024
+ hook.onCommitFiberRoot = orig || undefined;
1025
+ }
1026
+ delete window.__NEXT_BROWSER_RENDERS__;
1027
+ delete window.__NEXT_BROWSER_RENDERS_START__;
1028
+ delete window.__NEXT_BROWSER_RENDERS_ACTIVE__;
1029
+ delete window.__NEXT_BROWSER_RENDERS_ORIG_COMMIT__;
1030
+ delete window.__NEXT_BROWSER_RENDERS_FPS__;
1031
+ if (!data)
1032
+ return {
1033
+ elapsed: 0,
1034
+ fps: fpsStats,
1035
+ totalRenders: 0,
1036
+ totalMounts: 0,
1037
+ totalReRenders: 0,
1038
+ totalComponents: 0,
1039
+ components: [],
1040
+ };
1041
+ const round = (n) => Math.round(n * 100) / 100;
1042
+ const components = Object.entries(data)
1043
+ .map(([name, entry]) => {
1044
+ // Summarize changes: count by type+name for the table view
1045
+ const summary = {};
1046
+ for (const c of entry.changes) {
1047
+ const key = c.type === "props" ? "props." + c.name
1048
+ : c.type === "state" ? "state (" + c.name + ")"
1049
+ : c.type === "context" ? "context (" + c.name + ")"
1050
+ : c.type === "parent" ? "parent (" + c.name + ")"
1051
+ : c.type;
1052
+ summary[key] = (summary[key] || 0) + 1;
1053
+ }
1054
+ return {
1055
+ name,
1056
+ count: entry.count,
1057
+ mounts: entry.mounts,
1058
+ reRenders: entry.count - entry.mounts,
1059
+ instanceCount: entry._instances.size,
1060
+ totalTime: round(entry.totalTime),
1061
+ selfTime: round(entry.selfTime),
1062
+ domMutations: entry.domMutations,
1063
+ changes: entry.changes,
1064
+ changeSummary: summary,
1065
+ };
1066
+ })
1067
+ .sort((a, b) => b.totalTime - a.totalTime || b.count - a.count);
1068
+ const totalMounts = components.reduce((s, c) => s + c.mounts, 0);
1069
+ return {
1070
+ elapsed: round(elapsed / 1000),
1071
+ fps: fpsStats,
1072
+ totalRenders: components.reduce((s, c) => s + c.count, 0),
1073
+ totalMounts,
1074
+ totalReRenders: components.reduce((s, c) => s + c.reRenders, 0),
1075
+ totalComponents: components.length,
1076
+ components,
1077
+ };
1078
+ });
1079
+ }
693
1080
  /** Get network request log, or detail for a specific request index. */
694
1081
  export function network(idx) {
695
1082
  return idx == null ? net.format() : net.detail(idx);
@@ -791,6 +1178,44 @@ async function launch() {
791
1178
  return orig.apply(console, [label, ...args]);
792
1179
  };
793
1180
  });
1181
+ // Intercept console.log/warn/error/info to capture browser console output.
1182
+ // This works for both dev and prod builds — unlike `logs`/`errors` which
1183
+ // rely on the Next.js dev server MCP endpoint.
1184
+ await ctx.addInitScript(() => {
1185
+ const MAX = 500;
1186
+ const entries = [];
1187
+ window.__NEXT_BROWSER_CONSOLE_LOGS__ = entries;
1188
+ function safe(val) {
1189
+ if (val === undefined)
1190
+ return "undefined";
1191
+ if (val === null)
1192
+ return "null";
1193
+ if (val instanceof Error)
1194
+ return `${val.name}: ${val.message}`;
1195
+ if (typeof val === "object") {
1196
+ try {
1197
+ return JSON.stringify(val);
1198
+ }
1199
+ catch {
1200
+ return String(val);
1201
+ }
1202
+ }
1203
+ return String(val);
1204
+ }
1205
+ for (const level of ["log", "warn", "error", "info"]) {
1206
+ const orig = console[level];
1207
+ console[level] = function (...args) {
1208
+ entries.push({
1209
+ level,
1210
+ args: args.map(safe).join(" "),
1211
+ timestamp: performance.now(),
1212
+ });
1213
+ if (entries.length > MAX)
1214
+ entries.splice(0, entries.length - MAX);
1215
+ return orig.apply(console, args);
1216
+ };
1217
+ }
1218
+ });
794
1219
  // Next.js devtools overlay is removed before each screenshot via hideDevOverlay().
795
1220
  return ctx;
796
1221
  }
package/dist/cli.js CHANGED
@@ -1,5 +1,7 @@
1
1
  #!/usr/bin/env node
2
- import { readFileSync } from "node:fs";
2
+ import { readFileSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
3
5
  import { send } from "./client.js";
4
6
  const args = process.argv.slice(2);
5
7
  const cmd = args[0];
@@ -120,16 +122,22 @@ if (cmd === "goto") {
120
122
  const res = await send("goto", { url: arg });
121
123
  exit(res, res.ok ? `→ ${res.data}` : "");
122
124
  }
123
- if (cmd === "ssr-goto") {
124
- const res = await send("ssr-goto", { url: arg });
125
- exit(res, res.ok ? `→ ${res.data} (external scripts blocked)` : "");
125
+ if (cmd === "ssr" && arg === "lock") {
126
+ const res = await send("ssr-lock");
127
+ exit(res, "ssr locked external scripts blocked on all navigations");
128
+ }
129
+ if (cmd === "ssr" && arg === "unlock") {
130
+ const res = await send("ssr-unlock");
131
+ exit(res, "ssr unlocked — external scripts re-enabled");
126
132
  }
127
133
  if (cmd === "back") {
128
134
  const res = await send("back");
129
135
  exit(res, "back");
130
136
  }
131
137
  if (cmd === "screenshot") {
132
- const res = await send("screenshot");
138
+ const fullPage = args.includes("--full-page");
139
+ const caption = args.slice(1).filter((a) => a !== "--full-page").join(" ") || undefined;
140
+ const res = await send("screenshot", { fullPage, caption });
133
141
  exit(res, res.ok ? String(res.data) : "");
134
142
  }
135
143
  if (cmd === "snapshot") {
@@ -216,6 +224,113 @@ if (cmd === "logs") {
216
224
  console.log(content || "(log file is empty)");
217
225
  process.exit(0);
218
226
  }
227
+ if (cmd === "browser-logs") {
228
+ const res = await send("browser-logs");
229
+ if (!res.ok)
230
+ exit(res, "");
231
+ const entries = res.data;
232
+ if (entries.length === 0) {
233
+ console.log("(no console output captured)");
234
+ process.exit(0);
235
+ }
236
+ const lines = entries.map((e) => `[${e.level.toUpperCase().padEnd(5)}] ${e.args}`);
237
+ const output = lines.join("\n");
238
+ if (output.length > 4000) {
239
+ const path = join(tmpdir(), `next-browser-console-${process.pid}.log`);
240
+ writeFileSync(path, output);
241
+ console.log(`(${entries.length} entries written to ${path})`);
242
+ }
243
+ else {
244
+ console.log(output);
245
+ }
246
+ process.exit(0);
247
+ }
248
+ if (cmd === "renders" && arg === "start") {
249
+ const res = await send("renders-start");
250
+ exit(res, "recording renders — interact with the page, then run `renders stop`");
251
+ }
252
+ if (cmd === "renders" && arg === "stop") {
253
+ const useJson = args.includes("--json");
254
+ const res = await send("renders-stop");
255
+ if (!res.ok)
256
+ exit(res, "");
257
+ const d = res.data;
258
+ if (d.components.length === 0) {
259
+ if (useJson)
260
+ console.log(JSON.stringify(d, null, 2));
261
+ else
262
+ console.log("(no renders captured)");
263
+ process.exit(0);
264
+ }
265
+ if (useJson) {
266
+ const output = JSON.stringify(d, null, 2);
267
+ if (output.length > 4000) {
268
+ const path = join(tmpdir(), `next-browser-renders-${process.pid}.json`);
269
+ writeFileSync(path, output);
270
+ console.log(path);
271
+ }
272
+ else {
273
+ console.log(output);
274
+ }
275
+ process.exit(0);
276
+ }
277
+ const lines = [
278
+ `# Render Profile — ${d.elapsed}s recording`,
279
+ `# ${d.totalRenders} renders (${d.totalMounts} mounts + ${d.totalReRenders} re-renders) across ${d.totalComponents} components`,
280
+ `# FPS: avg ${d.fps.avg}, min ${d.fps.min}, max ${d.fps.max}, drops (<30fps): ${d.fps.drops}`,
281
+ "",
282
+ "## Components by total render time",
283
+ ];
284
+ const nameW = Math.max(9, ...d.components.slice(0, 50).map((c) => c.name.length));
285
+ const header = `| ${"Component".padEnd(nameW)} | Insts | Mounts | Re-renders | Total | Self | DOM | Top change reason |`;
286
+ const sep = `| ${"-".repeat(nameW)} | ----- | ------ | ---------- | -------- | -------- | ----- | -------------------------- |`;
287
+ lines.push(header, sep);
288
+ for (const c of d.components.slice(0, 50)) {
289
+ const total = c.totalTime > 0 ? `${c.totalTime}ms` : "—";
290
+ const self = c.selfTime > 0 ? `${c.selfTime}ms` : "—";
291
+ const dom = `${c.domMutations}/${c.count}`;
292
+ const topChange = Object.entries(c.changeSummary).sort((a, b) => b[1] - a[1])[0];
293
+ const changeStr = topChange ? topChange[0] : "—";
294
+ lines.push(`| ${c.name.padEnd(nameW)} | ${String(c.instanceCount).padStart(5)} | ${String(c.mounts).padStart(6)} | ${String(c.reRenders).padStart(10)} | ${total.padStart(8)} | ${self.padStart(8)} | ${dom.padStart(5)} | ${changeStr.padEnd(26)} |`);
295
+ }
296
+ if (d.components.length > 50) {
297
+ lines.push(`... and ${d.components.length - 50} more`);
298
+ }
299
+ // Detail: show prev→next values for top components with non-mount changes
300
+ const detailed = d.components
301
+ .filter((c) => c.changes.some((ch) => ch.type !== "mount" && ch.type !== "parent"))
302
+ .slice(0, 15);
303
+ if (detailed.length > 0) {
304
+ lines.push("", "## Change details (prev → next)");
305
+ for (const c of detailed) {
306
+ lines.push(` ${c.name}`);
307
+ // Deduplicate: show unique type+name combinations with their prev→next
308
+ const seen = new Set();
309
+ for (const ch of c.changes) {
310
+ if (ch.type === "mount" || ch.type === "parent")
311
+ continue;
312
+ const key = `${ch.type}:${ch.name}`;
313
+ if (seen.has(key))
314
+ continue;
315
+ seen.add(key);
316
+ const label = ch.type === "props" ? `props.${ch.name}`
317
+ : ch.type === "state" ? `state (${ch.name})`
318
+ : `context (${ch.name})`;
319
+ lines.push(` ${label}: ${ch.prev ?? "?"} → ${ch.next ?? "?"}`);
320
+ }
321
+ }
322
+ }
323
+ const output = lines.join("\n");
324
+ if (output.length > 4000) {
325
+ const path = join(tmpdir(), `next-browser-renders-${process.pid}.txt`);
326
+ writeFileSync(path, output);
327
+ console.log(`(${d.totalComponents} components written to ${path})`);
328
+ }
329
+ else {
330
+ console.log(output);
331
+ }
332
+ process.exit(0);
333
+ }
219
334
  if (cmd === "action") {
220
335
  const res = await send("mcp", { tool: "get_server_action_by_id", args: { actionId: arg } });
221
336
  exit(res, res.ok ? json(res.data) : "");
@@ -317,11 +432,14 @@ function printUsage() {
317
432
  " close close browser and daemon\n" +
318
433
  "\n" +
319
434
  " goto <url> full-page navigation (new document load)\n" +
320
- " ssr-goto <url> goto but block external scripts (SSR shell)\n" +
435
+ " ssr lock block external scripts on all navigations\n" +
436
+ " ssr unlock re-enable external scripts\n" +
321
437
  " push [path] client-side navigation (interactive picker if no path)\n" +
322
438
  " back go back in history\n" +
323
439
  " reload reload current page\n" +
324
440
  " perf [url] profile page load (CWVs + React hydration timing)\n" +
441
+ " renders start start recording React re-renders\n" +
442
+ " renders stop [--json] stop and print render profile\n" +
325
443
  " restart-server restart the Next.js dev server (clears fs cache)\n" +
326
444
  "\n" +
327
445
  " ppr lock enter PPR instant-navigation mode\n" +
@@ -331,7 +449,7 @@ function printUsage() {
331
449
  " tree <id> inspect component (props, hooks, state, source)\n" +
332
450
  "\n" +
333
451
  " viewport [WxH] show or set viewport size (e.g. 1280x720)\n" +
334
- " screenshot save full-page screenshot to tmp file\n" +
452
+ " screenshot [caption] [--full-page] screenshot + Screenshot Log\n" +
335
453
  " snapshot accessibility tree with interactive refs\n" +
336
454
  " click <ref|sel> click an element (real pointer events)\n" +
337
455
  " fill <ref|sel> <v> fill a text input\n" +
@@ -341,6 +459,7 @@ function printUsage() {
341
459
  "\n" +
342
460
  " errors show build/runtime errors\n" +
343
461
  " logs show recent dev server log output\n" +
462
+ " browser-logs show browser console output (log/warn/error/info)\n" +
344
463
  " network [idx] list network requests, or inspect one\n" +
345
464
  "\n" +
346
465
  " page show current page segments and router info\n" +
package/dist/daemon.js CHANGED
@@ -61,12 +61,24 @@ async function run(cmd) {
61
61
  const data = await browser.perf(cmd.url);
62
62
  return { ok: true, data };
63
63
  }
64
+ if (cmd.action === "browser-logs") {
65
+ const data = await browser.browserLogs();
66
+ return { ok: true, data };
67
+ }
68
+ if (cmd.action === "renders-start") {
69
+ await browser.rendersStart();
70
+ return { ok: true };
71
+ }
72
+ if (cmd.action === "renders-stop") {
73
+ const data = await browser.rendersStop();
74
+ return { ok: true, data };
75
+ }
64
76
  if (cmd.action === "restart") {
65
77
  const data = await browser.restart();
66
78
  return { ok: true, data };
67
79
  }
68
80
  if (cmd.action === "screenshot") {
69
- const data = await browser.screenshot();
81
+ const data = await browser.screenshot({ fullPage: cmd.fullPage, caption: cmd.caption });
70
82
  return { ok: true, data };
71
83
  }
72
84
  if (cmd.action === "links") {
@@ -81,9 +93,13 @@ async function run(cmd) {
81
93
  const data = await browser.goto(cmd.url);
82
94
  return { ok: true, data };
83
95
  }
84
- if (cmd.action === "ssr-goto") {
85
- const data = await browser.ssrGoto(cmd.url);
86
- return { ok: true, data };
96
+ if (cmd.action === "ssr-lock") {
97
+ await browser.ssrLock();
98
+ return { ok: true };
99
+ }
100
+ if (cmd.action === "ssr-unlock") {
101
+ await browser.ssrUnlock();
102
+ return { ok: true };
87
103
  }
88
104
  if (cmd.action === "back") {
89
105
  await browser.back();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vercel/next-browser",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Headed Playwright browser with React DevTools pre-loaded",
5
5
  "license": "MIT",
6
6
  "repository": {