@vercel/next-browser 0.3.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/dist/browser.js +387 -45
- package/dist/cli.js +116 -10
- package/dist/daemon.js +13 -5
- package/package.json +1 -1
package/dist/browser.js
CHANGED
|
@@ -30,9 +30,9 @@ let page = null;
|
|
|
30
30
|
let profileDirPath = null;
|
|
31
31
|
let initialOrigin = null;
|
|
32
32
|
let ssrLocked = false;
|
|
33
|
-
let
|
|
34
|
-
let
|
|
35
|
-
let
|
|
33
|
+
let screenshotBrowser = null;
|
|
34
|
+
let screenshotPage = null;
|
|
35
|
+
let screenshotEntries = [];
|
|
36
36
|
/** Install or remove the script-blocking route handler based on ssrLocked. */
|
|
37
37
|
async function syncSsrRoutes() {
|
|
38
38
|
if (!page)
|
|
@@ -77,10 +77,10 @@ export async function cookies(cookies, domain) {
|
|
|
77
77
|
}
|
|
78
78
|
/** Close the browser and reset all state. */
|
|
79
79
|
export async function close() {
|
|
80
|
-
await
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
80
|
+
await screenshotBrowser?.close().catch(() => { });
|
|
81
|
+
screenshotBrowser = null;
|
|
82
|
+
screenshotPage = null;
|
|
83
|
+
screenshotEntries = [];
|
|
84
84
|
await context?.close();
|
|
85
85
|
context = null;
|
|
86
86
|
page = null;
|
|
@@ -517,65 +517,59 @@ async function formatSource([file, line, col]) {
|
|
|
517
517
|
return `source: ${file}:${line}:${col}`;
|
|
518
518
|
}
|
|
519
519
|
// ── Utilities ────────────────────────────────────────────────────────────────
|
|
520
|
-
/**
|
|
521
|
-
*
|
|
522
|
-
|
|
523
|
-
if (
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
: "") +
|
|
536
|
-
`<span style="font-size:11px;opacity:0.5">${escapeHtml(img.timestamp)}</span>` +
|
|
537
|
-
`</div>` +
|
|
538
|
-
`<img src="data:image/png;base64,${img.imgData}" style="display:block;max-width:100%">`)
|
|
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
|
+
})
|
|
539
535
|
.join(`<hr style="border:none;border-top:1px solid #333;margin:12px 0">`);
|
|
540
|
-
const html = `<html><head><title>
|
|
536
|
+
const html = `<html><head><title>Screenshot Log</title></head>` +
|
|
541
537
|
`<body style="margin:0;background:#111;color:#fff;font-family:system-ui">` +
|
|
542
|
-
`<div style="padding:8px 12px;font-size:11px;opacity:0.5;text-transform:uppercase;letter-spacing:0.05em">
|
|
543
|
-
`${
|
|
538
|
+
`<div style="padding:8px 12px;font-size:11px;opacity:0.5;text-transform:uppercase;letter-spacing:0.05em">Screenshot Log</div>` +
|
|
539
|
+
`${entriesHtml}` +
|
|
544
540
|
`</body></html>`;
|
|
545
|
-
const htmlPath =
|
|
541
|
+
const htmlPath = join(tmpdir(), `next-browser-screenshots-${process.pid}.html`);
|
|
546
542
|
writeFileSync(htmlPath, html);
|
|
547
543
|
const target = `file://${htmlPath}`;
|
|
548
|
-
// Reuse existing
|
|
549
|
-
if (
|
|
544
|
+
// Reuse existing log window, or launch a new one.
|
|
545
|
+
if (screenshotPage && !screenshotPage.isClosed()) {
|
|
550
546
|
try {
|
|
551
|
-
await
|
|
552
|
-
await
|
|
553
|
-
return
|
|
547
|
+
await screenshotPage.goto(target);
|
|
548
|
+
await screenshotPage.bringToFront();
|
|
549
|
+
return;
|
|
554
550
|
}
|
|
555
551
|
catch {
|
|
556
552
|
// Window was closed by user — fall through to launch a new one.
|
|
557
|
-
await
|
|
553
|
+
await screenshotBrowser?.close().catch(() => { });
|
|
558
554
|
}
|
|
559
555
|
}
|
|
560
556
|
const { mkdtempSync } = await import("node:fs");
|
|
561
|
-
const
|
|
562
|
-
const { tmpdir } = await import("node:os");
|
|
563
|
-
const userDataDir = mkdtempSync(join(tmpdir(), "nb-preview-"));
|
|
557
|
+
const userDataDir = mkdtempSync(join(tmpdir(), "nb-screenshots-"));
|
|
564
558
|
const ctx = await chromium.launchPersistentContext(userDataDir, {
|
|
565
559
|
headless: false,
|
|
566
560
|
args: [`--app=${target}`, "--window-size=820,640"],
|
|
567
561
|
viewport: null,
|
|
568
562
|
});
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
await
|
|
572
|
-
await
|
|
573
|
-
return path;
|
|
563
|
+
screenshotBrowser = ctx;
|
|
564
|
+
screenshotPage = ctx.pages()[0] ?? (await ctx.waitForEvent("page"));
|
|
565
|
+
await screenshotPage.waitForLoadState();
|
|
566
|
+
await screenshotPage.bringToFront();
|
|
574
567
|
}
|
|
575
568
|
function escapeHtml(s) {
|
|
576
569
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
577
570
|
}
|
|
578
|
-
/** Screenshot saved to a temp file.
|
|
571
|
+
/** Screenshot saved to a temp file. Opens the Screenshot Log window in headed mode.
|
|
572
|
+
* Returns the file path. */
|
|
579
573
|
export async function screenshot(opts) {
|
|
580
574
|
if (!page)
|
|
581
575
|
throw new Error("browser not open");
|
|
@@ -584,6 +578,10 @@ export async function screenshot(opts) {
|
|
|
584
578
|
const { tmpdir } = await import("node:os");
|
|
585
579
|
const path = join(tmpdir(), `next-browser-${Date.now()}.png`);
|
|
586
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();
|
|
587
585
|
return path;
|
|
588
586
|
}
|
|
589
587
|
/** Remove Next.js devtools overlay from the page before screenshots. */
|
|
@@ -773,6 +771,312 @@ export async function mcp(tool, args) {
|
|
|
773
771
|
const origin = initialOrigin ?? new URL(page.url()).origin;
|
|
774
772
|
return nextMcp.call(origin, tool, args);
|
|
775
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
|
+
}
|
|
776
1080
|
/** Get network request log, or detail for a specific request index. */
|
|
777
1081
|
export function network(idx) {
|
|
778
1082
|
return idx == null ? net.format() : net.detail(idx);
|
|
@@ -874,6 +1178,44 @@ async function launch() {
|
|
|
874
1178
|
return orig.apply(console, [label, ...args]);
|
|
875
1179
|
};
|
|
876
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
|
+
});
|
|
877
1219
|
// Next.js devtools overlay is removed before each screenshot via hideDevOverlay().
|
|
878
1220
|
return ctx;
|
|
879
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];
|
|
@@ -132,15 +134,10 @@ if (cmd === "back") {
|
|
|
132
134
|
const res = await send("back");
|
|
133
135
|
exit(res, "back");
|
|
134
136
|
}
|
|
135
|
-
if (cmd === "preview") {
|
|
136
|
-
const clear = args.includes("--clear");
|
|
137
|
-
const caption = args.filter((a) => a !== "--clear").slice(1).join(" ") || undefined;
|
|
138
|
-
const res = await send("preview", { caption, clear });
|
|
139
|
-
exit(res, res.ok ? `preview → ${res.data}` : "");
|
|
140
|
-
}
|
|
141
137
|
if (cmd === "screenshot") {
|
|
142
138
|
const fullPage = args.includes("--full-page");
|
|
143
|
-
const
|
|
139
|
+
const caption = args.slice(1).filter((a) => a !== "--full-page").join(" ") || undefined;
|
|
140
|
+
const res = await send("screenshot", { fullPage, caption });
|
|
144
141
|
exit(res, res.ok ? String(res.data) : "");
|
|
145
142
|
}
|
|
146
143
|
if (cmd === "snapshot") {
|
|
@@ -227,6 +224,113 @@ if (cmd === "logs") {
|
|
|
227
224
|
console.log(content || "(log file is empty)");
|
|
228
225
|
process.exit(0);
|
|
229
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
|
+
}
|
|
230
334
|
if (cmd === "action") {
|
|
231
335
|
const res = await send("mcp", { tool: "get_server_action_by_id", args: { actionId: arg } });
|
|
232
336
|
exit(res, res.ok ? json(res.data) : "");
|
|
@@ -334,6 +438,8 @@ function printUsage() {
|
|
|
334
438
|
" back go back in history\n" +
|
|
335
439
|
" reload reload current page\n" +
|
|
336
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" +
|
|
337
443
|
" restart-server restart the Next.js dev server (clears fs cache)\n" +
|
|
338
444
|
"\n" +
|
|
339
445
|
" ppr lock enter PPR instant-navigation mode\n" +
|
|
@@ -343,8 +449,7 @@ function printUsage() {
|
|
|
343
449
|
" tree <id> inspect component (props, hooks, state, source)\n" +
|
|
344
450
|
"\n" +
|
|
345
451
|
" viewport [WxH] show or set viewport size (e.g. 1280x720)\n" +
|
|
346
|
-
"
|
|
347
|
-
" screenshot [--full-page] save screenshot to tmp file\n" +
|
|
452
|
+
" screenshot [caption] [--full-page] screenshot + Screenshot Log\n" +
|
|
348
453
|
" snapshot accessibility tree with interactive refs\n" +
|
|
349
454
|
" click <ref|sel> click an element (real pointer events)\n" +
|
|
350
455
|
" fill <ref|sel> <v> fill a text input\n" +
|
|
@@ -354,6 +459,7 @@ function printUsage() {
|
|
|
354
459
|
"\n" +
|
|
355
460
|
" errors show build/runtime errors\n" +
|
|
356
461
|
" logs show recent dev server log output\n" +
|
|
462
|
+
" browser-logs show browser console output (log/warn/error/info)\n" +
|
|
357
463
|
" network [idx] list network requests, or inspect one\n" +
|
|
358
464
|
"\n" +
|
|
359
465
|
" page show current page segments and router info\n" +
|
package/dist/daemon.js
CHANGED
|
@@ -61,16 +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 === "
|
|
65
|
-
const data = await browser.
|
|
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();
|
|
66
74
|
return { ok: true, data };
|
|
67
75
|
}
|
|
68
|
-
if (cmd.action === "
|
|
69
|
-
const data = await browser.
|
|
76
|
+
if (cmd.action === "restart") {
|
|
77
|
+
const data = await browser.restart();
|
|
70
78
|
return { ok: true, data };
|
|
71
79
|
}
|
|
72
80
|
if (cmd.action === "screenshot") {
|
|
73
|
-
const data = await browser.screenshot({ fullPage: cmd.fullPage });
|
|
81
|
+
const data = await browser.screenshot({ fullPage: cmd.fullPage, caption: cmd.caption });
|
|
74
82
|
return { ok: true, data };
|
|
75
83
|
}
|
|
76
84
|
if (cmd.action === "links") {
|