cbrowser 19.3.7 → 19.3.9
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/analysis/accessibility-empathy.d.ts.map +1 -1
- package/dist/analysis/accessibility-empathy.js +10 -1
- package/dist/analysis/accessibility-empathy.js.map +1 -1
- package/dist/analysis/agent-ready-audit.d.ts +9 -0
- package/dist/analysis/agent-ready-audit.d.ts.map +1 -1
- package/dist/analysis/agent-ready-audit.js +79 -6
- package/dist/analysis/agent-ready-audit.js.map +1 -1
- package/dist/analysis/bug-hunter.d.ts.map +1 -1
- package/dist/analysis/bug-hunter.js +27 -10
- package/dist/analysis/bug-hunter.js.map +1 -1
- package/dist/analysis/chaos-testing.d.ts.map +1 -1
- package/dist/analysis/chaos-testing.js +12 -3
- package/dist/analysis/chaos-testing.js.map +1 -1
- package/dist/analysis/competitive-benchmark.d.ts +50 -7
- package/dist/analysis/competitive-benchmark.d.ts.map +1 -1
- package/dist/analysis/competitive-benchmark.js +226 -106
- package/dist/analysis/competitive-benchmark.js.map +1 -1
- package/dist/analysis/page-understanding.d.ts +2 -0
- package/dist/analysis/page-understanding.d.ts.map +1 -1
- package/dist/analysis/page-understanding.js +104 -17
- package/dist/analysis/page-understanding.js.map +1 -1
- package/dist/artifact-store.d.ts +24 -2
- package/dist/artifact-store.d.ts.map +1 -1
- package/dist/artifact-store.js +34 -4
- package/dist/artifact-store.js.map +1 -1
- package/dist/browser/session-manager.d.ts.map +1 -1
- package/dist/browser/session-manager.js +3 -0
- package/dist/browser/session-manager.js.map +1 -1
- package/dist/browser/site-profile-manager.d.ts +17 -1
- package/dist/browser/site-profile-manager.d.ts.map +1 -1
- package/dist/browser/site-profile-manager.js +49 -12
- package/dist/browser/site-profile-manager.js.map +1 -1
- package/dist/browser.d.ts +58 -1
- package/dist/browser.d.ts.map +1 -1
- package/dist/browser.js +377 -50
- package/dist/browser.js.map +1 -1
- package/dist/charge-scope.d.ts.map +1 -1
- package/dist/charge-scope.js +6 -4
- package/dist/charge-scope.js.map +1 -1
- package/dist/cli.js +3 -0
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +8 -2
- package/dist/config.js.map +1 -1
- package/dist/mcp-server-remote.d.ts +27 -1
- package/dist/mcp-server-remote.d.ts.map +1 -1
- package/dist/mcp-server-remote.js +160 -87
- package/dist/mcp-server-remote.js.map +1 -1
- package/dist/mcp-server.d.ts.map +1 -1
- package/dist/mcp-server.js +4 -0
- package/dist/mcp-server.js.map +1 -1
- package/dist/mcp-tools/base/analysis-tools.d.ts.map +1 -1
- package/dist/mcp-tools/base/analysis-tools.js +5 -0
- package/dist/mcp-tools/base/analysis-tools.js.map +1 -1
- package/dist/mcp-tools/base/audit-tools.d.ts +14 -0
- package/dist/mcp-tools/base/audit-tools.d.ts.map +1 -1
- package/dist/mcp-tools/base/audit-tools.js +17 -8
- package/dist/mcp-tools/base/audit-tools.js.map +1 -1
- package/dist/mcp-tools/base/browser-state-tools.d.ts.map +1 -1
- package/dist/mcp-tools/base/browser-state-tools.js +18 -27
- package/dist/mcp-tools/base/browser-state-tools.js.map +1 -1
- package/dist/mcp-tools/base/capture-tools.d.ts +18 -0
- package/dist/mcp-tools/base/capture-tools.d.ts.map +1 -1
- package/dist/mcp-tools/base/capture-tools.js +109 -55
- package/dist/mcp-tools/base/capture-tools.js.map +1 -1
- package/dist/mcp-tools/base/cognitive-tools.d.ts.map +1 -1
- package/dist/mcp-tools/base/cognitive-tools.js +43 -16
- package/dist/mcp-tools/base/cognitive-tools.js.map +1 -1
- package/dist/mcp-tools/base/marketing-tools.d.ts.map +1 -1
- package/dist/mcp-tools/base/marketing-tools.js +8 -4
- package/dist/mcp-tools/base/marketing-tools.js.map +1 -1
- package/dist/mcp-tools/base/persona-comparison-tools.d.ts.map +1 -1
- package/dist/mcp-tools/base/persona-comparison-tools.js +14 -3
- package/dist/mcp-tools/base/persona-comparison-tools.js.map +1 -1
- package/dist/mcp-tools/base/remediation-tools.d.ts.map +1 -1
- package/dist/mcp-tools/base/remediation-tools.js +10 -2
- package/dist/mcp-tools/base/remediation-tools.js.map +1 -1
- package/dist/mcp-tools/base/session-tools.d.ts.map +1 -1
- package/dist/mcp-tools/base/session-tools.js +2 -0
- package/dist/mcp-tools/base/session-tools.js.map +1 -1
- package/dist/mcp-tools/base/visual-testing-tools.d.ts +13 -0
- package/dist/mcp-tools/base/visual-testing-tools.d.ts.map +1 -1
- package/dist/mcp-tools/base/visual-testing-tools.js +45 -16
- package/dist/mcp-tools/base/visual-testing-tools.js.map +1 -1
- package/dist/mcp-tools/persona-creation-tools.d.ts.map +1 -1
- package/dist/mcp-tools/persona-creation-tools.js +59 -13
- package/dist/mcp-tools/persona-creation-tools.js.map +1 -1
- package/dist/mcp-tools/persona-lifecycle-tools.d.ts.map +1 -1
- package/dist/mcp-tools/persona-lifecycle-tools.js +4 -2
- package/dist/mcp-tools/persona-lifecycle-tools.js.map +1 -1
- package/dist/mcp-tools/tier-gate.d.ts +4 -4
- package/dist/mcp-tools/tier-gate.d.ts.map +1 -1
- package/dist/mcp-tools/tier-gate.js +41 -19
- package/dist/mcp-tools/tier-gate.js.map +1 -1
- package/dist/mcp-tools/tool-categories.d.ts +11 -0
- package/dist/mcp-tools/tool-categories.d.ts.map +1 -1
- package/dist/mcp-tools/tool-categories.js +27 -1
- package/dist/mcp-tools/tool-categories.js.map +1 -1
- package/dist/mcp-tools/ui-resources.d.ts.map +1 -1
- package/dist/mcp-tools/ui-resources.js +32 -10
- package/dist/mcp-tools/ui-resources.js.map +1 -1
- package/dist/personas.d.ts +14 -0
- package/dist/personas.d.ts.map +1 -1
- package/dist/personas.js +26 -0
- package/dist/personas.js.map +1 -1
- package/dist/recording/engine.d.ts.map +1 -1
- package/dist/recording/engine.js +2 -1
- package/dist/recording/engine.js.map +1 -1
- package/dist/remediation/patch-generator.d.ts.map +1 -1
- package/dist/remediation/patch-generator.js +7 -1
- package/dist/remediation/patch-generator.js.map +1 -1
- package/dist/remediation/structured-data.d.ts +73 -1
- package/dist/remediation/structured-data.d.ts.map +1 -1
- package/dist/remediation/structured-data.js +239 -63
- package/dist/remediation/structured-data.js.map +1 -1
- package/dist/request-context.d.ts +18 -0
- package/dist/request-context.d.ts.map +1 -0
- package/dist/request-context.js +56 -0
- package/dist/request-context.js.map +1 -0
- package/dist/site-model/manager.d.ts +14 -1
- package/dist/site-model/manager.d.ts.map +1 -1
- package/dist/site-model/manager.js +24 -1
- package/dist/site-model/manager.js.map +1 -1
- package/dist/types.d.ts +90 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/values/index.d.ts +1 -1
- package/dist/values/index.d.ts.map +1 -1
- package/dist/values/index.js +1 -1
- package/dist/values/index.js.map +1 -1
- package/dist/values/persona-values.d.ts +16 -0
- package/dist/values/persona-values.d.ts.map +1 -1
- package/dist/values/persona-values.js +56 -4
- package/dist/values/persona-values.js.map +1 -1
- package/dist/visual/ab-comparison.d.ts +22 -0
- package/dist/visual/ab-comparison.d.ts.map +1 -1
- package/dist/visual/ab-comparison.js +36 -9
- package/dist/visual/ab-comparison.js.map +1 -1
- package/dist/visual/cognitive-transport.d.ts +15 -1
- package/dist/visual/cognitive-transport.d.ts.map +1 -1
- package/dist/visual/cognitive-transport.js +42 -12
- package/dist/visual/cognitive-transport.js.map +1 -1
- package/dist/visual/cross-browser.d.ts +8 -0
- package/dist/visual/cross-browser.d.ts.map +1 -1
- package/dist/visual/cross-browser.js +8 -4
- package/dist/visual/cross-browser.js.map +1 -1
- package/dist/visual/distance-metrics.d.ts +11 -2
- package/dist/visual/distance-metrics.d.ts.map +1 -1
- package/dist/visual/distance-metrics.js +44 -5
- package/dist/visual/distance-metrics.js.map +1 -1
- package/dist/visual/regression.d.ts +61 -0
- package/dist/visual/regression.d.ts.map +1 -1
- package/dist/visual/regression.js +123 -50
- package/dist/visual/regression.js.map +1 -1
- package/dist/visual/responsive.d.ts +8 -0
- package/dist/visual/responsive.d.ts.map +1 -1
- package/dist/visual/responsive.js +95 -17
- package/dist/visual/responsive.js.map +1 -1
- package/package.json +1 -1
package/dist/browser.js
CHANGED
|
@@ -204,6 +204,21 @@ export class CBrowser {
|
|
|
204
204
|
static MAX_PENDING_REQUESTS = 2000;
|
|
205
205
|
harEntries = [];
|
|
206
206
|
isRecordingHar = false;
|
|
207
|
+
/**
|
|
208
|
+
* Console capture, one ring buffer per tab (v8 B27, 2026-10-10). Filled by a
|
|
209
|
+
* single page.on("console") + page.on("pageerror") pair attached when the tab
|
|
210
|
+
* is created, so it holds what was logged during page load and what the
|
|
211
|
+
* browser itself reports (a 404 is "Failed to load resource", not a console.*
|
|
212
|
+
* call). Measured on 19.3.6: get_console_messages returned [] for a page that
|
|
213
|
+
* had logged and 404'd, because it patched console.* on its first call; and
|
|
214
|
+
* navigate added a listener per call (6 -> 9 over three navigates). Keyed by
|
|
215
|
+
* Page so a tab switched to by manage_tabs reads its own buffer.
|
|
216
|
+
*/
|
|
217
|
+
consoleBuffers = new WeakMap();
|
|
218
|
+
/** Increases on every captured message, across tabs; navigate slices by it. */
|
|
219
|
+
consoleSeq = 0;
|
|
220
|
+
static CONSOLE_BUFFER_CAP = 500;
|
|
221
|
+
static CONSOLE_TEXT_CAP = 2000;
|
|
207
222
|
skipSessionRestore;
|
|
208
223
|
// Modular components (extracted for maintainability)
|
|
209
224
|
sessionManager;
|
|
@@ -616,6 +631,15 @@ For more help: https://playwright.dev/docs/browsers
|
|
|
616
631
|
console.log(`🌐 Using proxy: ${this.config.proxy.server}`);
|
|
617
632
|
}
|
|
618
633
|
}
|
|
634
|
+
// Console capture for every tab of this context: the ones open now, and
|
|
635
|
+
// any opened later by the site (popups), followPopups, or manage_tabs,
|
|
636
|
+
// which calls context.newPage() directly (v8 B27, 2026-10-10).
|
|
637
|
+
const ctx = this.context;
|
|
638
|
+
if (ctx) {
|
|
639
|
+
ctx.on("page", (p) => this.attachConsoleCapture(p));
|
|
640
|
+
for (const p of ctx.pages())
|
|
641
|
+
this.attachConsoleCapture(p);
|
|
642
|
+
}
|
|
619
643
|
// Live-journey mode: when a click opens a new tab, swap the journey's
|
|
620
644
|
// active page to it and bring it to the front so VNC stream follows.
|
|
621
645
|
// Closes the old page after a short grace period so its DOM is freed.
|
|
@@ -1354,6 +1378,95 @@ For more help: https://playwright.dev/docs/browsers
|
|
|
1354
1378
|
this.pendingRequests.clear();
|
|
1355
1379
|
}
|
|
1356
1380
|
// =========================================================================
|
|
1381
|
+
// Console Capture (v8 B27)
|
|
1382
|
+
// =========================================================================
|
|
1383
|
+
/**
|
|
1384
|
+
* Give a tab its one console + pageerror listener pair. Idempotent.
|
|
1385
|
+
*
|
|
1386
|
+
* The buffer is NOT reset on navigation (decided 2026-10-10, v8 B27). A flow
|
|
1387
|
+
* that submits a form and lands on a confirmation page would otherwise lose
|
|
1388
|
+
* the form page's errors, which are usually what the caller is looking for,
|
|
1389
|
+
* and Playwright's public API cannot tell a new document from a pushState
|
|
1390
|
+
* route change, so a reset would also wipe an SPA's load errors on its first
|
|
1391
|
+
* client-side navigation. Each entry carries pageUrl instead; clear starts
|
|
1392
|
+
* fresh.
|
|
1393
|
+
*/
|
|
1394
|
+
attachConsoleCapture(page) {
|
|
1395
|
+
if (this.consoleBuffers.has(page))
|
|
1396
|
+
return;
|
|
1397
|
+
const buffer = { entries: [], dropped: 0 };
|
|
1398
|
+
this.consoleBuffers.set(page, buffer);
|
|
1399
|
+
const push = (msg) => {
|
|
1400
|
+
if (msg.text.length > CBrowser.CONSOLE_TEXT_CAP) {
|
|
1401
|
+
const extra = msg.text.length - CBrowser.CONSOLE_TEXT_CAP;
|
|
1402
|
+
msg.text = `${msg.text.slice(0, CBrowser.CONSOLE_TEXT_CAP)}... [${extra} more characters]`;
|
|
1403
|
+
}
|
|
1404
|
+
buffer.entries.push({ seq: ++this.consoleSeq, msg });
|
|
1405
|
+
if (buffer.entries.length > CBrowser.CONSOLE_BUFFER_CAP) {
|
|
1406
|
+
buffer.entries.shift();
|
|
1407
|
+
buffer.dropped++;
|
|
1408
|
+
}
|
|
1409
|
+
};
|
|
1410
|
+
const pageUrl = () => {
|
|
1411
|
+
try {
|
|
1412
|
+
return page.url();
|
|
1413
|
+
}
|
|
1414
|
+
catch {
|
|
1415
|
+
return "";
|
|
1416
|
+
}
|
|
1417
|
+
};
|
|
1418
|
+
page.on("console", (m) => {
|
|
1419
|
+
let loc;
|
|
1420
|
+
try {
|
|
1421
|
+
loc = m.location();
|
|
1422
|
+
}
|
|
1423
|
+
catch { /* no location */ }
|
|
1424
|
+
push({
|
|
1425
|
+
type: m.type(),
|
|
1426
|
+
text: m.text(),
|
|
1427
|
+
timestamp: Date.now(),
|
|
1428
|
+
source: "console",
|
|
1429
|
+
// For "Failed to load resource" Chrome puts the failed resource here,
|
|
1430
|
+
// not in the text (same finding as hunt_bugs v5 B24).
|
|
1431
|
+
...(loc?.url ? { url: loc.url, lineNumber: loc.lineNumber } : {}),
|
|
1432
|
+
pageUrl: pageUrl(),
|
|
1433
|
+
});
|
|
1434
|
+
});
|
|
1435
|
+
page.on("pageerror", (err) => {
|
|
1436
|
+
push({
|
|
1437
|
+
type: "error",
|
|
1438
|
+
text: `${err.name || "Error"}: ${err.message}`,
|
|
1439
|
+
timestamp: Date.now(),
|
|
1440
|
+
source: "pageerror",
|
|
1441
|
+
pageUrl: pageUrl(),
|
|
1442
|
+
});
|
|
1443
|
+
});
|
|
1444
|
+
}
|
|
1445
|
+
/**
|
|
1446
|
+
* The current tab's console messages, oldest first: the latest 500 since the
|
|
1447
|
+
* tab opened or since clearConsoleMessages(), across navigations, including
|
|
1448
|
+
* uncaught exceptions (source "pageerror") and browser-generated messages
|
|
1449
|
+
* such as a resource 404. `dropped` counts older ones the ring evicted.
|
|
1450
|
+
*/
|
|
1451
|
+
getConsoleMessages() {
|
|
1452
|
+
const buffer = this.page ? this.consoleBuffers.get(this.page) : undefined;
|
|
1453
|
+
if (!buffer)
|
|
1454
|
+
return { messages: [], dropped: 0 };
|
|
1455
|
+
return { messages: buffer.entries.map((e) => ({ ...e.msg })), dropped: buffer.dropped };
|
|
1456
|
+
}
|
|
1457
|
+
/** Empty the current tab's console buffer and its dropped count. */
|
|
1458
|
+
clearConsoleMessages() {
|
|
1459
|
+
const buffer = this.page ? this.consoleBuffers.get(this.page) : undefined;
|
|
1460
|
+
if (buffer) {
|
|
1461
|
+
buffer.entries = [];
|
|
1462
|
+
buffer.dropped = 0;
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
1465
|
+
/** What a tab logged after sequence number `afterSeq` (navigate's own slice). */
|
|
1466
|
+
consoleMessagesSince(page, afterSeq) {
|
|
1467
|
+
return (this.consoleBuffers.get(page)?.entries ?? []).filter((e) => e.seq > afterSeq).map((e) => e.msg);
|
|
1468
|
+
}
|
|
1469
|
+
// =========================================================================
|
|
1357
1470
|
// Performance Metrics
|
|
1358
1471
|
// =========================================================================
|
|
1359
1472
|
/**
|
|
@@ -1421,6 +1534,14 @@ For more help: https://playwright.dev/docs/browsers
|
|
|
1421
1534
|
const resources = performance.getEntriesByType("resource");
|
|
1422
1535
|
result.resourceCount = resources.length;
|
|
1423
1536
|
result.transferSize = resources.reduce((sum, r) => sum + (r.transferSize || 0), 0);
|
|
1537
|
+
// Timings are high-resolution DOMHighResTimeStamps (ttfb printed as
|
|
1538
|
+
// 53.69999998807907): whole milliseconds, and CLS to 4 dp. (v8 B41)
|
|
1539
|
+
for (const k of ["ttfb", "domContentLoaded", "load", "fcp", "lcp"]) {
|
|
1540
|
+
if (typeof result[k] === "number")
|
|
1541
|
+
result[k] = Math.round(result[k]);
|
|
1542
|
+
}
|
|
1543
|
+
if (typeof result.cls === "number")
|
|
1544
|
+
result.cls = Math.round(result.cls * 10000) / 10000;
|
|
1424
1545
|
return result;
|
|
1425
1546
|
});
|
|
1426
1547
|
// Rate the metrics
|
|
@@ -1699,17 +1820,27 @@ For more help: https://playwright.dev/docs/browsers
|
|
|
1699
1820
|
const page = await this.getPage();
|
|
1700
1821
|
this.skipSessionRestore = prevSkip;
|
|
1701
1822
|
const startTime = Date.now();
|
|
1702
|
-
const errors = [];
|
|
1703
1823
|
const warnings = [];
|
|
1704
|
-
//
|
|
1705
|
-
page.on("console"
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1824
|
+
// This navigation's console errors and warnings are the tab's capture
|
|
1825
|
+
// buffer sliced from here (v8 B27, 2026-10-10). The page.on("console") that
|
|
1826
|
+
// used to be added here was never removed, so every navigate left one more
|
|
1827
|
+
// listener on the tab (measured 6 -> 9 over three calls). Uncaught
|
|
1828
|
+
// exceptions stay out of `errors`, as before. The attach is a no-op for any
|
|
1829
|
+
// tab this CBrowser opened; it covers a page swapped in from elsewhere.
|
|
1830
|
+
this.attachConsoleCapture(page);
|
|
1831
|
+
const consoleStart = this.consoleSeq;
|
|
1832
|
+
const loggedHere = () => {
|
|
1833
|
+
const logged = { errors: [], warnings: [] };
|
|
1834
|
+
for (const m of this.consoleMessagesSince(page, consoleStart)) {
|
|
1835
|
+
if (m.source !== "console")
|
|
1836
|
+
continue;
|
|
1837
|
+
if (m.type === "error")
|
|
1838
|
+
logged.errors.push(m.text);
|
|
1839
|
+
else if (m.type === "warning")
|
|
1840
|
+
logged.warnings.push(m.text);
|
|
1711
1841
|
}
|
|
1712
|
-
|
|
1842
|
+
return logged;
|
|
1843
|
+
};
|
|
1713
1844
|
// v11.3.0: Configurable wait strategy
|
|
1714
1845
|
const waitStrategy = options.waitStrategy || "auto";
|
|
1715
1846
|
const waitTimeout = options.waitTimeout ?? 10000;
|
|
@@ -1839,12 +1970,13 @@ For more help: https://playwright.dev/docs/browsers
|
|
|
1839
1970
|
if (this.config.verbose) {
|
|
1840
1971
|
console.log(`⚠️ Page context desync detected: expected ${expectedHost}, got ${actualHost}`);
|
|
1841
1972
|
}
|
|
1973
|
+
const logged = loggedHere();
|
|
1842
1974
|
return {
|
|
1843
1975
|
url: actualUrl,
|
|
1844
1976
|
title: actualTitle,
|
|
1845
1977
|
screenshot,
|
|
1846
|
-
errors: [...errors, `Page context desync: navigated to ${url} but landed on ${actualUrl}`],
|
|
1847
|
-
warnings: [...warnings, `Expected domain: ${expectedHost}, Actual domain: ${actualHost}`],
|
|
1978
|
+
errors: [...logged.errors, `Page context desync: navigated to ${url} but landed on ${actualUrl}`],
|
|
1979
|
+
warnings: [...logged.warnings, ...warnings, `Expected domain: ${expectedHost}, Actual domain: ${actualHost}`],
|
|
1848
1980
|
loadTime,
|
|
1849
1981
|
success: false,
|
|
1850
1982
|
desyncDetected: true,
|
|
@@ -1852,12 +1984,13 @@ For more help: https://playwright.dev/docs/browsers
|
|
|
1852
1984
|
};
|
|
1853
1985
|
}
|
|
1854
1986
|
const screenshot = await this.screenshot();
|
|
1987
|
+
const logged = loggedHere();
|
|
1855
1988
|
return {
|
|
1856
1989
|
url: actualUrl,
|
|
1857
1990
|
title: actualTitle,
|
|
1858
1991
|
screenshot,
|
|
1859
|
-
errors,
|
|
1860
|
-
warnings,
|
|
1992
|
+
errors: logged.errors,
|
|
1993
|
+
warnings: [...logged.warnings, ...warnings],
|
|
1861
1994
|
loadTime,
|
|
1862
1995
|
success: true,
|
|
1863
1996
|
...(waitSelectorTimedOut ? { waitSelectorTimedOut: true } : {}),
|
|
@@ -3506,7 +3639,7 @@ For more help: https://playwright.dev/docs/browsers
|
|
|
3506
3639
|
}
|
|
3507
3640
|
const page = await this.getPage();
|
|
3508
3641
|
const analysis = await this.analyzePage();
|
|
3509
|
-
const tests = this.generateTestScenarios(analysis);
|
|
3642
|
+
const tests = this.generateTestScenarios(analysis, await this.clickTargetZones(analysis));
|
|
3510
3643
|
return {
|
|
3511
3644
|
url: page.url(),
|
|
3512
3645
|
analysis,
|
|
@@ -3515,6 +3648,38 @@ For more help: https://playwright.dev/docs/browsers
|
|
|
3515
3648
|
playwrightCode: this.generatePlaywrightCode(tests, page.url()),
|
|
3516
3649
|
};
|
|
3517
3650
|
}
|
|
3651
|
+
/**
|
|
3652
|
+
* The click gate's zone for every element generate_tests might click, keyed by selector: the selector
|
|
3653
|
+
* string as click() judges it, the element's names, and the element itself through elementZone (the
|
|
3654
|
+
* gate's own classifyElement, so a benign label on a button whose form posts to /account/delete is still
|
|
3655
|
+
* red). The generator used to click whatever it found: a fixture with a "Delete account" button got
|
|
3656
|
+
* `click text="Delete account"` as an unremarked step, which nl_test_inline then refuses, and the
|
|
3657
|
+
* Playwright export would simply run. (v8 b39, 2026-10-10)
|
|
3658
|
+
*/
|
|
3659
|
+
async clickTargetZones(analysis) {
|
|
3660
|
+
const zones = new Map();
|
|
3661
|
+
const candidates = [
|
|
3662
|
+
...analysis.buttons,
|
|
3663
|
+
...analysis.links,
|
|
3664
|
+
...analysis.forms.map(f => f.submitButton).filter((b) => !!b),
|
|
3665
|
+
].filter(el => !el.hidden && !el.inPage);
|
|
3666
|
+
for (const el of candidates) {
|
|
3667
|
+
// ignoreBlack throughout: the gate refuses red only, and black words describe an instruction, not a
|
|
3668
|
+
// control (the classifyElement rule), so a black match must not mask a red one.
|
|
3669
|
+
let zone = zones.get(el.selector) ?? "yellow";
|
|
3670
|
+
for (const name of [el.selector, el.text, el.ariaLabel]) {
|
|
3671
|
+
if (name)
|
|
3672
|
+
zone = this.maxZone(zone, this.classifyAction("click", name, { ignoreBlack: true }));
|
|
3673
|
+
}
|
|
3674
|
+
if (!zones.has(el.selector)) {
|
|
3675
|
+
const judged = await this.elementZone(el.selector).catch(() => null);
|
|
3676
|
+
if (judged)
|
|
3677
|
+
zone = this.maxZone(zone, judged.zone);
|
|
3678
|
+
}
|
|
3679
|
+
zones.set(el.selector, zone);
|
|
3680
|
+
}
|
|
3681
|
+
return zones;
|
|
3682
|
+
}
|
|
3518
3683
|
/**
|
|
3519
3684
|
* Analyze page structure for testable elements.
|
|
3520
3685
|
*/
|
|
@@ -3548,9 +3713,68 @@ For more help: https://playwright.dev/docs/browsers
|
|
|
3548
3713
|
return `text="${text}"`;
|
|
3549
3714
|
return el.tagName.toLowerCase();
|
|
3550
3715
|
};
|
|
3716
|
+
// What a user cannot see, flagged so generate_tests makes no step of it: no box at all (display:none
|
|
3717
|
+
// here or on an ancestor, <input type=hidden>), visibility:hidden, a 1-2px box (sr-only), parked
|
|
3718
|
+
// off-screen (left:-9999px, top:-40px, translateY(-100%)), or clipped the sr-only way. The clip and
|
|
3719
|
+
// class rules are empathy_audit's target exemptions (accessibility-empathy.ts). The generator turned
|
|
3720
|
+
// a skip link into "Click Skip to content". (v8 b39, 2026-10-10)
|
|
3721
|
+
const srOnly = (el, style) => ((style.position === "absolute" || style.position === "fixed") &&
|
|
3722
|
+
(style.clip === "rect(0px, 0px, 0px, 0px)" || style.clip === "rect(1px, 1px, 1px, 1px)" || style.clipPath === "inset(50%)")) ||
|
|
3723
|
+
el.classList.contains("sr-only") || el.classList.contains("visually-hidden") || el.classList.contains("screen-reader-text");
|
|
3724
|
+
const hiddenFromView = (el) => {
|
|
3725
|
+
if (el.getClientRects().length === 0)
|
|
3726
|
+
return true;
|
|
3727
|
+
// opacity:0 here or on an ancestor, and content-visibility:hidden, where the engine can say so.
|
|
3728
|
+
if (typeof el.checkVisibility === "function" && !el.checkVisibility({ opacityProperty: true, visibilityProperty: true }))
|
|
3729
|
+
return true;
|
|
3730
|
+
const style = getComputedStyle(el);
|
|
3731
|
+
if (style.visibility === "hidden" || style.visibility === "collapse")
|
|
3732
|
+
return true;
|
|
3733
|
+
if (style.opacity === "0")
|
|
3734
|
+
return true;
|
|
3735
|
+
const rect = el.getBoundingClientRect();
|
|
3736
|
+
if (rect.width <= 2 || rect.height <= 2)
|
|
3737
|
+
return true;
|
|
3738
|
+
if (rect.right + window.scrollX <= 0 || rect.bottom + window.scrollY <= 0)
|
|
3739
|
+
return true;
|
|
3740
|
+
if (srOnly(el, style))
|
|
3741
|
+
return true;
|
|
3742
|
+
// A wrapper hides what it holds: a link inside an sr-only div keeps its own full-size box, and became
|
|
3743
|
+
// `click text="Hidden child link"` plus a url assertion; a button at opacity:0 was clicked. So every
|
|
3744
|
+
// ancestor is judged too: sr-only (clipped or classed), opacity:0 (for engines without
|
|
3745
|
+
// checkVisibility), or a 1-2px box that clips its overflow. display:contents has no box to measure.
|
|
3746
|
+
// (v8 b39, 2026-10-10)
|
|
3747
|
+
for (let p = el.parentElement; p && p !== document.documentElement; p = p.parentElement) {
|
|
3748
|
+
const s = getComputedStyle(p);
|
|
3749
|
+
if (s.display === "contents")
|
|
3750
|
+
continue;
|
|
3751
|
+
if (srOnly(p, s) || s.opacity === "0")
|
|
3752
|
+
return true;
|
|
3753
|
+
if (s.overflowX !== "visible" || s.overflowY !== "visible") {
|
|
3754
|
+
const r = p.getBoundingClientRect();
|
|
3755
|
+
if (r.width <= 2 || r.height <= 2)
|
|
3756
|
+
return true;
|
|
3757
|
+
}
|
|
3758
|
+
}
|
|
3759
|
+
return false;
|
|
3760
|
+
};
|
|
3761
|
+
// A link to a fragment of this same page ("#main", "#") scrolls or moves focus; it never navigates.
|
|
3762
|
+
const inPageLink = (el) => {
|
|
3763
|
+
if (!(el.getAttribute("href") || "").includes("#"))
|
|
3764
|
+
return false;
|
|
3765
|
+
try {
|
|
3766
|
+
const u = new URL(el.href);
|
|
3767
|
+
return u.origin + u.pathname + u.search === location.origin + location.pathname + location.search;
|
|
3768
|
+
}
|
|
3769
|
+
catch {
|
|
3770
|
+
return false;
|
|
3771
|
+
}
|
|
3772
|
+
};
|
|
3773
|
+
const visibility = (el) => (hiddenFromView(el) ? { hidden: true } : {});
|
|
3551
3774
|
// Analyze forms
|
|
3552
3775
|
const forms = Array.from(document.querySelectorAll("form")).map(form => {
|
|
3553
3776
|
const fields = Array.from(form.querySelectorAll("input, select, textarea")).map(el => ({
|
|
3777
|
+
...visibility(el),
|
|
3554
3778
|
type: el.tagName.toLowerCase(),
|
|
3555
3779
|
selector: getSelector(el),
|
|
3556
3780
|
name: el.getAttribute("name") || undefined,
|
|
@@ -3562,6 +3786,7 @@ For more help: https://playwright.dev/docs/browsers
|
|
|
3562
3786
|
}));
|
|
3563
3787
|
const submitBtn = form.querySelector('button[type="submit"], input[type="submit"]');
|
|
3564
3788
|
const submitButton = submitBtn ? {
|
|
3789
|
+
...visibility(submitBtn),
|
|
3565
3790
|
type: "button",
|
|
3566
3791
|
selector: getSelector(submitBtn),
|
|
3567
3792
|
text: submitBtn.textContent?.trim(),
|
|
@@ -3646,6 +3871,7 @@ For more help: https://playwright.dev/docs/browsers
|
|
|
3646
3871
|
const buttons = Array.from(document.querySelectorAll('button, [role="button"], input[type="button"]'))
|
|
3647
3872
|
.slice(0, 20)
|
|
3648
3873
|
.map(el => ({
|
|
3874
|
+
...visibility(el),
|
|
3649
3875
|
type: "button",
|
|
3650
3876
|
selector: getSelector(el),
|
|
3651
3877
|
text: el.textContent?.trim(),
|
|
@@ -3657,6 +3883,8 @@ For more help: https://playwright.dev/docs/browsers
|
|
|
3657
3883
|
const links = Array.from(document.querySelectorAll("a[href]"))
|
|
3658
3884
|
.slice(0, 20)
|
|
3659
3885
|
.map(el => ({
|
|
3886
|
+
...visibility(el),
|
|
3887
|
+
...(inPageLink(el) ? { inPage: true } : {}),
|
|
3660
3888
|
type: "link",
|
|
3661
3889
|
selector: getSelector(el),
|
|
3662
3890
|
text: el.textContent?.trim(),
|
|
@@ -3708,57 +3936,118 @@ For more help: https://playwright.dev/docs/browsers
|
|
|
3708
3936
|
}
|
|
3709
3937
|
/**
|
|
3710
3938
|
* Generate test scenarios from page analysis.
|
|
3939
|
+
*
|
|
3940
|
+
* zones: the click gate's zone per selector (clickTargetZones). A red control is never an exploratory
|
|
3941
|
+
* click: it is left out and named in the test's notes. A form's own submit that is red stays (the test
|
|
3942
|
+
* means nothing without it) but is marked zone "red" and says it will be refused. Hidden elements and
|
|
3943
|
+
* in-page links make no step at all. (v8 b39, 2026-10-10)
|
|
3711
3944
|
*/
|
|
3712
|
-
generateTestScenarios(analysis) {
|
|
3945
|
+
generateTestScenarios(analysis, zones = new Map()) {
|
|
3713
3946
|
const tests = [];
|
|
3947
|
+
const isRed = (target) => (zones.get(target) ?? this.classifyAction("click", target, { ignoreBlack: true })) === "red";
|
|
3948
|
+
const REFUSED = "the click gate refuses it without force: true, and NL test steps never pass force";
|
|
3949
|
+
const clickStep = (target, description) => isRed(target)
|
|
3950
|
+
? { action: "click", target, description: `${description} (red zone: ${REFUSED})`, zone: "red" }
|
|
3951
|
+
: { action: "click", target, description };
|
|
3952
|
+
// The first n elements that are not red; each red one passed over is named in notes.
|
|
3953
|
+
const notRed = (els, n, notes) => {
|
|
3954
|
+
const picked = [];
|
|
3955
|
+
for (const el of els) {
|
|
3956
|
+
if (picked.length >= n)
|
|
3957
|
+
break;
|
|
3958
|
+
if (isRed(el.selector)) {
|
|
3959
|
+
// One line: a note is emitted as a comment, and a newline from textContent would end the comment
|
|
3960
|
+
// and leave the rest as a script step or as Playwright code.
|
|
3961
|
+
const note = `Not clicked: "${el.text || el.ariaLabel || el.selector}" (${el.selector}) is a red-zone control; ${REFUSED}.`;
|
|
3962
|
+
notes.push(note.replace(/\s+/g, " "));
|
|
3963
|
+
continue;
|
|
3964
|
+
}
|
|
3965
|
+
picked.push(el);
|
|
3966
|
+
}
|
|
3967
|
+
return picked;
|
|
3968
|
+
};
|
|
3969
|
+
// Notes ride on the test they explain; when every candidate was red there is no such test, and they
|
|
3970
|
+
// ride on the smoke test instead, so the caller still learns the control exists.
|
|
3971
|
+
const unplaced = [];
|
|
3972
|
+
const withNotes = (test, notes) => (notes.length ? { ...test, notes } : test);
|
|
3973
|
+
// A form's submit click, or none. A submit hidden from view is no target: falling back to the bare word
|
|
3974
|
+
// made `click Search` and `page.locator('Search')`, which resolve to nothing (6fbeda6 clicked the hidden
|
|
3975
|
+
// button itself). The NL dialect has no key press ("press Enter" parses as a click on "Enter"), so there
|
|
3976
|
+
// is no Enter step to offer instead: the click is left out and a note says why. A form with no submit
|
|
3977
|
+
// button at all keeps the word fallback it had before. (v8 b39, 2026-10-10)
|
|
3978
|
+
const submitSteps = (form, fallback, description) => form.submitButton?.hidden ? [] : [clickStep(form.submitButton?.selector || fallback, description)];
|
|
3979
|
+
const submitNotes = (form) => {
|
|
3980
|
+
const btn = form.submitButton;
|
|
3981
|
+
if (!btn?.hidden)
|
|
3982
|
+
return [];
|
|
3983
|
+
const note = `Not clicked: the form's submit "${btn.text || btn.selector}" (${btn.selector}) is hidden from view, and NL test steps have no key press; submit it by pressing Enter in the last field.`;
|
|
3984
|
+
return [note.replace(/\s+/g, " ")];
|
|
3985
|
+
};
|
|
3714
3986
|
// Generate form tests
|
|
3715
3987
|
for (const form of analysis.forms) {
|
|
3988
|
+
// A form whose every field is hidden (a collapsed search drawer) is not one a user can fill.
|
|
3989
|
+
const fields = form.fields.filter(f => !f.hidden);
|
|
3990
|
+
if (form.fields.length > 0 && fields.length === 0)
|
|
3991
|
+
continue;
|
|
3992
|
+
const formNotes = submitNotes(form);
|
|
3716
3993
|
if (form.purpose === "login") {
|
|
3717
|
-
tests.push({
|
|
3994
|
+
tests.push(withNotes({
|
|
3718
3995
|
name: "Login Form Submission",
|
|
3719
3996
|
description: "Test the login form with valid credentials",
|
|
3720
3997
|
steps: [
|
|
3721
3998
|
{ action: "navigate", target: analysis.url, description: "Navigate to page" },
|
|
3722
|
-
...
|
|
3999
|
+
...fields.filter(f => f.inputType !== "submit").map(f => ({
|
|
3723
4000
|
action: "fill",
|
|
3724
4001
|
target: f.selector,
|
|
3725
4002
|
value: f.inputType === "email" ? "test@example.com" : f.inputType === "password" ? "TestPassword123" : "test value",
|
|
3726
4003
|
description: `Fill ${f.name || f.placeholder || "field"}`,
|
|
3727
4004
|
})),
|
|
3728
|
-
|
|
4005
|
+
...submitSteps(form, "Submit", "Submit form"),
|
|
3729
4006
|
{ action: "wait", value: "1000", description: "Wait for response" },
|
|
3730
4007
|
],
|
|
3731
4008
|
assertions: ["url contains '/dashboard' OR url contains '/home'", "page does not contain 'error'"],
|
|
3732
|
-
});
|
|
3733
|
-
tests.push({
|
|
4009
|
+
}, formNotes));
|
|
4010
|
+
tests.push(withNotes({
|
|
3734
4011
|
name: "Login Form Validation",
|
|
3735
4012
|
description: "Test login form with invalid credentials",
|
|
3736
4013
|
steps: [
|
|
3737
4014
|
{ action: "navigate", target: analysis.url, description: "Navigate to page" },
|
|
3738
|
-
{ action: "fill", target:
|
|
3739
|
-
|
|
4015
|
+
{ action: "fill", target: fields.find(f => f.inputType === "email")?.selector || "", value: "invalid", description: "Enter invalid email" },
|
|
4016
|
+
...submitSteps(form, "Submit", "Submit form"),
|
|
3740
4017
|
],
|
|
3741
4018
|
assertions: ["page contains 'error' OR page contains 'invalid'"],
|
|
3742
|
-
});
|
|
4019
|
+
}, formNotes));
|
|
3743
4020
|
}
|
|
3744
4021
|
if (form.purpose === "search") {
|
|
3745
|
-
tests.push({
|
|
4022
|
+
tests.push(withNotes({
|
|
3746
4023
|
name: "Search Functionality",
|
|
3747
4024
|
description: "Test search with a query",
|
|
3748
4025
|
steps: [
|
|
3749
4026
|
{ action: "navigate", target: analysis.url, description: "Navigate to page" },
|
|
3750
|
-
{ action: "fill", target:
|
|
3751
|
-
|
|
4027
|
+
{ action: "fill", target: fields[0]?.selector || "[type='search']", value: "test query", description: "Enter search term" },
|
|
4028
|
+
...submitSteps(form, "Search", "Submit search"),
|
|
3752
4029
|
],
|
|
3753
4030
|
assertions: ["url contains 'search' OR url contains 'q='", "page contains 'result'"],
|
|
3754
|
-
});
|
|
4031
|
+
}, formNotes));
|
|
3755
4032
|
}
|
|
3756
4033
|
}
|
|
3757
4034
|
// Generate navigation tests
|
|
3758
4035
|
if (analysis.hasNavigation) {
|
|
3759
|
-
|
|
4036
|
+
// Not a skip link or any other "#fragment" link (it never navigates), nothing a user cannot see, and
|
|
4037
|
+
// no red control.
|
|
4038
|
+
const navNotes = [];
|
|
4039
|
+
const navLinks = notRed(analysis.links.filter(l => l.href?.startsWith(analysis.url.split("/").slice(0, 3).join("/")) && !l.hidden && !l.inPage), 5, navNotes);
|
|
4040
|
+
// Each pass starts again from the page under test, which is "home" only when it is the site root: a
|
|
4041
|
+
// page at /account/settings said "Start from home" for every link. (v8 b39, 2026-10-10)
|
|
4042
|
+
let startPath = analysis.url;
|
|
4043
|
+
try {
|
|
4044
|
+
const u = new URL(analysis.url);
|
|
4045
|
+
startPath = u.pathname + u.search;
|
|
4046
|
+
}
|
|
4047
|
+
catch { /* keep the url */ }
|
|
4048
|
+
const startDescription = startPath === "/" ? "Start from home" : `Start from ${startPath}`;
|
|
3760
4049
|
if (navLinks.length > 0) {
|
|
3761
|
-
tests.push({
|
|
4050
|
+
tests.push(withNotes({
|
|
3762
4051
|
name: "Navigation Links",
|
|
3763
4052
|
description: "Test main navigation links work",
|
|
3764
4053
|
// A same-page link has pathname "/", and `assert url contains '/'` is
|
|
@@ -3766,7 +4055,7 @@ For more help: https://playwright.dev/docs/browsers
|
|
|
3766
4055
|
// than no step, because it reads in the report as verified navigation.
|
|
3767
4056
|
// Emit the assertion only when the path can actually distinguish the
|
|
3768
4057
|
// destination from the origin. (2026-07-29)
|
|
3769
|
-
steps: navLinks.
|
|
4058
|
+
steps: navLinks.flatMap(link => {
|
|
3770
4059
|
let path = "";
|
|
3771
4060
|
try {
|
|
3772
4061
|
path = new URL(link.href || "", analysis.url).pathname;
|
|
@@ -3776,7 +4065,7 @@ For more help: https://playwright.dev/docs/browsers
|
|
|
3776
4065
|
}
|
|
3777
4066
|
const discriminating = path.length > 1 && path !== new URL(analysis.url).pathname;
|
|
3778
4067
|
return [
|
|
3779
|
-
{ action: "navigate", target: analysis.url, description:
|
|
4068
|
+
{ action: "navigate", target: analysis.url, description: startDescription },
|
|
3780
4069
|
{ action: "click", target: link.selector, description: `Click ${link.text || "link"}` },
|
|
3781
4070
|
...(discriminating
|
|
3782
4071
|
? [{ action: "assert", target: `url contains '${path}'`, description: "Verify navigation" }]
|
|
@@ -3784,28 +4073,35 @@ For more help: https://playwright.dev/docs/browsers
|
|
|
3784
4073
|
];
|
|
3785
4074
|
}),
|
|
3786
4075
|
assertions: ["no console errors"],
|
|
3787
|
-
});
|
|
4076
|
+
}, navNotes));
|
|
4077
|
+
}
|
|
4078
|
+
else {
|
|
4079
|
+
unplaced.push(...navNotes);
|
|
3788
4080
|
}
|
|
3789
4081
|
}
|
|
3790
4082
|
// Generate button interaction tests
|
|
3791
|
-
const
|
|
4083
|
+
const buttonNotes = [];
|
|
4084
|
+
const actionButtons = notRed(analysis.buttons.filter(b => b.text && !b.text.toLowerCase().includes("submit") && b.text.length < 30 && !b.hidden), 3, buttonNotes);
|
|
3792
4085
|
if (actionButtons.length > 0) {
|
|
3793
|
-
tests.push({
|
|
4086
|
+
tests.push(withNotes({
|
|
3794
4087
|
name: "Button Interactions",
|
|
3795
4088
|
description: "Test clickable buttons respond correctly",
|
|
3796
4089
|
steps: [
|
|
3797
4090
|
{ action: "navigate", target: analysis.url, description: "Navigate to page" },
|
|
3798
|
-
...actionButtons.
|
|
4091
|
+
...actionButtons.map(btn => ({
|
|
3799
4092
|
action: "click",
|
|
3800
4093
|
target: btn.selector,
|
|
3801
4094
|
description: `Click "${btn.text}"`,
|
|
3802
4095
|
})),
|
|
3803
4096
|
],
|
|
3804
4097
|
assertions: ["no console errors", "page is interactive"],
|
|
3805
|
-
});
|
|
4098
|
+
}, buttonNotes));
|
|
4099
|
+
}
|
|
4100
|
+
else {
|
|
4101
|
+
unplaced.push(...buttonNotes);
|
|
3806
4102
|
}
|
|
3807
4103
|
// Default smoke test
|
|
3808
|
-
tests.push({
|
|
4104
|
+
tests.push(withNotes({
|
|
3809
4105
|
name: "Page Load Smoke Test",
|
|
3810
4106
|
description: "Verify page loads without errors",
|
|
3811
4107
|
steps: [
|
|
@@ -3813,7 +4109,7 @@ For more help: https://playwright.dev/docs/browsers
|
|
|
3813
4109
|
{ action: "assert", target: `title contains '${analysis.title.split(" ")[0]}'`, description: "Verify title" },
|
|
3814
4110
|
],
|
|
3815
4111
|
assertions: ["page loads successfully", "no console errors"],
|
|
3816
|
-
});
|
|
4112
|
+
}, unplaced));
|
|
3817
4113
|
return tests;
|
|
3818
4114
|
}
|
|
3819
4115
|
/**
|
|
@@ -3824,6 +4120,11 @@ For more help: https://playwright.dev/docs/browsers
|
|
|
3824
4120
|
for (const test of tests) {
|
|
3825
4121
|
script += `# ${test.name}\n`;
|
|
3826
4122
|
script += `# ${test.description}\n`;
|
|
4123
|
+
// `//`, not `#`: parseNLTestSuite drops `//` lines, but reads every `#` line as a new test header,
|
|
4124
|
+
// so a `#` note would rename the test or split it in two. (v8 b39, 2026-10-10)
|
|
4125
|
+
for (const note of test.notes ?? []) {
|
|
4126
|
+
script += `// note: ${note}\n`;
|
|
4127
|
+
}
|
|
3827
4128
|
// This must be the dialect src/testing/nl-test-suite.ts PARSES, because the
|
|
3828
4129
|
// MCP tool tells callers to paste it straight into nl_test_inline. It used
|
|
3829
4130
|
// to emit a `verb "quoted-arg"` form of its own invention, and every line
|
|
@@ -3837,6 +4138,9 @@ For more help: https://playwright.dev/docs/browsers
|
|
|
3837
4138
|
script += `go to ${step.target}\n`;
|
|
3838
4139
|
break;
|
|
3839
4140
|
case "click":
|
|
4141
|
+
if (step.zone === "red") {
|
|
4142
|
+
script += `// red zone: the next click is refused without force: true, and NL test steps never pass force\n`;
|
|
4143
|
+
}
|
|
3840
4144
|
// Unquoted: the parser takes the rest of the line as the target.
|
|
3841
4145
|
script += `click ${step.target}\n`;
|
|
3842
4146
|
break;
|
|
@@ -3872,20 +4176,27 @@ For more help: https://playwright.dev/docs/browsers
|
|
|
3872
4176
|
for (const testDef of tests) {
|
|
3873
4177
|
const _testName = testDef.name.toLowerCase().replace(/\s+/g, "-");
|
|
3874
4178
|
code += `test('${testDef.name}', async ({ page }) => {\n`;
|
|
3875
|
-
code += ` // ${testDef.description}\n
|
|
4179
|
+
code += ` // ${testDef.description}\n`;
|
|
4180
|
+
for (const note of testDef.notes ?? []) {
|
|
4181
|
+
code += ` // note: ${note}\n`;
|
|
4182
|
+
}
|
|
4183
|
+
code += `\n`;
|
|
3876
4184
|
for (const step of testDef.steps) {
|
|
3877
4185
|
switch (step.action) {
|
|
3878
4186
|
case "navigate":
|
|
3879
4187
|
code += ` await page.goto('${step.target}');\n`;
|
|
3880
4188
|
break;
|
|
3881
|
-
case "click":
|
|
3882
|
-
|
|
3883
|
-
|
|
3884
|
-
|
|
3885
|
-
|
|
3886
|
-
|
|
3887
|
-
|
|
4189
|
+
case "click": {
|
|
4190
|
+
const click = step.target?.startsWith("text=")
|
|
4191
|
+
? `await page.getByText('${step.target.replace("text=", "").replace(/"/g, "")}').click();`
|
|
4192
|
+
: `await page.locator('${step.target}').click();`;
|
|
4193
|
+
// Playwright has no zone gate: an exported red click would simply run. It ships commented out.
|
|
4194
|
+
// (v8 b39, 2026-10-10)
|
|
4195
|
+
code += step.zone === "red"
|
|
4196
|
+
? ` // red zone: cbrowser refuses this click without force; left commented out so this test cannot run it.\n // ${click}\n`
|
|
4197
|
+
: ` ${click}\n`;
|
|
3888
4198
|
break;
|
|
4199
|
+
}
|
|
3889
4200
|
case "fill":
|
|
3890
4201
|
code += ` await page.locator('${step.target}').fill('${step.value}');\n`;
|
|
3891
4202
|
break;
|
|
@@ -5255,12 +5566,16 @@ For more help: https://playwright.dev/docs/browsers
|
|
|
5255
5566
|
this.config.viewportHeight = persona.context.viewport[1];
|
|
5256
5567
|
}
|
|
5257
5568
|
const page = await this.getPage();
|
|
5258
|
-
const steps = [];
|
|
5259
5569
|
const consoleLogs = [];
|
|
5260
5570
|
const frictionPoints = [];
|
|
5261
5571
|
const startTime = Date.now();
|
|
5262
|
-
// Capture console logs
|
|
5263
|
-
|
|
5572
|
+
// Capture console logs. Removed in the finally below (v8 B27, 2026-10-10):
|
|
5573
|
+
// it used to stay on the tab, which is the shared page in a hosted session,
|
|
5574
|
+
// so every heuristic journey left one more listener and log array behind
|
|
5575
|
+
// (measured 4 -> 7 console listeners over three runs). Not read from the
|
|
5576
|
+
// tab's console buffer instead: that keeps 500 entries and omits an empty
|
|
5577
|
+
// location, so a noisy page or an inline script would change consoleLogs.
|
|
5578
|
+
const onConsole = (msg) => {
|
|
5264
5579
|
const entry = {
|
|
5265
5580
|
type: msg.type(),
|
|
5266
5581
|
text: msg.text().substring(0, 500),
|
|
@@ -5272,7 +5587,19 @@ For more help: https://playwright.dev/docs/browsers
|
|
|
5272
5587
|
if (msg.type() === "error") {
|
|
5273
5588
|
frictionPoints.push(`Console error: ${msg.text().substring(0, 100)}`);
|
|
5274
5589
|
}
|
|
5275
|
-
}
|
|
5590
|
+
};
|
|
5591
|
+
page.on("console", onConsole);
|
|
5592
|
+
try {
|
|
5593
|
+
return await this.heuristicJourney(page, persona, options, consoleLogs, frictionPoints, startTime);
|
|
5594
|
+
}
|
|
5595
|
+
finally {
|
|
5596
|
+
page.off("console", onConsole);
|
|
5597
|
+
}
|
|
5598
|
+
}
|
|
5599
|
+
/** journey()'s heuristic exploration; journey's listener fills `consoleLogs`. */
|
|
5600
|
+
async heuristicJourney(page, persona, options, consoleLogs, frictionPoints, startTime) {
|
|
5601
|
+
const { persona: personaName, startUrl, goal, maxSteps = 20 } = options;
|
|
5602
|
+
const steps = [];
|
|
5276
5603
|
// Navigate to start
|
|
5277
5604
|
await this.navigate(startUrl);
|
|
5278
5605
|
steps.push({
|