cbrowser 19.5.0 → 20.0.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.
Files changed (187) hide show
  1. package/README.md +292 -232
  2. package/THIRD_PARTY_LICENSES.md +455 -7
  3. package/dist/analysis/accessibility-empathy.d.ts.map +1 -1
  4. package/dist/analysis/accessibility-empathy.js +2 -0
  5. package/dist/analysis/accessibility-empathy.js.map +1 -1
  6. package/dist/analysis/competitive-benchmark.d.ts.map +1 -1
  7. package/dist/analysis/competitive-benchmark.js +70 -558
  8. package/dist/analysis/competitive-benchmark.js.map +1 -1
  9. package/dist/analysis/natural-language.d.ts.map +1 -1
  10. package/dist/analysis/natural-language.js +43 -0
  11. package/dist/analysis/natural-language.js.map +1 -1
  12. package/dist/analysis/persona-comparison.d.ts +7 -0
  13. package/dist/analysis/persona-comparison.d.ts.map +1 -1
  14. package/dist/analysis/persona-comparison.js +8 -1
  15. package/dist/analysis/persona-comparison.js.map +1 -1
  16. package/dist/browser-options.d.ts +23 -0
  17. package/dist/browser-options.d.ts.map +1 -0
  18. package/dist/browser-options.js +24 -0
  19. package/dist/browser-options.js.map +1 -0
  20. package/dist/browser.d.ts +36 -14
  21. package/dist/browser.d.ts.map +1 -1
  22. package/dist/browser.js +172 -202
  23. package/dist/browser.js.map +1 -1
  24. package/dist/cli-args.d.ts +9 -1
  25. package/dist/cli-args.d.ts.map +1 -1
  26. package/dist/cli-args.js +35 -7
  27. package/dist/cli-args.js.map +1 -1
  28. package/dist/cli-browser-flags.d.ts +33 -0
  29. package/dist/cli-browser-flags.d.ts.map +1 -0
  30. package/dist/cli-browser-flags.js +89 -0
  31. package/dist/cli-browser-flags.js.map +1 -0
  32. package/dist/cli.js +1058 -478
  33. package/dist/cli.js.map +1 -1
  34. package/dist/cognitive/index.d.ts +5 -13
  35. package/dist/cognitive/index.d.ts.map +1 -1
  36. package/dist/cognitive/index.js +44 -15
  37. package/dist/cognitive/index.js.map +1 -1
  38. package/dist/config.d.ts +9 -0
  39. package/dist/config.d.ts.map +1 -1
  40. package/dist/config.js +16 -6
  41. package/dist/config.js.map +1 -1
  42. package/dist/daemon.d.ts +9 -5
  43. package/dist/daemon.d.ts.map +1 -1
  44. package/dist/daemon.js +102 -36
  45. package/dist/daemon.js.map +1 -1
  46. package/dist/device-names.d.ts +10 -0
  47. package/dist/device-names.d.ts.map +1 -0
  48. package/dist/device-names.js +16 -0
  49. package/dist/device-names.js.map +1 -0
  50. package/dist/device-presets-devtools.d.ts +3 -0
  51. package/dist/device-presets-devtools.d.ts.map +1 -0
  52. package/dist/device-presets-devtools.js +823 -0
  53. package/dist/device-presets-devtools.js.map +1 -0
  54. package/dist/devices.d.ts +35 -0
  55. package/dist/devices.d.ts.map +1 -0
  56. package/dist/devices.js +214 -0
  57. package/dist/devices.js.map +1 -0
  58. package/dist/eval-script.d.ts +29 -0
  59. package/dist/eval-script.d.ts.map +1 -0
  60. package/dist/eval-script.js +46 -0
  61. package/dist/eval-script.js.map +1 -0
  62. package/dist/help-client.d.ts +48 -0
  63. package/dist/help-client.d.ts.map +1 -0
  64. package/dist/help-client.js +96 -0
  65. package/dist/help-client.js.map +1 -0
  66. package/dist/index.d.ts +1 -0
  67. package/dist/index.d.ts.map +1 -1
  68. package/dist/index.js +1 -0
  69. package/dist/index.js.map +1 -1
  70. package/dist/mcp-server.d.ts.map +1 -1
  71. package/dist/mcp-server.js +165 -2281
  72. package/dist/mcp-server.js.map +1 -1
  73. package/dist/mcp-tools/base/advanced-interaction-tools.d.ts.map +1 -1
  74. package/dist/mcp-tools/base/advanced-interaction-tools.js +21 -21
  75. package/dist/mcp-tools/base/advanced-interaction-tools.js.map +1 -1
  76. package/dist/mcp-tools/base/assertion-tools.d.ts.map +1 -1
  77. package/dist/mcp-tools/base/assertion-tools.js +6 -3
  78. package/dist/mcp-tools/base/assertion-tools.js.map +1 -1
  79. package/dist/mcp-tools/base/audit-tools.d.ts.map +1 -1
  80. package/dist/mcp-tools/base/audit-tools.js +15 -4
  81. package/dist/mcp-tools/base/audit-tools.js.map +1 -1
  82. package/dist/mcp-tools/base/browser-state-tools.d.ts.map +1 -1
  83. package/dist/mcp-tools/base/browser-state-tools.js +33 -27
  84. package/dist/mcp-tools/base/browser-state-tools.js.map +1 -1
  85. package/dist/mcp-tools/base/bug-analysis-tools.d.ts.map +1 -1
  86. package/dist/mcp-tools/base/bug-analysis-tools.js +9 -2
  87. package/dist/mcp-tools/base/bug-analysis-tools.js.map +1 -1
  88. package/dist/mcp-tools/base/capture-tools.d.ts +60 -4
  89. package/dist/mcp-tools/base/capture-tools.d.ts.map +1 -1
  90. package/dist/mcp-tools/base/capture-tools.js +118 -14
  91. package/dist/mcp-tools/base/capture-tools.js.map +1 -1
  92. package/dist/mcp-tools/base/index.d.ts +6 -5
  93. package/dist/mcp-tools/base/index.d.ts.map +1 -1
  94. package/dist/mcp-tools/base/index.js +7 -6
  95. package/dist/mcp-tools/base/index.js.map +1 -1
  96. package/dist/mcp-tools/base/interaction-tools.d.ts.map +1 -1
  97. package/dist/mcp-tools/base/interaction-tools.js +20 -6
  98. package/dist/mcp-tools/base/interaction-tools.js.map +1 -1
  99. package/dist/mcp-tools/base/navigation-tools.d.ts +1 -1
  100. package/dist/mcp-tools/base/navigation-tools.d.ts.map +1 -1
  101. package/dist/mcp-tools/base/navigation-tools.js +83 -3
  102. package/dist/mcp-tools/base/navigation-tools.js.map +1 -1
  103. package/dist/mcp-tools/base/site-knowledge-tools.d.ts.map +1 -1
  104. package/dist/mcp-tools/base/site-knowledge-tools.js +11 -23
  105. package/dist/mcp-tools/base/site-knowledge-tools.js.map +1 -1
  106. package/dist/mcp-tools/base/visual-testing-tools.d.ts.map +1 -1
  107. package/dist/mcp-tools/base/visual-testing-tools.js +13 -3
  108. package/dist/mcp-tools/base/visual-testing-tools.js.map +1 -1
  109. package/dist/mcp-tools/enterprise-stubs.d.ts +2 -2
  110. package/dist/mcp-tools/enterprise-stubs.d.ts.map +1 -1
  111. package/dist/mcp-tools/enterprise-stubs.js +29 -16
  112. package/dist/mcp-tools/enterprise-stubs.js.map +1 -1
  113. package/dist/mcp-tools/index.d.ts +19 -14
  114. package/dist/mcp-tools/index.d.ts.map +1 -1
  115. package/dist/mcp-tools/index.js +2 -2
  116. package/dist/mcp-tools/index.js.map +1 -1
  117. package/dist/mcp-tools/red-zone-refusal.d.ts +33 -0
  118. package/dist/mcp-tools/red-zone-refusal.d.ts.map +1 -0
  119. package/dist/mcp-tools/red-zone-refusal.js +43 -0
  120. package/dist/mcp-tools/red-zone-refusal.js.map +1 -0
  121. package/dist/mcp-tools/tier-gate.js +1 -1
  122. package/dist/mcp-tools/tier-gate.js.map +1 -1
  123. package/dist/mcp-tools/tool-categories.d.ts.map +1 -1
  124. package/dist/mcp-tools/tool-categories.js +2 -0
  125. package/dist/mcp-tools/tool-categories.js.map +1 -1
  126. package/dist/output.d.ts.map +1 -1
  127. package/dist/output.js +18 -2
  128. package/dist/output.js.map +1 -1
  129. package/dist/performance/metrics.d.ts +8 -3
  130. package/dist/performance/metrics.d.ts.map +1 -1
  131. package/dist/performance/metrics.js +16 -5
  132. package/dist/performance/metrics.js.map +1 -1
  133. package/dist/recording/contact-sheet.js.map +1 -1
  134. package/dist/security/tool-permissions.d.ts.map +1 -1
  135. package/dist/security/tool-permissions.js +1 -0
  136. package/dist/security/tool-permissions.js.map +1 -1
  137. package/dist/testing/coverage.d.ts +2 -2
  138. package/dist/testing/coverage.d.ts.map +1 -1
  139. package/dist/testing/coverage.js +6 -2
  140. package/dist/testing/coverage.js.map +1 -1
  141. package/dist/testing/flaky-detection.d.ts +2 -2
  142. package/dist/testing/flaky-detection.d.ts.map +1 -1
  143. package/dist/testing/flaky-detection.js +11 -3
  144. package/dist/testing/flaky-detection.js.map +1 -1
  145. package/dist/testing/nl-test-suite.d.ts +20 -0
  146. package/dist/testing/nl-test-suite.d.ts.map +1 -1
  147. package/dist/testing/nl-test-suite.js +7 -1
  148. package/dist/testing/nl-test-suite.js.map +1 -1
  149. package/dist/testing/suite-results.d.ts +27 -0
  150. package/dist/testing/suite-results.d.ts.map +1 -0
  151. package/dist/testing/suite-results.js +54 -0
  152. package/dist/testing/suite-results.js.map +1 -0
  153. package/dist/testing/test-repair.d.ts +2 -2
  154. package/dist/testing/test-repair.d.ts.map +1 -1
  155. package/dist/testing/test-repair.js +6 -2
  156. package/dist/testing/test-repair.js.map +1 -1
  157. package/dist/types.d.ts +61 -8
  158. package/dist/types.d.ts.map +1 -1
  159. package/dist/types.js +17 -103
  160. package/dist/types.js.map +1 -1
  161. package/dist/visual/ab-comparison.d.ts.map +1 -1
  162. package/dist/visual/ab-comparison.js +7 -2
  163. package/dist/visual/ab-comparison.js.map +1 -1
  164. package/dist/visual/cross-browser.d.ts +7 -2
  165. package/dist/visual/cross-browser.d.ts.map +1 -1
  166. package/dist/visual/cross-browser.js +33 -5
  167. package/dist/visual/cross-browser.js.map +1 -1
  168. package/dist/visual/distance-metrics.d.ts +7 -0
  169. package/dist/visual/distance-metrics.d.ts.map +1 -1
  170. package/dist/visual/distance-metrics.js.map +1 -1
  171. package/dist/visual/heatmap-overlay.js.map +1 -1
  172. package/dist/visual/llm-relevance.d.ts.map +1 -1
  173. package/dist/visual/llm-relevance.js.map +1 -1
  174. package/dist/visual/regression.d.ts +17 -7
  175. package/dist/visual/regression.d.ts.map +1 -1
  176. package/dist/visual/regression.js +77 -38
  177. package/dist/visual/regression.js.map +1 -1
  178. package/dist/visual/responsive.d.ts +22 -0
  179. package/dist/visual/responsive.d.ts.map +1 -1
  180. package/dist/visual/responsive.js +44 -7
  181. package/dist/visual/responsive.js.map +1 -1
  182. package/dist/visual/visual-overlays.js.map +1 -1
  183. package/dist/webhooks.d.ts +67 -0
  184. package/dist/webhooks.d.ts.map +1 -0
  185. package/dist/webhooks.js +128 -0
  186. package/dist/webhooks.js.map +1 -0
  187. package/package.json +23 -24
package/dist/browser.js CHANGED
@@ -15,7 +15,8 @@ import { join, dirname } from "path";
15
15
  import { execSync } from "child_process";
16
16
  import { mergeConfig, getPaths, ensureDirectories } from "./config.js";
17
17
  import { getPersonaOrRefuse } from "./personas.js";
18
- import { DEVICE_PRESETS, LOCATION_PRESETS, CBrowserErrorCode } from "./types.js";
18
+ import { LOCATION_PRESETS, CBrowserError, CBrowserErrorCode } from "./types.js";
19
+ import { DEVICE_PRESETS, resolveDevice } from "./devices.js";
19
20
  import { runCognitiveJourney, isApiKeyConfigured, } from "./cognitive/index.js";
20
21
  import { SessionManager } from "./browser/session-manager.js";
21
22
  import { SelectorCacheManager } from "./browser/selector-cache.js";
@@ -258,6 +259,8 @@ export class CBrowser {
258
259
  // =========================================================================
259
260
  // Session State Persistence
260
261
  // =========================================================================
262
+ /** True when this run was given --device or --viewport (see launch). */
263
+ explicitPageShape = false;
261
264
  get sessionStateFile() {
262
265
  return join(this.paths.dataDir, "browser-state", "last-session.json");
263
266
  }
@@ -317,11 +320,22 @@ export class CBrowser {
317
320
  // Don't save about:blank or empty URLs
318
321
  if (!url || url === "about:blank" || url === "")
319
322
  return;
323
+ // Device and location persist only when set on purpose (`device set`,
324
+ // `geo set`), so carry those over from the file. Saving this run's
325
+ // values made a one-off --device or --geo stick to every later command
326
+ // for an hour (audit 2026-10-21).
327
+ let previous = null;
328
+ try {
329
+ if (existsSync(this.sessionStateFile))
330
+ previous = JSON.parse(readFileSync(this.sessionStateFile, "utf-8"));
331
+ }
332
+ catch { /* unreadable: start fresh */ }
320
333
  const state = {
321
334
  url,
322
335
  timestamp: Date.now(),
323
336
  viewport,
324
- device: device || this.config.device,
337
+ device: device ?? previous?.device,
338
+ ...(previous?.geolocation ? { geolocation: previous.geolocation } : {}),
325
339
  };
326
340
  const stateDir = join(this.paths.dataDir, "browser-state");
327
341
  if (!existsSync(stateDir)) {
@@ -362,6 +376,30 @@ export class CBrowser {
362
376
  console.error(`Failed to save device setting: ${e.message}`);
363
377
  }
364
378
  }
379
+ /**
380
+ * Save a geolocation to session state, so later commands launch with it
381
+ * (`cbrowser geo set`). Returns false when the state could not be written.
382
+ * Before 2026-10-21, geo set changed a browser that was never launched and
383
+ * printed success, so nothing changed.
384
+ */
385
+ saveGeolocationSetting(geo) {
386
+ try {
387
+ const stateDir = join(this.paths.dataDir, "browser-state");
388
+ if (!existsSync(stateDir))
389
+ mkdirSync(stateDir, { recursive: true });
390
+ const state = existsSync(this.sessionStateFile)
391
+ ? JSON.parse(readFileSync(this.sessionStateFile, "utf-8"))
392
+ : { url: "", timestamp: Date.now() };
393
+ state.geolocation = geo;
394
+ state.timestamp = Date.now();
395
+ writeFileSync(this.sessionStateFile, JSON.stringify(state, null, 2));
396
+ return true;
397
+ }
398
+ catch (e) {
399
+ console.error(`Failed to save geolocation: ${e.message}`);
400
+ return false;
401
+ }
402
+ }
365
403
  loadSessionState() {
366
404
  try {
367
405
  if (!existsSync(this.sessionStateFile))
@@ -460,6 +498,31 @@ For more help: https://playwright.dev/docs/browsers
460
498
  async launch() {
461
499
  if (this.browser || this.context)
462
500
  return;
501
+ // An unknown device is an error, never a silent desktop render (reported
502
+ // 2026-10-21: "iphone-16" captured 1280x800 and called it success, and so did
503
+ // "Pixel 7" from the MCP tools' own descriptions). Names are normalised here,
504
+ // so "iPhone 16" and "iphone-16" are the same preset.
505
+ if (this.config.device) {
506
+ const resolved = resolveDevice(this.config.device);
507
+ if ("error" in resolved) {
508
+ const closest = resolved.suggestions.length > 0 ? ` Closest presets: ${resolved.suggestions.join(", ")}.` : "";
509
+ throw new CBrowserError(CBrowserErrorCode.CONFIG_INVALID, `${resolved.error}.${closest}`, {
510
+ device: this.config.device,
511
+ suggestions: resolved.suggestions,
512
+ howToFix: "Use a preset name ('cbrowser device list' shows them all; case and spaces do not matter), or set a custom size with --viewport WIDTHxHEIGHT.",
513
+ });
514
+ }
515
+ this.config.device = resolved.key;
516
+ }
517
+ // Was the page shape asked for on this run? Then the saved session's
518
+ // viewport must not overwrite it after launch.
519
+ this.explicitPageShape = !!this.config.device || !!this.config.viewportOverride;
520
+ // Restore a location saved by `cbrowser geo set` unless one was given.
521
+ if (this.config.persistent && !this.config.geolocation) {
522
+ const saved = this.loadSessionState();
523
+ if (saved?.geolocation)
524
+ this.config.geolocation = saved.geolocation;
525
+ }
463
526
  // Load saved session state to restore device setting if not explicitly set
464
527
  if (this.config.persistent && !this.config.device) {
465
528
  const savedSession = this.loadSessionState();
@@ -489,7 +552,11 @@ For more help: https://playwright.dev/docs/browsers
489
552
  if (this.config.device && DEVICE_PRESETS[this.config.device]) {
490
553
  const device = DEVICE_PRESETS[this.config.device];
491
554
  contextOptions.viewport = device.viewport;
492
- contextOptions.userAgent = device.userAgent;
555
+ // The DevTools laptops have no user agent of their own and keep the
556
+ // browser's. Playwright 1.58 already treats "" as unset; this does not
557
+ // rely on that.
558
+ if (device.userAgent)
559
+ contextOptions.userAgent = device.userAgent;
493
560
  contextOptions.deviceScaleFactor = device.deviceScaleFactor;
494
561
  contextOptions.isMobile = device.isMobile;
495
562
  contextOptions.hasTouch = device.hasTouch;
@@ -502,6 +569,12 @@ For more help: https://playwright.dev/docs/browsers
502
569
  contextOptions.isMobile = device.isMobile;
503
570
  contextOptions.hasTouch = device.hasTouch;
504
571
  }
572
+ // An explicit --viewport sets the size even with a device preset, which
573
+ // keeps its pixel ratio, touch and user agent. Before 2026-10-21 the preset
574
+ // silently won.
575
+ if (this.config.viewportOverride) {
576
+ contextOptions.viewport = { ...this.config.viewportOverride };
577
+ }
505
578
  // Apply custom user agent if set (overrides device)
506
579
  if (this.config.userAgent) {
507
580
  contextOptions.userAgent = this.config.userAgent;
@@ -719,7 +792,8 @@ For more help: https://playwright.dev/docs/browsers
719
792
  await this.page.waitForLoadState("networkidle", { timeout: 10000 }).catch(() => { });
720
793
  await this.page.waitForTimeout(1000);
721
794
  // Apply saved viewport if available
722
- if (savedSession.viewport) {
795
+ // An explicit --device or --viewport wins over last run's size.
796
+ if (savedSession.viewport && !this.explicitPageShape) {
723
797
  await this.page.setViewportSize(savedSession.viewport);
724
798
  }
725
799
  }
@@ -854,7 +928,7 @@ For more help: https://playwright.dev/docs/browsers
854
928
  // Wait for network idle and JS hydration
855
929
  await this.page.waitForLoadState("networkidle", { timeout: 10000 }).catch(() => { });
856
930
  await this.page.waitForTimeout(1000);
857
- if (savedSession.viewport) {
931
+ if (savedSession.viewport && !this.explicitPageShape) {
858
932
  await this.page.setViewportSize(savedSession.viewport);
859
933
  }
860
934
  }
@@ -1658,7 +1732,8 @@ For more help: https://playwright.dev/docs/browsers
1658
1732
  const filtered = cookies.filter((c) => {
1659
1733
  if (c.name !== name)
1660
1734
  return true;
1661
- if (domain && c.domain !== domain)
1735
+ // ".example.com" and "example.com" name the same cookie domain.
1736
+ if (domain && c.domain.replace(/^\./, "") !== domain.replace(/^\./, ""))
1662
1737
  return true;
1663
1738
  return false;
1664
1739
  });
@@ -1700,11 +1775,42 @@ For more help: https://playwright.dev/docs/browsers
1700
1775
  * Set device emulation (requires browser restart).
1701
1776
  */
1702
1777
  setDevice(deviceName) {
1703
- if (DEVICE_PRESETS[deviceName]) {
1704
- this.config.device = deviceName;
1705
- return true;
1778
+ const resolved = resolveDevice(deviceName);
1779
+ if ("error" in resolved)
1780
+ return false;
1781
+ this.config.device = resolved.key;
1782
+ return true;
1783
+ }
1784
+ /**
1785
+ * Emulate a device preset and/or set the page size, relaunching if the
1786
+ * browser is already up (context options are fixed at launch). An unknown
1787
+ * device throws, as at launch. Returns the device key applied.
1788
+ */
1789
+ async emulate(opts) {
1790
+ let key;
1791
+ if (opts.device) {
1792
+ const resolved = resolveDevice(opts.device);
1793
+ if ("error" in resolved) {
1794
+ const closest = resolved.suggestions.length > 0 ? ` Closest presets: ${resolved.suggestions.join(", ")}.` : "";
1795
+ throw new CBrowserError(CBrowserErrorCode.CONFIG_INVALID, `${resolved.error}.${closest}`, {
1796
+ device: opts.device,
1797
+ suggestions: resolved.suggestions,
1798
+ });
1799
+ }
1800
+ key = resolved.key;
1706
1801
  }
1707
- return false;
1802
+ if (this.browser || this.context)
1803
+ await this.close();
1804
+ if (key)
1805
+ this.config.device = key;
1806
+ if (opts.viewport) {
1807
+ this.config.viewportOverride = { ...opts.viewport };
1808
+ if (!key) {
1809
+ this.config.viewportWidth = opts.viewport.width;
1810
+ this.config.viewportHeight = opts.viewport.height;
1811
+ }
1812
+ }
1813
+ return { device: this.config.device ?? null };
1708
1814
  }
1709
1815
  /**
1710
1816
  * List available device presets.
@@ -5507,202 +5613,57 @@ For more help: https://playwright.dev/docs/browsers
5507
5613
  // Journeys
5508
5614
  // =========================================================================
5509
5615
  /**
5510
- * Run an autonomous journey.
5616
+ * Run an autonomous journey: a model-driven cognitive journey for one persona.
5511
5617
  *
5512
- * When API key is configured, uses AI-driven cognitive journeys (Claude API)
5513
- * for realistic user simulation with cognitive state tracking.
5618
+ * Needs an Anthropic API key. There is no keyless fallback any more (20.0.0):
5619
+ * the old heuristic explorer clicked the highest keyword match among the first
5620
+ * twenty links and called the goal reached when any goal word, "the"
5621
+ * included, appeared anywhere on the page. Measured 2026-10-21: "buy the blue
5622
+ * widget" on a page with no widget reported success for every persona, with
5623
+ * identical click paths. A result that is always yes is not a measurement.
5514
5624
  *
5515
- * Without API key, falls back to heuristic exploration (simplified).
5516
- *
5517
- * @requires ANTHROPIC_API_KEY for cognitive mode (set via `npx cbrowser config set-api-key`)
5625
+ * @requires ANTHROPIC_API_KEY (set via `npx cbrowser config set-api-key`)
5518
5626
  */
5519
5627
  async journey(options) {
5520
5628
  const { persona: personaName, startUrl, goal, maxSteps = 20, maxTime = 120 } = options;
5521
- // Check if cognitive journeys are available (API key configured)
5522
- if (isApiKeyConfigured()) {
5523
- try {
5524
- const cognitiveResult = await runCognitiveJourney({
5525
- persona: personaName,
5526
- startUrl,
5527
- goal,
5528
- maxSteps,
5529
- maxTime,
5530
- headless: this.config.headless,
5531
- vision: false,
5532
- verbose: false,
5533
- });
5534
- // Map cognitive journey result to JourneyResult format
5535
- return {
5536
- persona: personaName,
5537
- goal,
5538
- steps: cognitiveResult.frictionPoints.map((fp, _i) => ({
5539
- action: fp.type,
5540
- target: fp.element || 'page',
5541
- result: fp.monologue.substring(0, 100),
5542
- screenshot: fp.screenshot,
5543
- timestamp: new Date().toISOString(),
5544
- })),
5545
- success: cognitiveResult.goalAchieved,
5546
- frictionPoints: cognitiveResult.frictionPoints.map(fp => `${fp.type}: ${fp.monologue.substring(0, 80)}`),
5547
- totalTime: cognitiveResult.totalTime * 1000, // Convert to ms
5548
- consoleLogs: [],
5549
- cognitive: {
5550
- patienceRemaining: cognitiveResult.finalState.patienceRemaining,
5551
- frustrationLevel: cognitiveResult.finalState.frustrationLevel,
5552
- confusionLevel: cognitiveResult.finalState.confusionLevel,
5553
- abandonmentReason: cognitiveResult.abandonmentReason,
5554
- backtrackCount: cognitiveResult.summary.backtrackCount,
5555
- monologue: cognitiveResult.fullMonologue,
5556
- },
5557
- };
5558
- }
5559
- catch (e) {
5560
- console.warn(`Cognitive journey failed, falling back to heuristic: ${e.message}`);
5561
- // Fall through to heuristic mode
5562
- }
5563
- }
5564
- // Heuristic mode (no API key or cognitive journey failed)
5565
- // NOTE: This is simplified exploration. For realistic user simulation,
5566
- // configure an API key: npx cbrowser config set-api-key YOUR_KEY
5567
- const persona = getPersonaOrRefuse(personaName);
5568
- this.currentPersona = persona;
5569
- // Set viewport based on persona
5570
- if (persona.context?.viewport) {
5571
- this.config.viewportWidth = persona.context.viewport[0];
5572
- this.config.viewportHeight = persona.context.viewport[1];
5573
- }
5574
- const page = await this.getPage();
5575
- const consoleLogs = [];
5576
- const frictionPoints = [];
5577
- const startTime = Date.now();
5578
- // Capture console logs. Removed in the finally below (v8 B27, 2026-10-10):
5579
- // it used to stay on the tab, which is the shared page in a hosted session,
5580
- // so every heuristic journey left one more listener and log array behind
5581
- // (measured 4 -> 7 console listeners over three runs). Not read from the
5582
- // tab's console buffer instead: that keeps 500 entries and omits an empty
5583
- // location, so a noisy page or an inline script would change consoleLogs.
5584
- const onConsole = (msg) => {
5585
- const entry = {
5586
- type: msg.type(),
5587
- text: msg.text().substring(0, 500),
5588
- timestamp: new Date().toISOString(),
5589
- url: msg.location()?.url,
5590
- lineNumber: msg.location()?.lineNumber,
5591
- };
5592
- consoleLogs.push(entry);
5593
- if (msg.type() === "error") {
5594
- frictionPoints.push(`Console error: ${msg.text().substring(0, 100)}`);
5595
- }
5596
- };
5597
- page.on("console", onConsole);
5598
- try {
5599
- return await this.heuristicJourney(page, persona, options, consoleLogs, frictionPoints, startTime);
5629
+ if (!isApiKeyConfigured()) {
5630
+ throw new Error("A journey needs an Anthropic API key (cbrowser config set-api-key <key>). The keyless heuristic explorer was removed in 20.0.0 because its results were not accurate.");
5600
5631
  }
5601
- finally {
5602
- page.off("console", onConsole);
5603
- }
5604
- }
5605
- /** journey()'s heuristic exploration; journey's listener fills `consoleLogs`. */
5606
- async heuristicJourney(page, persona, options, consoleLogs, frictionPoints, startTime) {
5607
- const { persona: personaName, startUrl, goal, maxSteps = 20 } = options;
5608
- const steps = [];
5609
- // Navigate to start
5610
- await this.navigate(startUrl);
5611
- steps.push({
5612
- action: "navigate",
5613
- target: startUrl,
5614
- result: `Loaded: ${await page.title()}`,
5615
- screenshot: await this.screenshot(),
5616
- timestamp: new Date().toISOString(),
5617
- });
5618
- // Heuristic exploration (simplified - no API key available)
5619
- // For realistic AI-driven exploration, set API key: npx cbrowser config set-api-key
5620
- let stepCount = 0;
5621
- let success = false;
5622
- while (stepCount < maxSteps && !success) {
5623
- stepCount++;
5624
- // Wait with human-like timing
5625
- const delay = this.getHumanDelay(persona);
5626
- await new Promise((r) => setTimeout(r, delay));
5627
- // Try to find clickable elements
5628
- const clickable = await page.evaluate((goalText) => {
5629
- const elements = Array.from(document.querySelectorAll('a, button, [role="button"], input[type="submit"]'));
5630
- // Score elements by relevance to goal (deterministic, not random)
5631
- const scored = elements.slice(0, 20).map((el) => {
5632
- const text = el.textContent?.trim().substring(0, 50) || '';
5633
- const href = el.href || '';
5634
- const textLower = text.toLowerCase();
5635
- const goalWords = goalText.toLowerCase().split(' ').filter(w => w.length > 2);
5636
- // Calculate relevance score
5637
- let score = 0;
5638
- for (const word of goalWords) {
5639
- if (textLower.includes(word))
5640
- score += 3;
5641
- if (href.toLowerCase().includes(word))
5642
- score += 2;
5643
- }
5644
- // Boost common action words
5645
- if (textLower.match(/submit|continue|next|proceed|sign|login|register/))
5646
- score += 1;
5647
- return { tag: el.tagName, text, href, score };
5648
- });
5649
- // Sort by score (highest first) and return top 5
5650
- return scored.sort((a, b) => b.score - a.score).slice(0, 5);
5651
- }, goal);
5652
- if (clickable.length === 0) {
5653
- frictionPoints.push("No clickable elements found");
5654
- break;
5655
- }
5656
- // Select highest-scored element (deterministic)
5657
- const target = clickable[0];
5658
- const result = await this.click(target.text || target.tag);
5659
- steps.push({
5660
- action: "click",
5661
- target: target.text || target.tag,
5662
- result: result.message,
5663
- screenshot: result.screenshot,
5664
- timestamp: new Date().toISOString(),
5665
- });
5666
- // Check if goal might be achieved
5667
- const pageText = await page.evaluate(() => document.body.innerText.toLowerCase());
5668
- if (goal.toLowerCase().split(" ").some((word) => pageText.includes(word))) {
5669
- success = true;
5670
- }
5671
- }
5672
- // Save final screenshot
5673
- steps.push({
5674
- action: "final",
5675
- result: `Final page: ${await page.title()}`,
5676
- screenshot: await this.screenshot(),
5677
- timestamp: new Date().toISOString(),
5632
+ // Refuse an unknown persona before spending tokens.
5633
+ this.currentPersona = getPersonaOrRefuse(personaName);
5634
+ const cognitiveResult = await runCognitiveJourney({
5635
+ persona: personaName,
5636
+ startUrl,
5637
+ goal,
5638
+ maxSteps,
5639
+ maxTime,
5640
+ headless: this.config.headless,
5641
+ vision: false,
5642
+ verbose: false,
5678
5643
  });
5679
- const result = {
5644
+ return {
5680
5645
  persona: personaName,
5681
5646
  goal,
5682
- steps,
5683
- success,
5684
- frictionPoints,
5685
- totalTime: Date.now() - startTime,
5686
- consoleLogs,
5647
+ steps: cognitiveResult.frictionPoints.map((fp) => ({
5648
+ action: fp.type,
5649
+ target: fp.element || 'page',
5650
+ result: fp.monologue.substring(0, 100),
5651
+ screenshot: fp.screenshot,
5652
+ timestamp: new Date().toISOString(),
5653
+ })),
5654
+ success: cognitiveResult.goalAchieved,
5655
+ frictionPoints: cognitiveResult.frictionPoints.map(fp => `${fp.type}: ${fp.monologue.substring(0, 80)}`),
5656
+ totalTime: cognitiveResult.totalTime * 1000, // Convert to ms
5657
+ consoleLogs: [],
5658
+ cognitive: {
5659
+ patienceRemaining: cognitiveResult.finalState.patienceRemaining,
5660
+ frustrationLevel: cognitiveResult.finalState.frustrationLevel,
5661
+ confusionLevel: cognitiveResult.finalState.confusionLevel,
5662
+ abandonmentReason: cognitiveResult.abandonmentReason,
5663
+ backtrackCount: cognitiveResult.summary.backtrackCount,
5664
+ monologue: cognitiveResult.fullMonologue,
5665
+ },
5687
5666
  };
5688
- // Save journey results
5689
- const journeyFile = join(this.paths.dataDir, `journey-${Date.now()}.json`);
5690
- writeFileSync(journeyFile, JSON.stringify(result, null, 2));
5691
- return result;
5692
- }
5693
- /**
5694
- * Get human-like delay based on persona.
5695
- * Uses deterministic average of min/max (no random).
5696
- * For realistic variable timing, use cognitive journeys with API key.
5697
- */
5698
- getHumanDelay(persona) {
5699
- const timing = persona.humanBehavior?.timing;
5700
- if (!timing)
5701
- return 500;
5702
- const min = timing.reactionTime.min;
5703
- const max = timing.reactionTime.max;
5704
- // Use midpoint for deterministic behavior (no random)
5705
- return Math.floor((min + max) / 2);
5706
5667
  }
5707
5668
  // =========================================================================
5708
5669
  // Constitutional Safety
@@ -6825,12 +6786,18 @@ For more help: https://playwright.dev/docs/browsers
6825
6786
  */
6826
6787
  exportJUnit(suite, outputPath) {
6827
6788
  const filename = outputPath || join(this.paths.dataDir, `junit-${Date.now()}.xml`);
6789
+ // Names and errors are page and test-file text: escaped, or a test named
6790
+ // "Search & filter" wrote XML no CI parser accepts. A failed test is a
6791
+ // failure with or without an error message; it used to pass without one.
6792
+ const esc = (text) => text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
6793
+ const failures = suite.tests.filter((t) => t.status === "failed").length;
6828
6794
  let xml = `<?xml version="1.0" encoding="UTF-8"?>\n`;
6829
- xml += `<testsuite name="${suite.name}" tests="${suite.tests.length}">\n`;
6795
+ xml += `<testsuite name="${esc(suite.name)}" tests="${suite.tests.length}" failures="${failures}">\n`;
6830
6796
  for (const test of suite.tests) {
6831
- xml += ` <testcase name="${test.name}" time="${(test.duration / 1000).toFixed(3)}">\n`;
6832
- if (test.status === "failed" && test.error) {
6833
- xml += ` <failure message="${test.error.replace(/"/g, "&quot;")}">${test.error}</failure>\n`;
6797
+ xml += ` <testcase name="${esc(test.name)}" time="${(test.duration / 1000).toFixed(3)}">\n`;
6798
+ if (test.status === "failed") {
6799
+ const message = test.error || "failed";
6800
+ xml += ` <failure message="${esc(message)}">${esc(message)}</failure>\n`;
6834
6801
  }
6835
6802
  xml += ` </testcase>\n`;
6836
6803
  }
@@ -6847,7 +6814,8 @@ For more help: https://playwright.dev/docs/browsers
6847
6814
  tap += `1..${suite.tests.length}\n`;
6848
6815
  suite.tests.forEach((test, i) => {
6849
6816
  const status = test.status === "passed" ? "ok" : "not ok";
6850
- tap += `${status} ${i + 1} ${test.name}\n`;
6817
+ // One line per test, and a "#" in a name is not read as a directive.
6818
+ tap += `${status} ${i + 1} ${test.name.replace(/[\r\n]+/g, " ").replace(/#/g, "\\#")}\n`;
6851
6819
  });
6852
6820
  writeFileSync(filename, tap);
6853
6821
  return filename;
@@ -6894,9 +6862,11 @@ For more help: https://playwright.dev/docs/browsers
6894
6862
  * Run the same task across multiple device configurations in parallel.
6895
6863
  */
6896
6864
  static async parallelDevices(devices, run, options = {}) {
6865
+ // `config` is shared by every device (headless, engine); each task's own
6866
+ // device wins over one in it.
6897
6867
  const tasks = devices.map(device => ({
6898
6868
  name: device,
6899
- config: { device },
6869
+ config: { ...options.config, device },
6900
6870
  run: (browser) => run(browser, device),
6901
6871
  }));
6902
6872
  const results = await CBrowser.parallel(tasks, options);