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.
Files changed (159) hide show
  1. package/dist/analysis/accessibility-empathy.d.ts.map +1 -1
  2. package/dist/analysis/accessibility-empathy.js +10 -1
  3. package/dist/analysis/accessibility-empathy.js.map +1 -1
  4. package/dist/analysis/agent-ready-audit.d.ts +9 -0
  5. package/dist/analysis/agent-ready-audit.d.ts.map +1 -1
  6. package/dist/analysis/agent-ready-audit.js +79 -6
  7. package/dist/analysis/agent-ready-audit.js.map +1 -1
  8. package/dist/analysis/bug-hunter.d.ts.map +1 -1
  9. package/dist/analysis/bug-hunter.js +27 -10
  10. package/dist/analysis/bug-hunter.js.map +1 -1
  11. package/dist/analysis/chaos-testing.d.ts.map +1 -1
  12. package/dist/analysis/chaos-testing.js +12 -3
  13. package/dist/analysis/chaos-testing.js.map +1 -1
  14. package/dist/analysis/competitive-benchmark.d.ts +50 -7
  15. package/dist/analysis/competitive-benchmark.d.ts.map +1 -1
  16. package/dist/analysis/competitive-benchmark.js +226 -106
  17. package/dist/analysis/competitive-benchmark.js.map +1 -1
  18. package/dist/analysis/page-understanding.d.ts +2 -0
  19. package/dist/analysis/page-understanding.d.ts.map +1 -1
  20. package/dist/analysis/page-understanding.js +104 -17
  21. package/dist/analysis/page-understanding.js.map +1 -1
  22. package/dist/artifact-store.d.ts +24 -2
  23. package/dist/artifact-store.d.ts.map +1 -1
  24. package/dist/artifact-store.js +34 -4
  25. package/dist/artifact-store.js.map +1 -1
  26. package/dist/browser/session-manager.d.ts.map +1 -1
  27. package/dist/browser/session-manager.js +3 -0
  28. package/dist/browser/session-manager.js.map +1 -1
  29. package/dist/browser/site-profile-manager.d.ts +17 -1
  30. package/dist/browser/site-profile-manager.d.ts.map +1 -1
  31. package/dist/browser/site-profile-manager.js +49 -12
  32. package/dist/browser/site-profile-manager.js.map +1 -1
  33. package/dist/browser.d.ts +58 -1
  34. package/dist/browser.d.ts.map +1 -1
  35. package/dist/browser.js +377 -50
  36. package/dist/browser.js.map +1 -1
  37. package/dist/charge-scope.d.ts.map +1 -1
  38. package/dist/charge-scope.js +6 -4
  39. package/dist/charge-scope.js.map +1 -1
  40. package/dist/cli.js +3 -0
  41. package/dist/cli.js.map +1 -1
  42. package/dist/config.d.ts.map +1 -1
  43. package/dist/config.js +8 -2
  44. package/dist/config.js.map +1 -1
  45. package/dist/mcp-server-remote.d.ts +27 -1
  46. package/dist/mcp-server-remote.d.ts.map +1 -1
  47. package/dist/mcp-server-remote.js +160 -87
  48. package/dist/mcp-server-remote.js.map +1 -1
  49. package/dist/mcp-server.d.ts.map +1 -1
  50. package/dist/mcp-server.js +4 -0
  51. package/dist/mcp-server.js.map +1 -1
  52. package/dist/mcp-tools/base/analysis-tools.d.ts.map +1 -1
  53. package/dist/mcp-tools/base/analysis-tools.js +5 -0
  54. package/dist/mcp-tools/base/analysis-tools.js.map +1 -1
  55. package/dist/mcp-tools/base/audit-tools.d.ts +14 -0
  56. package/dist/mcp-tools/base/audit-tools.d.ts.map +1 -1
  57. package/dist/mcp-tools/base/audit-tools.js +17 -8
  58. package/dist/mcp-tools/base/audit-tools.js.map +1 -1
  59. package/dist/mcp-tools/base/browser-state-tools.d.ts.map +1 -1
  60. package/dist/mcp-tools/base/browser-state-tools.js +18 -27
  61. package/dist/mcp-tools/base/browser-state-tools.js.map +1 -1
  62. package/dist/mcp-tools/base/capture-tools.d.ts +18 -0
  63. package/dist/mcp-tools/base/capture-tools.d.ts.map +1 -1
  64. package/dist/mcp-tools/base/capture-tools.js +109 -55
  65. package/dist/mcp-tools/base/capture-tools.js.map +1 -1
  66. package/dist/mcp-tools/base/cognitive-tools.d.ts.map +1 -1
  67. package/dist/mcp-tools/base/cognitive-tools.js +43 -16
  68. package/dist/mcp-tools/base/cognitive-tools.js.map +1 -1
  69. package/dist/mcp-tools/base/marketing-tools.d.ts.map +1 -1
  70. package/dist/mcp-tools/base/marketing-tools.js +8 -4
  71. package/dist/mcp-tools/base/marketing-tools.js.map +1 -1
  72. package/dist/mcp-tools/base/persona-comparison-tools.d.ts.map +1 -1
  73. package/dist/mcp-tools/base/persona-comparison-tools.js +14 -3
  74. package/dist/mcp-tools/base/persona-comparison-tools.js.map +1 -1
  75. package/dist/mcp-tools/base/remediation-tools.d.ts.map +1 -1
  76. package/dist/mcp-tools/base/remediation-tools.js +10 -2
  77. package/dist/mcp-tools/base/remediation-tools.js.map +1 -1
  78. package/dist/mcp-tools/base/session-tools.d.ts.map +1 -1
  79. package/dist/mcp-tools/base/session-tools.js +2 -0
  80. package/dist/mcp-tools/base/session-tools.js.map +1 -1
  81. package/dist/mcp-tools/base/visual-testing-tools.d.ts +13 -0
  82. package/dist/mcp-tools/base/visual-testing-tools.d.ts.map +1 -1
  83. package/dist/mcp-tools/base/visual-testing-tools.js +45 -16
  84. package/dist/mcp-tools/base/visual-testing-tools.js.map +1 -1
  85. package/dist/mcp-tools/persona-creation-tools.d.ts.map +1 -1
  86. package/dist/mcp-tools/persona-creation-tools.js +59 -13
  87. package/dist/mcp-tools/persona-creation-tools.js.map +1 -1
  88. package/dist/mcp-tools/persona-lifecycle-tools.d.ts.map +1 -1
  89. package/dist/mcp-tools/persona-lifecycle-tools.js +4 -2
  90. package/dist/mcp-tools/persona-lifecycle-tools.js.map +1 -1
  91. package/dist/mcp-tools/tier-gate.d.ts +4 -4
  92. package/dist/mcp-tools/tier-gate.d.ts.map +1 -1
  93. package/dist/mcp-tools/tier-gate.js +41 -19
  94. package/dist/mcp-tools/tier-gate.js.map +1 -1
  95. package/dist/mcp-tools/tool-categories.d.ts +11 -0
  96. package/dist/mcp-tools/tool-categories.d.ts.map +1 -1
  97. package/dist/mcp-tools/tool-categories.js +27 -1
  98. package/dist/mcp-tools/tool-categories.js.map +1 -1
  99. package/dist/mcp-tools/ui-resources.d.ts.map +1 -1
  100. package/dist/mcp-tools/ui-resources.js +32 -10
  101. package/dist/mcp-tools/ui-resources.js.map +1 -1
  102. package/dist/personas.d.ts +14 -0
  103. package/dist/personas.d.ts.map +1 -1
  104. package/dist/personas.js +26 -0
  105. package/dist/personas.js.map +1 -1
  106. package/dist/recording/engine.d.ts.map +1 -1
  107. package/dist/recording/engine.js +2 -1
  108. package/dist/recording/engine.js.map +1 -1
  109. package/dist/remediation/patch-generator.d.ts.map +1 -1
  110. package/dist/remediation/patch-generator.js +7 -1
  111. package/dist/remediation/patch-generator.js.map +1 -1
  112. package/dist/remediation/structured-data.d.ts +73 -1
  113. package/dist/remediation/structured-data.d.ts.map +1 -1
  114. package/dist/remediation/structured-data.js +239 -63
  115. package/dist/remediation/structured-data.js.map +1 -1
  116. package/dist/request-context.d.ts +18 -0
  117. package/dist/request-context.d.ts.map +1 -0
  118. package/dist/request-context.js +56 -0
  119. package/dist/request-context.js.map +1 -0
  120. package/dist/site-model/manager.d.ts +14 -1
  121. package/dist/site-model/manager.d.ts.map +1 -1
  122. package/dist/site-model/manager.js +24 -1
  123. package/dist/site-model/manager.js.map +1 -1
  124. package/dist/types.d.ts +90 -2
  125. package/dist/types.d.ts.map +1 -1
  126. package/dist/types.js.map +1 -1
  127. package/dist/values/index.d.ts +1 -1
  128. package/dist/values/index.d.ts.map +1 -1
  129. package/dist/values/index.js +1 -1
  130. package/dist/values/index.js.map +1 -1
  131. package/dist/values/persona-values.d.ts +16 -0
  132. package/dist/values/persona-values.d.ts.map +1 -1
  133. package/dist/values/persona-values.js +56 -4
  134. package/dist/values/persona-values.js.map +1 -1
  135. package/dist/visual/ab-comparison.d.ts +22 -0
  136. package/dist/visual/ab-comparison.d.ts.map +1 -1
  137. package/dist/visual/ab-comparison.js +36 -9
  138. package/dist/visual/ab-comparison.js.map +1 -1
  139. package/dist/visual/cognitive-transport.d.ts +15 -1
  140. package/dist/visual/cognitive-transport.d.ts.map +1 -1
  141. package/dist/visual/cognitive-transport.js +42 -12
  142. package/dist/visual/cognitive-transport.js.map +1 -1
  143. package/dist/visual/cross-browser.d.ts +8 -0
  144. package/dist/visual/cross-browser.d.ts.map +1 -1
  145. package/dist/visual/cross-browser.js +8 -4
  146. package/dist/visual/cross-browser.js.map +1 -1
  147. package/dist/visual/distance-metrics.d.ts +11 -2
  148. package/dist/visual/distance-metrics.d.ts.map +1 -1
  149. package/dist/visual/distance-metrics.js +44 -5
  150. package/dist/visual/distance-metrics.js.map +1 -1
  151. package/dist/visual/regression.d.ts +61 -0
  152. package/dist/visual/regression.d.ts.map +1 -1
  153. package/dist/visual/regression.js +123 -50
  154. package/dist/visual/regression.js.map +1 -1
  155. package/dist/visual/responsive.d.ts +8 -0
  156. package/dist/visual/responsive.d.ts.map +1 -1
  157. package/dist/visual/responsive.js +95 -17
  158. package/dist/visual/responsive.js.map +1 -1
  159. 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
- // Capture console errors
1705
- page.on("console", (msg) => {
1706
- if (msg.type() === "error") {
1707
- errors.push(msg.text());
1708
- }
1709
- else if (msg.type() === "warning") {
1710
- warnings.push(msg.text());
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
- ...form.fields.filter(f => f.inputType !== "submit").map(f => ({
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
- { action: "click", target: form.submitButton?.selector || "Submit", description: "Submit form" },
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: form.fields.find(f => f.inputType === "email")?.selector || "", value: "invalid", description: "Enter invalid email" },
3739
- { action: "click", target: form.submitButton?.selector || "Submit", description: "Submit form" },
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: form.fields[0]?.selector || "[type='search']", value: "test query", description: "Enter search term" },
3751
- { action: "click", target: form.submitButton?.selector || "Search", description: "Submit search" },
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
- const navLinks = analysis.links.filter(l => l.href?.startsWith(analysis.url.split("/").slice(0, 3).join("/")));
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.slice(0, 5).flatMap(link => {
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: "Start from home" },
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 actionButtons = analysis.buttons.filter(b => b.text && !b.text.toLowerCase().includes("submit") && b.text.length < 30);
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.slice(0, 3).map(btn => ({
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\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
- if (step.target?.startsWith("text=")) {
3883
- code += ` await page.getByText('${step.target.replace("text=", "").replace(/"/g, "")}').click();\n`;
3884
- }
3885
- else {
3886
- code += ` await page.locator('${step.target}').click();\n`;
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
- page.on("console", (msg) => {
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({