@vercel/next-browser 0.1.8 → 0.3.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 +154 -53
- package/dist/browser.js +423 -88
- package/dist/cli.js +119 -17
- package/dist/daemon.js +27 -7
- package/dist/paths.js +3 -1
- package/dist/suspense.js +34 -49
- 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
|
@@ -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 previewBrowser = null;
|
|
34
|
+
let previewPage = null;
|
|
35
|
+
let previewImages = [];
|
|
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 (
|
|
40
|
-
|
|
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 previewBrowser?.close().catch(() => { });
|
|
81
|
+
previewBrowser = null;
|
|
82
|
+
previewPage = null;
|
|
83
|
+
previewImages = [];
|
|
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
|
-
|
|
114
|
+
return Promise.resolve();
|
|
92
115
|
return new Promise((locked) => {
|
|
93
116
|
settled = instant(page, () => {
|
|
94
117
|
locked();
|
|
@@ -139,7 +162,7 @@ export async function unlock() {
|
|
|
139
162
|
// For goto case: the page auto-reloads. Wait for the new page to load
|
|
140
163
|
// and React/DevTools to reconnect before trying to snapshot boundaries.
|
|
141
164
|
await page.waitForLoadState("load").catch(() => { });
|
|
142
|
-
await
|
|
165
|
+
await waitForDevToolsReconnect(page);
|
|
143
166
|
// Wait for all boundaries to resolve after unlock.
|
|
144
167
|
await waitForSuspenseToSettle(page);
|
|
145
168
|
// Capture the fully-resolved state with rich suspendedBy data.
|
|
@@ -206,6 +229,48 @@ async function waitForSuspenseToSettle(p) {
|
|
|
206
229
|
await new Promise((r) => setTimeout(r, 500));
|
|
207
230
|
}
|
|
208
231
|
}
|
|
232
|
+
/**
|
|
233
|
+
* Wait for React DevTools to reconnect after a page reload.
|
|
234
|
+
*
|
|
235
|
+
* After the goto case unlocks, the page auto-reloads and DevTools loses its
|
|
236
|
+
* renderer connection. Poll until the DevTools hook reports at least one
|
|
237
|
+
* renderer, or bail after 5s. This replaces the old hardcoded 2s sleep.
|
|
238
|
+
*/
|
|
239
|
+
async function waitForDevToolsReconnect(p) {
|
|
240
|
+
const deadline = Date.now() + 5_000;
|
|
241
|
+
while (Date.now() < deadline) {
|
|
242
|
+
const connected = await p.evaluate(() => {
|
|
243
|
+
const hook = window.__REACT_DEVTOOLS_GLOBAL_HOOK__;
|
|
244
|
+
return hook?.rendererInterfaces?.size > 0;
|
|
245
|
+
}).catch(() => false);
|
|
246
|
+
if (connected)
|
|
247
|
+
return;
|
|
248
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
249
|
+
}
|
|
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
|
+
}
|
|
209
274
|
// ── Navigation ───────────────────────────────────────────────────────────────
|
|
210
275
|
/** Hard reload the current page. Returns the URL after reload. */
|
|
211
276
|
export async function reload() {
|
|
@@ -215,66 +280,114 @@ export async function reload() {
|
|
|
215
280
|
return page.url();
|
|
216
281
|
}
|
|
217
282
|
/**
|
|
218
|
-
*
|
|
219
|
-
*
|
|
220
|
-
*
|
|
221
|
-
*
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
* Lock PPR → goto → screenshot the shell → unlock → screenshot frames
|
|
225
|
-
* until the page settles. Just PNGs in a directory — the AI reads them.
|
|
283
|
+
* Profile a page load: reload (or navigate to a URL) and collect Core Web
|
|
284
|
+
* Vitals (LCP, CLS, TTFB) plus React hydration timing.
|
|
285
|
+
*
|
|
286
|
+
* CWVs come from PerformanceObserver and Navigation Timing API.
|
|
287
|
+
* Hydration timing comes from console.timeStamp entries emitted by React's
|
|
288
|
+
* profiling build (see the addInitScript interceptor in launch()).
|
|
226
289
|
*
|
|
227
|
-
*
|
|
228
|
-
* through hydration and data loading. Stops after 3s of no visual change.
|
|
290
|
+
* Returns structured data that the CLI formats into a readable report.
|
|
229
291
|
*/
|
|
230
|
-
export async function
|
|
292
|
+
export async function perf(url) {
|
|
231
293
|
if (!page)
|
|
232
294
|
throw new Error("browser not open");
|
|
233
295
|
const targetUrl = url || page.url();
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
296
|
+
// Install CWV observers before navigation so they capture everything.
|
|
297
|
+
await page.evaluate(() => {
|
|
298
|
+
window.__NEXT_BROWSER_REACT_TIMING__ = [];
|
|
299
|
+
const cwv = { lcp: null, cls: 0, clsEntries: [] };
|
|
300
|
+
window.__NEXT_BROWSER_CWV__ = cwv;
|
|
301
|
+
// Largest Contentful Paint
|
|
302
|
+
new PerformanceObserver((list) => {
|
|
303
|
+
const entries = list.getEntries();
|
|
304
|
+
if (entries.length > 0) {
|
|
305
|
+
const last = entries[entries.length - 1];
|
|
306
|
+
cwv.lcp = {
|
|
307
|
+
startTime: Math.round(last.startTime * 100) / 100,
|
|
308
|
+
size: last.size,
|
|
309
|
+
element: last.element?.tagName?.toLowerCase() ?? null,
|
|
310
|
+
url: last.url || null,
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
}).observe({ type: "largest-contentful-paint", buffered: true });
|
|
314
|
+
// Cumulative Layout Shift
|
|
315
|
+
new PerformanceObserver((list) => {
|
|
316
|
+
for (const entry of list.getEntries()) {
|
|
317
|
+
if (!entry.hadRecentInput) {
|
|
318
|
+
cwv.cls += entry.value;
|
|
319
|
+
cwv.clsEntries.push({
|
|
320
|
+
value: Math.round(entry.value * 10000) / 10000,
|
|
321
|
+
startTime: Math.round(entry.startTime * 100) / 100,
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}).observe({ type: "layout-shift", buffered: true });
|
|
326
|
+
});
|
|
327
|
+
// Navigate or reload to trigger a full page load.
|
|
328
|
+
if (url) {
|
|
329
|
+
await page.goto(targetUrl, { waitUntil: "load" });
|
|
243
330
|
}
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
await page.goto(targetUrl, { waitUntil: "load" }).catch(() => { });
|
|
247
|
-
await new Promise((r) => setTimeout(r, 300));
|
|
248
|
-
await snap();
|
|
249
|
-
// Unlock → page reloads, hydrates, loads data.
|
|
250
|
-
const unlockDone = unlock();
|
|
251
|
-
await new Promise((r) => setTimeout(r, 200));
|
|
252
|
-
let lastChangeTime = Date.now();
|
|
253
|
-
let prevHash = "";
|
|
254
|
-
const SETTLE_MS = 3_000;
|
|
255
|
-
const HARD_TIMEOUT = 30_000;
|
|
256
|
-
const start = Date.now();
|
|
257
|
-
while (true) {
|
|
258
|
-
const buf = await snap();
|
|
259
|
-
let hash = "";
|
|
260
|
-
if (buf) {
|
|
261
|
-
let h = 0;
|
|
262
|
-
for (let i = 0; i < buf.length; i += 200)
|
|
263
|
-
h = ((h << 5) - h + buf[i]) | 0;
|
|
264
|
-
hash = String(h);
|
|
265
|
-
}
|
|
266
|
-
if (hash !== prevHash) {
|
|
267
|
-
lastChangeTime = Date.now();
|
|
268
|
-
prevHash = hash;
|
|
269
|
-
}
|
|
270
|
-
if (Date.now() - start > HARD_TIMEOUT)
|
|
271
|
-
break;
|
|
272
|
-
if (lastChangeTime > 0 && Date.now() - lastChangeTime > SETTLE_MS)
|
|
273
|
-
break;
|
|
274
|
-
await new Promise((r) => setTimeout(r, 150));
|
|
331
|
+
else {
|
|
332
|
+
await page.reload({ waitUntil: "load" });
|
|
275
333
|
}
|
|
276
|
-
|
|
277
|
-
|
|
334
|
+
// Wait for passive effects, late paints, and layout shifts to flush.
|
|
335
|
+
await new Promise((r) => setTimeout(r, 3000));
|
|
336
|
+
// Collect all metrics from the page.
|
|
337
|
+
const metrics = await page.evaluate(() => {
|
|
338
|
+
const cwv = window.__NEXT_BROWSER_CWV__ ?? {};
|
|
339
|
+
const timing = window.__NEXT_BROWSER_REACT_TIMING__ ?? [];
|
|
340
|
+
// TTFB from Navigation Timing API.
|
|
341
|
+
const nav = performance.getEntriesByType("navigation")[0];
|
|
342
|
+
const ttfb = nav
|
|
343
|
+
? Math.round((nav.responseStart - nav.requestStart) * 100) / 100
|
|
344
|
+
: null;
|
|
345
|
+
return { cwv, timing, ttfb };
|
|
346
|
+
});
|
|
347
|
+
// Process React hydration timing.
|
|
348
|
+
const phases = metrics.timing.filter((e) => e.trackGroup === "Scheduler ⚛" && e.endTime > e.startTime);
|
|
349
|
+
const components = metrics.timing.filter((e) => e.track === "Components ⚛" && e.endTime > e.startTime);
|
|
350
|
+
const hydrationPhases = phases.filter((e) => e.label === "Hydrated");
|
|
351
|
+
const hydratedComponents = components.filter((e) => e.color?.startsWith("tertiary"));
|
|
352
|
+
let hydrationStart = Infinity;
|
|
353
|
+
let hydrationEnd = 0;
|
|
354
|
+
for (const p of hydrationPhases) {
|
|
355
|
+
if (p.startTime < hydrationStart)
|
|
356
|
+
hydrationStart = p.startTime;
|
|
357
|
+
if (p.endTime > hydrationEnd)
|
|
358
|
+
hydrationEnd = p.endTime;
|
|
359
|
+
}
|
|
360
|
+
const round = (n) => Math.round(n * 100) / 100;
|
|
361
|
+
return {
|
|
362
|
+
url: targetUrl,
|
|
363
|
+
ttfb: metrics.ttfb,
|
|
364
|
+
lcp: metrics.cwv.lcp,
|
|
365
|
+
cls: {
|
|
366
|
+
score: round(metrics.cwv.cls),
|
|
367
|
+
entries: metrics.cwv.clsEntries,
|
|
368
|
+
},
|
|
369
|
+
hydration: hydrationPhases.length > 0
|
|
370
|
+
? {
|
|
371
|
+
startTime: round(hydrationStart),
|
|
372
|
+
endTime: round(hydrationEnd),
|
|
373
|
+
duration: round(hydrationEnd - hydrationStart),
|
|
374
|
+
}
|
|
375
|
+
: null,
|
|
376
|
+
phases: phases.map((p) => ({
|
|
377
|
+
label: p.label,
|
|
378
|
+
startTime: round(p.startTime),
|
|
379
|
+
endTime: round(p.endTime),
|
|
380
|
+
duration: round(p.endTime - p.startTime),
|
|
381
|
+
})),
|
|
382
|
+
hydratedComponents: hydratedComponents
|
|
383
|
+
.map((c) => ({
|
|
384
|
+
name: c.label,
|
|
385
|
+
startTime: round(c.startTime),
|
|
386
|
+
endTime: round(c.endTime),
|
|
387
|
+
duration: round(c.endTime - c.startTime),
|
|
388
|
+
}))
|
|
389
|
+
.sort((a, b) => b.duration - a.duration),
|
|
390
|
+
};
|
|
278
391
|
}
|
|
279
392
|
/**
|
|
280
393
|
* Restart the Next.js dev server via its internal endpoint, then reload.
|
|
@@ -345,30 +458,9 @@ export async function push(path) {
|
|
|
345
458
|
/** Full-page navigation (new document load). Resolves relative URLs against the current page. */
|
|
346
459
|
export async function goto(url) {
|
|
347
460
|
if (!page)
|
|
348
|
-
|
|
349
|
-
await page.unrouteAll({ behavior: "wait" });
|
|
350
|
-
const target = new URL(url, page.url()).href;
|
|
351
|
-
initialOrigin = new URL(target).origin;
|
|
352
|
-
await page.goto(target, { waitUntil: "domcontentloaded" });
|
|
353
|
-
return target;
|
|
354
|
-
}
|
|
355
|
-
/**
|
|
356
|
-
* Navigate like goto but block external script resources.
|
|
357
|
-
* The HTML loads and inline <script> blocks still execute, but external JS
|
|
358
|
-
* bundles (React, hydration, etc.) are aborted. Shows the SSR shell.
|
|
359
|
-
*/
|
|
360
|
-
export async function ssrGoto(url) {
|
|
361
|
-
if (!page)
|
|
362
|
-
throw new Error("browser not open");
|
|
461
|
+
await open(undefined);
|
|
363
462
|
const target = new URL(url, page.url()).href;
|
|
364
463
|
initialOrigin = new URL(target).origin;
|
|
365
|
-
// Clear any stale route handlers from previous ssr-goto calls.
|
|
366
|
-
await page.unrouteAll({ behavior: "wait" });
|
|
367
|
-
await page.route("**/*", (route) => {
|
|
368
|
-
if (route.request().resourceType() === "script")
|
|
369
|
-
return route.abort();
|
|
370
|
-
return route.continue();
|
|
371
|
-
});
|
|
372
464
|
await page.goto(target, { waitUntil: "domcontentloaded" });
|
|
373
465
|
return target;
|
|
374
466
|
}
|
|
@@ -425,15 +517,73 @@ async function formatSource([file, line, col]) {
|
|
|
425
517
|
return `source: ${file}:${line}:${col}`;
|
|
426
518
|
}
|
|
427
519
|
// ── Utilities ────────────────────────────────────────────────────────────────
|
|
428
|
-
/**
|
|
429
|
-
|
|
520
|
+
/** Take a screenshot and display it in a separate headed Chromium window.
|
|
521
|
+
* Images accumulate across calls — use `clear` to reset. */
|
|
522
|
+
export async function preview(caption, clear) {
|
|
523
|
+
if (!page)
|
|
524
|
+
throw new Error("browser not open");
|
|
525
|
+
if (clear)
|
|
526
|
+
previewImages = [];
|
|
527
|
+
const path = await screenshot();
|
|
528
|
+
const imgData = readFileSync(path).toString("base64");
|
|
529
|
+
const timestamp = new Date().toLocaleTimeString();
|
|
530
|
+
previewImages.unshift({ caption, imgData, timestamp });
|
|
531
|
+
const imagesHtml = previewImages
|
|
532
|
+
.map((img) => `<div style="padding:4px 12px;display:flex;align-items:baseline;gap:8px">` +
|
|
533
|
+
(img.caption
|
|
534
|
+
? `<span style="font-size:14px">${escapeHtml(img.caption)}</span>`
|
|
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%">`)
|
|
539
|
+
.join(`<hr style="border:none;border-top:1px solid #333;margin:12px 0">`);
|
|
540
|
+
const html = `<html><head><title>next-browser preview</title></head>` +
|
|
541
|
+
`<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">next-browser preview</div>` +
|
|
543
|
+
`${imagesHtml}` +
|
|
544
|
+
`</body></html>`;
|
|
545
|
+
const htmlPath = path.replace(/\.png$/, ".html");
|
|
546
|
+
writeFileSync(htmlPath, html);
|
|
547
|
+
const target = `file://${htmlPath}`;
|
|
548
|
+
// Reuse existing preview window, or launch a new one.
|
|
549
|
+
if (previewPage && !previewPage.isClosed()) {
|
|
550
|
+
try {
|
|
551
|
+
await previewPage.goto(target);
|
|
552
|
+
await previewPage.bringToFront();
|
|
553
|
+
return path;
|
|
554
|
+
}
|
|
555
|
+
catch {
|
|
556
|
+
// Window was closed by user — fall through to launch a new one.
|
|
557
|
+
await previewBrowser?.close().catch(() => { });
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
const { mkdtempSync } = await import("node:fs");
|
|
561
|
+
const { join } = await import("node:path");
|
|
562
|
+
const { tmpdir } = await import("node:os");
|
|
563
|
+
const userDataDir = mkdtempSync(join(tmpdir(), "nb-preview-"));
|
|
564
|
+
const ctx = await chromium.launchPersistentContext(userDataDir, {
|
|
565
|
+
headless: false,
|
|
566
|
+
args: [`--app=${target}`, "--window-size=820,640"],
|
|
567
|
+
viewport: null,
|
|
568
|
+
});
|
|
569
|
+
previewBrowser = ctx;
|
|
570
|
+
previewPage = ctx.pages()[0] ?? (await ctx.waitForEvent("page"));
|
|
571
|
+
await previewPage.waitForLoadState();
|
|
572
|
+
await previewPage.bringToFront();
|
|
573
|
+
return path;
|
|
574
|
+
}
|
|
575
|
+
function escapeHtml(s) {
|
|
576
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
577
|
+
}
|
|
578
|
+
/** Screenshot saved to a temp file. Returns the file path. */
|
|
579
|
+
export async function screenshot(opts) {
|
|
430
580
|
if (!page)
|
|
431
581
|
throw new Error("browser not open");
|
|
432
582
|
await hideDevOverlay();
|
|
433
583
|
const { join } = await import("node:path");
|
|
434
584
|
const { tmpdir } = await import("node:os");
|
|
435
585
|
const path = join(tmpdir(), `next-browser-${Date.now()}.png`);
|
|
436
|
-
await page.screenshot({ path });
|
|
586
|
+
await page.screenshot({ path, fullPage: opts?.fullPage });
|
|
437
587
|
return path;
|
|
438
588
|
}
|
|
439
589
|
/** Remove Next.js devtools overlay from the page before screenshots. */
|
|
@@ -444,10 +594,170 @@ async function hideDevOverlay() {
|
|
|
444
594
|
document.querySelectorAll("[data-nextjs-dev-overlay]").forEach((el) => el.remove());
|
|
445
595
|
}).catch(() => { });
|
|
446
596
|
}
|
|
447
|
-
|
|
448
|
-
|
|
597
|
+
// ── Ref map for interactive elements ──────────────────────────────────
|
|
598
|
+
const INTERACTIVE_ROLES = new Set([
|
|
599
|
+
"button", "link", "textbox", "checkbox", "radio", "combobox", "listbox",
|
|
600
|
+
"menuitem", "menuitemcheckbox", "menuitemradio", "option", "searchbox",
|
|
601
|
+
"slider", "spinbutton", "switch", "tab", "treeitem",
|
|
602
|
+
]);
|
|
603
|
+
let refMap = [];
|
|
604
|
+
/**
|
|
605
|
+
* Snapshot the accessibility tree via CDP and return a text representation
|
|
606
|
+
* with [ref=e0], [ref=e1] … markers on interactive elements.
|
|
607
|
+
* Stores a ref map so that `click("e3")` can resolve back to role+name.
|
|
608
|
+
*/
|
|
609
|
+
export async function snapshot() {
|
|
449
610
|
if (!page)
|
|
450
611
|
throw new Error("browser not open");
|
|
612
|
+
const cdp = await page.context().newCDPSession(page);
|
|
613
|
+
try {
|
|
614
|
+
const { nodes } = (await cdp.send("Accessibility.getFullAXTree"));
|
|
615
|
+
// Index nodes by ID
|
|
616
|
+
const byId = new Map();
|
|
617
|
+
for (const n of nodes)
|
|
618
|
+
byId.set(n.nodeId, n);
|
|
619
|
+
refMap = [];
|
|
620
|
+
const roleNameCount = new Map();
|
|
621
|
+
const lines = [];
|
|
622
|
+
function walk(node, depth) {
|
|
623
|
+
const role = node.role?.value || "unknown";
|
|
624
|
+
const name = (node.name?.value || "").trim().slice(0, 80);
|
|
625
|
+
const isInteractive = INTERACTIVE_ROLES.has(role);
|
|
626
|
+
// Read properties into a map
|
|
627
|
+
const propMap = new Map();
|
|
628
|
+
for (const p of node.properties || [])
|
|
629
|
+
propMap.set(p.name, p.value.value);
|
|
630
|
+
const ignored = propMap.get("hidden") === true;
|
|
631
|
+
if (ignored)
|
|
632
|
+
return;
|
|
633
|
+
// Always skip leaf text nodes — parent already carries the text
|
|
634
|
+
if (role === "InlineTextBox" || role === "StaticText" || role === "LineBreak")
|
|
635
|
+
return;
|
|
636
|
+
// Skip generic/none wrappers with no name — just recurse children
|
|
637
|
+
const SKIP_ROLES = new Set(["none", "generic", "GenericContainer"]);
|
|
638
|
+
if (SKIP_ROLES.has(role) && !name) {
|
|
639
|
+
for (const id of node.childIds || []) {
|
|
640
|
+
const child = byId.get(id);
|
|
641
|
+
if (child)
|
|
642
|
+
walk(child, depth);
|
|
643
|
+
}
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
// Skip root WebArea — just recurse
|
|
647
|
+
if (role === "WebArea" || role === "RootWebArea") {
|
|
648
|
+
for (const id of node.childIds || []) {
|
|
649
|
+
const child = byId.get(id);
|
|
650
|
+
if (child)
|
|
651
|
+
walk(child, depth);
|
|
652
|
+
}
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
const indent = " ".repeat(depth);
|
|
656
|
+
let line = `${indent}- ${role}`;
|
|
657
|
+
if (name)
|
|
658
|
+
line += ` "${name}"`;
|
|
659
|
+
const disabled = propMap.get("disabled") === true;
|
|
660
|
+
if (isInteractive && !disabled) {
|
|
661
|
+
const key = `${role}::${name}`;
|
|
662
|
+
const count = roleNameCount.get(key) || 0;
|
|
663
|
+
roleNameCount.set(key, count + 1);
|
|
664
|
+
const ref = { role, name };
|
|
665
|
+
if (count > 0)
|
|
666
|
+
ref.nth = count;
|
|
667
|
+
const idx = refMap.length;
|
|
668
|
+
refMap.push(ref);
|
|
669
|
+
line += ` [ref=e${idx}]`;
|
|
670
|
+
}
|
|
671
|
+
// Append state properties
|
|
672
|
+
const tags = [];
|
|
673
|
+
if (propMap.get("checked") === "true" || propMap.get("checked") === true)
|
|
674
|
+
tags.push("checked");
|
|
675
|
+
if (propMap.get("checked") === "mixed")
|
|
676
|
+
tags.push("mixed");
|
|
677
|
+
if (disabled)
|
|
678
|
+
tags.push("disabled");
|
|
679
|
+
if (propMap.get("expanded") === true)
|
|
680
|
+
tags.push("expanded");
|
|
681
|
+
if (propMap.get("expanded") === false)
|
|
682
|
+
tags.push("collapsed");
|
|
683
|
+
if (propMap.get("selected") === true)
|
|
684
|
+
tags.push("selected");
|
|
685
|
+
if (tags.length)
|
|
686
|
+
line += ` (${tags.join(", ")})`;
|
|
687
|
+
lines.push(line);
|
|
688
|
+
for (const id of node.childIds || []) {
|
|
689
|
+
const child = byId.get(id);
|
|
690
|
+
if (child)
|
|
691
|
+
walk(child, depth + 1);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
// Start from the root (first node)
|
|
695
|
+
if (nodes.length)
|
|
696
|
+
walk(nodes[0], 0);
|
|
697
|
+
return lines.join("\n");
|
|
698
|
+
}
|
|
699
|
+
finally {
|
|
700
|
+
await cdp.detach();
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
/** Resolve a ref (e.g. "e3") or selector string to a Playwright Locator. */
|
|
704
|
+
function resolveLocator(selectorOrRef) {
|
|
705
|
+
if (!page)
|
|
706
|
+
throw new Error("browser not open");
|
|
707
|
+
const refMatch = selectorOrRef.match(/^e(\d+)$/);
|
|
708
|
+
if (refMatch) {
|
|
709
|
+
const idx = Number(refMatch[1]);
|
|
710
|
+
const ref = refMap[idx];
|
|
711
|
+
if (!ref)
|
|
712
|
+
throw new Error(`ref e${idx} not found — run snapshot first`);
|
|
713
|
+
const locator = page.getByRole(ref.role, {
|
|
714
|
+
name: ref.name,
|
|
715
|
+
exact: true,
|
|
716
|
+
});
|
|
717
|
+
return ref.nth != null ? locator.nth(ref.nth) : locator;
|
|
718
|
+
}
|
|
719
|
+
const hasPrefix = /^(css=|text=|role=|#|\[|\.|\w+\s*>)/.test(selectorOrRef);
|
|
720
|
+
return page.locator(hasPrefix ? selectorOrRef : `text=${selectorOrRef}`);
|
|
721
|
+
}
|
|
722
|
+
/**
|
|
723
|
+
* Click an element using real pointer events.
|
|
724
|
+
* Accepts: "e3" (ref from snapshot), plain text, or Playwright selectors.
|
|
725
|
+
*/
|
|
726
|
+
export async function click(selectorOrRef) {
|
|
727
|
+
if (!page)
|
|
728
|
+
throw new Error("browser not open");
|
|
729
|
+
await resolveLocator(selectorOrRef).click();
|
|
730
|
+
}
|
|
731
|
+
/**
|
|
732
|
+
* Fill a text input/textarea. Clears existing value, then types the new one.
|
|
733
|
+
* Accepts: "e3" (ref from snapshot), or a selector.
|
|
734
|
+
*/
|
|
735
|
+
export async function fill(selectorOrRef, value) {
|
|
736
|
+
if (!page)
|
|
737
|
+
throw new Error("browser not open");
|
|
738
|
+
await resolveLocator(selectorOrRef).fill(value);
|
|
739
|
+
}
|
|
740
|
+
/**
|
|
741
|
+
* Evaluate arbitrary JavaScript in the page context.
|
|
742
|
+
* If ref is provided (e.g. "e3"), the script receives the DOM element as its
|
|
743
|
+
* first argument: `next-browser eval e3 'el => el.textContent'`
|
|
744
|
+
*/
|
|
745
|
+
export async function evaluate(script, ref) {
|
|
746
|
+
if (!page)
|
|
747
|
+
throw new Error("browser not open");
|
|
748
|
+
if (ref) {
|
|
749
|
+
const locator = resolveLocator(ref);
|
|
750
|
+
const handle = await locator.elementHandle();
|
|
751
|
+
if (!handle)
|
|
752
|
+
throw new Error(`ref ${ref} not found in DOM`);
|
|
753
|
+
// The script should be an arrow/function that receives the element.
|
|
754
|
+
// We wrap it so page.evaluate can pass the element handle as an arg.
|
|
755
|
+
return page.evaluate(([fn, el]) => {
|
|
756
|
+
// eslint-disable-next-line no-eval
|
|
757
|
+
const f = (0, eval)(fn);
|
|
758
|
+
return f(el);
|
|
759
|
+
}, [script, handle]);
|
|
760
|
+
}
|
|
451
761
|
return page.evaluate(script);
|
|
452
762
|
}
|
|
453
763
|
/**
|
|
@@ -534,11 +844,36 @@ async function launch() {
|
|
|
534
844
|
profileDirPath = dir;
|
|
535
845
|
const ctx = await chromium.launchPersistentContext(dir, {
|
|
536
846
|
headless,
|
|
537
|
-
viewport:
|
|
847
|
+
viewport: null,
|
|
538
848
|
// --no-sandbox is required when Chrome runs as root (common in containers/cloud sandboxes)
|
|
539
|
-
args:
|
|
849
|
+
args: [
|
|
850
|
+
...(headless ? ["--no-sandbox"] : []),
|
|
851
|
+
"--window-size=1440,900",
|
|
852
|
+
],
|
|
540
853
|
});
|
|
541
854
|
await ctx.addInitScript(installHook);
|
|
855
|
+
// Intercept console.timeStamp to capture React's Performance Track entries.
|
|
856
|
+
// React's profiling build calls console.timeStamp(label, startTime, endTime,
|
|
857
|
+
// track, trackGroup, color) for render phases and per-component timing.
|
|
858
|
+
// startTime/endTime are performance.now() values from the reconciler.
|
|
859
|
+
await ctx.addInitScript(() => {
|
|
860
|
+
const entries = [];
|
|
861
|
+
window.__NEXT_BROWSER_REACT_TIMING__ = entries;
|
|
862
|
+
const orig = console.timeStamp;
|
|
863
|
+
console.timeStamp = function (label, ...args) {
|
|
864
|
+
if (typeof label === "string" && args.length >= 2 && typeof args[0] === "number") {
|
|
865
|
+
entries.push({
|
|
866
|
+
label,
|
|
867
|
+
startTime: args[0],
|
|
868
|
+
endTime: args[1],
|
|
869
|
+
track: args[2] ?? "",
|
|
870
|
+
trackGroup: args[3] ?? "",
|
|
871
|
+
color: args[4] ?? "",
|
|
872
|
+
});
|
|
873
|
+
}
|
|
874
|
+
return orig.apply(console, [label, ...args]);
|
|
875
|
+
};
|
|
876
|
+
});
|
|
542
877
|
// Next.js devtools overlay is removed before each screenshot via hideDevOverlay().
|
|
543
878
|
return ctx;
|
|
544
879
|
}
|