@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/README.md +148 -48
- package/dist/browser.js +362 -71
- package/dist/cli.js +108 -12
- package/dist/daemon.js +19 -3
- package/dist/paths.js +3 -1
- package/dist/suspense.js +502 -73
- package/package.json +12 -9
- package/dist/cloud-client.js +0 -72
- package/dist/cloud-daemon.js +0 -87
- package/dist/cloud-paths.js +0 -7
- package/dist/cloud.js +0 -230
package/dist/browser.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*
|
|
11
11
|
* Module-level state: one browser context, one page, one PPR lock.
|
|
12
12
|
*/
|
|
13
|
-
import { readFileSync, mkdirSync
|
|
13
|
+
import { readFileSync, mkdirSync } from "node:fs";
|
|
14
14
|
import { join, resolve } from "node:path";
|
|
15
15
|
import { tmpdir } from "node:os";
|
|
16
16
|
import { chromium } from "playwright";
|
|
@@ -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
|
-
|
|
130
|
-
//
|
|
131
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
212
|
-
*
|
|
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
|
-
*
|
|
221
|
-
*
|
|
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
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
237
|
-
|
|
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
|
-
|
|
269
|
-
|
|
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
|
-
/**
|
|
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
|
|
503
|
+
await page.screenshot({ path });
|
|
407
504
|
return path;
|
|
408
505
|
}
|
|
409
|
-
/**
|
|
410
|
-
|
|
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:
|
|
764
|
+
viewport: null,
|
|
500
765
|
// --no-sandbox is required when Chrome runs as root (common in containers/cloud sandboxes)
|
|
501
|
-
args:
|
|
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
|
-
|
|
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 === "
|
|
53
|
-
const res = await send("
|
|
54
|
+
if (cmd === "perf") {
|
|
55
|
+
const res = await send("perf", arg ? { url: arg } : {});
|
|
54
56
|
if (!res.ok)
|
|
55
57
|
exit(res, "");
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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 === "
|
|
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
|
|
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:
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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" +
|