playwright-e2e-mcp 0.4.2 → 0.5.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 +175 -33
- package/dist/http.d.ts +1 -1
- package/dist/http.js +1 -1
- package/dist/server.d.ts +2 -2
- package/dist/server.js +31 -4
- package/dist/server.js.map +1 -1
- package/dist/tools/analyze-history.d.ts +12 -1
- package/dist/tools/analyze-history.js +89 -5
- package/dist/tools/analyze-history.js.map +1 -1
- package/dist/tools/audit-accessibility.d.ts +85 -0
- package/dist/tools/audit-accessibility.js +4 -1
- package/dist/tools/audit-accessibility.js.map +1 -1
- package/dist/tools/audit-performance.d.ts +333 -0
- package/dist/tools/audit-performance.js +377 -0
- package/dist/tools/audit-performance.js.map +1 -0
- package/dist/tools/compare-visual-state.d.ts +84 -0
- package/dist/tools/generate-e2e-test.d.ts +90 -0
- package/dist/tools/generate-e2e-test.js +223 -34
- package/dist/tools/generate-e2e-test.js.map +1 -1
- package/dist/tools/get-failure.js +2 -1
- package/dist/tools/get-failure.js.map +1 -1
- package/dist/tools/inspect-page.d.ts +94 -0
- package/dist/tools/inspect-page.js +42 -5
- package/dist/tools/inspect-page.js.map +1 -1
- package/dist/tools/load-report.d.ts +48 -0
- package/dist/tools/load-report.js +167 -0
- package/dist/tools/load-report.js.map +1 -0
- package/dist/tools/probe-script.d.ts +7 -0
- package/dist/tools/probe-script.js +256 -7
- package/dist/tools/probe-script.js.map +1 -1
- package/dist/tools/shared.d.ts +137 -2
- package/dist/tools/shared.js +151 -7
- package/dist/tools/shared.js.map +1 -1
- package/dist/tools/suggest-fix.d.ts +35 -1
- package/dist/tools/suggest-fix.js +197 -17
- package/dist/tools/suggest-fix.js.map +1 -1
- package/dist/tools/validate-selector.d.ts +84 -0
- package/dist/tools/validate-selector.js +7 -1
- package/dist/tools/validate-selector.js.map +1 -1
- package/dist/types/index.d.ts +28 -0
- package/dist/utils/report-parser.d.ts +28 -3
- package/dist/utils/report-parser.js +77 -11
- package/dist/utils/report-parser.js.map +1 -1
- package/dist/utils/run-history.d.ts +66 -3
- package/dist/utils/run-history.js +149 -3
- package/dist/utils/run-history.js.map +1 -1
- package/package.json +2 -2
- package/skill/playwright-e2e-mcp/SKILL.md +71 -15
- package/skill/playwright-e2e-mcp/references/setup.md +1 -1
- package/skill/playwright-e2e-mcp/references/tools.md +67 -6
|
@@ -15,6 +15,13 @@
|
|
|
15
15
|
* on the live page or on a DOM snapshot taken from a trace
|
|
16
16
|
* - a11y run axe-core on the page (or a scoped part) and describe the
|
|
17
17
|
* offending elements, each with a verified locator
|
|
18
|
+
* - perf lab Web Vitals (LCP, CLS, TBT, INP during actions, FCP, TTFB)
|
|
19
|
+
* and the network cost per resource type, optionally under
|
|
20
|
+
* CPU and network throttling; the LCP element and the elements
|
|
21
|
+
* that shifted come back with verified locators
|
|
22
|
+
*
|
|
23
|
+
* Any mode that navigates applies the caller's network mocks (cfg.routes)
|
|
24
|
+
* first and reports how many requests each one handled.
|
|
18
25
|
*
|
|
19
26
|
* Every element the script reports carries Playwright locators that were
|
|
20
27
|
* proven, in this same page, to resolve to exactly that one element.
|
|
@@ -30,7 +37,7 @@ function send(payload) {
|
|
|
30
37
|
function classifyError(message) {
|
|
31
38
|
const m = String(message);
|
|
32
39
|
if (/Executable doesn't exist|Please run the following command to install/i.test(m)) {
|
|
33
|
-
return { kind: 'NO_PLAYWRIGHT', hint: 'Playwright browsers are not installed for this project. Run: npx playwright install chromium' };
|
|
40
|
+
return { kind: 'NO_PLAYWRIGHT', hint: 'Playwright browsers are not installed for this project. Run: npx playwright install chromium (or firefox / webkit for those engines)' };
|
|
34
41
|
}
|
|
35
42
|
if (/ECONNREFUSED|ERR_CONNECTION_REFUSED|ERR_NAME_NOT_RESOLVED|ERR_EMPTY_RESPONSE|getaddrinfo|net::ERR_/i.test(m)) {
|
|
36
43
|
return { kind: 'SERVER_NOT_RUNNING', hint: 'The URL is not reachable. Start your dev server (e.g. npm run dev / npm start) and retry.' };
|
|
@@ -429,6 +436,152 @@ async function runActions(page, actions, timeout) {
|
|
|
429
436
|
return done;
|
|
430
437
|
}
|
|
431
438
|
|
|
439
|
+
/* ---------------- performance (mode: perf) ---------------- */
|
|
440
|
+
|
|
441
|
+
// Runs in every new document before its own scripts, so no early paint,
|
|
442
|
+
// shift or long task is missed.
|
|
443
|
+
function perfInit() {
|
|
444
|
+
const P = { lcp: null, shifts: [], longTasks: [], events: [] };
|
|
445
|
+
Object.defineProperty(window, '__pwmcpPerf', { value: P, configurable: true });
|
|
446
|
+
const watch = (type, fn, extra) => {
|
|
447
|
+
try {
|
|
448
|
+
new PerformanceObserver((list) => list.getEntries().forEach(fn)).observe(Object.assign({ type: type, buffered: true }, extra || {}));
|
|
449
|
+
} catch (err) { /* entry type not supported */ }
|
|
450
|
+
};
|
|
451
|
+
watch('largest-contentful-paint', (e) => { P.lcp = e; });
|
|
452
|
+
watch('layout-shift', (e) => { if (!e.hadRecentInput) P.shifts.push(e); });
|
|
453
|
+
watch('longtask', (e) => { P.longTasks.push({ start: e.startTime, duration: e.duration }); });
|
|
454
|
+
watch('event', (e) => { if (e.interactionId) P.events.push({ id: e.interactionId, duration: e.duration, name: e.name, target: e.target }); }, { durationThreshold: 16 });
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function collectPerf() {
|
|
458
|
+
const h = window.__pwmcp;
|
|
459
|
+
const P = window.__pwmcpPerf || { lcp: null, shifts: [], longTasks: [], events: [] };
|
|
460
|
+
h.els = [];
|
|
461
|
+
const keep = (el) => {
|
|
462
|
+
if (!el || el.nodeType !== 1 || !el.isConnected) return -1;
|
|
463
|
+
const at = h.els.indexOf(el);
|
|
464
|
+
if (at >= 0) return at;
|
|
465
|
+
h.els.push(el);
|
|
466
|
+
return h.els.length - 1;
|
|
467
|
+
};
|
|
468
|
+
const nav = performance.getEntriesByType('navigation')[0];
|
|
469
|
+
const fcpEntry = performance.getEntriesByName('first-contentful-paint')[0];
|
|
470
|
+
const fcp = fcpEntry ? fcpEntry.startTime : undefined;
|
|
471
|
+
|
|
472
|
+
// CLS: the worst session window (shifts < 1s apart, window <= 5s).
|
|
473
|
+
let cls = 0, win = 0, winStart = 0, prev = 0, cur = [], worst = [];
|
|
474
|
+
for (const s of P.shifts) {
|
|
475
|
+
if (cur.length && (s.startTime - prev > 1000 || s.startTime - winStart > 5000)) { win = 0; cur = []; }
|
|
476
|
+
if (!cur.length) winStart = s.startTime;
|
|
477
|
+
win += s.value; cur.push(s); prev = s.startTime;
|
|
478
|
+
if (win > cls) { cls = win; worst = cur.slice(); }
|
|
479
|
+
}
|
|
480
|
+
const shiftElements = [];
|
|
481
|
+
for (const s of worst) {
|
|
482
|
+
for (const src of s.sources || []) {
|
|
483
|
+
const i = keep(src.node);
|
|
484
|
+
if (i >= 0 && !shiftElements.includes(i) && shiftElements.length < 5) shiftElements.push(i);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// TBT: blocking time of long tasks after first paint.
|
|
489
|
+
let tbt = 0, longest = 0;
|
|
490
|
+
for (const t of P.longTasks) {
|
|
491
|
+
longest = Math.max(longest, t.duration);
|
|
492
|
+
if (fcp === undefined || t.start >= fcp) tbt += Math.max(0, t.duration - 50);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// INP (lab): the slowest interaction, events grouped by interactionId.
|
|
496
|
+
const byInteraction = new Map();
|
|
497
|
+
for (const e of P.events) {
|
|
498
|
+
const prior = byInteraction.get(e.id);
|
|
499
|
+
if (!prior || e.duration > prior.duration) byInteraction.set(e.id, e);
|
|
500
|
+
}
|
|
501
|
+
let inp;
|
|
502
|
+
for (const e of byInteraction.values()) if (!inp || e.duration > inp.duration) inp = e;
|
|
503
|
+
let inpTarget;
|
|
504
|
+
if (inp) {
|
|
505
|
+
const i = keep(inp.target);
|
|
506
|
+
inpTarget = inp.name + (i >= 0 ? ' on ' + h.describe(inp.target).tag : '');
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
const lcpEl = P.lcp ? P.lcp.element : null;
|
|
510
|
+
const lcpElement = keep(lcpEl);
|
|
511
|
+
let lcpMs;
|
|
512
|
+
if (P.lcp) lcpMs = P.lcp.renderTime || P.lcp.loadTime || P.lcp.startTime;
|
|
513
|
+
|
|
514
|
+
const timings = {};
|
|
515
|
+
for (const r of performance.getEntriesByType('resource')) {
|
|
516
|
+
timings[r.name] = { duration: r.duration, blocking: r.renderBlockingStatus === 'blocking' };
|
|
517
|
+
}
|
|
518
|
+
let heap;
|
|
519
|
+
try { heap = performance.memory ? performance.memory.usedJSHeapSize : undefined; } catch (err) { /* not exposed */ }
|
|
520
|
+
return {
|
|
521
|
+
title: document.title,
|
|
522
|
+
finalUrl: location.href,
|
|
523
|
+
viewport: { width: window.innerWidth, height: window.innerHeight },
|
|
524
|
+
elements: h.els.map((el) => h.describe(el)),
|
|
525
|
+
perf: {
|
|
526
|
+
ttfbMs: nav ? nav.responseStart : undefined,
|
|
527
|
+
fcpMs: fcp,
|
|
528
|
+
lcpMs: lcpMs,
|
|
529
|
+
lcpElement: lcpElement,
|
|
530
|
+
lcpTag: lcpEl ? lcpEl.tagName.toLowerCase() : undefined,
|
|
531
|
+
lcpUrl: P.lcp && P.lcp.url ? String(P.lcp.url).slice(0, 300) : undefined,
|
|
532
|
+
cls: Math.round(cls * 1000) / 1000,
|
|
533
|
+
shiftElements: shiftElements,
|
|
534
|
+
tbtMs: Math.round(tbt),
|
|
535
|
+
longTasks: P.longTasks.length,
|
|
536
|
+
longestTaskMs: Math.round(longest),
|
|
537
|
+
inpMs: inp ? Math.round(inp.duration) : undefined,
|
|
538
|
+
inpTarget: inpTarget,
|
|
539
|
+
domContentLoadedMs: nav && nav.domContentLoadedEventEnd ? nav.domContentLoadedEventEnd : undefined,
|
|
540
|
+
loadMs: nav && nav.loadEventEnd ? nav.loadEventEnd : undefined,
|
|
541
|
+
domNodes: document.getElementsByTagName('*').length,
|
|
542
|
+
jsHeapBytes: heap,
|
|
543
|
+
},
|
|
544
|
+
timings: timings,
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function registrableDomain(host) {
|
|
549
|
+
const parts = String(host).split('.');
|
|
550
|
+
return parts.length <= 2 ? host : parts.slice(-2).join('.');
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function summariseRequests(requests, timings, pageUrl) {
|
|
554
|
+
const site = registrableDomain(new URL(pageUrl).hostname);
|
|
555
|
+
const byType = {};
|
|
556
|
+
const all = [];
|
|
557
|
+
const thirdParty = new Set();
|
|
558
|
+
let thirdPartyBytes = 0;
|
|
559
|
+
let total = 0;
|
|
560
|
+
for (const r of requests.values()) {
|
|
561
|
+
if (!r.url || /^data:|^blob:/.test(r.url)) continue;
|
|
562
|
+
const bytes = r.bytes || 0;
|
|
563
|
+
total += bytes;
|
|
564
|
+
const type = (r.type || 'Other').toLowerCase();
|
|
565
|
+
if (!byType[type]) byType[type] = { type: type, requests: 0, transferBytes: 0 };
|
|
566
|
+
byType[type].requests += 1;
|
|
567
|
+
byType[type].transferBytes += bytes;
|
|
568
|
+
let host = '';
|
|
569
|
+
try { host = new URL(r.url).hostname; } catch (err) { /* opaque */ }
|
|
570
|
+
if (host && registrableDomain(host) !== site) { thirdParty.add(host); thirdPartyBytes += bytes; }
|
|
571
|
+
const t = timings[r.url] || {};
|
|
572
|
+
all.push({ url: r.url.slice(0, 200), type: type, transferBytes: bytes, durationMs: Math.round(t.duration || 0), renderBlocking: !!t.blocking });
|
|
573
|
+
}
|
|
574
|
+
return {
|
|
575
|
+
requests: all.length,
|
|
576
|
+
transferBytes: total,
|
|
577
|
+
byType: Object.values(byType).sort((a, b) => b.transferBytes - a.transferBytes),
|
|
578
|
+
largest: all.slice().sort((a, b) => b.transferBytes - a.transferBytes).slice(0, 5),
|
|
579
|
+
renderBlocking: all.filter((r) => r.renderBlocking).slice(0, 8),
|
|
580
|
+
thirdPartyBytes: thirdPartyBytes,
|
|
581
|
+
thirdPartyOrigins: thirdParty.size,
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
|
|
432
585
|
(async () => {
|
|
433
586
|
const { createRequire } = require('node:module');
|
|
434
587
|
const req = createRequire(cfg.projectRoot + '/package.json');
|
|
@@ -450,8 +603,16 @@ async function runActions(page, actions, timeout) {
|
|
|
450
603
|
}
|
|
451
604
|
|
|
452
605
|
let browser = null;
|
|
606
|
+
let openContext = null;
|
|
453
607
|
let actionsDone = [];
|
|
608
|
+
// A recorded HAR is written when its context closes, so close the
|
|
609
|
+
// context before the browser.
|
|
610
|
+
const closeBrowser = async () => {
|
|
611
|
+
if (openContext) await openContext.close().catch(() => undefined);
|
|
612
|
+
await browser.close();
|
|
613
|
+
};
|
|
454
614
|
try {
|
|
615
|
+
const engine = cfg.browser || 'chromium';
|
|
455
616
|
const launchOptions = { headless: true };
|
|
456
617
|
if (cfg.blockPrivate) {
|
|
457
618
|
// SSRF guard: every browser connection (each redirect hop and
|
|
@@ -461,16 +622,73 @@ async function runActions(page, actions, timeout) {
|
|
|
461
622
|
launchOptions.proxy = { server: 'http://127.0.0.1:' + proxyPort };
|
|
462
623
|
// WebRTC STUN/TURN over UDP does not go through the proxy; keep it off
|
|
463
624
|
// so a page cannot send UDP to (or scan) the private network.
|
|
464
|
-
|
|
625
|
+
if (engine === 'firefox') {
|
|
626
|
+
launchOptions.firefoxUserPrefs = { 'network.proxy.allow_hijacking_localhost': true, 'media.peerconnection.enabled': false };
|
|
627
|
+
} else {
|
|
628
|
+
launchOptions.args = ['--proxy-bypass-list=<-loopback>', '--force-webrtc-ip-handling-policy=disable_non_proxied_udp'];
|
|
629
|
+
}
|
|
465
630
|
}
|
|
466
|
-
browser = await pw.
|
|
631
|
+
browser = await pw[engine].launch(launchOptions);
|
|
467
632
|
const contextOptions = { viewport: cfg.viewport || { width: 1280, height: 720 } };
|
|
633
|
+
if (cfg.mobile) Object.assign(contextOptions, { isMobile: true, hasTouch: true, deviceScaleFactor: 1.75 });
|
|
468
634
|
if (cfg.storageState) contextOptions.storageState = cfg.storageState;
|
|
469
635
|
if (cfg.headers) contextOptions.extraHTTPHeaders = cfg.headers;
|
|
470
636
|
if (cfg.testIdAttribute) pw.selectors.setTestIdAttribute(cfg.testIdAttribute);
|
|
637
|
+
const recording = cfg.har && cfg.har.mode === 'record' && !cfg.snapshotHtmlPath;
|
|
638
|
+
if (recording) contextOptions.recordHar = { path: cfg.har.path, urlFilter: cfg.har.url, mode: 'minimal' };
|
|
471
639
|
const context = await browser.newContext(contextOptions);
|
|
640
|
+
openContext = context;
|
|
641
|
+
// Replay goes first so the network mocks below, registered later, win.
|
|
642
|
+
if (cfg.har && cfg.har.mode === 'replay' && !cfg.snapshotHtmlPath) {
|
|
643
|
+
await context.routeFromHAR(cfg.har.path, { url: cfg.har.url, notFound: cfg.har.notFound || 'abort' });
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// Network mocks. Playwright runs the most recently registered handler
|
|
647
|
+
// first, so register in reverse to make the first listed mock win.
|
|
648
|
+
const routeHits = (cfg.routes || []).map(() => 0);
|
|
649
|
+
if (cfg.routes && cfg.routes.length && !cfg.snapshotHtmlPath) {
|
|
650
|
+
for (let i = cfg.routes.length - 1; i >= 0; i -= 1) {
|
|
651
|
+
const r = cfg.routes[i];
|
|
652
|
+
await context.route(r.url, async (route) => {
|
|
653
|
+
if (r.method && route.request().method() !== r.method) return route.fallback();
|
|
654
|
+
routeHits[i] += 1;
|
|
655
|
+
if (r.delayMs) await new Promise((resolve) => setTimeout(resolve, r.delayMs));
|
|
656
|
+
if (r.abort) return route.abort('failed');
|
|
657
|
+
if (r.status !== undefined) {
|
|
658
|
+
return route.fulfill({ status: r.status, body: r.body === undefined ? '' : r.body, contentType: r.contentType, headers: r.headers });
|
|
659
|
+
}
|
|
660
|
+
return route.fallback();
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
if (cfg.mode === 'perf') await context.addInitScript(perfInit);
|
|
472
666
|
const page = await context.newPage();
|
|
473
667
|
|
|
668
|
+
// perf: count bytes per request over CDP (cross-origin sizes included)
|
|
669
|
+
// and apply CPU / network throttling before the first byte.
|
|
670
|
+
const requests = new Map();
|
|
671
|
+
if (cfg.mode === 'perf') {
|
|
672
|
+
const cdp = await context.newCDPSession(page);
|
|
673
|
+
cdp.on('Network.responseReceived', (e) => {
|
|
674
|
+
if (requests.size < 2000 || requests.has(e.requestId)) requests.set(e.requestId, Object.assign(requests.get(e.requestId) || {}, { url: e.response.url, type: e.type }));
|
|
675
|
+
});
|
|
676
|
+
cdp.on('Network.loadingFinished', (e) => {
|
|
677
|
+
const r = requests.get(e.requestId);
|
|
678
|
+
if (r) r.bytes = e.encodedDataLength;
|
|
679
|
+
});
|
|
680
|
+
await cdp.send('Network.enable');
|
|
681
|
+
if (cfg.network) {
|
|
682
|
+
await cdp.send('Network.emulateNetworkConditions', {
|
|
683
|
+
offline: false,
|
|
684
|
+
latency: cfg.network.latencyMs,
|
|
685
|
+
downloadThroughput: cfg.network.downloadBps,
|
|
686
|
+
uploadThroughput: cfg.network.uploadBps,
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
if (cfg.cpuThrottle && cfg.cpuThrottle > 1) await cdp.send('Emulation.setCPUThrottlingRate', { rate: cfg.cpuThrottle });
|
|
690
|
+
}
|
|
691
|
+
|
|
474
692
|
const consoleMessages = [];
|
|
475
693
|
page.on('console', (msg) => {
|
|
476
694
|
if (consoleMessages.length < 50) consoleMessages.push({ type: msg.type(), text: String(msg.text()).slice(0, 300) });
|
|
@@ -487,7 +705,15 @@ async function runActions(page, actions, timeout) {
|
|
|
487
705
|
} else {
|
|
488
706
|
await page.goto(cfg.url, { waitUntil: cfg.waitUntil || 'domcontentloaded', timeout: cfg.gotoTimeout || 15000 });
|
|
489
707
|
if (cfg.waitFor) await page.locator(cfg.waitFor).first().waitFor({ state: 'visible', timeout: cfg.waitTimeout || 5000 });
|
|
490
|
-
|
|
708
|
+
// perf: let late images, fonts and shifts land before anything else.
|
|
709
|
+
if (cfg.mode === 'perf' && cfg.settleMs) await page.waitForTimeout(cfg.settleMs);
|
|
710
|
+
if (cfg.actions && cfg.actions.length) {
|
|
711
|
+
actionsDone = await runActions(page, cfg.actions, cfg.actionTimeout || 10000);
|
|
712
|
+
if (cfg.mode === 'perf') {
|
|
713
|
+
await page.waitForLoadState('load', { timeout: cfg.waitTimeout || 5000 }).catch(() => undefined);
|
|
714
|
+
await page.waitForTimeout(Math.min(cfg.settleMs || 1000, 2000));
|
|
715
|
+
}
|
|
716
|
+
}
|
|
491
717
|
}
|
|
492
718
|
|
|
493
719
|
if (cfg.mode === 'screenshot') {
|
|
@@ -507,10 +733,11 @@ async function runActions(page, actions, timeout) {
|
|
|
507
733
|
matchCount: 0,
|
|
508
734
|
elements: [],
|
|
509
735
|
actionsDone: actionsDone,
|
|
736
|
+
routeHits: routeHits,
|
|
510
737
|
durationMs: Date.now() - cfg.startedAt,
|
|
511
738
|
},
|
|
512
739
|
});
|
|
513
|
-
await
|
|
740
|
+
await closeBrowser();
|
|
514
741
|
process.exit(0);
|
|
515
742
|
}
|
|
516
743
|
|
|
@@ -584,6 +811,13 @@ async function runActions(page, actions, timeout) {
|
|
|
584
811
|
};
|
|
585
812
|
}, { tags: cfg.axeTags || [], selector: cfg.selector, maxNodes: cfg.maxNodes || 3 });
|
|
586
813
|
await attachLocators(page, data.elements, 40, 2);
|
|
814
|
+
} else if (cfg.mode === 'perf') {
|
|
815
|
+
const collected = await page.evaluate(collectPerf);
|
|
816
|
+
const network = summariseRequests(requests, collected.timings, page.url());
|
|
817
|
+
delete collected.timings;
|
|
818
|
+
Object.assign(collected.perf, network);
|
|
819
|
+
data = Object.assign(collected, { matchCount: 0, elementCount: collected.elements.length });
|
|
820
|
+
await attachLocators(page, data.elements, 12, 2);
|
|
587
821
|
} else if (cfg.mode === 'heal') {
|
|
588
822
|
data = await page.evaluate((input) => {
|
|
589
823
|
const h = window.__pwmcp;
|
|
@@ -612,6 +846,20 @@ async function runActions(page, actions, timeout) {
|
|
|
612
846
|
return { matchCount: elements.length, elementCount: document.querySelectorAll('*').length, elements: elements, title: document.title };
|
|
613
847
|
}, { tokens: cfg.healTokens || [], role: cfg.healRole });
|
|
614
848
|
await attachLocators(page, data.elements, 5, 3);
|
|
849
|
+
} else if (cfg.view === 'aria') {
|
|
850
|
+
const scope = cfg.selector ? page.locator(cfg.selector) : page.locator('body');
|
|
851
|
+
const count = await scope.count();
|
|
852
|
+
data = { matchCount: count, elementCount: count, elements: [], title: await page.title(), finalUrl: page.url() };
|
|
853
|
+
if (count > 0) {
|
|
854
|
+
if (typeof scope.ariaSnapshot !== 'function') {
|
|
855
|
+
throw new Error('ariaSnapshot needs Playwright 1.49 or newer');
|
|
856
|
+
}
|
|
857
|
+
const snapshot = await scope.first().ariaSnapshot({ timeout: cfg.waitTimeout || 10000 });
|
|
858
|
+
const max = cfg.maxHtmlChars || 12000;
|
|
859
|
+
data.ariaSnapshot = snapshot.slice(0, max);
|
|
860
|
+
data.ariaTruncated = snapshot.length > max;
|
|
861
|
+
}
|
|
862
|
+
data.viewport = page.viewportSize() || undefined;
|
|
615
863
|
} else {
|
|
616
864
|
data = await page.evaluate((input) => {
|
|
617
865
|
const h = window.__pwmcp;
|
|
@@ -671,16 +919,17 @@ async function runActions(page, actions, timeout) {
|
|
|
671
919
|
|
|
672
920
|
data.consoleMessages = consoleMessages;
|
|
673
921
|
data.actionsDone = actionsDone;
|
|
922
|
+
data.routeHits = routeHits;
|
|
674
923
|
data.finalUrl = data.finalUrl || page.url();
|
|
675
924
|
data.durationMs = Date.now() - cfg.startedAt;
|
|
676
925
|
send({ ok: true, data: data });
|
|
677
|
-
await
|
|
926
|
+
await closeBrowser();
|
|
678
927
|
process.exit(0);
|
|
679
928
|
} catch (err) {
|
|
680
929
|
const message = err && err.message ? err.message : String(err);
|
|
681
930
|
const classified = classifyError(message);
|
|
682
931
|
send({ ok: false, kind: classified.kind, error: message, hint: classified.hint, actionsDone: err && err.actionsDone });
|
|
683
|
-
try { if (browser) await
|
|
932
|
+
try { if (browser) await closeBrowser(); } catch (closeErr) { /* ignore */ }
|
|
684
933
|
process.exit(1);
|
|
685
934
|
}
|
|
686
935
|
})().catch((err) => {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"probe-script.js","sourceRoot":"","sources":["../../src/tools/probe-script.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"probe-script.js","sourceRoot":"","sources":["../../src/tools/probe-script.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAEH,MAAM,CAAC,MAAM,YAAY,GAAG,MAAM,CAAC,GAAG,CAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA84BrC,CAAC"}
|
package/dist/tools/shared.d.ts
CHANGED
|
@@ -72,6 +72,21 @@ export declare const sessionShape: {
|
|
|
72
72
|
storageState: z.ZodOptional<z.ZodString>;
|
|
73
73
|
headers: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
74
74
|
};
|
|
75
|
+
/**
|
|
76
|
+
* A network mock, applied with context.route before the page loads: fulfil
|
|
77
|
+
* with a canned response, abort, or only delay the real request.
|
|
78
|
+
*/
|
|
79
|
+
export declare const routeMockSchema: z.ZodObject<{
|
|
80
|
+
url: z.ZodString;
|
|
81
|
+
method: z.ZodOptional<z.ZodString>;
|
|
82
|
+
status: z.ZodOptional<z.ZodNumber>;
|
|
83
|
+
json: z.ZodOptional<z.ZodUnknown>;
|
|
84
|
+
body: z.ZodOptional<z.ZodString>;
|
|
85
|
+
contentType: z.ZodOptional<z.ZodString>;
|
|
86
|
+
headers: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
87
|
+
abort: z.ZodOptional<z.ZodBoolean>;
|
|
88
|
+
delayMs: z.ZodOptional<z.ZodNumber>;
|
|
89
|
+
}, z.core.$strip>;
|
|
75
90
|
/** Inputs shared by every tool that opens a page. */
|
|
76
91
|
export declare const pageStateShape: {
|
|
77
92
|
storageState: z.ZodOptional<z.ZodString>;
|
|
@@ -94,19 +109,67 @@ export declare const pageStateShape: {
|
|
|
94
109
|
url: z.ZodOptional<z.ZodString>;
|
|
95
110
|
ms: z.ZodOptional<z.ZodNumber>;
|
|
96
111
|
}, z.core.$strip>>>;
|
|
112
|
+
routes: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
113
|
+
url: z.ZodString;
|
|
114
|
+
method: z.ZodOptional<z.ZodString>;
|
|
115
|
+
status: z.ZodOptional<z.ZodNumber>;
|
|
116
|
+
json: z.ZodOptional<z.ZodUnknown>;
|
|
117
|
+
body: z.ZodOptional<z.ZodString>;
|
|
118
|
+
contentType: z.ZodOptional<z.ZodString>;
|
|
119
|
+
headers: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
120
|
+
abort: z.ZodOptional<z.ZodBoolean>;
|
|
121
|
+
delayMs: z.ZodOptional<z.ZodNumber>;
|
|
122
|
+
}, z.core.$strip>>>;
|
|
97
123
|
viewport: z.ZodOptional<z.ZodObject<{
|
|
98
124
|
width: z.ZodNumber;
|
|
99
125
|
height: z.ZodNumber;
|
|
100
126
|
}, z.core.$strip>>;
|
|
127
|
+
har: z.ZodOptional<z.ZodObject<{
|
|
128
|
+
path: z.ZodString;
|
|
129
|
+
mode: z.ZodOptional<z.ZodEnum<{
|
|
130
|
+
record: "record";
|
|
131
|
+
replay: "replay";
|
|
132
|
+
}>>;
|
|
133
|
+
url: z.ZodOptional<z.ZodString>;
|
|
134
|
+
notFound: z.ZodOptional<z.ZodEnum<{
|
|
135
|
+
abort: "abort";
|
|
136
|
+
fallback: "fallback";
|
|
137
|
+
}>>;
|
|
138
|
+
}, z.core.$strip>>;
|
|
139
|
+
browser: z.ZodOptional<z.ZodEnum<{
|
|
140
|
+
chromium: "chromium";
|
|
141
|
+
firefox: "firefox";
|
|
142
|
+
webkit: "webkit";
|
|
143
|
+
}>>;
|
|
101
144
|
};
|
|
102
145
|
export interface PageStateInput {
|
|
103
146
|
storageState?: string;
|
|
104
147
|
headers?: Record<string, string>;
|
|
105
148
|
actions?: Array<z.infer<typeof pageActionSchema>>;
|
|
149
|
+
routes?: Array<z.infer<typeof routeMockSchema>>;
|
|
106
150
|
viewport?: {
|
|
107
151
|
width: number;
|
|
108
152
|
height: number;
|
|
109
153
|
};
|
|
154
|
+
har?: {
|
|
155
|
+
path: string;
|
|
156
|
+
mode?: 'replay' | 'record';
|
|
157
|
+
url?: string;
|
|
158
|
+
notFound?: 'abort' | 'fallback';
|
|
159
|
+
};
|
|
160
|
+
browser?: BrowserEngine;
|
|
161
|
+
}
|
|
162
|
+
export type BrowserEngine = 'chromium' | 'firefox' | 'webkit';
|
|
163
|
+
export interface ResolvedRouteMock {
|
|
164
|
+
url: string;
|
|
165
|
+
label: string;
|
|
166
|
+
method?: string;
|
|
167
|
+
status?: number;
|
|
168
|
+
body?: string;
|
|
169
|
+
contentType?: string;
|
|
170
|
+
headers?: Record<string, string>;
|
|
171
|
+
abort?: boolean;
|
|
172
|
+
delayMs?: number;
|
|
110
173
|
}
|
|
111
174
|
export interface ResolvedPageState {
|
|
112
175
|
storageState?: string;
|
|
@@ -119,11 +182,21 @@ export interface ResolvedPageState {
|
|
|
119
182
|
url?: string;
|
|
120
183
|
ms?: number;
|
|
121
184
|
}>;
|
|
185
|
+
routes?: ResolvedRouteMock[];
|
|
122
186
|
viewport?: {
|
|
123
187
|
width: number;
|
|
124
188
|
height: number;
|
|
125
189
|
};
|
|
190
|
+
har?: {
|
|
191
|
+
path: string;
|
|
192
|
+
mode: 'replay' | 'record';
|
|
193
|
+
url?: string;
|
|
194
|
+
notFound: 'abort' | 'fallback';
|
|
195
|
+
};
|
|
196
|
+
browser?: BrowserEngine;
|
|
126
197
|
}
|
|
198
|
+
/** Validate network mocks and turn each into a plain fulfil/abort/delay spec. */
|
|
199
|
+
export declare function resolveRouteMocks(routes: NonNullable<PageStateInput['routes']>): ResolvedRouteMock[];
|
|
127
200
|
/**
|
|
128
201
|
* Validate page-state inputs: the storageState file must sit inside the
|
|
129
202
|
* project root (symlinks resolved) and parse as JSON, every goto URL passes
|
|
@@ -133,10 +206,12 @@ export interface ResolvedPageState {
|
|
|
133
206
|
export declare function resolvePageState(input: PageStateInput, root: string, entryUrl: string, ctx: ToolContext): Promise<ResolvedPageState>;
|
|
134
207
|
/** One line describing the page state, for tool output headers. */
|
|
135
208
|
export declare function describePageState(state: ResolvedPageState, root: string): string | undefined;
|
|
209
|
+
/** One line per network mock with how often it matched; unmatched mocks are flagged. */
|
|
210
|
+
export declare function describeRouteHits(state: ResolvedPageState, hits: number[] | undefined): string[];
|
|
136
211
|
/** A hint when an unauthenticated page landed on a login screen. */
|
|
137
212
|
export declare function loginHint(finalUrl: string | undefined, state: ResolvedPageState): string | undefined;
|
|
138
213
|
export interface BrowserScriptConfig extends Omit<ResolvedPageState, never> {
|
|
139
|
-
mode: 'inspect' | 'validate' | 'screenshot' | 'heal' | 'a11y';
|
|
214
|
+
mode: 'inspect' | 'validate' | 'screenshot' | 'heal' | 'a11y' | 'perf';
|
|
140
215
|
projectRoot: string;
|
|
141
216
|
/** Page to open; omitted when rendering a snapshot. */
|
|
142
217
|
url?: string;
|
|
@@ -147,7 +222,7 @@ export interface BrowserScriptConfig extends Omit<ResolvedPageState, never> {
|
|
|
147
222
|
/** Parsed locator chain for validate / screenshot modes. */
|
|
148
223
|
selectorChain?: LocatorCall[];
|
|
149
224
|
/** inspect: 'elements' (DOM inventory) or 'locators' (compact locator map). */
|
|
150
|
-
view?: 'elements' | 'locators';
|
|
225
|
+
view?: 'elements' | 'locators' | 'aria';
|
|
151
226
|
/** heal: words from the broken locator, and its role when it had one. */
|
|
152
227
|
healTokens?: string[];
|
|
153
228
|
healRole?: string;
|
|
@@ -166,8 +241,21 @@ export interface BrowserScriptConfig extends Omit<ResolvedPageState, never> {
|
|
|
166
241
|
axeSourcePath?: string;
|
|
167
242
|
axeTags?: string[];
|
|
168
243
|
maxNodes?: number;
|
|
244
|
+
/** perf: CPU slowdown factor and network profile (CDP emulation). */
|
|
245
|
+
cpuThrottle?: number;
|
|
246
|
+
network?: NetworkProfile;
|
|
247
|
+
/** perf: emulate a phone (isMobile, touch, device pixel ratio). */
|
|
248
|
+
mobile?: boolean;
|
|
249
|
+
/** perf: ms to keep observing after load so late shifts and LCP land. */
|
|
250
|
+
settleMs?: number;
|
|
169
251
|
startedAt: number;
|
|
170
252
|
}
|
|
253
|
+
/** Network emulation in Chrome DevTools terms (throughput in bytes/s). */
|
|
254
|
+
export interface NetworkProfile {
|
|
255
|
+
latencyMs: number;
|
|
256
|
+
downloadBps: number;
|
|
257
|
+
uploadBps: number;
|
|
258
|
+
}
|
|
171
259
|
export interface BrowserScriptData {
|
|
172
260
|
title?: string;
|
|
173
261
|
finalUrl?: string;
|
|
@@ -188,6 +276,53 @@ export interface BrowserScriptData {
|
|
|
188
276
|
durationMs?: number;
|
|
189
277
|
/** mode 'a11y': axe-core violations; node.element indexes `elements`. */
|
|
190
278
|
a11y?: AccessibilityScan;
|
|
279
|
+
/** mode 'perf': lab Web Vitals and resource stats; element fields index `elements`. */
|
|
280
|
+
perf?: PerformanceScan;
|
|
281
|
+
/** inspect view 'aria': the YAML accessibility snapshot of the scope. */
|
|
282
|
+
ariaSnapshot?: string;
|
|
283
|
+
ariaTruncated?: boolean;
|
|
284
|
+
/** Requests each network mock in `routes` handled, by index. */
|
|
285
|
+
routeHits?: number[];
|
|
286
|
+
}
|
|
287
|
+
export interface PerformanceResource {
|
|
288
|
+
url: string;
|
|
289
|
+
type: string;
|
|
290
|
+
transferBytes: number;
|
|
291
|
+
durationMs: number;
|
|
292
|
+
renderBlocking: boolean;
|
|
293
|
+
}
|
|
294
|
+
export interface PerformanceScan {
|
|
295
|
+
ttfbMs?: number;
|
|
296
|
+
fcpMs?: number;
|
|
297
|
+
lcpMs?: number;
|
|
298
|
+
/** Index into `elements` of the LCP element, -1 when unknown. */
|
|
299
|
+
lcpElement: number;
|
|
300
|
+
lcpTag?: string;
|
|
301
|
+
lcpUrl?: string;
|
|
302
|
+
cls: number;
|
|
303
|
+
/** Elements that moved most in the worst layout-shift window. */
|
|
304
|
+
shiftElements: number[];
|
|
305
|
+
tbtMs: number;
|
|
306
|
+
longTasks: number;
|
|
307
|
+
longestTaskMs: number;
|
|
308
|
+
/** Slowest interaction during `actions` (lab INP), when there were any. */
|
|
309
|
+
inpMs?: number;
|
|
310
|
+
inpTarget?: string;
|
|
311
|
+
domContentLoadedMs?: number;
|
|
312
|
+
loadMs?: number;
|
|
313
|
+
requests: number;
|
|
314
|
+
transferBytes: number;
|
|
315
|
+
byType: Array<{
|
|
316
|
+
type: string;
|
|
317
|
+
requests: number;
|
|
318
|
+
transferBytes: number;
|
|
319
|
+
}>;
|
|
320
|
+
largest: PerformanceResource[];
|
|
321
|
+
renderBlocking: PerformanceResource[];
|
|
322
|
+
thirdPartyBytes: number;
|
|
323
|
+
thirdPartyOrigins: number;
|
|
324
|
+
domNodes: number;
|
|
325
|
+
jsHeapBytes?: number;
|
|
191
326
|
}
|
|
192
327
|
export interface AccessibilityNode {
|
|
193
328
|
target: string;
|