cbrowser 19.6.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 (170) hide show
  1. package/README.md +292 -232
  2. package/THIRD_PARTY_LICENSES.md +416 -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 +139 -196
  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 +979 -401
  33. package/dist/cli.js.map +1 -1
  34. package/dist/cognitive/index.d.ts +0 -13
  35. package/dist/cognitive/index.d.ts.map +1 -1
  36. package/dist/cognitive/index.js +39 -15
  37. package/dist/cognitive/index.js.map +1 -1
  38. package/dist/config.d.ts.map +1 -1
  39. package/dist/config.js +16 -6
  40. package/dist/config.js.map +1 -1
  41. package/dist/daemon.d.ts +5 -4
  42. package/dist/daemon.d.ts.map +1 -1
  43. package/dist/daemon.js +87 -29
  44. package/dist/daemon.js.map +1 -1
  45. package/dist/devices.d.ts +20 -0
  46. package/dist/devices.d.ts.map +1 -1
  47. package/dist/devices.js +18 -0
  48. package/dist/devices.js.map +1 -1
  49. package/dist/help-client.d.ts +48 -0
  50. package/dist/help-client.d.ts.map +1 -0
  51. package/dist/help-client.js +96 -0
  52. package/dist/help-client.js.map +1 -0
  53. package/dist/mcp-server.d.ts.map +1 -1
  54. package/dist/mcp-server.js +165 -2281
  55. package/dist/mcp-server.js.map +1 -1
  56. package/dist/mcp-tools/base/advanced-interaction-tools.d.ts.map +1 -1
  57. package/dist/mcp-tools/base/advanced-interaction-tools.js +21 -21
  58. package/dist/mcp-tools/base/advanced-interaction-tools.js.map +1 -1
  59. package/dist/mcp-tools/base/assertion-tools.d.ts.map +1 -1
  60. package/dist/mcp-tools/base/assertion-tools.js +6 -3
  61. package/dist/mcp-tools/base/assertion-tools.js.map +1 -1
  62. package/dist/mcp-tools/base/audit-tools.d.ts.map +1 -1
  63. package/dist/mcp-tools/base/audit-tools.js +15 -4
  64. package/dist/mcp-tools/base/audit-tools.js.map +1 -1
  65. package/dist/mcp-tools/base/browser-state-tools.d.ts.map +1 -1
  66. package/dist/mcp-tools/base/browser-state-tools.js +33 -27
  67. package/dist/mcp-tools/base/browser-state-tools.js.map +1 -1
  68. package/dist/mcp-tools/base/bug-analysis-tools.d.ts.map +1 -1
  69. package/dist/mcp-tools/base/bug-analysis-tools.js +9 -2
  70. package/dist/mcp-tools/base/bug-analysis-tools.js.map +1 -1
  71. package/dist/mcp-tools/base/capture-tools.d.ts +60 -4
  72. package/dist/mcp-tools/base/capture-tools.d.ts.map +1 -1
  73. package/dist/mcp-tools/base/capture-tools.js +118 -14
  74. package/dist/mcp-tools/base/capture-tools.js.map +1 -1
  75. package/dist/mcp-tools/base/index.d.ts +6 -5
  76. package/dist/mcp-tools/base/index.d.ts.map +1 -1
  77. package/dist/mcp-tools/base/index.js +7 -6
  78. package/dist/mcp-tools/base/index.js.map +1 -1
  79. package/dist/mcp-tools/base/interaction-tools.d.ts.map +1 -1
  80. package/dist/mcp-tools/base/interaction-tools.js +20 -6
  81. package/dist/mcp-tools/base/interaction-tools.js.map +1 -1
  82. package/dist/mcp-tools/base/navigation-tools.d.ts +1 -1
  83. package/dist/mcp-tools/base/navigation-tools.d.ts.map +1 -1
  84. package/dist/mcp-tools/base/navigation-tools.js +83 -3
  85. package/dist/mcp-tools/base/navigation-tools.js.map +1 -1
  86. package/dist/mcp-tools/base/site-knowledge-tools.d.ts.map +1 -1
  87. package/dist/mcp-tools/base/site-knowledge-tools.js +11 -23
  88. package/dist/mcp-tools/base/site-knowledge-tools.js.map +1 -1
  89. package/dist/mcp-tools/base/visual-testing-tools.d.ts.map +1 -1
  90. package/dist/mcp-tools/base/visual-testing-tools.js +13 -3
  91. package/dist/mcp-tools/base/visual-testing-tools.js.map +1 -1
  92. package/dist/mcp-tools/enterprise-stubs.d.ts +2 -2
  93. package/dist/mcp-tools/enterprise-stubs.d.ts.map +1 -1
  94. package/dist/mcp-tools/enterprise-stubs.js +29 -16
  95. package/dist/mcp-tools/enterprise-stubs.js.map +1 -1
  96. package/dist/mcp-tools/index.d.ts +19 -14
  97. package/dist/mcp-tools/index.d.ts.map +1 -1
  98. package/dist/mcp-tools/index.js +2 -2
  99. package/dist/mcp-tools/index.js.map +1 -1
  100. package/dist/mcp-tools/red-zone-refusal.d.ts +33 -0
  101. package/dist/mcp-tools/red-zone-refusal.d.ts.map +1 -0
  102. package/dist/mcp-tools/red-zone-refusal.js +43 -0
  103. package/dist/mcp-tools/red-zone-refusal.js.map +1 -0
  104. package/dist/mcp-tools/tier-gate.js +1 -1
  105. package/dist/mcp-tools/tier-gate.js.map +1 -1
  106. package/dist/mcp-tools/tool-categories.d.ts.map +1 -1
  107. package/dist/mcp-tools/tool-categories.js +2 -0
  108. package/dist/mcp-tools/tool-categories.js.map +1 -1
  109. package/dist/output.d.ts.map +1 -1
  110. package/dist/output.js +18 -2
  111. package/dist/output.js.map +1 -1
  112. package/dist/performance/metrics.d.ts +8 -3
  113. package/dist/performance/metrics.d.ts.map +1 -1
  114. package/dist/performance/metrics.js +16 -5
  115. package/dist/performance/metrics.js.map +1 -1
  116. package/dist/recording/contact-sheet.js.map +1 -1
  117. package/dist/security/tool-permissions.d.ts.map +1 -1
  118. package/dist/security/tool-permissions.js +1 -0
  119. package/dist/security/tool-permissions.js.map +1 -1
  120. package/dist/testing/coverage.d.ts +2 -2
  121. package/dist/testing/coverage.d.ts.map +1 -1
  122. package/dist/testing/coverage.js +6 -2
  123. package/dist/testing/coverage.js.map +1 -1
  124. package/dist/testing/flaky-detection.d.ts +2 -2
  125. package/dist/testing/flaky-detection.d.ts.map +1 -1
  126. package/dist/testing/flaky-detection.js +11 -3
  127. package/dist/testing/flaky-detection.js.map +1 -1
  128. package/dist/testing/nl-test-suite.d.ts +13 -0
  129. package/dist/testing/nl-test-suite.d.ts.map +1 -1
  130. package/dist/testing/nl-test-suite.js +5 -1
  131. package/dist/testing/nl-test-suite.js.map +1 -1
  132. package/dist/testing/suite-results.d.ts +27 -0
  133. package/dist/testing/suite-results.d.ts.map +1 -0
  134. package/dist/testing/suite-results.js +54 -0
  135. package/dist/testing/suite-results.js.map +1 -0
  136. package/dist/testing/test-repair.d.ts +2 -2
  137. package/dist/testing/test-repair.d.ts.map +1 -1
  138. package/dist/testing/test-repair.js +6 -2
  139. package/dist/testing/test-repair.js.map +1 -1
  140. package/dist/types.d.ts +61 -7
  141. package/dist/types.d.ts.map +1 -1
  142. package/dist/types.js +17 -4
  143. package/dist/types.js.map +1 -1
  144. package/dist/visual/ab-comparison.d.ts.map +1 -1
  145. package/dist/visual/ab-comparison.js +7 -2
  146. package/dist/visual/ab-comparison.js.map +1 -1
  147. package/dist/visual/cross-browser.d.ts +7 -2
  148. package/dist/visual/cross-browser.d.ts.map +1 -1
  149. package/dist/visual/cross-browser.js +33 -5
  150. package/dist/visual/cross-browser.js.map +1 -1
  151. package/dist/visual/distance-metrics.d.ts +7 -0
  152. package/dist/visual/distance-metrics.d.ts.map +1 -1
  153. package/dist/visual/distance-metrics.js.map +1 -1
  154. package/dist/visual/heatmap-overlay.js.map +1 -1
  155. package/dist/visual/llm-relevance.d.ts.map +1 -1
  156. package/dist/visual/llm-relevance.js.map +1 -1
  157. package/dist/visual/regression.d.ts +17 -7
  158. package/dist/visual/regression.d.ts.map +1 -1
  159. package/dist/visual/regression.js +77 -38
  160. package/dist/visual/regression.js.map +1 -1
  161. package/dist/visual/responsive.d.ts +22 -0
  162. package/dist/visual/responsive.d.ts.map +1 -1
  163. package/dist/visual/responsive.js +44 -7
  164. package/dist/visual/responsive.js.map +1 -1
  165. package/dist/visual/visual-overlays.js.map +1 -1
  166. package/dist/webhooks.d.ts +67 -0
  167. package/dist/webhooks.d.ts.map +1 -0
  168. package/dist/webhooks.js +128 -0
  169. package/dist/webhooks.js.map +1 -0
  170. package/package.json +23 -24
package/dist/cli.js CHANGED
@@ -11,6 +11,10 @@
11
11
  */
12
12
  import { CBrowser } from "./browser.js";
13
13
  import { parseArgs } from "./cli-args.js";
14
+ import { browserFlagsOrExit, refuseBrowserFlags } from "./cli-browser-flags.js";
15
+ import { browserLaunchConfig } from "./browser-options.js";
16
+ import { SENT_WEBHOOK_EVENTS, WEBHOOK_FORMATS, WEBHOOK_TIMEOUT_MS, loadWebhooks, sendWebhookEvent, webhookUrlProblem, webhooksPath } from "./webhooks.js";
17
+ import { loadSuiteForExport, resultsName, saveSuiteResult } from "./testing/suite-results.js";
14
18
  // Analysis module imports
15
19
  import { executeNaturalLanguage, executeNaturalLanguageScript, huntBugs, runChaosTest, comparePersonas, formatComparisonReport, findElementByIntent, runAgentReadyAudit, formatAgentReadyReport, generateAgentReadyHtmlReport, runCompetitiveBenchmark, formatCompetitiveBenchmarkReport, generateCompetitiveBenchmarkHtmlReport, runEmpathyAudit, formatEmpathyAuditReport, generateEmpathyAuditHtmlReport, runWebMCPReadyAudit, generateWebMCPReadyHtmlReport, runAIReadinessBenchmark } from "./analysis/index.js";
16
20
  // Testing module imports
@@ -18,8 +22,9 @@ import { parseNLInstruction, parseNLTestSuite, runNLTestSuite, formatNLTestRepor
18
22
  // Performance module imports
19
23
  import { capturePerformanceBaseline, listPerformanceBaselines, loadPerformanceBaseline, deletePerformanceBaseline, detectPerformanceRegression, formatPerformanceRegressionReport } from "./performance/index.js";
20
24
  // Visual module imports
21
- import { captureVisualBaseline, listVisualBaselines, getVisualBaseline, deleteVisualBaseline, runVisualRegression, runVisualRegressionSuite, formatVisualRegressionReport, generateVisualRegressionHtmlReport, runCrossBrowserTest, runCrossBrowserSuite, formatCrossBrowserReport, generateCrossBrowserHtmlReport, runResponsiveTest, runResponsiveSuite, formatResponsiveReport, generateResponsiveHtmlReport, listViewportPresets, runABComparison, runABSuite, formatABReport, generateABHtmlReport, crossBrowserDiff } from "./visual/index.js";
22
- import { BUILTIN_PERSONAS, loadCustomPersonas, saveCustomPersona, deleteCustomPersona, isBuiltinPersona, generatePersonaFromDescription, listAccessibilityPersonas, } from "./personas.js";
25
+ import { captureVisualBaseline, listVisualBaselines, getVisualBaseline, deleteVisualBaseline, runVisualRegression, runVisualRegressionSuite, formatVisualRegressionReport, generateVisualRegressionHtmlReport, runCrossBrowserTest, runCrossBrowserSuite, formatCrossBrowserReport, generateCrossBrowserHtmlReport, runResponsiveTest, runResponsiveSuite, formatResponsiveReport, generateResponsiveHtmlReport, listViewportPresets, formatViewportPresetList, runABComparison, runABSuite, formatABReport, generateABHtmlReport, crossBrowserDiff } from "./visual/index.js";
26
+ import { VIEWPORT_PRESETS } from "./types.js";
27
+ import { BUILTIN_PERSONAS, loadCustomPersonas, saveCustomPersona, deleteCustomPersona, isBuiltinPersona, generatePersonaFromDescription, listAccessibilityPersonas, PERSONA_ALIASES, } from "./personas.js";
23
28
  import { LOCATION_PRESETS } from "./types.js";
24
29
  import { CBROWSER_DEVICE_KEYS, DEVICE_PRESETS, resolveDevice } from "./devices.js";
25
30
  import { startMcpServer } from "./mcp-server.js";
@@ -83,7 +88,7 @@ function helpText() {
83
88
 
84
89
  NAVIGATION
85
90
  navigate <url> Navigate and take screenshot
86
- screenshot [path] Take screenshot of current page
91
+ screenshot [url] [path] Take a screenshot (of <url> when given, else the current page)
87
92
  --no-restore Don't reload saved URL (capture current state after interactions)
88
93
 
89
94
  INTERACTION
@@ -118,6 +123,7 @@ SCRIPTING
118
123
  --raw Print the raw value, unquoted
119
124
  --wait-for <selector> Wait for the selector before evaluating
120
125
  --expect-truthy Exit 1 when the result is falsy (CI assertion)
126
+ --timeout <ms> How long --wait-for waits: ms, or 5s / 2m (default: 30000)
121
127
  Examples:
122
128
  cbrowser evaluate "document.title" --force
123
129
  cbrowser evaluate --file check.js --arg '"#main"' --expect-truthy --force
@@ -130,6 +136,7 @@ KEYBOARD
130
136
  --hold <mod> Hold a modifier across the whole sequence
131
137
  --repeat <n> Repeat the whole sequence n times
132
138
  --text Treat every token as literal text (for "+" in text)
139
+ --url <url> Navigate to URL first
133
140
  A token with modifiers is a chord (Control+A); a known key name is that key
134
141
  (Enter, Tab, ArrowLeft); anything else is typed as literal text. Unknown
135
142
  key names are rejected, not typed. For a single key use 'press', for a
@@ -139,12 +146,12 @@ KEYBOARD
139
146
  cbrowser keyboard "ArrowRight" --repeat 5 --hold Shift
140
147
  cbrowser keyboard "Tab" "Tab" "Enter" --selector "#email" --delay 120
141
148
 
142
- SITE EXPLORATION (Free, heuristic-based)
143
- explore <persona> Quick autonomous exploration using built-in heuristics
149
+ SITE EXPLORATION (API-powered)
150
+ explore <persona> Short cognitive journey (needs an Anthropic API key; costs tokens)
144
151
  --start <url> Starting URL (required)
145
152
  --goal <goal> What to accomplish
146
153
  --record-video Record exploration as video
147
- Note: No API key needed. Uses rule-based navigation - fast but not "smart".
154
+ Note: the keyless heuristic explorer was removed in 20.0.0; its success verdicts were not accurate.
148
155
 
149
156
  COGNITIVE JOURNEY (API-powered, realistic user simulation)
150
157
  cognitive-journey Simulate a real user with emotions, patience, and abandonment
@@ -160,10 +167,13 @@ COGNITIVE JOURNEY (API-powered, realistic user simulation)
160
167
  --verbose Show step-by-step narration and internal monologue
161
168
  --no-vision Disable vision mode (text-only, less accurate but faster)
162
169
  --output <file> Save JSON report to file
163
- --html Generate HTML report
170
+ --trace Record the journey to ~/.cbrowser/traces/<id>.jsonl
171
+ --replay-trace <file> Replay a recorded journey: no model calls, no API key
172
+ --persistent Run in the shared browser profile (default: a fresh one,
173
+ so the persona starts as a new visitor)
164
174
  --timezone <tz> Override persona timezone (e.g., "America/New_York", "Europe/London")
165
175
  --locale <loc> Override persona locale (e.g., "en-US", "de-DE")
166
- --geolocation <lat,lng> Override persona geolocation (e.g., "40.7128,-74.0060")
176
+ --geo <preset|lat,lon> Override persona location (also --geolocation <lat,lng>)
167
177
 
168
178
  Why use this over 'explore'?
169
179
  • Tracks cognitive state: patience, frustration, confusion
@@ -179,12 +189,14 @@ COGNITIVE JOURNEY (API-powered, realistic user simulation)
179
189
 
180
190
  Note: Requires Anthropic API key. Set with: cbrowser config set-api-key <key>
181
191
 
182
- MULTI-PERSONA COMPARISON (v6.0.0)
192
+ MULTI-PERSONA COMPARISON
183
193
  compare-personas Compare multiple personas on the same journey
184
194
  --start <url> Starting URL (required)
185
195
  --goal <goal> What to accomplish (required)
186
196
  --personas <list> Comma-separated persona names
187
197
  --concurrency <n> Max parallel browsers (default: 3)
198
+ --device <name> Device every journey emulates
199
+ --viewport <WxH> Page size for every journey
188
200
  --output <file> Save JSON report to file
189
201
  --html Generate HTML report
190
202
  Examples:
@@ -192,8 +204,10 @@ MULTI-PERSONA COMPARISON (v6.0.0)
192
204
  --goal "Complete checkout" \\
193
205
  --personas power-user,first-timer,elderly-user,mobile-user
194
206
 
195
- NATURAL LANGUAGE TEST SUITES (v6.1.0)
207
+ NATURAL LANGUAGE TEST SUITES
196
208
  test-suite <file.txt> Run tests written in plain English
209
+ Saves each run for 'export'; sends test.pass or
210
+ test.fail to webhooks ('webhook add')
197
211
  --continue-on-failure Keep running after a test fails
198
212
  --screenshot-on-failure Take screenshots on failure (default: true)
199
213
  --output <file> Save JSON report to file
@@ -228,29 +242,31 @@ NATURAL LANGUAGE TEST SUITES (v6.1.0)
228
242
  click search button
229
243
  verify page contains "results"
230
244
 
231
- AI TEST REPAIR (v6.2.0)
245
+ AI TEST REPAIR
232
246
  repair-tests <file.txt> Analyze failing tests and suggest/apply repairs
233
247
  --auto-apply Automatically apply the best repair
234
248
  --verify Re-run tests after repair to verify they pass
235
249
  --output <file> Save repaired tests to new file
236
- --json <file> Save repair report as JSON
250
+ --json-out <file> Save repair report as JSON (--json prints it)
251
+ --device, --viewport, --browser, --no-headless The browser the tests run in
237
252
  Examples:
238
253
  cbrowser repair-tests broken-test.txt
239
254
  cbrowser repair-tests tests.txt --auto-apply --verify
240
255
  cbrowser repair-tests tests.txt --auto-apply --output fixed-tests.txt
241
256
 
242
- FLAKY TEST DETECTION (v6.3.0)
257
+ FLAKY TEST DETECTION
243
258
  flaky-check <file.txt> Run tests multiple times to detect flaky tests
244
259
  --runs <n> Number of runs per test (default: 5)
245
260
  --threshold <n> Flakiness % threshold to flag (default: 20)
246
261
  --delay <ms> Delay between runs (default: 500)
247
262
  --output <file> Save JSON report to file
263
+ --device, --viewport, --browser, --no-headless The browser every run uses
248
264
  Examples:
249
265
  cbrowser flaky-check tests.txt
250
266
  cbrowser flaky-check tests.txt --runs 10
251
267
  cbrowser flaky-check tests.txt --runs 5 --threshold 30 --output flaky-report.json
252
268
 
253
- TEST COVERAGE MAP (v6.5.0)
269
+ TEST COVERAGE MAP
254
270
  coverage <url> Generate test coverage map for a site
255
271
  --tests <glob> Test files to analyze (default: tests/*.txt)
256
272
  --sitemap <url> Use sitemap.xml instead of crawling
@@ -259,7 +275,8 @@ TEST COVERAGE MAP (v6.5.0)
259
275
  --exclude <pattern> Exclude paths matching pattern
260
276
  --min-coverage <n> Min coverage % to not flag (default: 50)
261
277
  --html Generate HTML report
262
- --output <file> Save JSON report to file
278
+ --output <file> Save the report (JSON if the name ends in .json, else text)
279
+ --device, --viewport, --browser, --no-headless The crawl's browser (not with --sitemap)
263
280
  Examples:
264
281
  cbrowser coverage "https://example.com" --tests "tests/*.txt"
265
282
  cbrowser coverage "https://example.com" --sitemap "https://example.com/sitemap.xml"
@@ -282,6 +299,8 @@ PERSONAS
282
299
  persona delete <name> Delete a custom persona
283
300
  persona export <name> Export persona to JSON file
284
301
  persona import <file> Import persona from JSON file
302
+ persona cleanup Remove custom personas that fail validation
303
+ --dry-run List them without removing anything
285
304
 
286
305
  SESSION MANAGEMENT
287
306
  session save <name> Save browser session (cookies, storage, URL)
@@ -289,6 +308,12 @@ SESSION MANAGEMENT
289
308
  session load <name> Load a saved session
290
309
  session list List all saved sessions
291
310
  session delete <name> Delete a saved session
311
+ session show <name> Show a saved session's cookies, storage and URL
312
+ session cleanup Delete saved sessions older than N days
313
+ --older-than <days> Age in days (default: 30)
314
+ session export <name> Export a session to a JSON file
315
+ --output <file> File to write (default: <name>.json)
316
+ session import <file> Import a session from a JSON file
292
317
 
293
318
  COOKIE MANAGEMENT
294
319
  cookie list List all cookies for current page
@@ -319,10 +344,12 @@ PERFORMANCE
319
344
  --budget-fcp <ms> FCP budget (default: 1800)
320
345
  --budget-cls <score> CLS budget (default: 0.1)
321
346
 
322
- PERFORMANCE REGRESSION (v6.4.0)
347
+ PERFORMANCE REGRESSION
323
348
  perf-baseline save <url> Capture and save performance baseline
324
349
  --name <name> Human-readable name for baseline
325
350
  --runs <n> Number of runs to average (default: 3)
351
+ --device <name> Device to measure as; perf-regression renders the same
352
+ --viewport <WxH> Page size; perf-regression renders the same
326
353
  Examples:
327
354
  cbrowser perf-baseline save "https://example.com" --name "homepage"
328
355
  cbrowser perf-baseline save "https://example.com/checkout" --runs 5
@@ -336,6 +363,9 @@ PERFORMANCE REGRESSION (v6.4.0)
336
363
  --threshold-lcp <n> Max LCP increase % (default: 20)
337
364
  --threshold-cls <n> Max CLS increase (default: 0.1)
338
365
  --threshold-fcp <n> Max FCP increase % (default: 20)
366
+ --threshold-ttfb <n> Max TTFB increase % (default: 30)
367
+ --threshold-tti <n> Max TTI increase %
368
+ --threshold-tbt <n> Max TBT increase %
339
369
  --output <file> Save JSON report to file
340
370
  Examples:
341
371
  cbrowser perf-regression "https://example.com" homepage
@@ -346,14 +376,17 @@ NETWORK / HAR (need the daemon: each command is its own process)
346
376
  daemon start Start the daemon first; it holds the browser between commands
347
377
  har start Start recording HAR in the daemon's browser
348
378
  har stop [output] Stop and save HAR file
349
- network list List network requests the daemon's browser captured
379
+ network list List network requests the daemon's browser captured, newest first
380
+ --filter <text> Only requests whose URL contains <text>, or of that type or method
381
+ --all List every request (default: the newest 20)
382
+ --json Print the list as JSON
350
383
  network clear Clear that list
351
384
  console List the daemon tab's console messages (errors, warnings, logs)
352
385
  --filter <type> all | error | warning | log | info (default all)
353
386
  --clear Empty the console buffer after reading
354
387
  --json Print the raw messages as JSON
355
388
 
356
- VISUAL REGRESSION (v2.5.0)
389
+ VISUAL REGRESSION
357
390
  visual save <name> Save baseline screenshot
358
391
  --url <url> Navigate to URL first
359
392
  visual compare <name> Compare current page against baseline
@@ -361,19 +394,22 @@ VISUAL REGRESSION (v2.5.0)
361
394
  visual list List all saved baselines
362
395
  visual delete <name> Delete a baseline
363
396
 
364
- AI VISUAL REGRESSION (v7.0.0)
397
+ AI VISUAL REGRESSION
365
398
  ai-visual capture <url> Capture AI-analyzed visual baseline
366
399
  --name <name> Baseline name (required)
367
400
  --selector <sel> Capture specific element instead of viewport
368
401
  --device <device> Device emulation (iphone-15, pixel-7, etc.)
402
+ --viewport <WxH> Page size (default: 1920x1080)
369
403
  --width <n> Viewport width (default: 1920)
370
404
  --height <n> Viewport height (default: 1080)
405
+ --browser <engine> chromium, firefox or webkit; the baseline keeps it
371
406
  --wait <ms|selector> Wait before capture (ms or CSS selector)
372
407
  Examples:
373
408
  cbrowser ai-visual capture "https://example.com" --name homepage
374
409
  cbrowser ai-visual capture "https://example.com/dashboard" --name dashboard --device iphone-15
375
410
 
376
411
  ai-visual test <url> <baseline> Test current page against baseline using AI
412
+ Renders with the baseline's engine, device and size
377
413
  --threshold <n> Similarity threshold 0-1 (default: 0.9)
378
414
  --sensitivity <level> Detection sensitivity: low, medium, high (default: medium)
379
415
  --html Generate HTML report
@@ -391,41 +427,13 @@ AI VISUAL REGRESSION (v7.0.0)
391
427
  ai-visual show <name> Show baseline details
392
428
  ai-visual delete <name> Delete a baseline
393
429
 
394
- SMART BASELINES & TRANSPORT MAP (v18.0.0)
395
- smart-baseline capture <url> Capture consensus baseline from multiple screenshots
396
- --name <name> Baseline name (required)
397
- --captures <n> Number of screenshots to take (default: 5)
398
- --delay <ms> Delay between captures (default: 1500)
399
- --selector <sel> Capture specific element
400
- --device <device> Device emulation
401
- --outlier-threshold <n> Outlier rejection threshold in stddev (default: 2.0)
402
- Examples:
403
- cbrowser smart-baseline capture "https://example.com" --name homepage
404
- cbrowser smart-baseline capture "https://example.com" --name hero --captures 10
405
-
406
- smart-baseline test <url> <name> Test against smart baseline
407
- --threshold <n> Override adaptive threshold (0-1)
408
- --transport-map Also generate visual transport map
409
- Examples:
410
- cbrowser smart-baseline test "https://staging.example.com" homepage
411
- cbrowser smart-baseline test "https://staging.example.com" homepage --transport-map
412
-
413
- smart-baseline list List all smart baselines
414
- smart-baseline delete <name> Delete a smart baseline
415
-
416
- transport-map <baseline> <current> Generate visual transport map between two screenshots
417
- --cell-size <n> Grid cell size in pixels (default: 32)
418
- --output <file.svg> Save SVG to file
419
- --hotspots <n> Number of hotspots to identify (default: 5)
420
- Examples:
421
- cbrowser transport-map baseline.png current.png
422
- cbrowser transport-map baseline.png current.png --cell-size 16 --output changes.svg
423
-
424
- CROSS-BROWSER VISUAL TESTING (v7.3.0)
430
+ CROSS-BROWSER VISUAL TESTING
425
431
  cross-browser <url> Compare visual rendering across browsers
426
432
  --browsers <list> Browsers to test: chromium,firefox,webkit (default: all)
433
+ --viewport <WxH> Page size (default: 1920x1080)
427
434
  --width <n> Viewport width (default: 1920)
428
435
  --height <n> Viewport height (default: 1080)
436
+ --device <name> Device preset (firefox cannot emulate a mobile one)
429
437
  --wait <ms> Wait before screenshot (ms)
430
438
  --wait-for <selector> Wait for selector before screenshot
431
439
  --sensitivity <level> Comparison sensitivity: low, medium, high
@@ -447,9 +455,11 @@ CROSS-BROWSER VISUAL TESTING (v7.3.0)
447
455
  "options": { "browsers": ["chromium", "firefox"] }
448
456
  }
449
457
 
450
- RESPONSIVE VISUAL TESTING (v7.3.0)
458
+ RESPONSIVE VISUAL TESTING
451
459
  responsive <url> Test visual rendering across viewport sizes
452
460
  --viewports <list> Viewports to test (default: mobile,tablet,desktop)
461
+ --browser <engine> chromium, firefox or webkit (firefox cannot emulate
462
+ the mobile and tablet presets)
453
463
  --wait <ms> Wait before screenshot (ms)
454
464
  --wait-for <selector> Wait for selector before screenshot
455
465
  --sensitivity <level> Comparison sensitivity: low, medium, high
@@ -473,18 +483,19 @@ RESPONSIVE VISUAL TESTING (v7.3.0)
473
483
  "options": { "viewports": ["mobile", "tablet", "desktop"] }
474
484
  }
475
485
 
476
- Available viewport presets:
477
- mobile-sm (320x568) mobile (375x667) mobile-lg (414x896)
478
- mobile-xl (428x926) tablet (768x1024) tablet-lg (1024x1366)
479
- desktop-sm (1280x800) desktop (1440x900) desktop-lg (1920x1080)
480
- desktop-xl (2560x1440)
486
+ Available viewport presets (mobile and tablet emulate their device: touch,
487
+ user agent, pixel ratio):
488
+ ${formatViewportPresetList(" ")}
481
489
 
482
- A/B VISUAL COMPARISON (v7.3.0)
490
+ A/B VISUAL COMPARISON
483
491
  ab <urlA> <urlB> Compare two URLs visually
484
492
  --label-a <name> Label for URL A (default: "Version A")
485
493
  --label-b <name> Label for URL B (default: "Version B")
494
+ --viewport <WxH> Page size (default: 1920x1080)
486
495
  --width <n> Viewport width (default: 1920)
487
496
  --height <n> Viewport height (default: 1080)
497
+ --device <name> Device preset
498
+ --browser <engine> chromium, firefox or webkit
488
499
  --wait <ms> Wait before screenshot (ms)
489
500
  --wait-for <selector> Wait for selector before screenshot
490
501
  --sensitivity <level> Comparison sensitivity: low, medium, high
@@ -514,6 +525,9 @@ AI FRIENDLINESS - Audit sites for AI agent compatibility
514
525
 
515
526
  agent-ready-audit <url> Audit site for AI-agent friendliness (A-F grade)
516
527
  --headless Run in headless mode (default: true)
528
+ --device <name> Device to audit on, e.g. iphone-16
529
+ --geo-region <region> Run through a configured regional proxy (exits 1 if not configured)
530
+ --lightpanda Use Lightpanda instead of Chromium (see LIGHTPANDA)
517
531
  --output <file> Save JSON report to file
518
532
  --html Generate HTML report
519
533
  Grades sites on: Findability (35%), Stability (30%),
@@ -524,6 +538,7 @@ AI FRIENDLINESS - Audit sites for AI agent compatibility
524
538
 
525
539
  ai-benchmark Compare AI-friendliness across multiple sites
526
540
  --urls <list> Comma-separated URLs to benchmark (required)
541
+ --concurrency <n> Sites audited at once (default: 3)
527
542
  --output <file> Save JSON report to file
528
543
  --html Generate HTML report
529
544
  Runs agent-ready-audit on each URL and produces ranked comparison.
@@ -532,14 +547,17 @@ AI FRIENDLINESS - Audit sites for AI agent compatibility
532
547
  cbrowser ai-benchmark --urls "https://amazon.com,https://ebay.com" --html
533
548
 
534
549
  empathy-audit <url> Simulate disability experience on site
535
- --goal <goal> Task goal (required)
536
- --personas <list> Disability personas (comma-separated)
550
+ --goal <goal> Task goal (optional; without one the page is checked)
551
+ --personas <list> Disability personas (comma-separated; also --disabilities)
552
+ --device <name> Device to audit on, e.g. iphone-16
553
+ --scope <scope> viewport (default) or full_page
537
554
  --wcag-level <level> WCAG level: A, AA, AAA (default: AA)
555
+ --max-steps <n> Steps per persona when a goal is given
556
+ --max-time <s> Seconds per persona when a goal is given
538
557
  --output <file> Save JSON report to file
539
558
  --html Generate HTML report
540
559
  Available personas:
541
- motor-impairment-tremor, low-vision-magnified, cognitive-adhd,
542
- dyslexic-user, deaf-user, elderly-low-vision, color-blind-deuteranopia
560
+ ${listAccessibilityPersonas().join(", ")}
543
561
  Examples:
544
562
  cbrowser empathy-audit "https://example.com" \\
545
563
  --goal "complete checkout" \\
@@ -557,13 +575,13 @@ AI FRIENDLINESS - Audit sites for AI agent compatibility
557
575
  cbrowser webmcp-ready "https://demo.cbrowser.ai/mcp"
558
576
  cbrowser webmcp-ready "https://your-server.com/mcp" --api-key KEY --html
559
577
 
560
- hunt-bugs <url> Automatically find UX bugs on a page
578
+ hunt-bugs <url> Automatically find UX bugs on a page (alias: hunt)
561
579
  --max-pages <n> Max pages to crawl (default: 10)
562
580
  --timeout <ms> Timeout in ms (default: 60000)
563
581
  Examples:
564
582
  cbrowser hunt-bugs "https://example.com"
565
583
 
566
- competitive-benchmark Compare UX across competitor sites
584
+ competitive-benchmark Compare UX across competitor sites (needs an Anthropic API key)
567
585
  --sites <list> Comma-separated site URLs (required)
568
586
  --goal <goal> Task goal (required)
569
587
  --persona <name> Persona to use (default: first-timer)
@@ -576,12 +594,12 @@ AI FRIENDLINESS - Audit sites for AI agent compatibility
576
594
  --sites "https://yoursite.com,https://competitor.com" \\
577
595
  --goal "sign up for free trial" --html
578
596
 
579
- ACCESSIBILITY (v2.5.0)
597
+ ACCESSIBILITY
580
598
  a11y audit Run WCAG accessibility audit
581
599
  --url <url> Navigate to URL first
582
600
  a11y audit [url] Audit a specific URL
583
601
 
584
- TEST RECORDING (v2.5.0)
602
+ TEST RECORDING
585
603
  record start Start recording interactions
586
604
  --url <url> Navigate to URL to begin recording
587
605
  record stop Stop recording and show actions
@@ -593,7 +611,7 @@ SCREEN CAPTURE (video/GIF — distinct from 'record', which captures actions)
593
611
  capture start <url> Capture the screen to GIF/WebP/video
594
612
  --fps <n> Target frames per second (default: 10)
595
613
  --duration <5s> Auto-stop after this long (5s, 2m, 1500ms)
596
- --viewport <WxH> Viewport size, e.g. 1280x720
614
+ --viewport <WxH> Viewport size, e.g. 1280x720, or a size name (mobile, tablet, desktop...)
597
615
  --region <x,y,w,h> Capture a fixed region instead of the viewport
598
616
  --element <selector> Track an element; crop follows it as it moves
599
617
  --element-padding <n> Expand the element box by n CSS px per side
@@ -627,13 +645,16 @@ SCREEN CAPTURE (video/GIF — distinct from 'record', which captures actions)
627
645
  and the still stretches are reported as frame_gaps rather than padded out.
628
646
  mp4 needs a full ffmpeg (CBROWSER_FFMPEG_PATH); the bundled one does webm.
629
647
 
630
- TEST EXPORT (v2.5.0)
648
+ TEST EXPORT
631
649
  export junit <name> [output] Export test results as JUnit XML
632
650
  export tap <name> [output] Export test results as TAP format
651
+ <name> is a suite test-suite has run (its file name); each run is saved
633
652
 
634
- WEBHOOKS (v2.5.0)
635
- webhook add <name> <url> Add webhook notification
636
- --events <events> Comma-separated: test.pass,test.fail,journey.complete
653
+ WEBHOOKS
654
+ webhook add <name> <url> Add webhook notification (http or https)
655
+ --events <events> test.pass,test.fail,journey.complete (default: test.fail);
656
+ test-suite sends one per run, cognitive-journey one per
657
+ journey; JSON, 10s timeout, no retries
637
658
  --format <format> slack, discord, or generic
638
659
  webhook list List configured webhooks
639
660
  webhook delete <name> Delete a webhook
@@ -641,30 +662,37 @@ WEBHOOKS (v2.5.0)
641
662
 
642
663
  PERSONA QUESTIONNAIRE
643
664
  persona-questionnaire start Build a persona from a behavioural questionnaire
644
- --comprehensive All 26 traits (default: 8 core traits)
665
+ --comprehensive All 30 traits (default: 13 core traits)
645
666
  --name <name> Persona name (prompted if omitted)
646
667
  --output <file> Save answers to JSON
647
668
  persona-questionnaire list-traits List available traits with descriptions
648
669
  persona-questionnaire lookup Look up behaviours for a trait value
649
670
 
650
- PARALLEL EXECUTION (v2.5.0)
671
+ PARALLEL EXECUTION
651
672
  parallel devices <url> Run same URL across multiple devices
652
673
  --devices <list> Comma-separated device names (default: all)
653
674
  --concurrency <n> Max parallel browsers (default: 3)
675
+ --browser, --no-headless The browser for every device
654
676
  parallel urls <urls> Run same task across multiple URLs
655
677
  --concurrency <n> Max parallel browsers (default: 3)
678
+ --device, --viewport, --browser, --no-headless The browser for every URL
656
679
  parallel perf <urls> Performance audit multiple URLs in parallel
657
680
  --concurrency <n> Max parallel browsers (default: 3)
681
+ --device, --viewport, --browser, --no-headless The browser for every URL
658
682
 
659
- NATURAL LANGUAGE (v3.0.0)
683
+ NATURAL LANGUAGE
660
684
  run "<command>" Execute natural language command
661
685
  Examples:
662
686
  cbrowser run "go to https://example.com"
663
687
  cbrowser run "click the login button"
664
688
  cbrowser run "type 'hello' in the search box"
665
689
  script <file> Execute script file with natural language commands
690
+ scroll <top|bottom|up|down> [px] Scroll the page (up/down default 300px)
691
+ --url <url> Navigate to URL first
692
+ Note: without the daemon the next command reloads the page, so the
693
+ scroll position does not carry over; start the daemon first.
666
694
 
667
- VISUAL AI (v4.0.0)
695
+ VISUAL AI
668
696
  ai find "<intent>" Find element by semantic intent
669
697
  Examples:
670
698
  cbrowser ai find "the cheapest product"
@@ -672,23 +700,20 @@ VISUAL AI (v4.0.0)
672
700
  cbrowser ai find "search box"
673
701
  ai click "<intent>" Find and click element by intent
674
702
 
675
- BUG HUNTER (v4.0.0)
676
- hunt <url> Automatically find bugs on a page
677
- --max-pages <n> Max pages to crawl (default: 10)
678
- --timeout <ms> Timeout in ms (default: 60000)
679
-
680
- CROSS-BROWSER (v4.0.0)
703
+ BROWSER DIFF
681
704
  diff <url> Compare page across browsers
682
705
  --browsers <list> chromium,firefox,webkit (default: all)
706
+ --viewport <WxH> Page size (default: 1920x1080)
707
+ --device <name> Device preset (firefox cannot emulate a mobile one)
683
708
 
684
- CHAOS ENGINEERING (v4.0.0)
709
+ CHAOS ENGINEERING
685
710
  chaos <url> Test app resilience
686
711
  --latency <ms> Add network latency
687
712
  --offline Simulate offline mode
688
713
  --block <patterns> Block URL patterns (comma-separated)
689
714
  --fail-api <pattern:status> Fail specific API calls
690
715
 
691
- SMART RETRY & ASSERTIONS (v5.0.0)
716
+ SMART RETRY & ASSERTIONS
692
717
  smart-click <selector> Click with auto-retry and alternative selectors
693
718
  --url <url> Navigate to URL first
694
719
  --max-retries <n> Maximum retry attempts (default: 3)
@@ -707,7 +732,7 @@ SMART RETRY & ASSERTIONS (v5.0.0)
707
732
  cbrowser assert "'Login' button exists"
708
733
  cbrowser assert "page has 3 items"
709
734
 
710
- SELF-HEALING SELECTORS (v5.0.0)
735
+ SELF-HEALING SELECTORS
711
736
  heal stats Show selector cache statistics
712
737
  heal list List all cached selector mappings
713
738
  --domain <domain> Filter by domain
@@ -716,18 +741,22 @@ SELF-HEALING SELECTORS (v5.0.0)
716
741
  heal test <selector> Test if a selector would be healed
717
742
  --url <url> Navigate to URL first
718
743
 
719
- AI TEST GENERATION (v5.0.0)
744
+ AI TEST GENERATION
720
745
  generate <url> Analyze page and generate test scenarios
721
746
  --format <format> Output: summary, cbrowser, playwright, all (default: summary)
722
747
  --output <file> Save to file instead of stdout
723
748
  analyze <url> Show page analysis without generating tests
749
+ --output <file> Save the analysis as JSON
724
750
 
725
- MCP SERVER (v5.0.0)
751
+ MCP SERVER
726
752
  mcp-server Start CBrowser as MCP server for Claude integration
727
753
  Use with Claude Desktop or other MCP-compatible clients
728
754
  mcp-remote Start remote HTTP MCP server for claude.ai connectors
729
755
  --port <port> Port to listen on (default: 3000)
730
756
  --host <host> Host to bind to (default: 0.0.0.0)
757
+ --stateful Use stateful session mode
758
+ Smart visual baselines and transport maps have no CLI command; they are the
759
+ MCP tools visual_baseline / visual_regression (smart mode) and transport_map.
731
760
 
732
761
  UNINSTALL
733
762
  uninstall Remove CBrowser data: credentials, sessions, captures
@@ -742,49 +771,58 @@ CLAUDE SKILL INSTALLATION & SYNC
742
771
  Downloads latest skill files from GitHub
743
772
  Run after 'npm update -g cbrowser' to stay in sync
744
773
  Add to skill-index.json after first installation
745
- --stateful Use stateful session mode
746
774
 
747
- DAEMON MODE (v6.4.0)
775
+ DAEMON MODE
748
776
  daemon start Start background daemon (keeps browser running)
749
777
  --port <port> Daemon port (default: 9222)
750
778
  --device <name> Device the daemon's browser emulates
751
779
  --viewport <WxH> Page size of the daemon's browser
752
- --timeout <min> Idle timeout in minutes (default: 30)
780
+ --timeout <min> Idle timeout: minutes, or 90s / 45m / 2h (default: 30)
753
781
  daemon stop Stop the running daemon
754
- daemon status Check if daemon is running
782
+ daemon status Check if daemon is running (exits 1 when it is not)
755
783
  daemon run Run daemon in foreground (internal use)
756
- Note: When daemon is running, all commands automatically connect to it
757
- instead of launching a new browser - much faster for iteration!
784
+ Note: while the daemon runs, navigate, click, fill, hover, type, press,
785
+ keyboard, screenshot, extract, run, scroll, evaluate, capture, har,
786
+ network and console use its browser. Commands that would open a second
787
+ browser on its profile (script, ai, explore, a11y, perf, visual,
788
+ drag, upload, reset and others) refuse until you stop it.
758
789
 
759
- API CONFIGURATION (v8.0.0)
790
+ API CONFIGURATION
760
791
  config set-api-key <key> Set Anthropic API key for cognitive journeys
761
792
  config show-api-key Show configured API key (masked)
762
793
  config remove-api-key Remove stored API key
763
794
  config set-model <model> Set Claude model (default: claude-sonnet-5)
764
795
 
765
- LIGHTPANDA (v18.20.0) - High-Performance Headless Browser (OPT-IN ONLY)
796
+ LIGHTPANDA - High-Performance Headless Browser (OPT-IN ONLY)
766
797
  lightpanda-status Check Lightpanda availability and configuration
767
798
  lightpanda-setup Show setup instructions with security warnings
768
799
  ⚠️ SECURITY NOTICE:
769
800
  • Lightpanda is BETA software with no security audit
770
801
  • Cloud mode exposes traffic to lightpanda.io servers
771
802
  • NEVER use for auth, credentials, or payments
772
- • Requires explicit --lightpanda flag (not automatic)
803
+ • Used only by agent-ready-audit, and only with --lightpanda
773
804
  Environment Variables:
774
805
  LIGHTPANDA_ENDPOINT WebSocket endpoint (e.g., ws://127.0.0.1:9222)
775
806
  LIGHTPANDA_TOKEN Cloud API token (⚠️ data visible to lightpanda.io)
776
807
  Examples:
777
808
  cbrowser lightpanda-status
778
- cbrowser screenshot https://example.com --lightpanda
779
809
  cbrowser agent-ready-audit https://example.com --lightpanda
780
810
 
811
+ PRODUCT HELP
812
+ ask "<question>" Ask the CBrowser docs a question; prints an AI answer and its sources
813
+ --context <text> What you are looking at (a URL, a tool, a score)
814
+ --json Print the answer as JSON
815
+ Sends your question to cbrowser.ai. No account or API key; rate-limited.
816
+ Example:
817
+ cbrowser ask "Which persona should I use for a screen reader user?"
818
+
781
819
  DIAGNOSTICS
782
820
  version, -v, --version Show version number
783
821
  status Show environment status and diagnostics
784
822
  Displays data directories, browsers, config, heal cache
785
823
  doctor Check environment health (Node, Playwright, API key)
786
824
 
787
- ACCESSIBILITY
825
+ OUTPUT MODES (accessibility)
788
826
  --no-color Disable all color output (also respects NO_COLOR env var)
789
827
  --plain Disable emoji, box-drawing, and unicode decorations
790
828
  --json-output Output structured JSON (for scripts and screen readers)
@@ -796,6 +834,7 @@ STORAGE & CLEANUP
796
834
  --older-than <days> Delete files older than N days (default: 7)
797
835
  --keep-screenshots <n> Keep at least N screenshots (default: 10)
798
836
  --keep-journeys <n> Keep at least N journeys (default: 5)
837
+ --keep-sessions <n> Keep at least N sessions (default: 3)
799
838
  reset Clear persistent browser state (cookies, storage)
800
839
 
801
840
  OPTIONS
@@ -803,6 +842,8 @@ OPTIONS
803
842
  --device <name> Device preset: iphone-16, pixel-9, ipad-pro-13, ... ('device list')
804
843
  --viewport <WxH> Page size, e.g. 390x844; with --device it sets the size and
805
844
  the device keeps its pixel ratio, touch and user agent
845
+ Size names work too (mobile, tablet, desktop-md...): size
846
+ only; use --device for touch and a mobile user agent
806
847
  --geo <location> Location preset or lat,lon coordinates
807
848
  --locale <locale> Browser locale (e.g., en-US, fr-FR)
808
849
  --timezone <tz> Timezone (e.g., America/New_York)
@@ -870,8 +911,27 @@ const COMMAND_ALIASES = {
870
911
  "chaos-test": "chaos",
871
912
  "generate-tests": "generate",
872
913
  journey: "explore",
914
+ // Dispatch aliases that `help` used to resolve by text search, so
915
+ // `help stats` showed "heal stats" (audit 2026-10-21).
916
+ hunt: "hunt-bugs",
917
+ flaky: "flaky-check",
918
+ repair: "repair-tests",
919
+ stats: "storage",
873
920
  };
874
921
  /** Escape a string for safe use inside a RegExp. */
922
+ /**
923
+ * Print a command result that is data. Under --json-output it becomes the
924
+ * envelope's `data`, untouched; otherwise pretty JSON on stdout. Before 20.0
925
+ * extract went through the text capture, which stored its JSON as text lines
926
+ * (audit 2026-10-21).
927
+ */
928
+ function printData(command, data) {
929
+ if (getOutputOptions().json) {
930
+ emitJson({ success: true, command, data, meta: { version: VERSION } });
931
+ return;
932
+ }
933
+ console.log(JSON.stringify(data, null, 2));
934
+ }
875
935
  function escapeForRegExp(value) {
876
936
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
877
937
  }
@@ -976,10 +1036,18 @@ function showCommandHelp(command) {
976
1036
  * against a second browser while the daemon holds the first. Anything listed
977
1037
  * here must either be daemon-routed or refuse to run.
978
1038
  */
1039
+ /** What `extract` can return (browser.extract). */
1040
+ const EXTRACT_KINDS = ["text", "links", "headings", "images", "forms"];
979
1041
  const DRIVES_THE_PAGE = [
980
1042
  "navigate", "click", "fill", "hover", "screenshot", "extract", "run",
981
1043
  "keyboard", "press", "type", "evaluate", "eval", "drag", "upload",
982
1044
  "smart-click", "dismiss-overlay", "assert",
1045
+ // These also launch the shared profile the daemon holds, so they acted on
1046
+ // a page the user was not watching or failed on its lock (audit
1047
+ // 2026-10-21). reset deleted the profile under the daemon, which wrote its
1048
+ // cookies back on exit.
1049
+ "script", "ai", "explore", "analyze", "generate", "hunt", "hunt-bugs",
1050
+ "chaos", "a11y", "perf", "visual", "reset",
983
1051
  ];
984
1052
  /**
985
1053
  * Flags the shared CBrowser construction consumes, for every command that uses
@@ -991,7 +1059,12 @@ const SHARED_BROWSER_FLAGS = [
991
1059
  "url", "browser", "device", "viewport", "headless", "persistent", "restore",
992
1060
  "locale", "timezone", "geo", "verbose",
993
1061
  ];
994
- const GLOBAL_FLAGS = [...SHARED_BROWSER_FLAGS, "help", "no-color", "quiet", "json"];
1062
+ /**
1063
+ * Globals every command accepts. --plain and --json-output are the documented
1064
+ * output modes (src/output.ts); before 2026-10-21 the validated commands
1065
+ * rejected them.
1066
+ */
1067
+ const GLOBAL_FLAGS = [...SHARED_BROWSER_FLAGS, "help", "no-color", "json", "plain", "json-output"];
995
1068
  /**
996
1069
  * Declared options per command.
997
1070
  *
@@ -1022,25 +1095,19 @@ const COMMAND_FLAGS = {
1022
1095
  evaluate: ["file", "arg", "json", "raw", "wait-for", "timeout", "expect-truthy", "force"],
1023
1096
  eval: ["file", "arg", "json", "raw", "wait-for", "timeout", "expect-truthy", "force"],
1024
1097
  keyboard: ["delay", "selector", "hold", "repeat", "text", "force"],
1098
+ // test-suite and cognitive-journey list only the flags their code reads
1099
+ // (measured 2026-10-21). They used to declare every report command's flags,
1100
+ // so `test-suite --persona x` passed validation and did nothing.
1101
+ ask: ["context", "json"],
1025
1102
  "test-suite": [
1026
1103
  "capture", "capture-fps", "capture-format", "capture-out",
1027
- "api-key", "auto-apply", "comprehensive", "concurrency", "continue-on-failure",
1028
- "delay", "disabilities", "dry-run", "exclude", "fuzzy-match", "goal",
1029
- "html", "include", "inline", "length", "lightpanda", "max-pages", "max-steps",
1030
- "max-time", "min-coverage", "name", "no-vision", "oauth-token", "output",
1031
- "persona", "retries", "runs", "screenshot-on-failure", "sensitivity",
1032
- "sitemap", "sites", "start", "step-through", "tests", "threshold",
1033
- "threshold-cls", "threshold-fcp", "threshold-lcp", "threshold-tbt",
1034
- "threshold-ttfb", "threshold-tti", "timeout", "trait", "urls", "value",
1035
- "verify", "wcag-level", "geolocation", "geo-region",
1104
+ "continue-on-failure", "dry-run", "fuzzy-match", "html", "inline", "output",
1105
+ "screenshot-on-failure", "step-through", "timeout",
1036
1106
  ],
1037
1107
  "cognitive-journey": [
1038
1108
  "capture", "capture-fps", "capture-format", "capture-out",
1039
- "api-key", "comprehensive", "concurrency", "disabilities", "goal", "html",
1040
- "length", "lightpanda", "max-steps", "max-time", "name", "no-vision",
1041
- "oauth-token", "output", "persona", "sites", "start", "timeout", "trait",
1042
- "urls", "value", "wcag-level", "geolocation", "geo-region", "vision",
1043
- "trace", "replay-trace",
1109
+ "goal", "persona", "start", "max-steps", "max-time", "no-vision", "output",
1110
+ "geolocation", "trace", "replay-trace",
1044
1111
  ],
1045
1112
  };
1046
1113
  /**
@@ -1057,11 +1124,29 @@ const UNVALIDATED_REASON = "not yet declared - see COMMAND_FLAGS in src/cli.ts";
1057
1124
  * Fails on the FIRST invalid flag with the command's full accepted set, so a
1058
1125
  * typo is a one-command fix rather than a guessing game.
1059
1126
  */
1127
+ /**
1128
+ * Every flag a validated command accepts: its declared flags, the globals, and
1129
+ * both halves of each on/off pair. The parser turns --no-x into "no-x" and
1130
+ * "x"=false, so declaring either half must accept both; before 2026-10-21
1131
+ * --no-color failed with "does not support --color", a flag the user never
1132
+ * typed. The capture, evaluate and keyboard gates read this same set, so one
1133
+ * command line can no longer pass one gate and fail the other.
1134
+ */
1135
+ function allowedFlags(command) {
1136
+ const allowed = new Set([...(COMMAND_FLAGS[command] ?? []), ...GLOBAL_FLAGS]);
1137
+ for (const flag of [...allowed]) {
1138
+ if (flag.startsWith("no-"))
1139
+ allowed.add(flag.slice(3));
1140
+ else
1141
+ allowed.add(`no-${flag}`);
1142
+ }
1143
+ return allowed;
1144
+ }
1060
1145
  function validateCommandFlags(command, options) {
1061
1146
  const declared = COMMAND_FLAGS[command];
1062
1147
  if (!declared)
1063
1148
  return; // UNVALIDATED_REASON
1064
- const allowed = new Set([...declared, ...GLOBAL_FLAGS]);
1149
+ const allowed = allowedFlags(command);
1065
1150
  const unknown = Object.keys(options).filter((flag) => !allowed.has(flag));
1066
1151
  if (unknown.length === 0)
1067
1152
  return;
@@ -1086,14 +1171,66 @@ function captureStatePath() {
1086
1171
  return join(getPaths().videosDir, "active.json");
1087
1172
  }
1088
1173
  /** Parse "5s", "2m", "1500ms" or a bare millisecond count. */
1174
+ /**
1175
+ * A numeric option, checked. parseInt/parseFloat let "abc" through as NaN and
1176
+ * read "0.2" as 0 (flaky-check --threshold flagged every test), so a value
1177
+ * outside the range or not a number exits 1 naming the flag (audit F21).
1178
+ */
1179
+ function numberOption(flag, value, fallback, range) {
1180
+ if (value === undefined)
1181
+ return fallback;
1182
+ const n = typeof value === "string" && value.trim() !== "" ? Number(value) : NaN;
1183
+ if (!Number.isFinite(n) || n < range.min || n > range.max) {
1184
+ console.error(`Error: --${flag} must be a number from ${range.min} to ${range.max}, got "${String(value)}"`);
1185
+ process.exit(1);
1186
+ }
1187
+ return n;
1188
+ }
1189
+ /** An option from a fixed set of words; anything else exits 1 (audit F21). */
1190
+ function enumOption(flag, value, allowed) {
1191
+ if (value === undefined)
1192
+ return undefined;
1193
+ if (typeof value !== "string" || !allowed.includes(value)) {
1194
+ console.error(`Error: --${flag} must be ${allowed.join(", ")}, got "${String(value)}"`);
1195
+ process.exit(1);
1196
+ }
1197
+ return value;
1198
+ }
1199
+ /**
1200
+ * Where --html writes when --output names the JSON report. The HTML used to
1201
+ * take `output.replace(".json", ".html")`, so `--output report` or
1202
+ * `--output report.html` wrote the HTML over the JSON (audit 2026-10-21).
1203
+ */
1204
+ function htmlReportPath(output, fallback) {
1205
+ if (typeof output !== "string" || output === "")
1206
+ return fallback;
1207
+ const base = output.replace(/\.(json|html?)$/i, "");
1208
+ const html = `${base}.html`;
1209
+ return html === output ? `${base}.report.html` : html;
1210
+ }
1211
+ /**
1212
+ * A --timeout value in milliseconds: a bare number is ms, and 5s / 2m / 1h work.
1213
+ * Exits 1 on anything else rather than guessing.
1214
+ */
1215
+ function timeoutOption(value, fallbackMs) {
1216
+ if (value === undefined)
1217
+ return fallbackMs;
1218
+ try {
1219
+ return parseDurationMs(String(value));
1220
+ }
1221
+ catch {
1222
+ console.error(`Error: --timeout must be a positive number of milliseconds or a duration (5s, 2m, 1h), got "${String(value)}"`);
1223
+ process.exit(1);
1224
+ }
1225
+ }
1089
1226
  function parseDurationMs(input) {
1090
- const match = /^(\d+(?:\.\d+)?)\s*(ms|s|m)?$/i.exec(input.trim());
1227
+ const match = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h)?$/i.exec(input.trim());
1091
1228
  if (!match) {
1092
- throw new Error(`Invalid duration "${input}" - use 5s, 2m, 1500ms or a millisecond count`);
1229
+ throw new Error(`Invalid duration "${input}" - use 5s, 2m, 1h, 1500ms or a millisecond count`);
1093
1230
  }
1094
1231
  const value = parseFloat(match[1]);
1095
1232
  const unit = (match[2] || "ms").toLowerCase();
1096
- const ms = unit === "s" ? value * 1000 : unit === "m" ? value * 60_000 : value;
1233
+ const ms = unit === "s" ? value * 1000 : unit === "m" ? value * 60_000 : unit === "h" ? value * 3_600_000 : value;
1097
1234
  if (ms <= 0)
1098
1235
  throw new Error(`Invalid duration "${input}" - must be greater than zero`);
1099
1236
  return Math.round(ms);
@@ -1112,9 +1249,13 @@ function parseFormats(input) {
1112
1249
  }
1113
1250
  /** Parse "1280x720". */
1114
1251
  function parseViewportSize(input) {
1252
+ const preset = VIEWPORT_PRESETS.find((p) => p.name === input.trim().toLowerCase());
1253
+ if (preset)
1254
+ return { width: preset.width, height: preset.height };
1115
1255
  const match = /^(\d+)x(\d+)$/i.exec(input.trim());
1116
- if (!match)
1117
- throw new Error(`Invalid viewport "${input}" - use WIDTHxHEIGHT, e.g. 1280x720`);
1256
+ if (!match) {
1257
+ throw new Error(`Invalid viewport "${input}" - use WIDTHxHEIGHT, e.g. 1280x720, or a size name: ${VIEWPORT_PRESETS.map((p) => p.name).join(", ")}`);
1258
+ }
1118
1259
  const width = parseInt(match[1], 10);
1119
1260
  const height = parseInt(match[2], 10);
1120
1261
  if (width <= 0 || height <= 0) {
@@ -1152,15 +1293,7 @@ function parseRegion(input) {
1152
1293
  * confident "✓" for a capture the user did not ask for, which is worse than
1153
1294
  * an error. The globals are the ones the CLI itself consumes before dispatch.
1154
1295
  */
1155
- const CAPTURE_FLAGS = new Set([
1156
- // capture-specific
1157
- "fps", "duration", "format", "out", "name", "quality", "max-frames",
1158
- "viewport", "region", "element", "element-padding",
1159
- "contact-sheet", "attention-overlay", "overlay-persona", "overlay-goal",
1160
- "after", "after-element", "after-delay",
1161
- "until-element", "until-element-gone", "until-idle", "timeout",
1162
- ...SHARED_BROWSER_FLAGS,
1163
- ]);
1296
+ const CAPTURE_FLAGS = allowedFlags("capture");
1164
1297
  /**
1165
1298
  * Resolve `--device` to a real preset key, or exit naming the bad value.
1166
1299
  *
@@ -1513,12 +1646,19 @@ function failCaptureCommand(message) {
1513
1646
  * later `cbrowser click` or `cbrowser fill` appear in the recording.
1514
1647
  */
1515
1648
  async function runCaptureStartViaDaemon(url, options) {
1649
+ // The daemon's page size and device are fixed when it starts. These were
1650
+ // accepted here and dropped, so a capture "at 390x844" recorded at the
1651
+ // daemon's size and reported success (audit 2026-10-21).
1652
+ for (const flag of ["viewport", "device", "browser"]) {
1653
+ if (options[flag] !== undefined) {
1654
+ failCaptureCommand(`--${flag} cannot change a running daemon's browser. Restart it with: cbrowser daemon stop && cbrowser daemon start --${flag} <value>`);
1655
+ }
1656
+ }
1516
1657
  const target = buildCaptureTarget(options);
1517
1658
  const triggers = buildCaptureTriggers(options);
1518
1659
  const slug = slugFor(options);
1519
1660
  const response = await sendToDaemon("captureStart", {
1520
1661
  url,
1521
- engine: options.browser === "firefox" ? "firefox" : options.browser === "webkit" ? "webkit" : "chromium",
1522
1662
  options: {
1523
1663
  fps: options.fps !== undefined ? Number(options.fps) : undefined,
1524
1664
  quality: options.quality !== undefined ? Number(options.quality) : undefined,
@@ -1528,6 +1668,16 @@ async function runCaptureStartViaDaemon(url, options) {
1528
1668
  contactSheet: options["contact-sheet"] === true,
1529
1669
  slug,
1530
1670
  target,
1671
+ // Plain strings, so the overlay crosses the HTTP boundary intact; the
1672
+ // daemon path used to drop it.
1673
+ ...(options["attention-overlay"]
1674
+ ? {
1675
+ saliencyOverlay: {
1676
+ ...(typeof options["overlay-persona"] === "string" ? { persona: options["overlay-persona"] } : {}),
1677
+ ...(typeof options["overlay-goal"] === "string" ? { goal: options["overlay-goal"] } : {}),
1678
+ },
1679
+ }
1680
+ : {}),
1531
1681
  ...triggers,
1532
1682
  },
1533
1683
  });
@@ -1758,10 +1908,7 @@ function reportAutoCapture(capture) {
1758
1908
  // evaluate — run JavaScript in the page
1759
1909
  // =========================================================================
1760
1910
  /** Flags `evaluate` understands; anything else is rejected rather than ignored. */
1761
- const EVALUATE_FLAGS = new Set([
1762
- "file", "arg", "json", "raw", "wait-for", "timeout", "expect-truthy", "force",
1763
- ...SHARED_BROWSER_FLAGS,
1764
- ]);
1911
+ const EVALUATE_FLAGS = allowedFlags("evaluate");
1765
1912
  /**
1766
1913
  * `--force` is a bare flag, but the parser gives any flag the next non-flag
1767
1914
  * word as its value, so `evaluate --force "document.title"` reads the SCRIPT as
@@ -1794,14 +1941,14 @@ function repeatedFlag(name) {
1794
1941
  return values;
1795
1942
  }
1796
1943
  /**
1797
- * Run JavaScript in the page.
1798
- *
1799
- * The bare `evaluate "<js>"` form is unchanged. Everything else is additive:
1800
- * a script from a file (shell quoting mangles multi-line scripts), JSON
1801
- * arguments (so a script is reusable instead of string-interpolated), an
1802
- * explicit output mode, and an assertion mode for CI.
1944
+ * Validate evaluate's flags and build the script, its arguments and the wait.
1945
+ * Exits with a message on any error. Shared by the local path and the daemon
1946
+ * route so they cannot drift: before 2026-10-21 the daemon route sent only the
1947
+ * script, so --arg, --url, --wait-for, --timeout, --raw, --json and
1948
+ * --expect-truthy did nothing when a daemon was running, and an
1949
+ * --expect-truthy CI check passed on a falsy result.
1803
1950
  */
1804
- async function runEvaluate(browser, args, options) {
1951
+ async function prepareEvaluate(args, options) {
1805
1952
  const unknown = Object.keys(options).filter((f) => !EVALUATE_FLAGS.has(f));
1806
1953
  if (unknown.length > 0) {
1807
1954
  console.error(`Error: 'evaluate' does not support ${unknown.map((f) => `--${f}`).join(", ")}`);
@@ -1870,45 +2017,53 @@ async function runEvaluate(browser, args, options) {
1870
2017
  }
1871
2018
  // Said on stderr so stdout stays exactly the result (--raw / --json pipes).
1872
2019
  console.error("⚠ Red zone: running caller-supplied page script (--force)");
1873
- if (options.url)
1874
- await browser.navigate(options.url);
1875
- const page = await browser.getPage();
1876
- if (typeof options["wait-for"] === "string") {
1877
- // Bounded by the caller, not by a constant.
1878
- //
1879
- // This was a hardcoded 30s, which is two defects wearing one number. For a
1880
- // user it meant no way to fail fast on a page that renders in 200ms and no
1881
- // way to wait longer on one that does not. For the suite it meant the test
1882
- // asserting that a never-matching selector fails loudly spent 30 of its 60
1883
- // second budget deliberately waiting, so browser launch plus teardown had
1884
- // to fit in the rest -- which held when the test ran alone and did not when
1885
- // the full suite ran browsers in parallel. The test read as flaky and was
1886
- // not: it was structurally tight against a constant it could not reach.
1887
- // (2026-08-02)
1888
- // Falls back to the configured default rather than a fresh literal.
1889
- // `CBROWSER_TIMEOUT` is documented in --help as "Default timeout in ms" and
1890
- // config.ts already resolves it; a second hardcoded 30_000 here would mean
1891
- // the documented env var silently did not apply to this one wait.
2020
+ const waitFor = typeof options["wait-for"] === "string" ? options["wait-for"] : undefined;
2021
+ let waitMs = 30_000;
2022
+ if (waitFor) {
1892
2023
  const { getDefaultConfig } = await import("./config.js");
1893
2024
  const configuredMs = getDefaultConfig().timeout ?? 30_000;
1894
- const waitMs = typeof options.timeout === "string" ? Number(options.timeout)
1895
- : typeof options.timeout === "number" ? options.timeout
1896
- : configuredMs;
2025
+ waitMs = typeof options.timeout === "number" ? options.timeout : timeoutOption(options.timeout, configuredMs);
1897
2026
  if (!Number.isFinite(waitMs) || waitMs <= 0) {
1898
2027
  console.error(`Error: --timeout must be a positive number of milliseconds, got: ${options.timeout}`);
1899
2028
  process.exit(1);
1900
2029
  }
2030
+ }
2031
+ return { script, evalArgs, waitFor, waitMs };
2032
+ }
2033
+ /** Print an evaluate result the same way for the local path and the daemon. */
2034
+ function printEvaluateResult(result, options) {
2035
+ if (options.raw === true) {
2036
+ console.log(String(result));
2037
+ }
2038
+ else if (options.json === true) {
2039
+ console.log(JSON.stringify(result, null, 2));
2040
+ }
2041
+ else {
2042
+ // Legacy default, kept so the existing one-arg form behaves identically.
2043
+ console.log(typeof result === "string" ? result : JSON.stringify(result, null, 2));
2044
+ }
2045
+ if (options["expect-truthy"] === true && !result) {
2046
+ console.error(`✗ Expected a truthy result, got ${JSON.stringify(result)}`);
2047
+ process.exit(1);
2048
+ }
2049
+ }
2050
+ async function runEvaluate(browser, args, options) {
2051
+ const prep = await prepareEvaluate(args, options);
2052
+ if (options.url)
2053
+ await browser.navigate(options.url);
2054
+ const page = await browser.getPage();
2055
+ if (prep.waitFor) {
1901
2056
  try {
1902
- await page.waitForSelector(options["wait-for"], { timeout: waitMs });
2057
+ await page.waitForSelector(prep.waitFor, { timeout: prep.waitMs });
1903
2058
  }
1904
2059
  catch {
1905
- console.error(`✗ Timed out waiting for selector: ${options["wait-for"]} (after ${waitMs}ms)`);
2060
+ console.error(`✗ Timed out waiting for selector: ${prep.waitFor} (after ${prep.waitMs}ms)`);
1906
2061
  process.exit(1);
1907
2062
  }
1908
2063
  }
1909
2064
  let result;
1910
2065
  try {
1911
- result = await evaluateScript(page, script, evalArgs);
2066
+ result = await evaluateScript(page, prep.script, prep.evalArgs);
1912
2067
  }
1913
2068
  catch (e) {
1914
2069
  const error = e;
@@ -1918,20 +2073,7 @@ async function runEvaluate(browser, args, options) {
1918
2073
  console.error(error.stack.split("\n").slice(1).join("\n"));
1919
2074
  process.exit(1);
1920
2075
  }
1921
- if (options.raw === true) {
1922
- console.log(String(result));
1923
- }
1924
- else if (options.json === true) {
1925
- console.log(JSON.stringify(result, null, 2));
1926
- }
1927
- else {
1928
- // Legacy default, kept so the existing one-arg form behaves identically.
1929
- console.log(typeof result === "string" ? result : JSON.stringify(result, null, 2));
1930
- }
1931
- if (options["expect-truthy"] === true && !result) {
1932
- console.error(`✗ Expected a truthy result, got ${JSON.stringify(result)}`);
1933
- process.exit(1);
1934
- }
2076
+ printEvaluateResult(result, options);
1935
2077
  }
1936
2078
  // =========================================================================
1937
2079
  // keyboard — sequences, chords and held modifiers
@@ -2029,11 +2171,26 @@ function parseKeyboardToken(token, literal = false) {
2029
2171
  }
2030
2172
  return { kind: "text", text: token };
2031
2173
  }
2174
+ /**
2175
+ * --delay and --repeat for keyboard, checked the same way whether the keys go
2176
+ * to the local browser or the daemon. The daemon route used to skip this, so
2177
+ * --repeat 0 typed nothing and reported success, and --repeat 2.5 ran 3 passes.
2178
+ */
2179
+ function parseKeyboardNumbers(options) {
2180
+ const delay = options.delay !== undefined ? Number(options.delay) : 50;
2181
+ if (!Number.isFinite(delay) || delay < 0) {
2182
+ console.error(`Error: --delay must be a non-negative number (got "${String(options.delay)}")`);
2183
+ process.exit(1);
2184
+ }
2185
+ const repeat = options.repeat !== undefined ? Number(options.repeat) : 1;
2186
+ if (!Number.isInteger(repeat) || repeat < 1) {
2187
+ console.error(`Error: --repeat must be a positive integer (got "${String(options.repeat)}")`);
2188
+ process.exit(1);
2189
+ }
2190
+ return { delay, repeat };
2191
+ }
2032
2192
  /** Flags `keyboard` understands. */
2033
- const KEYBOARD_FLAGS = new Set([
2034
- "delay", "selector", "hold", "repeat", "text", "force",
2035
- ...SHARED_BROWSER_FLAGS,
2036
- ]);
2193
+ const KEYBOARD_FLAGS = allowedFlags("keyboard");
2037
2194
  /**
2038
2195
  * Drive a keyboard sequence.
2039
2196
  *
@@ -2053,16 +2210,7 @@ async function runKeyboard(browser, args, options) {
2053
2210
  console.error(" Run 'cbrowser help keyboard' for options");
2054
2211
  process.exit(1);
2055
2212
  }
2056
- const delay = options.delay !== undefined ? Number(options.delay) : 50;
2057
- if (!Number.isFinite(delay) || delay < 0) {
2058
- console.error(`Error: --delay must be a non-negative number (got "${String(options.delay)}")`);
2059
- process.exit(1);
2060
- }
2061
- const repeat = options.repeat !== undefined ? Number(options.repeat) : 1;
2062
- if (!Number.isInteger(repeat) || repeat < 1) {
2063
- console.error(`Error: --repeat must be a positive integer (got "${String(options.repeat)}")`);
2064
- process.exit(1);
2065
- }
2213
+ const { delay, repeat } = parseKeyboardNumbers(options);
2066
2214
  const hold = typeof options.hold === "string"
2067
2215
  ? options.hold.split("+").map((m) => m.trim()).filter(Boolean)
2068
2216
  : [];
@@ -2572,8 +2720,29 @@ function parseGeoLocation(location) {
2572
2720
  }
2573
2721
  return null;
2574
2722
  }
2723
+ /**
2724
+ * `scroll <top|bottom|up|down> [px]` is `run "scroll ..."`: the same executor,
2725
+ * the same daemon route. The CLI had no way to scroll at all (audit 2026-10-21,
2726
+ * daemon lane: the likely cause of a screenshot stuck at the top of the page).
2727
+ */
2728
+ function rewriteScroll(parsed) {
2729
+ if (parsed.command !== "scroll")
2730
+ return parsed;
2731
+ const [where, px] = parsed.args;
2732
+ const dir = (where ?? "").toLowerCase();
2733
+ let nl;
2734
+ if ((dir === "top" || dir === "bottom") && px === undefined)
2735
+ nl = `scroll to ${dir}`;
2736
+ else if ((dir === "up" || dir === "down") && (px === undefined || /^\d+$/.test(px)))
2737
+ nl = `scroll ${dir}${px ? ` ${px}` : ""}`;
2738
+ if (!nl) {
2739
+ console.error("Usage: cbrowser scroll <top|bottom|up|down> [pixels] (default 300 for up/down)");
2740
+ process.exit(1);
2741
+ }
2742
+ return { command: "run", args: [nl], options: parsed.options };
2743
+ }
2575
2744
  async function main() {
2576
- const { command, args, options } = parseArgs(process.argv.slice(2));
2745
+ const { command, args, options } = rewriteScroll(parseArgs(process.argv.slice(2)));
2577
2746
  // Initialize accessible output system (v18.54.0)
2578
2747
  const outputOpts = detectOutputOptions(options);
2579
2748
  setOutputOptions(outputOpts);
@@ -2615,6 +2784,35 @@ async function main() {
2615
2784
  // path unreachable from a shell no matter what the checks found.
2616
2785
  process.exit(process.exitCode ?? 0);
2617
2786
  }
2787
+ // ask: product help from the CBrowser docs (src/help-client.ts). No browser.
2788
+ if (command === "ask") {
2789
+ const question = args.join(" ").trim();
2790
+ if (!question) {
2791
+ console.error('Usage: cbrowser ask "<question>" [--context <text>] [--json]');
2792
+ console.error(' e.g. cbrowser ask "How do I test my site on an iPhone?"');
2793
+ process.exit(1);
2794
+ }
2795
+ const { askCbrowser } = await import("./help-client.js");
2796
+ try {
2797
+ const r = await askCbrowser(question, { context: typeof options.context === "string" ? options.context : undefined });
2798
+ if (options.json === true || getOutputOptions().json) {
2799
+ printData("ask", { question: r.question, answer: r.answer, sources: r.sources });
2800
+ }
2801
+ else {
2802
+ console.log(r.answer);
2803
+ if (r.sources.length > 0) {
2804
+ console.log("\nSources:");
2805
+ for (const s of r.sources)
2806
+ console.log(` - ${s.split("\n")[0].slice(0, 120)}`);
2807
+ }
2808
+ }
2809
+ process.exit(0);
2810
+ }
2811
+ catch (e) {
2812
+ console.error(`Error: ${e.message}`);
2813
+ process.exit(1);
2814
+ }
2815
+ }
2618
2816
  // MCP Server mode - runs before browser instantiation
2619
2817
  if (command === "mcp-server") {
2620
2818
  console.error("🔌 Starting CBrowser MCP Server...");
@@ -2758,17 +2956,10 @@ async function main() {
2758
2956
  console.log("Creating ~/.claude/skills directory...");
2759
2957
  fs.mkdirSync(skillsDir, { recursive: true });
2760
2958
  }
2761
- // Check if skill exists
2762
- if (fs.existsSync(skillDir)) {
2763
- console.log("CBrowser skill already exists. Updating...");
2764
- fs.rmSync(skillDir, { recursive: true });
2765
- }
2766
- // Create directories
2767
- console.log("Creating skill directory structure...");
2768
- fs.mkdirSync(path.join(skillDir, "Workflows"), { recursive: true });
2769
- fs.mkdirSync(path.join(skillDir, "Tools"), { recursive: true });
2770
- fs.mkdirSync(path.join(skillDir, ".memory", "sessions"), { recursive: true });
2771
- // Download files using native fetch (cross-platform, better error handling)
2959
+ // Download everything first and change nothing unless every file arrived.
2960
+ // This used to delete the installed skill (its .memory/sessions included)
2961
+ // before downloading, then print "Installed Successfully" even when every
2962
+ // download failed (audit 2026-10-21).
2772
2963
  console.log("Downloading skill files...");
2773
2964
  const files = [
2774
2965
  "SKILL.md",
@@ -2785,22 +2976,35 @@ async function main() {
2785
2976
  "Workflows/Journey.md",
2786
2977
  "Tools/CBrowser.ts",
2787
2978
  ];
2979
+ const downloaded = new Map();
2980
+ const failed = [];
2788
2981
  for (const file of files) {
2789
2982
  try {
2790
2983
  const url = `${repoUrl}/skill/${file}`;
2791
- const dest = path.join(skillDir, file);
2792
2984
  console.log(` - ${file}`);
2793
- const response = await fetch(url);
2985
+ const response = await fetch(url, { signal: AbortSignal.timeout(30_000) });
2794
2986
  if (!response.ok) {
2795
2987
  throw new Error(`HTTP ${response.status}`);
2796
2988
  }
2797
- const content = await response.text();
2798
- fs.writeFileSync(dest, content, "utf-8");
2989
+ downloaded.set(file, await response.text());
2799
2990
  }
2800
2991
  catch (e) {
2801
- console.log(` Warning: Could not download ${file} (${e.message})`);
2992
+ failed.push(`${file} (${e.message})`);
2802
2993
  }
2803
2994
  }
2995
+ if (failed.length > 0) {
2996
+ console.error(`✗ ${failed.length} of ${files.length} files could not be downloaded; the installed skill was left unchanged:`);
2997
+ for (const f of failed)
2998
+ console.error(` ${f}`);
2999
+ process.exit(1);
3000
+ }
3001
+ console.log(fs.existsSync(skillDir) ? "Updating the installed skill..." : "Creating skill directory structure...");
3002
+ fs.mkdirSync(path.join(skillDir, "Workflows"), { recursive: true });
3003
+ fs.mkdirSync(path.join(skillDir, "Tools"), { recursive: true });
3004
+ fs.mkdirSync(path.join(skillDir, ".memory", "sessions"), { recursive: true });
3005
+ for (const [file, content] of downloaded) {
3006
+ fs.writeFileSync(path.join(skillDir, file), content, "utf-8");
3007
+ }
2804
3008
  console.log(`
2805
3009
  ╔═══════════════════════════════════════════════════════════════╗
2806
3010
  ║ CBrowser Skill Installed Successfully! ║
@@ -2843,11 +3047,52 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
2843
3047
  }
2844
3048
  launchArgs.push("--viewport", options.viewport);
2845
3049
  }
3050
+ // The rest of the shared browser settings. daemon start used to forward
3051
+ // only --device and --viewport, so --browser firefox or --no-headless
3052
+ // silently gave a headless chromium daemon.
3053
+ if (typeof options.browser === "string") {
3054
+ if (!["chromium", "firefox", "webkit"].includes(options.browser)) {
3055
+ console.error(`Error: --browser must be chromium, firefox or webkit, got: ${options.browser}`);
3056
+ process.exit(1);
3057
+ }
3058
+ launchArgs.push("--browser", options.browser);
3059
+ }
3060
+ if (options.headless === false)
3061
+ launchArgs.push("--no-headless");
3062
+ for (const flag of ["locale", "timezone"]) {
3063
+ if (typeof options[flag] === "string")
3064
+ launchArgs.push(`--${flag}`, options[flag]);
3065
+ }
3066
+ if (typeof options.geo === "string") {
3067
+ if (!parseGeoLocation(options.geo)) {
3068
+ console.error(`Error: invalid --geo: ${options.geo} (a preset name or lat,lon)`);
3069
+ process.exit(1);
3070
+ }
3071
+ launchArgs.push("--geo", options.geo);
3072
+ }
3073
+ // --timeout: a bare number is minutes, as the help has always said;
3074
+ // 90s / 45m / 2h also work. It used to be accepted and ignored.
3075
+ if (options.timeout !== undefined) {
3076
+ const raw = String(options.timeout).trim();
3077
+ let ms;
3078
+ try {
3079
+ ms = /^\d+(\.\d+)?$/.test(raw) ? Math.round(parseFloat(raw) * 60_000) : parseDurationMs(raw);
3080
+ }
3081
+ catch {
3082
+ console.error(`Error: --timeout must be a positive number of minutes or a duration (90s, 45m, 2h), got "${raw}"`);
3083
+ process.exit(1);
3084
+ }
3085
+ if (!(ms > 0)) {
3086
+ console.error(`Error: --timeout must be greater than zero, got: ${raw}`);
3087
+ process.exit(1);
3088
+ }
3089
+ launchArgs.push("--idle-timeout-ms", String(ms));
3090
+ }
2846
3091
  console.log("🚀 Starting CBrowser daemon...");
2847
3092
  const result = await startDaemon(port, launchArgs);
2848
3093
  console.log(result.success ? `✓ ${result.message}` : `✗ ${result.message}`);
2849
3094
  if (result.alreadyRunning && launchArgs.length > 0) {
2850
- console.error("⚠ --device and --viewport were not applied: the daemon was already running. Run cbrowser daemon stop first.");
3095
+ console.error("⚠ The browser flags were not applied: the daemon was already running. Run cbrowser daemon stop first.");
2851
3096
  }
2852
3097
  process.exit(result.success ? 0 : 1);
2853
3098
  break;
@@ -2856,13 +3101,14 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
2856
3101
  console.log("🛑 Stopping CBrowser daemon...");
2857
3102
  const result = await stopDaemon();
2858
3103
  console.log(result.success ? `✓ ${result.message}` : `✗ ${result.message}`);
2859
- process.exit(0);
3104
+ process.exit(result.success ? 0 : 1);
2860
3105
  break;
2861
3106
  }
2862
3107
  case "status": {
2863
3108
  const status = await getDaemonStatus();
2864
3109
  console.log(status);
2865
- process.exit(0);
3110
+ // Non-zero when nothing is running, so `cbrowser daemon status && ...` works.
3111
+ process.exit((await isDaemonRunning()) ? 0 : 1);
2866
3112
  break;
2867
3113
  }
2868
3114
  case "run": {
@@ -2878,7 +3124,10 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
2878
3124
  headless: options.headless !== false && options.headless !== "false",
2879
3125
  ...(typeof options.device === "string" ? { device: options.device } : {}),
2880
3126
  ...(runViewport ? { viewportWidth: runViewport.width, viewportHeight: runViewport.height, viewportOverride: runViewport } : {}),
2881
- }, port);
3127
+ ...(typeof options.locale === "string" ? { locale: options.locale } : {}),
3128
+ ...(typeof options.timezone === "string" ? { timezone: options.timezone } : {}),
3129
+ ...(typeof options.geo === "string" && parseGeoLocation(options.geo) ? { geolocation: parseGeoLocation(options.geo) } : {}),
3130
+ }, port, typeof options["idle-timeout-ms"] === "string" ? Number(options["idle-timeout-ms"]) : undefined);
2882
3131
  return;
2883
3132
  }
2884
3133
  default:
@@ -2899,27 +3148,35 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
2899
3148
  "keyboard", "press", "type", "evaluate", "eval",
2900
3149
  ];
2901
3150
  if (daemonRunning && INTERACTION_COMMANDS.includes(command)) {
2902
- console.log("🔌 Connected to running daemon");
2903
- // The daemon's browser keeps the device and size it started with; a flag on
2904
- // a command it runs cannot change them, so say so instead of ignoring it.
2905
- if (typeof options.device === "string" || typeof options.viewport === "string") {
2906
- console.error("⚠ --device and --viewport do not apply to commands the daemon runs: its browser keeps the device and size it started with.");
2907
- console.error(" To change them: cbrowser daemon stop, then cbrowser daemon start --device <name> --viewport <WxH>");
3151
+ // Status on stderr: stdout carries only the command's result, so piping
3152
+ // it (--raw, --json) works the same with and without a daemon.
3153
+ console.error("🔌 Connected to running daemon");
3154
+ // The daemon's browser keeps the settings it started with; a flag on a
3155
+ // command it runs cannot change them, so say so instead of ignoring it.
3156
+ const browserFlags = ["device", "viewport", "browser", "headless", "locale", "timezone", "geo"]
3157
+ .filter((f) => options[f] !== undefined);
3158
+ if (browserFlags.length > 0) {
3159
+ console.error(`⚠ ${browserFlags.map((f) => `--${f}`).join(", ")} do not apply to commands the daemon runs: its browser keeps the settings it started with.`);
3160
+ console.error(" To change them: cbrowser daemon stop, then cbrowser daemon start with those flags");
2908
3161
  }
2909
- const daemonCommand = command;
3162
+ // The daemon knows "evaluate"; "eval" reached it unchanged and failed.
3163
+ const daemonCommand = command === "eval" ? "evaluate" : command;
2910
3164
  let daemonArgs = {};
3165
+ // --url means "act on this page" for every routed command; the daemon
3166
+ // navigates first, as the local path does.
3167
+ const urlArg = typeof options.url === "string" ? { url: options.url } : {};
2911
3168
  switch (command) {
2912
3169
  case "navigate":
2913
3170
  daemonArgs = { url: args[0] || options.url };
2914
3171
  break;
2915
3172
  case "click":
2916
- daemonArgs = { selector: args[0], force: options.force === true };
3173
+ daemonArgs = { selector: args[0], force: options.force === true, ...urlArg };
2917
3174
  break;
2918
3175
  case "hover":
2919
- daemonArgs = { selector: args[0] };
3176
+ daemonArgs = { selector: args[0], ...urlArg };
2920
3177
  break;
2921
3178
  case "fill":
2922
- daemonArgs = { selector: args[0], value: args[1] };
3179
+ daemonArgs = { selector: args[0], value: args[1], ...urlArg };
2923
3180
  break;
2924
3181
  case "keyboard": {
2925
3182
  validateCommandFlags("keyboard", options);
@@ -2941,46 +3198,45 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
2941
3198
  process.exit(1);
2942
3199
  }
2943
3200
  }
3201
+ const { delay, repeat } = parseKeyboardNumbers(options);
2944
3202
  daemonArgs = {
2945
3203
  steps,
2946
3204
  hold,
2947
- delay: options.delay !== undefined ? Number(options.delay) : 50,
2948
- repeat: options.repeat !== undefined ? Number(options.repeat) : 1,
3205
+ delay,
3206
+ repeat,
2949
3207
  selector: typeof options.selector === "string" ? options.selector : undefined,
2950
3208
  force: options.force === true,
3209
+ ...urlArg,
2951
3210
  };
2952
3211
  break;
2953
3212
  }
2954
3213
  case "press":
2955
- daemonArgs = { key: args[0], force: options.force === true };
3214
+ daemonArgs = { key: args[0], force: options.force === true, ...urlArg };
2956
3215
  break;
2957
- case "type":
2958
- daemonArgs = { text: args[0], delay: options.delay !== undefined ? Number(options.delay) : 50, force: options.force === true };
3216
+ case "type": {
3217
+ const delay = options.delay !== undefined ? Number(options.delay) : 50;
3218
+ if (!Number.isFinite(delay) || delay < 0) {
3219
+ console.error(`Error: --delay must be a number of milliseconds, got: ${options.delay}`);
3220
+ process.exit(1);
3221
+ }
3222
+ daemonArgs = { text: args[0], delay, force: options.force === true, ...urlArg };
2959
3223
  break;
3224
+ }
2960
3225
  case "eval":
2961
3226
  case "evaluate": {
2962
3227
  validateCommandFlags(command, options);
2963
- rejectForceValue(options);
2964
- const script = typeof options.file === "string"
2965
- ? readFileSync(resolve(options.file), "utf-8")
2966
- : args[0];
2967
- if (!script) {
2968
- console.error('Usage: cbrowser evaluate "<javascript>" | --file <path>');
2969
- process.exit(1);
2970
- }
2971
- // Refused here, before the request leaves, and again daemon-side
2972
- // (anything on this machine can POST to the daemon).
2973
- const refusal = evaluateScriptRefusal(options.force === true);
2974
- if (refusal) {
2975
- console.error(`✗ ${refusal}`);
2976
- console.error(" Re-run with --force to run it anyway.");
2977
- process.exit(1);
2978
- }
2979
- // The raw script: the daemon compiles it with the same evaluateScript as the
2980
- // local path. This hand-built body treated a "return" anywhere - even inside
2981
- // a nested function - as statements, and an IIFE's value was dropped.
2982
- // (2026-10-13)
2983
- daemonArgs = { script, args: [], force: true };
3228
+ // Same validation as the local path (prepareEvaluate), then the whole
3229
+ // request: refused here and again daemon-side, since anything on this
3230
+ // machine can POST to the daemon. The daemon compiles the raw script
3231
+ // with the same evaluateScript as the local path.
3232
+ const prep = await prepareEvaluate(args, options);
3233
+ daemonArgs = {
3234
+ script: prep.script,
3235
+ args: prep.evalArgs,
3236
+ force: true,
3237
+ ...(typeof options.url === "string" ? { url: options.url } : {}),
3238
+ ...(prep.waitFor ? { waitFor: prep.waitFor, timeout: prep.waitMs } : {}),
3239
+ };
2984
3240
  break;
2985
3241
  }
2986
3242
  case "screenshot": {
@@ -2996,7 +3252,9 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
2996
3252
  ssPath = args[0];
2997
3253
  }
2998
3254
  }
2999
- daemonArgs = { path: ssPath, url: ssUrl || options.url };
3255
+ // Resolved here: the daemon's cwd is wherever `daemon start` ran, so a
3256
+ // relative path used to land there instead of where the user is.
3257
+ daemonArgs = { path: ssPath ? resolve(ssPath) : undefined, url: ssUrl || options.url };
3000
3258
  break;
3001
3259
  }
3002
3260
  case "extract": {
@@ -3011,19 +3269,30 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
3011
3269
  extractWhat = args[0];
3012
3270
  }
3013
3271
  }
3272
+ if (!EXTRACT_KINDS.includes(extractWhat.toLowerCase())) {
3273
+ console.error(`Error: extract takes one of: ${EXTRACT_KINDS.join(", ")} (got "${extractWhat}")`);
3274
+ process.exit(1);
3275
+ }
3014
3276
  daemonArgs = { what: extractWhat, url: extractUrl || options.url };
3015
3277
  break;
3016
3278
  }
3017
3279
  case "run":
3018
- daemonArgs = { command: args.join(" ") };
3280
+ daemonArgs = { command: args.join(" "), ...urlArg };
3019
3281
  break;
3020
3282
  }
3021
3283
  const result = await sendToDaemon(daemonCommand, daemonArgs);
3022
3284
  if (result.success) {
3023
- console.log("✓ Command executed via daemon");
3024
- // evaluate's value is the answer, so 0, false and "" print too; for other
3025
- // commands an empty result means nothing to show. (2026-10-13)
3026
- if (daemonCommand === "evaluate" ? result.result !== undefined : result.result) {
3285
+ console.error("✓ Command executed via daemon");
3286
+ if (daemonCommand === "evaluate") {
3287
+ // The same output and --expect-truthy rule as the local path; the
3288
+ // daemon used to print strings JSON-quoted.
3289
+ printEvaluateResult(result.result, options);
3290
+ }
3291
+ else if (daemonCommand === "extract" && result.result && typeof result.result === "object" && "data" in result.result) {
3292
+ // As the local path prints it: the extracted data, not the wrapper.
3293
+ printData("extract", result.result.data);
3294
+ }
3295
+ else if (result.result) {
3027
3296
  console.log(JSON.stringify(result.result, null, 2));
3028
3297
  }
3029
3298
  }
@@ -3085,15 +3354,17 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
3085
3354
  process.exit(1);
3086
3355
  }
3087
3356
  }
3357
+ // Only what the user passed: an unset flag leaves the env var or config file
3358
+ // value in place (mergeConfig drops undefined keys too).
3088
3359
  const browser = new CBrowser({
3089
- browser: browserType,
3090
- headless,
3360
+ ...(options.browser !== undefined ? { browser: browserType } : {}),
3361
+ ...(options.headless !== undefined ? { headless } : {}),
3091
3362
  device: options.device,
3092
3363
  ...(viewportOverride ? { viewportWidth: viewportOverride.width, viewportHeight: viewportOverride.height, viewportOverride } : {}),
3093
3364
  geolocation,
3094
3365
  locale: options.locale,
3095
3366
  timezone: options.timezone,
3096
- recordVideo: options["record-video"] === true,
3367
+ ...(options["record-video"] !== undefined ? { recordVideo: options["record-video"] === true } : {}),
3097
3368
  persistent: persistentMode,
3098
3369
  skipSessionRestore: skipRestore,
3099
3370
  verbose: true,
@@ -3107,7 +3378,14 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
3107
3378
  process.exit(1);
3108
3379
  }
3109
3380
  const result = await browser.navigate(url);
3110
- console.log(`✓ Navigated to: ${result.url}`);
3381
+ // A navigation that landed somewhere else (an interstitial, a
3382
+ // cross-host redirect) reports success: false; it used to print
3383
+ // "Navigated to" and exit 0.
3384
+ if (result.success === false) {
3385
+ console.error(`✗ Navigation to ${url} did not land on that page (now at ${result.url})`);
3386
+ process.exitCode = 1;
3387
+ }
3388
+ console.log(`${result.success === false ? "✗" : "✓"} Navigated to: ${result.url}`);
3111
3389
  console.log(` Title: ${result.title}`);
3112
3390
  console.log(` Load time: ${result.loadTime}ms`);
3113
3391
  console.log(` Screenshot: ${result.screenshot}`);
@@ -3178,7 +3456,13 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
3178
3456
  if (options.url)
3179
3457
  await browser.navigate(options.url);
3180
3458
  const page = await browser.getPage();
3181
- const delay = typeof options.delay === "number" ? options.delay : 50;
3459
+ // parseArgs yields strings, so the old `typeof === "number"` test was
3460
+ // never true and --delay was always 50 ms (audit 2026-10-21).
3461
+ const delay = typeof options.delay === "string" ? Number(options.delay) : 50;
3462
+ if (!Number.isFinite(delay) || delay < 0) {
3463
+ console.error(`Error: --delay must be a number of milliseconds, got: ${options.delay}`);
3464
+ process.exit(1);
3465
+ }
3182
3466
  const refusal = await browser.keystrokeRedZone({ text }, options.force === true);
3183
3467
  if (refusal) {
3184
3468
  console.error(`✗ ${refusal}`);
@@ -3298,8 +3582,15 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
3298
3582
  if (url) {
3299
3583
  await browser.navigate(url);
3300
3584
  }
3585
+ // Five kinds exist. Anything else used to return the whole page text, so
3586
+ // a typo ("link") or a description ("all product names") looked like a
3587
+ // result (audit 2026-10-21).
3588
+ if (!EXTRACT_KINDS.includes(what.toLowerCase())) {
3589
+ console.error(`Error: extract takes one of: ${EXTRACT_KINDS.join(", ")} (got "${what}")`);
3590
+ process.exit(1);
3591
+ }
3301
3592
  const result = await browser.extract(what);
3302
- console.log(JSON.stringify(result.data, null, 2));
3593
+ printData("extract", result.data);
3303
3594
  break;
3304
3595
  }
3305
3596
  // =========================================================================
@@ -3337,7 +3628,7 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
3337
3628
  else {
3338
3629
  console.error(`\n✗ ${result.message}`);
3339
3630
  if (result.aiSuggestion) {
3340
- console.log(`\n💡 Suggestion:\n${result.aiSuggestion}`);
3631
+ console.error(`\n💡 Suggestion:\n${result.aiSuggestion}`);
3341
3632
  }
3342
3633
  process.exit(1);
3343
3634
  }
@@ -3504,6 +3795,11 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
3504
3795
  console.log(`\n🧪 Analyzing page and generating tests...\n`);
3505
3796
  const result = await browser.generateTests(url);
3506
3797
  const format = options.format || "summary";
3798
+ // An unknown format printed nothing and exited 0.
3799
+ if (!["summary", "cbrowser", "playwright", "all"].includes(format)) {
3800
+ console.error(`Error: --format must be summary, cbrowser, playwright or all, got: ${format}`);
3801
+ process.exit(1);
3802
+ }
3507
3803
  const outputFile = options.output;
3508
3804
  let output = "";
3509
3805
  if (format === "summary" || format === "all") {
@@ -3555,6 +3851,17 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
3555
3851
  }
3556
3852
  await browser.navigate(url);
3557
3853
  const analysis = await browser.analyzePage();
3854
+ // --output was accepted and ignored, so the GitHub Action's url mode
3855
+ // (`analyze "$URL" --output .../analysis.json`) never wrote its file.
3856
+ if (typeof options.output === "string") {
3857
+ const { writeFileSync } = await import("node:fs");
3858
+ writeFileSync(options.output, JSON.stringify(analysis, null, 2));
3859
+ console.error(`📄 Analysis saved: ${options.output}`);
3860
+ }
3861
+ if (getOutputOptions().json) {
3862
+ printData("analyze", analysis);
3863
+ break;
3864
+ }
3558
3865
  console.log(`\n📊 Page Analysis: ${analysis.url}\n`);
3559
3866
  console.log(`Title: ${analysis.title}`);
3560
3867
  console.log(`\n📝 Forms (${analysis.forms.length}):`);
@@ -3606,20 +3913,28 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
3606
3913
  console.warn("⚠️ 'journey' is deprecated. Use 'explore' instead.\n");
3607
3914
  }
3608
3915
  const persona = args[0] || "first-timer";
3609
- const startUrl = options.start;
3916
+ // --url works too (audit F25: four spellings of "start URL").
3917
+ const startUrl = (options.start ?? options.url);
3610
3918
  const goal = options.goal || "Explore the site";
3611
3919
  if (!startUrl) {
3612
3920
  console.error("Error: --start URL required");
3613
3921
  process.exit(1);
3614
3922
  }
3923
+ // 20.0.0: no keyless mode. The heuristic explorer reported success
3924
+ // whenever any goal word ("the" included) was on the page (2026-10-21).
3925
+ if (!isApiKeyConfigured()) {
3926
+ console.error("Error: explore needs an Anthropic API key (cbrowser config set-api-key <key>). The keyless heuristic mode was removed in 20.0.0 because its results were not accurate.");
3927
+ process.exit(1);
3928
+ }
3615
3929
  console.log(`🔍 Starting exploration as "${persona}"...`);
3616
3930
  console.log(` Goal: ${goal}`);
3617
- console.log(` Mode: Heuristic (free, no API)`);
3618
3931
  console.log("");
3619
3932
  const result = await browser.journey({ persona, startUrl, goal });
3620
3933
  console.log("");
3621
3934
  console.log(`📊 Journey Results`);
3622
3935
  console.log(` Success: ${result.success ? "✓" : "✗"}`);
3936
+ if (!result.success)
3937
+ process.exitCode = 1;
3623
3938
  console.log(` Steps: ${result.steps.length}`);
3624
3939
  console.log(` Time: ${result.totalTime}ms`);
3625
3940
  console.log(` Friction points: ${result.frictionPoints.length}`);
@@ -3654,12 +3969,18 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
3654
3969
  const concurrency = options.concurrency
3655
3970
  ? parseInt(options.concurrency)
3656
3971
  : 3;
3972
+ const personaFlags = browserFlagsOrExit("compare-personas", options);
3973
+ refuseBrowserFlags("compare-personas", personaFlags, {
3974
+ browser: "it runs cognitive journeys, which take no engine option",
3975
+ });
3657
3976
  const comparison = await comparePersonas({
3658
3977
  startUrl,
3659
3978
  goal,
3660
3979
  personas: personaNames,
3661
3980
  maxConcurrency: concurrency,
3662
3981
  headless,
3982
+ device: personaFlags.device,
3983
+ viewport: personaFlags.viewport,
3663
3984
  });
3664
3985
  // Print formatted report
3665
3986
  const report = formatComparisonReport(comparison);
@@ -3674,7 +3995,7 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
3674
3995
  if (options.html) {
3675
3996
  const fs = await import("fs");
3676
3997
  const htmlReport = generateHtmlReport(comparison);
3677
- const htmlPath = options.output?.replace(".json", ".html") || "comparison-report.html";
3998
+ const htmlPath = htmlReportPath(options.output, "comparison-report.html");
3678
3999
  fs.writeFileSync(htmlPath, htmlReport);
3679
4000
  console.log(`\n🌐 HTML report saved: ${htmlPath}`);
3680
4001
  }
@@ -3773,7 +4094,7 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
3773
4094
  // Save the persona
3774
4095
  const filepath = saveCustomPersona(persona);
3775
4096
  console.log(`\n✓ Persona saved: ${filepath}`);
3776
- console.log(`\nUse with: cbrowser journey ${personaName} --start <url> --goal "<goal>"`);
4097
+ console.log(`\nUse with: cbrowser cognitive-journey --persona ${personaName} --start <url> --goal "<goal>"`);
3777
4098
  break;
3778
4099
  }
3779
4100
  case "show": {
@@ -3916,6 +4237,7 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
3916
4237
  console.error(" cbrowser persona delete my-custom-persona");
3917
4238
  console.error(" cbrowser persona cleanup --dry-run # Preview invalid personas");
3918
4239
  console.error(" cbrowser persona cleanup # Remove invalid personas");
4240
+ process.exit(1);
3919
4241
  }
3920
4242
  break;
3921
4243
  }
@@ -4096,6 +4418,7 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
4096
4418
  }
4097
4419
  else {
4098
4420
  console.error(`✗ Session not found: ${name}`);
4421
+ process.exit(1);
4099
4422
  }
4100
4423
  break;
4101
4424
  }
@@ -4109,6 +4432,7 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
4109
4432
  console.error(" cleanup --older-than <days> Delete old sessions");
4110
4433
  console.error(" export <name> --output <file> Export session to file");
4111
4434
  console.error(" import <file> --name <name> Import session from file");
4435
+ process.exit(1);
4112
4436
  }
4113
4437
  break;
4114
4438
  }
@@ -4224,6 +4548,7 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
4224
4548
  }
4225
4549
  default:
4226
4550
  console.error("Usage: cbrowser cookie [list|set|delete|clear]");
4551
+ process.exit(1);
4227
4552
  }
4228
4553
  break;
4229
4554
  }
@@ -4264,6 +4589,7 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
4264
4589
  }
4265
4590
  default:
4266
4591
  console.error("Usage: cbrowser device [list|set]");
4592
+ process.exit(1);
4267
4593
  }
4268
4594
  break;
4269
4595
  }
@@ -4294,12 +4620,18 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
4294
4620
  console.error(`Invalid location: ${location}`);
4295
4621
  process.exit(1);
4296
4622
  }
4297
- await browser.setGeolocationRuntime(geo);
4623
+ // Saved to session state like `device set`: each command is its own
4624
+ // process, so a runtime change here died with this one.
4625
+ const geoBrowser = new CBrowser({ persistent: true, headless: true, verbose: false });
4626
+ if (!geoBrowser.saveGeolocationSetting(geo))
4627
+ process.exit(1);
4298
4628
  console.log(`✓ Geolocation set: ${geo.latitude}, ${geo.longitude}`);
4629
+ console.log(" Applies to later commands in the persistent session (for an hour, like device set)");
4299
4630
  break;
4300
4631
  }
4301
4632
  default:
4302
4633
  console.error("Usage: cbrowser geo [list|set]");
4634
+ process.exit(1);
4303
4635
  }
4304
4636
  break;
4305
4637
  }
@@ -4328,6 +4660,8 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
4328
4660
  console.log("\n📊 Performance Audit:\n");
4329
4661
  console.log(` URL: ${result.url}`);
4330
4662
  console.log(` Result: ${result.passed ? "✓ PASSED" : "✗ FAILED"}`);
4663
+ if (!result.passed)
4664
+ process.exitCode = 1;
4331
4665
  console.log("");
4332
4666
  console.log(" Metrics:");
4333
4667
  if (result.metrics.lcp)
@@ -4389,7 +4723,7 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
4389
4723
  const subcommand = args[0];
4390
4724
  if (subcommand !== "start" && subcommand !== "stop") {
4391
4725
  console.error("Usage: cbrowser har [start|stop]");
4392
- break;
4726
+ process.exit(1);
4393
4727
  }
4394
4728
  // A recording has to span several commands, and each cbrowser command is
4395
4729
  // its own process: only the daemon's browser outlives them. Recorded here,
@@ -4474,7 +4808,7 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
4474
4808
  const subcommand = args[0];
4475
4809
  if (subcommand !== "list" && subcommand !== "clear") {
4476
4810
  console.error("Usage: cbrowser network [list|clear]");
4477
- break;
4811
+ process.exit(1);
4478
4812
  }
4479
4813
  // Same reason as har: the request log lives in a browser, and only the
4480
4814
  // daemon's browser outlives a command. A fresh process always listed
@@ -4502,19 +4836,32 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
4502
4836
  }
4503
4837
  switch (subcommand) {
4504
4838
  case "list": {
4505
- const requests = listed.result.requests;
4506
- if (requests.length === 0) {
4507
- console.log("No network requests captured");
4508
- console.log("Navigate to a page first to capture requests");
4839
+ const all = listed.result.requests;
4840
+ // Newest first, as the MCP tool lists them; it used to print the
4841
+ // oldest of the last 20 first and offered no way past 20.
4842
+ const filter = typeof options.filter === "string" ? options.filter.toLowerCase() : undefined;
4843
+ const matching = all.filter((r) => !filter || r.url.toLowerCase().includes(filter)
4844
+ || r.resourceType.toLowerCase() === filter || r.method.toLowerCase() === filter);
4845
+ const newestFirst = [...matching].reverse();
4846
+ const shown = options.all === true ? newestFirst : newestFirst.slice(0, 20);
4847
+ if (options.json === true || getOutputOptions().json) {
4848
+ printData("network", { total: all.length, matching: matching.length, requests: shown });
4849
+ break;
4850
+ }
4851
+ if (matching.length === 0) {
4852
+ console.log(filter ? `No captured request matches "${filter}"` : "No network requests captured");
4853
+ if (!filter)
4854
+ console.log("Navigate to a page first to capture requests");
4509
4855
  }
4510
4856
  else {
4511
- console.log(`\n🌐 Network Requests (${requests.length}):\n`);
4512
- for (const req of requests.slice(-20)) {
4513
- console.log(` ${req.method} ${req.url.substring(0, 80)}${req.url.length > 80 ? "..." : ""}`);
4857
+ console.log(`\n🌐 Network Requests (${matching.length}${filter ? ` of ${all.length}` : ""}, newest first):\n`);
4858
+ for (const req of shown) {
4859
+ const status = req.status !== undefined ? ` ${req.status}` : "";
4860
+ console.log(` ${req.method}${status} ${req.url.substring(0, 80)}${req.url.length > 80 ? "..." : ""}`);
4514
4861
  console.log(` Type: ${req.resourceType} | Time: ${req.timestamp}`);
4515
4862
  }
4516
- if (requests.length > 20) {
4517
- console.log(`\n ... and ${requests.length - 20} more requests`);
4863
+ if (matching.length > shown.length) {
4864
+ console.log(`\n ... and ${matching.length - shown.length} older requests (--all lists them)`);
4518
4865
  }
4519
4866
  }
4520
4867
  break;
@@ -4551,13 +4898,15 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
4551
4898
  if (options.url) {
4552
4899
  await browser.navigate(options.url);
4553
4900
  }
4554
- const threshold = options.threshold ? parseFloat(options.threshold) : 0.1;
4901
+ const threshold = numberOption("threshold", options.threshold, 0.1, { min: 0, max: 1 });
4555
4902
  const result = await browser.compareBaseline(name, threshold);
4556
4903
  console.log("\n🔍 Visual Comparison:\n");
4557
4904
  console.log(` Baseline: ${name}`);
4558
4905
  console.log(` Difference: ${(result.diffPercentage * 100).toFixed(2)}%`);
4559
4906
  console.log(` Threshold: ${(threshold * 100).toFixed(0)}%`);
4560
4907
  console.log(` Result: ${result.passed ? "✓ PASSED" : "✗ FAILED"}`);
4908
+ if (!result.passed)
4909
+ process.exitCode = 1;
4561
4910
  if (result.diffPath) {
4562
4911
  console.log(` Diff image: ${result.diffPath}`);
4563
4912
  }
@@ -4601,6 +4950,7 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
4601
4950
  }
4602
4951
  default:
4603
4952
  console.error("Usage: cbrowser visual [save|compare|list|delete]");
4953
+ process.exit(1);
4604
4954
  }
4605
4955
  break;
4606
4956
  }
@@ -4609,6 +4959,12 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
4609
4959
  // =========================================================================
4610
4960
  case "ai-visual": {
4611
4961
  const subcommand = args[0];
4962
+ // A test renders the way its baseline was captured.
4963
+ const BASELINE_SHAPE_FLAGS = {
4964
+ browser: "a test renders with its baseline's engine; capture a new baseline to change it",
4965
+ device: "a test renders with its baseline's device; capture a new baseline to change it",
4966
+ viewport: "a test renders at its baseline's size; capture a new baseline to change it",
4967
+ };
4612
4968
  switch (subcommand) {
4613
4969
  case "capture": {
4614
4970
  const url = args[1];
@@ -4616,20 +4972,18 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
4616
4972
  console.error("Usage: cbrowser ai-visual capture <url> --name <name> [options]");
4617
4973
  console.error(" --selector <sel> Capture specific element");
4618
4974
  console.error(" --device <device> Device emulation");
4619
- console.error(" --width <n> Viewport width");
4620
- console.error(" --height <n> Viewport height");
4975
+ console.error(" --viewport <WxH> Page size (default: 1920x1080)");
4976
+ console.error(" --width/--height One side of the page size");
4977
+ console.error(" --browser <engine> chromium, firefox or webkit");
4621
4978
  console.error(" --wait <ms|sel> Wait before capture");
4622
4979
  process.exit(1);
4623
4980
  }
4981
+ const flags = browserFlagsOrExit("ai-visual capture", options);
4624
4982
  console.log(`📸 Capturing visual baseline: ${options.name}`);
4625
4983
  console.log(` URL: ${url}`);
4626
4984
  const baseline = await captureVisualBaseline(url, options.name, {
4627
4985
  selector: options.selector,
4628
- device: options.device,
4629
- viewport: options.width || options.height ? {
4630
- width: parseInt(options.width) || 1920,
4631
- height: parseInt(options.height) || 1080,
4632
- } : undefined,
4986
+ ...flags,
4633
4987
  waitFor: options.wait ? (/^\d+$/.test(options.wait)
4634
4988
  ? parseInt(options.wait)
4635
4989
  : options.wait) : undefined,
@@ -4637,6 +4991,10 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
4637
4991
  console.log(`\n✅ Baseline captured successfully!`);
4638
4992
  console.log(` ID: ${baseline.id}`);
4639
4993
  console.log(` Viewport: ${baseline.viewport.width}x${baseline.viewport.height}`);
4994
+ if (baseline.device)
4995
+ console.log(` Device: ${baseline.device}`);
4996
+ if (baseline.browser)
4997
+ console.log(` Browser: ${baseline.browser}`);
4640
4998
  console.log(` Screenshot: ${baseline.screenshotPath}`);
4641
4999
  break;
4642
5000
  }
@@ -4651,13 +5009,16 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
4651
5009
  console.error(" --output <file> Save JSON report");
4652
5010
  process.exit(1);
4653
5011
  }
5012
+ const flags = browserFlagsOrExit("ai-visual test", options);
5013
+ refuseBrowserFlags("ai-visual test", flags, BASELINE_SHAPE_FLAGS);
4654
5014
  console.log(`\n🔍 Running AI visual regression test...`);
4655
5015
  console.log(` URL: ${url}`);
4656
5016
  console.log(` Baseline: ${baselineName}\n`);
4657
5017
  const result = await runVisualRegression(url, baselineName, {
4658
- threshold: options.threshold ? parseFloat(options.threshold) : 0.9,
4659
- sensitivity: options.sensitivity || "medium",
5018
+ threshold: numberOption("threshold", options.threshold, 0.9, { min: 0, max: 1 }),
5019
+ sensitivity: enumOption("sensitivity", options.sensitivity, ["low", "medium", "high"]) ?? "medium",
4660
5020
  generateDiff: true,
5021
+ headless: flags.headless,
4661
5022
  });
4662
5023
  // Print report
4663
5024
  console.log(formatVisualRegressionReport(result));
@@ -4709,10 +5070,13 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
4709
5070
  console.error(`Suite file not found: ${suiteFile}`);
4710
5071
  process.exit(1);
4711
5072
  }
5073
+ const flags = browserFlagsOrExit("ai-visual suite", options);
5074
+ refuseBrowserFlags("ai-visual suite", flags, BASELINE_SHAPE_FLAGS);
4712
5075
  const suite = JSON.parse(fs.readFileSync(suiteFile, "utf-8"));
4713
5076
  console.log(`\n🔍 Running visual regression suite: ${suite.name}`);
4714
5077
  const result = await runVisualRegressionSuite(suite, {
4715
- threshold: options.threshold ? parseFloat(options.threshold) : 0.9,
5078
+ threshold: numberOption("threshold", options.threshold, 0.9, { min: 0, max: 1 }),
5079
+ headless: flags.headless,
4716
5080
  });
4717
5081
  // Save outputs
4718
5082
  if (options.output && !options.html) {
@@ -4803,6 +5167,7 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
4803
5167
  console.error(" cbrowser ai-visual list");
4804
5168
  console.error(" cbrowser ai-visual show <name>");
4805
5169
  console.error(" cbrowser ai-visual delete <name>");
5170
+ process.exit(1);
4806
5171
  }
4807
5172
  break;
4808
5173
  }
@@ -4811,6 +5176,10 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
4811
5176
  // =========================================================================
4812
5177
  case "cross-browser": {
4813
5178
  const subcommand = args[0];
5179
+ const flags = browserFlagsOrExit("cross-browser", options);
5180
+ refuseBrowserFlags("cross-browser", flags, {
5181
+ browser: "it runs each engine in turn; choose them with --browsers chromium,firefox,webkit",
5182
+ });
4814
5183
  if (subcommand === "suite") {
4815
5184
  // Cross-browser suite
4816
5185
  const suiteFile = args[1];
@@ -4826,7 +5195,8 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
4826
5195
  process.exit(1);
4827
5196
  }
4828
5197
  const suite = JSON.parse(fs.readFileSync(suiteFile, "utf-8"));
4829
- const result = await runCrossBrowserSuite(suite);
5198
+ // Flags given on the command line win over the suite file's options.
5199
+ const result = await runCrossBrowserSuite({ ...suite, options: { ...suite.options, ...flags } });
4830
5200
  // Save outputs
4831
5201
  if (options.output && !options.html) {
4832
5202
  fs.writeFileSync(options.output, JSON.stringify(result, null, 2));
@@ -4854,8 +5224,9 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
4854
5224
  console.error(" cbrowser cross-browser suite <file.json>");
4855
5225
  console.error("\nOptions:");
4856
5226
  console.error(" --browsers <list> chromium,firefox,webkit (default: all)");
4857
- console.error(" --width <n> Viewport width (default: 1920)");
4858
- console.error(" --height <n> Viewport height (default: 1080)");
5227
+ console.error(" --viewport <WxH> Page size (default: 1920x1080)");
5228
+ console.error(" --width/--height One side of the page size");
5229
+ console.error(" --device <name> Device preset (firefox cannot emulate a mobile one)");
4859
5230
  console.error(" --wait <ms> Wait before screenshot");
4860
5231
  console.error(" --wait-for <sel> Wait for selector");
4861
5232
  console.error(" --sensitivity <l> low, medium, high");
@@ -4868,13 +5239,10 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
4868
5239
  : undefined;
4869
5240
  const result = await runCrossBrowserTest(url, {
4870
5241
  browsers,
4871
- viewport: options.width || options.height ? {
4872
- width: parseInt(options.width) || 1920,
4873
- height: parseInt(options.height) || 1080,
4874
- } : undefined,
5242
+ ...flags,
4875
5243
  waitBeforeCapture: options.wait ? parseInt(options.wait) : undefined,
4876
5244
  waitForSelector: options["wait-for"],
4877
- sensitivity: options.sensitivity,
5245
+ sensitivity: enumOption("sensitivity", options.sensitivity, ["low", "medium", "high"]),
4878
5246
  });
4879
5247
  // Print report
4880
5248
  console.log("\n" + formatCrossBrowserReport(result));
@@ -4913,6 +5281,19 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
4913
5281
  // =========================================================================
4914
5282
  case "responsive": {
4915
5283
  const subcommand = args[0];
5284
+ // Headless and the engine apply; the page shapes are the presets'.
5285
+ const responsiveFlags = () => {
5286
+ const flags = browserFlagsOrExit("responsive", options);
5287
+ refuseBrowserFlags("responsive", flags, {
5288
+ device: "the sizes come from --viewports, whose mobile and tablet presets emulate their own devices",
5289
+ viewport: "the sizes come from --viewports ('cbrowser responsive viewports' lists them)",
5290
+ });
5291
+ // Only the flags given, so a suite file's own options stand otherwise.
5292
+ return {
5293
+ ...(flags.headless !== undefined ? { headless: flags.headless } : {}),
5294
+ ...(flags.browser ? { browser: flags.browser } : {}),
5295
+ };
5296
+ };
4916
5297
  if (subcommand === "viewports") {
4917
5298
  // List viewport presets
4918
5299
  const presets = listViewportPresets();
@@ -4938,6 +5319,7 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
4938
5319
  }
4939
5320
  else if (subcommand === "suite") {
4940
5321
  // Responsive suite
5322
+ const flags = responsiveFlags();
4941
5323
  const suiteFile = args[1];
4942
5324
  if (!suiteFile) {
4943
5325
  console.error("Usage: cbrowser responsive suite <file.json> [options]");
@@ -4951,7 +5333,8 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
4951
5333
  process.exit(1);
4952
5334
  }
4953
5335
  const suite = JSON.parse(fs.readFileSync(suiteFile, "utf-8"));
4954
- const result = await runResponsiveSuite(suite);
5336
+ // Flags given on the command line win over the suite file's options.
5337
+ const result = await runResponsiveSuite({ ...suite, options: { ...suite.options, ...flags } });
4955
5338
  // Save outputs
4956
5339
  if (options.output && !options.html) {
4957
5340
  fs.writeFileSync(options.output, JSON.stringify(result, null, 2));
@@ -4986,19 +5369,20 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
4986
5369
  console.error(" --sensitivity <l> low, medium, high");
4987
5370
  console.error(" --html Generate HTML report");
4988
5371
  console.error(" --output <file> Save report");
4989
- console.error("\nViewport presets: mobile-sm, mobile, mobile-lg, mobile-xl,");
4990
- console.error(" tablet, tablet-lg, desktop-sm, desktop,");
4991
- console.error(" desktop-lg, desktop-xl");
5372
+ console.error("\nViewport presets:");
5373
+ console.error(formatViewportPresetList(" "));
4992
5374
  process.exit(1);
4993
5375
  }
5376
+ const flags = responsiveFlags();
4994
5377
  const viewports = options.viewports
4995
5378
  ? options.viewports.split(",")
4996
5379
  : undefined;
4997
5380
  const result = await runResponsiveTest(url, {
4998
5381
  viewports,
5382
+ ...flags,
4999
5383
  waitBeforeCapture: options.wait ? parseInt(options.wait) : undefined,
5000
5384
  waitForSelector: options["wait-for"],
5001
- sensitivity: options.sensitivity,
5385
+ sensitivity: enumOption("sensitivity", options.sensitivity, ["low", "medium", "high"]),
5002
5386
  });
5003
5387
  // Print report
5004
5388
  console.log("\n" + formatResponsiveReport(result));
@@ -5039,6 +5423,7 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
5039
5423
  // =========================================================================
5040
5424
  case "ab": {
5041
5425
  const subcommand = args[0];
5426
+ const flags = browserFlagsOrExit("ab", options);
5042
5427
  if (subcommand === "suite") {
5043
5428
  // A/B suite
5044
5429
  const suiteFile = args[1];
@@ -5054,7 +5439,8 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
5054
5439
  process.exit(1);
5055
5440
  }
5056
5441
  const suite = JSON.parse(fs.readFileSync(suiteFile, "utf-8"));
5057
- const result = await runABSuite(suite);
5442
+ // Flags given on the command line win over the suite file's options.
5443
+ const result = await runABSuite({ ...suite, options: { ...suite.options, ...flags } });
5058
5444
  // Save outputs
5059
5445
  if (options.output && !options.html) {
5060
5446
  fs.writeFileSync(options.output, JSON.stringify(result, null, 2));
@@ -5084,8 +5470,9 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
5084
5470
  console.error("\nOptions:");
5085
5471
  console.error(" --label-a <name> Label for URL A (default: 'Version A')");
5086
5472
  console.error(" --label-b <name> Label for URL B (default: 'Version B')");
5087
- console.error(" --width <n> Viewport width (default: 1920)");
5088
- console.error(" --height <n> Viewport height (default: 1080)");
5473
+ console.error(" --viewport <WxH> Page size (default: 1920x1080)");
5474
+ console.error(" --width/--height One side of the page size");
5475
+ console.error(" --device <name> Device preset");
5089
5476
  console.error(" --wait <ms> Wait before screenshot");
5090
5477
  console.error(" --wait-for <sel> Wait for selector");
5091
5478
  console.error(" --sensitivity <l> low, medium, high");
@@ -5098,13 +5485,10 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
5098
5485
  a: options["label-a"] || "Version A",
5099
5486
  b: options["label-b"] || "Version B",
5100
5487
  } : undefined,
5101
- viewport: options.width || options.height ? {
5102
- width: parseInt(options.width) || 1920,
5103
- height: parseInt(options.height) || 1080,
5104
- } : undefined,
5488
+ ...flags,
5105
5489
  waitBeforeCapture: options.wait ? parseInt(options.wait) : undefined,
5106
5490
  waitForSelector: options["wait-for"],
5107
- sensitivity: options.sensitivity,
5491
+ sensitivity: enumOption("sensitivity", options.sensitivity, ["low", "medium", "high"]),
5108
5492
  });
5109
5493
  // Print report
5110
5494
  console.log("\n" + formatABReport(result));
@@ -5170,6 +5554,7 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
5170
5554
  }
5171
5555
  else {
5172
5556
  console.error("Usage: cbrowser a11y audit [url]");
5557
+ process.exit(1);
5173
5558
  }
5174
5559
  break;
5175
5560
  }
@@ -5178,6 +5563,17 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
5178
5563
  // =========================================================================
5179
5564
  case "record": {
5180
5565
  const subcommand = args[0];
5566
+ // A recording has to span several commands, and each command is its own
5567
+ // process, so start / stop / save each saw a fresh, empty recorder: stop
5568
+ // always reported 0 actions and save wrote an empty test (audit
5569
+ // 2026-10-21, the class HAR had until 19.4.3). Only list and generate
5570
+ // work on their own; the rest are refused instead of pretending.
5571
+ if (subcommand === "start" || subcommand === "stop" || subcommand === "save") {
5572
+ console.error(`Error: 'record ${subcommand}' cannot capture anything: each cbrowser command is its own process, so a recording does not survive between them.`);
5573
+ console.error(" To record a flow, write it as a test and run it in one process:");
5574
+ console.error(' cbrowser test-suite --inline "go to https://example.com ; click Sign in ; verify page contains Welcome" --capture');
5575
+ process.exit(1);
5576
+ }
5181
5577
  switch (subcommand) {
5182
5578
  case "start": {
5183
5579
  const url = options.url;
@@ -5254,6 +5650,7 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
5254
5650
  }
5255
5651
  default:
5256
5652
  console.error("Usage: cbrowser record [start|stop|save|list|generate]");
5653
+ process.exit(1);
5257
5654
  }
5258
5655
  break;
5259
5656
  }
@@ -5291,31 +5688,25 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
5291
5688
  console.error("Usage: cbrowser export [junit|tap] <name> [output]");
5292
5689
  process.exit(1);
5293
5690
  }
5294
- // Load test results (for now, create a mock suite)
5295
- const fs = await import("fs");
5296
- const path = await import("path");
5297
- const resultsPath = path.join(browser.getDataDir(), "results", `${name}.json`);
5298
- let suite;
5299
- if (fs.existsSync(resultsPath)) {
5300
- suite = JSON.parse(fs.readFileSync(resultsPath, "utf-8"));
5691
+ if (format !== "junit" && format !== "tap") {
5692
+ console.error("Unknown export format. Use 'junit' or 'tap'");
5693
+ process.exit(1);
5301
5694
  }
5302
- else {
5695
+ // The last `test-suite` run of that suite (it saves every run).
5696
+ const suite = loadSuiteForExport(browser.getDataDir(), name);
5697
+ if (!suite) {
5303
5698
  console.error(`Test results not found: ${name}`);
5304
- console.error("Run tests first to generate results");
5699
+ console.error(` Run 'cbrowser test-suite <file>' first; it saves results as ${resultsName(name)}.json under ${join(browser.getDataDir(), "results")}`);
5305
5700
  process.exit(1);
5306
5701
  }
5307
5702
  if (format === "junit") {
5308
5703
  const exportPath = browser.exportJUnit(suite, output);
5309
5704
  console.log(`✓ JUnit XML exported: ${exportPath}`);
5310
5705
  }
5311
- else if (format === "tap") {
5706
+ else {
5312
5707
  const exportPath = browser.exportTAP(suite, output);
5313
5708
  console.log(`✓ TAP exported: ${exportPath}`);
5314
5709
  }
5315
- else {
5316
- console.error("Unknown export format. Use 'junit' or 'tap'");
5317
- process.exit(1);
5318
- }
5319
5710
  break;
5320
5711
  }
5321
5712
  // =========================================================================
@@ -5324,13 +5715,9 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
5324
5715
  case "webhook": {
5325
5716
  const subcommand = args[0];
5326
5717
  const fs = await import("fs");
5327
- const path = await import("path");
5328
- const webhooksPath = path.join(browser.getDataDir(), "webhooks.json");
5718
+ const hooksFile = webhooksPath(browser.getDataDir());
5329
5719
  // Load existing webhooks
5330
- let webhooks = [];
5331
- if (fs.existsSync(webhooksPath)) {
5332
- webhooks = JSON.parse(fs.readFileSync(webhooksPath, "utf-8"));
5333
- }
5720
+ let webhooks = loadWebhooks(browser.getDataDir());
5334
5721
  switch (subcommand) {
5335
5722
  case "add": {
5336
5723
  const name = args[1];
@@ -5339,14 +5726,30 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
5339
5726
  console.error("Usage: cbrowser webhook add <name> <url> [--events <events>] [--format <format>]");
5340
5727
  process.exit(1);
5341
5728
  }
5342
- const events = options.events
5343
- ? options.events.split(",")
5344
- : ["test.fail", "journey.complete"];
5345
- const format = options.format || "generic";
5729
+ const urlProblem = webhookUrlProblem(url);
5730
+ if (urlProblem) {
5731
+ console.error(`Error: ${urlProblem}`);
5732
+ process.exit(1);
5733
+ }
5734
+ // Only events something sends. journey.complete was the default
5735
+ // and nothing ever sent it, nor test.fail (audit O17).
5736
+ const events = options.events === undefined
5737
+ ? ["test.fail"]
5738
+ : String(options.events).split(",").map((e) => e.trim()).filter(Boolean);
5739
+ const unsent = events.filter((e) => !SENT_WEBHOOK_EVENTS.includes(e));
5740
+ if (events.length === 0 || unsent.length > 0) {
5741
+ console.error(`Error: no command sends ${unsent.join(", ") || "an empty event list"}. Events sent: ${SENT_WEBHOOK_EVENTS.join(", ")} (test.pass/test.fail by test-suite once per run, journey.complete by cognitive-journey)`);
5742
+ process.exit(1);
5743
+ }
5744
+ const format = options.format === undefined ? "generic" : String(options.format);
5745
+ if (!WEBHOOK_FORMATS.includes(format)) {
5746
+ console.error(`Error: --format must be ${WEBHOOK_FORMATS.join(", ")}, got "${format}"`);
5747
+ process.exit(1);
5748
+ }
5346
5749
  // Remove existing webhook with same name
5347
5750
  webhooks = webhooks.filter(w => w.name !== name);
5348
5751
  webhooks.push({ name, url, events, format });
5349
- fs.writeFileSync(webhooksPath, JSON.stringify(webhooks, null, 2));
5752
+ fs.writeFileSync(hooksFile, JSON.stringify(webhooks, null, 2));
5350
5753
  console.log(`✓ Webhook added: ${name}`);
5351
5754
  console.log(` URL: ${url}`);
5352
5755
  console.log(` Events: ${events.join(", ")}`);
@@ -5360,9 +5763,11 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
5360
5763
  else {
5361
5764
  console.log("\n🔔 Configured Webhooks:\n");
5362
5765
  for (const w of webhooks) {
5766
+ // An event no command sends (a typo, or one stored before 20.0 checked) is named as such.
5767
+ const unsent = w.events.filter((e) => !SENT_WEBHOOK_EVENTS.includes(e));
5363
5768
  console.log(` ${w.name}`);
5364
5769
  console.log(` URL: ${w.url}`);
5365
- console.log(` Events: ${w.events.join(", ")}`);
5770
+ console.log(` Events: ${w.events.join(", ")}${unsent.length > 0 ? ` (never sent: ${unsent.join(", ")})` : ""}`);
5366
5771
  console.log(` Format: ${w.format}`);
5367
5772
  console.log("");
5368
5773
  }
@@ -5378,7 +5783,7 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
5378
5783
  const originalLength = webhooks.length;
5379
5784
  webhooks = webhooks.filter(w => w.name !== name);
5380
5785
  if (webhooks.length < originalLength) {
5381
- fs.writeFileSync(webhooksPath, JSON.stringify(webhooks, null, 2));
5786
+ fs.writeFileSync(hooksFile, JSON.stringify(webhooks, null, 2));
5382
5787
  console.log(`✓ Webhook deleted: ${name}`);
5383
5788
  }
5384
5789
  else {
@@ -5398,6 +5803,11 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
5398
5803
  console.error(`✗ Webhook not found: ${name}`);
5399
5804
  process.exit(1);
5400
5805
  }
5806
+ const urlProblem = webhookUrlProblem(webhook.url);
5807
+ if (urlProblem) {
5808
+ console.error(`✗ ${urlProblem}`);
5809
+ process.exit(1);
5810
+ }
5401
5811
  // Send test notification
5402
5812
  const testPayload = webhook.format === "slack"
5403
5813
  ? { text: "🔔 CBrowser test notification" }
@@ -5409,6 +5819,8 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
5409
5819
  method: "POST",
5410
5820
  headers: { "Content-Type": "application/json" },
5411
5821
  body: JSON.stringify(testPayload),
5822
+ redirect: "error",
5823
+ signal: AbortSignal.timeout(WEBHOOK_TIMEOUT_MS),
5412
5824
  });
5413
5825
  if (response.ok) {
5414
5826
  console.log(`✓ Test notification sent to: ${name}`);
@@ -5426,6 +5838,7 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
5426
5838
  }
5427
5839
  default:
5428
5840
  console.error("Usage: cbrowser webhook [add|list|delete|test]");
5841
+ process.exit(1);
5429
5842
  }
5430
5843
  break;
5431
5844
  }
@@ -5434,6 +5847,7 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
5434
5847
  // =========================================================================
5435
5848
  case "parallel": {
5436
5849
  const subcommand = args[0];
5850
+ const flags = browserFlagsOrExit("parallel", options);
5437
5851
  switch (subcommand) {
5438
5852
  case "devices": {
5439
5853
  const url = args[1];
@@ -5441,6 +5855,10 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
5441
5855
  console.error("Usage: cbrowser parallel devices <url> [--devices <list>] [--concurrency <n>]");
5442
5856
  process.exit(1);
5443
5857
  }
5858
+ refuseBrowserFlags("parallel devices", flags, {
5859
+ device: "it runs every device in --devices; list them there",
5860
+ viewport: "each device renders at its own size",
5861
+ });
5444
5862
  // Without --devices, cbrowser's own presets, not all few hundred.
5445
5863
  const deviceList = options.devices
5446
5864
  ? options.devices.split(",").map((d) => resolveDevicePreset(d))
@@ -5454,11 +5872,12 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
5454
5872
  const nav = await b.navigate(url);
5455
5873
  const screenshot = await b.screenshot();
5456
5874
  return { title: nav.title, loadTime: nav.loadTime, screenshot };
5457
- }, { maxConcurrency: concurrency });
5875
+ }, { maxConcurrency: concurrency, config: browserLaunchConfig(flags) });
5458
5876
  console.log("📊 Results:\n");
5459
5877
  for (const r of results) {
5460
5878
  if (r.error) {
5461
5879
  console.log(` ✗ ${r.device}: ${r.error} (${r.duration}ms)`);
5880
+ process.exitCode = 1;
5462
5881
  }
5463
5882
  else {
5464
5883
  console.log(` ✓ ${r.device}: ${r.result?.title} - ${r.result?.loadTime}ms (${r.duration}ms total)`);
@@ -5481,11 +5900,12 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
5481
5900
  const results = await CBrowser.parallelUrls(urls, async (b, url) => {
5482
5901
  const nav = await b.navigate(url);
5483
5902
  return { title: nav.title, loadTime: nav.loadTime };
5484
- }, { maxConcurrency: concurrency });
5903
+ }, { maxConcurrency: concurrency, config: browserLaunchConfig(flags) });
5485
5904
  console.log("📊 Results:\n");
5486
5905
  for (const r of results) {
5487
5906
  if (r.error) {
5488
5907
  console.log(` ✗ ${r.url}: ${r.error}`);
5908
+ process.exitCode = 1;
5489
5909
  }
5490
5910
  else {
5491
5911
  console.log(` ✓ ${r.url}: ${r.result?.title} (${r.result?.loadTime}ms)`);
@@ -5506,11 +5926,12 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
5506
5926
  const results = await CBrowser.parallelUrls(urls, async (b, url) => {
5507
5927
  await b.navigate(url);
5508
5928
  return await b.getPerformanceMetrics();
5509
- }, { maxConcurrency: concurrency });
5929
+ }, { maxConcurrency: concurrency, config: browserLaunchConfig(flags) });
5510
5930
  console.log("📊 Performance Results:\n");
5511
5931
  for (const r of results) {
5512
5932
  if (r.error) {
5513
5933
  console.log(` ✗ ${r.url}: ${r.error}`);
5934
+ process.exitCode = 1;
5514
5935
  }
5515
5936
  else {
5516
5937
  const m = r.result;
@@ -5527,6 +5948,7 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
5527
5948
  }
5528
5949
  default:
5529
5950
  console.error("Usage: cbrowser parallel [devices|urls|perf]");
5951
+ process.exit(1);
5530
5952
  }
5531
5953
  break;
5532
5954
  }
@@ -5544,6 +5966,8 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
5544
5966
  process.exit(1);
5545
5967
  }
5546
5968
  console.log(`\n🗣️ Executing: "${nlCommand}"\n`);
5969
+ if (typeof options.url === "string")
5970
+ await browser.navigate(options.url);
5547
5971
  const result = await executeNaturalLanguage(browser, nlCommand);
5548
5972
  if (result.success) {
5549
5973
  console.log(`✓ Action: ${result.action}`);
@@ -5557,6 +5981,8 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
5557
5981
  console.log(` ${r.message}`);
5558
5982
  if (r.screenshot)
5559
5983
  console.log(` Screenshot: ${r.screenshot}`);
5984
+ if (typeof r.scrollY === "number")
5985
+ console.log(` scrollY: ${r.scrollY}`);
5560
5986
  }
5561
5987
  }
5562
5988
  else {
@@ -5587,7 +6013,8 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
5587
6013
  }
5588
6014
  else {
5589
6015
  console.log(`✗ ${r.command}`);
5590
- console.log(` Error: ${r.error}`);
6016
+ process.exitCode = 1;
6017
+ console.error(` Error: ${r.error}`);
5591
6018
  }
5592
6019
  }
5593
6020
  const passed = results.filter(r => r.success).length;
@@ -5624,7 +6051,7 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
5624
6051
  console.log(` Description: ${result.description}`);
5625
6052
  }
5626
6053
  else {
5627
- console.log(`✗ Could not find element matching: "${intent}"`);
6054
+ console.error(`✗ Could not find element matching: "${intent}"`);
5628
6055
  process.exit(1);
5629
6056
  }
5630
6057
  break;
@@ -5634,17 +6061,24 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
5634
6061
  const result = await findElementByIntent(browser, intent);
5635
6062
  if (result) {
5636
6063
  console.log(`✓ Found: ${result.description}`);
5637
- await browser.click(result.selector);
6064
+ // The click's own result decides: a red-zone refusal or a miss used
6065
+ // to print "Clicked element" and exit 0 (audit 2026-10-21).
6066
+ const clicked = await browser.click(result.selector, { force: options.force === true });
6067
+ if (!clicked.success) {
6068
+ console.error(`✗ ${clicked.message}`);
6069
+ process.exit(1);
6070
+ }
5638
6071
  console.log(`✓ Clicked element`);
5639
6072
  }
5640
6073
  else {
5641
- console.log(`✗ Could not find element matching: "${intent}"`);
6074
+ console.error(`✗ Could not find element matching: "${intent}"`);
5642
6075
  process.exit(1);
5643
6076
  }
5644
6077
  break;
5645
6078
  }
5646
6079
  default:
5647
6080
  console.error("Usage: cbrowser ai [find|click] \"<intent>\"");
6081
+ process.exit(1);
5648
6082
  }
5649
6083
  break;
5650
6084
  }
@@ -5659,7 +6093,7 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
5659
6093
  process.exit(1);
5660
6094
  }
5661
6095
  const maxPages = options["max-pages"] ? parseInt(options["max-pages"]) : 10;
5662
- const timeout = options.timeout ? parseInt(options.timeout) : 60000;
6096
+ const timeout = timeoutOption(options.timeout, 60000);
5663
6097
  console.log(`\n🔍 Bug Hunter starting...`);
5664
6098
  console.log(` URL: ${url}`);
5665
6099
  console.log(` Max pages: ${maxPages}`);
@@ -5694,16 +6128,24 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
5694
6128
  case "diff": {
5695
6129
  const url = args[0];
5696
6130
  if (!url) {
5697
- console.error("Usage: cbrowser diff <url> [--browsers <list>]");
6131
+ console.error("Usage: cbrowser diff <url> [--browsers <list>] [--viewport <WxH>] [--device <name>]");
5698
6132
  process.exit(1);
5699
6133
  }
6134
+ const flags = browserFlagsOrExit("diff", options);
6135
+ refuseBrowserFlags("diff", flags, {
6136
+ browser: "it runs each engine in turn; choose them with --browsers chromium,firefox,webkit",
6137
+ });
5700
6138
  const browserList = options.browsers
5701
6139
  ? options.browsers.split(",")
5702
6140
  : ["chromium", "firefox", "webkit"];
5703
6141
  console.log(`\n🔀 Cross-Browser Diff`);
5704
6142
  console.log(` URL: ${url}`);
5705
6143
  console.log(` Browsers: ${browserList.join(", ")}\n`);
5706
- const result = await crossBrowserDiff(url, [...browserList]);
6144
+ const result = await crossBrowserDiff(url, [...browserList], {
6145
+ headless: flags.headless,
6146
+ device: flags.device,
6147
+ viewport: flags.viewport,
6148
+ });
5707
6149
  console.log(`📊 Results:\n`);
5708
6150
  console.log(` Metrics:`);
5709
6151
  for (const [browser, metrics] of Object.entries(result.metrics)) {
@@ -5755,6 +6197,8 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
5755
6197
  const result = await runChaosTest(browser, url, chaosConfig);
5756
6198
  console.log(`📊 Results:\n`);
5757
6199
  console.log(` Passed: ${result.passed ? "✅ Yes" : "❌ No"}`);
6200
+ if (!result.passed)
6201
+ process.exitCode = 1;
5758
6202
  console.log(` Duration: ${result.duration}ms`);
5759
6203
  if (result.errors.length > 0) {
5760
6204
  console.log(`\n Errors:`);
@@ -5801,10 +6245,9 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
5801
6245
  console.log("");
5802
6246
  }
5803
6247
  console.log("Usage (OPT-IN REQUIRED):");
5804
- console.log(" cbrowser screenshot https://example.com --lightpanda");
5805
6248
  console.log(" cbrowser agent-ready-audit https://example.com --lightpanda");
5806
6249
  console.log("");
5807
- console.log("Note: Lightpanda is NOT used automatically. The --lightpanda flag is required.");
6250
+ console.log("Note: Lightpanda is NOT used automatically. Only agent-ready-audit uses it, with --lightpanda.");
5808
6251
  }
5809
6252
  else {
5810
6253
  console.log("⚠️ Configured but unavailable");
@@ -5975,6 +6418,7 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
5975
6418
  }
5976
6419
  catch (e) {
5977
6420
  console.log(` ✗ Failed: ${e.message} (${Date.now() - start}ms)`);
6421
+ process.exitCode = 1;
5978
6422
  }
5979
6423
  }
5980
6424
  }
@@ -5987,12 +6431,16 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
5987
6431
  }
5988
6432
  assertCaptureNamespaceFlags("test-suite", options);
5989
6433
  const suiteOptions = {
5990
- stepTimeout: options.timeout ? parseInt(options.timeout) : 30000,
6434
+ stepTimeout: timeoutOption(options.timeout, 30000),
5991
6435
  continueOnFailure: options["continue-on-failure"] === true,
5992
6436
  screenshotOnFailure: options["screenshot-on-failure"] !== false,
5993
6437
  headless,
5994
6438
  device: typeof options.device === "string" ? options.device : undefined,
5995
6439
  viewport: viewportOverride,
6440
+ ...(options.browser !== undefined ? { browser: browserType } : {}),
6441
+ ...(geolocation ? { geolocation } : {}),
6442
+ ...(typeof options.locale === "string" ? { locale: options.locale } : {}),
6443
+ ...(typeof options.timezone === "string" ? { timezone: options.timezone } : {}),
5996
6444
  fuzzyMatch: options["fuzzy-match"] === true,
5997
6445
  capture: buildAutoCaptureSetting(options, `test-suite-${suite.name.replace(/[^a-zA-Z0-9-_]+/g, "-").toLowerCase()}-${Date.now()}`),
5998
6446
  };
@@ -6001,6 +6449,22 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
6001
6449
  const report = formatNLTestReport(result);
6002
6450
  console.log(report);
6003
6451
  reportAutoCapture(result.capture);
6452
+ // Saved for `cbrowser export junit|tap`, which read this directory
6453
+ // while nothing wrote it (audit O17).
6454
+ const savedPath = saveSuiteResult(browser.getDataDir(), result);
6455
+ console.log(`\n💾 Results saved: ${savedPath} (export: cbrowser export junit ${resultsName(result.name)})`);
6456
+ // Webhooks: one event per run. A failed send is a warning, never the
6457
+ // run's result.
6458
+ await sendWebhookEvent(browser.getDataDir(), result.summary.failed > 0 ? "test.fail" : "test.pass", {
6459
+ suite: result.name,
6460
+ summary: {
6461
+ total: result.summary.total,
6462
+ passed: result.summary.passed,
6463
+ failed: result.summary.failed,
6464
+ skipped: result.summary.skipped,
6465
+ },
6466
+ failedTests: result.testResults.filter((t) => !t.passed).map((t) => ({ name: t.name, ...(t.error ? { error: t.error } : {}) })),
6467
+ });
6004
6468
  // Save JSON output if requested
6005
6469
  if (options.output) {
6006
6470
  const fs = await import("fs");
@@ -6011,7 +6475,7 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
6011
6475
  if (options.html) {
6012
6476
  const fs = await import("fs");
6013
6477
  const htmlReport = generateTestSuiteHtmlReport(result);
6014
- const htmlPath = options.output?.replace(".json", ".html") || "test-report.html";
6478
+ const htmlPath = htmlReportPath(options.output, "test-report.html");
6015
6479
  fs.writeFileSync(htmlPath, htmlReport);
6016
6480
  console.log(`\n🌐 HTML report saved: ${htmlPath}`);
6017
6481
  }
@@ -6034,7 +6498,7 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
6034
6498
  console.error(" --auto-apply Automatically apply the best repair suggestion");
6035
6499
  console.error(" --verify Re-run repaired tests to verify they pass");
6036
6500
  console.error(" --output <file> Save repaired tests to a new file");
6037
- console.error(" --json <file> Save repair report as JSON");
6501
+ console.error(" --json-out <file> Save repair report as JSON (--json prints it)");
6038
6502
  console.error("");
6039
6503
  console.error("Examples:");
6040
6504
  console.error(" cbrowser repair-tests broken-test.txt");
@@ -6054,8 +6518,18 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
6054
6518
  for (const test of suite.tests) {
6055
6519
  console.log(` - ${test.name}: ${test.steps.length} steps`);
6056
6520
  }
6521
+ // --output writes the repaired file, which only exists with --auto-apply;
6522
+ // without it the flag used to do nothing and say nothing.
6523
+ if (options.output && options["auto-apply"] !== true) {
6524
+ console.error("Error: --output saves the repaired tests, which needs --auto-apply");
6525
+ process.exit(1);
6526
+ }
6527
+ const repairFlags = browserFlagsOrExit("repair-tests", options);
6057
6528
  const repairOptions = {
6058
6529
  headless,
6530
+ browser: repairFlags.browser,
6531
+ device: repairFlags.device,
6532
+ viewport: repairFlags.viewport,
6059
6533
  autoApply: options["auto-apply"] === true,
6060
6534
  verifyRepairs: options.verify === true,
6061
6535
  maxRetries: options.retries ? parseInt(options.retries) : 3,
@@ -6064,10 +6538,18 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
6064
6538
  // Print formatted report
6065
6539
  const report = formatRepairReport(result);
6066
6540
  console.log(report);
6067
- // Save JSON report if requested
6068
- if (options.json) {
6069
- fs.writeFileSync(options.json, JSON.stringify(result, null, 2));
6070
- console.log(`\n📄 JSON report saved: ${options.json}`);
6541
+ // Save the JSON report: --json-out <file>, or the older --json=<file>.
6542
+ // --json alone is the on/off flag every other command uses, so here it
6543
+ // prints the report to stdout instead of passing `true` as a path.
6544
+ // (Audit 2026-10-21: --json was a path here and a switch elsewhere.)
6545
+ const jsonOut = typeof options["json-out"] === "string" ? options["json-out"]
6546
+ : typeof options.json === "string" ? options.json : undefined;
6547
+ if (jsonOut) {
6548
+ fs.writeFileSync(jsonOut, JSON.stringify(result, null, 2));
6549
+ console.log(`\n📄 JSON report saved: ${jsonOut}`);
6550
+ }
6551
+ else if (options.json === true) {
6552
+ console.log(JSON.stringify(result, null, 2));
6071
6553
  }
6072
6554
  // Save repaired tests if requested
6073
6555
  if (options.output && repairOptions.autoApply) {
@@ -6084,6 +6566,11 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
6084
6566
  console.log("\n💡 Run with --auto-apply to automatically fix issues");
6085
6567
  process.exit(1);
6086
6568
  }
6569
+ // With --auto-apply, a test that failed and was not repaired still fails.
6570
+ if (result.summary.testsRepaired < result.summary.testsWithFailures) {
6571
+ console.error(`✗ ${result.summary.testsWithFailures - result.summary.testsRepaired} failing test(s) could not be repaired`);
6572
+ process.exit(1);
6573
+ }
6087
6574
  break;
6088
6575
  }
6089
6576
  // =========================================================================
@@ -6119,10 +6606,15 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
6119
6606
  for (const test of suite.tests) {
6120
6607
  console.log(` - ${test.name}: ${test.steps.length} steps`);
6121
6608
  }
6609
+ const flakyFlags = browserFlagsOrExit("flaky-check", options);
6122
6610
  const flakyOptions = {
6123
6611
  headless,
6612
+ browser: flakyFlags.browser,
6613
+ device: flakyFlags.device,
6614
+ viewport: flakyFlags.viewport,
6124
6615
  runs: options.runs ? parseInt(options.runs) : 5,
6125
- flakinessThreshold: options.threshold ? parseInt(options.threshold) : 20,
6616
+ // A percentage: parseInt read "0.2" as 0, which flagged every test.
6617
+ flakinessThreshold: numberOption("threshold", options.threshold, 20, { min: 0, max: 100 }),
6126
6618
  delayBetweenRuns: options.delay ? parseInt(options.delay) : 500,
6127
6619
  };
6128
6620
  const result = await detectFlakyTests(suite, flakyOptions);
@@ -6153,9 +6645,15 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
6153
6645
  console.error("Usage: cbrowser perf-baseline save <url> [--name <name>] [--runs <n>]");
6154
6646
  process.exit(1);
6155
6647
  }
6648
+ const perfFlags = browserFlagsOrExit("perf-baseline save", options);
6649
+ refuseBrowserFlags("perf-baseline save", perfFlags, {
6650
+ browser: "each run disables the HTTP cache through Chromium's DevTools protocol; on another engine the runs would mix cold and warm loads",
6651
+ });
6156
6652
  console.log(`\n📊 Capturing performance baseline for: ${url}`);
6157
6653
  const baselineOptions = {
6158
6654
  headless,
6655
+ device: perfFlags.device,
6656
+ viewport: perfFlags.viewport,
6159
6657
  name: options.name,
6160
6658
  runs: options.runs ? parseInt(options.runs) : 3,
6161
6659
  };
@@ -6164,6 +6662,8 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
6164
6662
  console.log(`\n✅ Baseline saved: ${baseline.name}`);
6165
6663
  console.log(` ID: ${baseline.id}`);
6166
6664
  console.log(` URL: ${baseline.url}`);
6665
+ const env = baseline.environment;
6666
+ console.log(` Page: ${env.viewport.width}x${env.viewport.height}${env.device ? ` (${env.device})` : ""}`);
6167
6667
  console.log(` Timestamp: ${new Date(baseline.timestamp).toLocaleString()}`);
6168
6668
  console.log(`\n📈 Metrics (averaged over ${baseline.runsAveraged} runs):`);
6169
6669
  if (baseline.metrics.lcp !== undefined) {
@@ -6283,7 +6783,19 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
6283
6783
  console.error(" cbrowser perf-regression https://example.com homepage --threshold-lcp 30");
6284
6784
  process.exit(1);
6285
6785
  }
6786
+ // A regression renders as its baseline was captured; a different
6787
+ // device or size here would compare unlike pages (audit F20: these
6788
+ // flags were accepted and dropped).
6789
+ refuseBrowserFlags("perf-regression", browserFlagsOrExit("perf-regression", options), {
6790
+ device: "it renders with the baseline's recorded device; capture a new baseline with perf-baseline save --device",
6791
+ viewport: "it renders with the baseline's recorded size; capture a new baseline with perf-baseline save --viewport",
6792
+ browser: "performance baselines are measured in Chromium",
6793
+ });
6286
6794
  const sensitivity = options.sensitivity || "normal";
6795
+ if (!["strict", "normal", "ci", "lenient"].includes(sensitivity)) {
6796
+ console.error(`Error: --sensitivity must be strict, normal, ci or lenient, got: ${sensitivity}`);
6797
+ process.exit(1);
6798
+ }
6287
6799
  console.log(`\n🔍 Checking for performance regressions...`);
6288
6800
  console.log(` URL: ${url}`);
6289
6801
  console.log(` Baseline: ${baselineName}`);
@@ -6355,6 +6867,12 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
6355
6867
  }
6356
6868
  return files;
6357
6869
  }
6870
+ // The browser flags reach the crawl. A sitemap is read without one.
6871
+ const coverageFlags = browserFlagsOrExit("coverage", options);
6872
+ if (options.sitemap) {
6873
+ const noBrowser = "--sitemap reads the sitemap without a browser, so there is no page to apply it to";
6874
+ refuseBrowserFlags("coverage", coverageFlags, { headless: noBrowser, browser: noBrowser, device: noBrowser, viewport: noBrowser });
6875
+ }
6358
6876
  // Handle "coverage gaps" subcommand
6359
6877
  if (subcommand === "gaps") {
6360
6878
  const url = args[1];
@@ -6374,6 +6892,7 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
6374
6892
  sitemapUrl: options.sitemap,
6375
6893
  maxPages: 50, // Quick mode
6376
6894
  minCoverage: 50,
6895
+ ...coverageFlags,
6377
6896
  };
6378
6897
  const result = await generateCoverageMap(url, testFiles, coverageOptions);
6379
6898
  // Show only gaps
@@ -6414,6 +6933,7 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
6414
6933
  includePattern: options.include,
6415
6934
  excludePattern: options.exclude,
6416
6935
  minCoverage: options["min-coverage"] ? parseInt(options["min-coverage"]) : 50,
6936
+ ...coverageFlags,
6417
6937
  };
6418
6938
  if (coverageOptions.sitemapUrl) {
6419
6939
  console.log(` Using sitemap: ${coverageOptions.sitemapUrl}`);
@@ -6453,8 +6973,16 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
6453
6973
  // Cognitive Journey (v8.0.0) - API-powered autonomous user simulation
6454
6974
  // =========================================================================
6455
6975
  case "cognitive-journey": {
6976
+ // A replay makes no model calls, so it needs no key (audit 2026-10-21:
6977
+ // the key gate blocked replays, and --replay-trace never reached the
6978
+ // engine, so a "replay" was a live, paid run).
6979
+ const replayTrace = typeof options["replay-trace"] === "string" ? resolve(options["replay-trace"]) : undefined;
6980
+ if (replayTrace && !existsSync(replayTrace)) {
6981
+ console.error(`Error: trace file not found: ${replayTrace}`);
6982
+ process.exit(1);
6983
+ }
6456
6984
  // Check API key first
6457
- if (!isApiKeyConfigured()) {
6985
+ if (!replayTrace && !isApiKeyConfigured()) {
6458
6986
  // Output structured prompt for Claude Code integration
6459
6987
  // Format matches Claude Agent SDK's AskUserQuestion tool
6460
6988
  const apiKeyPrompt = {
@@ -6489,10 +7017,10 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
6489
7017
  console.log(JSON.stringify(apiKeyPrompt, null, 2));
6490
7018
  process.exit(1);
6491
7019
  }
6492
- const startUrl = options.start;
7020
+ const startUrl = (options.start ?? options.url); // --url works too (audit F25)
6493
7021
  const goal = options.goal;
6494
7022
  if (!startUrl) {
6495
- console.error("Error: --start <url> is required");
7023
+ console.error("Error: --start <url> is required (--url works too)");
6496
7024
  process.exit(1);
6497
7025
  }
6498
7026
  if (!goal) {
@@ -6503,12 +7031,18 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
6503
7031
  const maxSteps = options["max-steps"] ? parseInt(options["max-steps"]) : 50;
6504
7032
  const maxTime = options["max-time"] ? parseInt(options["max-time"]) : 120;
6505
7033
  const verbose = options.verbose === true;
6506
- const headless = options.headless === true;
7034
+ // Undefined unless asked, so the engine decides from $DISPLAY. This was
7035
+ // `=== true`, which made a plain journey launch headed on a server
7036
+ // with no display, unlike every other command.
7037
+ const headless = options.headless === true ? true : options.headless === false ? false : undefined;
6507
7038
  const vision = options["no-vision"] !== true; // Vision ON by default (v17.1.0)
6508
7039
  // Parse location options
6509
7040
  let locationOverride;
6510
- if (options.timezone || options.locale || options.geolocation) {
7041
+ if (options.timezone || options.locale || options.geolocation || geolocation) {
6511
7042
  locationOverride = {};
7043
+ // The global --geo (a preset or lat,lon) was accepted here and ignored.
7044
+ if (geolocation)
7045
+ locationOverride.geolocation = { latitude: geolocation.latitude, longitude: geolocation.longitude };
6512
7046
  if (options.timezone)
6513
7047
  locationOverride.timezone = options.timezone;
6514
7048
  if (options.locale)
@@ -6569,6 +7103,12 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
6569
7103
  headless,
6570
7104
  device: typeof options.device === "string" ? options.device : undefined,
6571
7105
  viewport: viewportOverride,
7106
+ trace: options.trace === true,
7107
+ replayTrace,
7108
+ // A persona is a new visitor: a fresh profile unless --persistent.
7109
+ // The engine's default used the shared logged-in profile, so a
7110
+ // "first-timer" carried the user's cookies (audit 2026-10-21).
7111
+ persistent: options.persistent === true,
6572
7112
  vision,
6573
7113
  customTraits, // Pass custom traits from env var
6574
7114
  location: locationOverride, // Pass location override
@@ -6579,6 +7119,10 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
6579
7119
  });
6580
7120
  console.log("\n");
6581
7121
  reportAutoCapture(result.capture);
7122
+ if (result.tracePath) {
7123
+ console.log(`🧾 Trace: ${result.tracePath}`);
7124
+ console.log(` Replay it with no API calls: cbrowser cognitive-journey --start <url> --goal <goal> --replay-trace ${result.tracePath}`);
7125
+ }
6582
7126
  if (result.goalAchieved) {
6583
7127
  console.log(`✅ GOAL ACHIEVED in ${result.stepCount} steps (${result.totalTime.toFixed(1)}s)`);
6584
7128
  }
@@ -6620,7 +7164,20 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
6620
7164
  // goalAchieved genuinely means the persona reached the goal — unlike
6621
7165
  // the empathy audit's field of the same old name, which measured
6622
7166
  // barriers and was renamed to noBlockingBarriers.
6623
- process.exit(result.goalAchieved ? 0 : 1);
7167
+ // journey.complete, once per journey (audit O17: it was the default
7168
+ // event and nothing ever sent it). Never fails the journey.
7169
+ await sendWebhookEvent(browser.getDataDir(), "journey.complete", {
7170
+ persona: personaName,
7171
+ goal,
7172
+ startUrl,
7173
+ goalAchieved: result.goalAchieved,
7174
+ ...(result.abandonmentReason ? { abandonmentReason: result.abandonmentReason } : {}),
7175
+ stepCount: result.stepCount,
7176
+ totalTimeSeconds: Math.round(result.totalTime * 10) / 10,
7177
+ });
7178
+ // Keep a failure reportAutoCapture already set: a lost capture used
7179
+ // to be overwritten by the goal's 0.
7180
+ process.exit(result.goalAchieved ? (process.exitCode ?? 0) : 1);
6624
7181
  }
6625
7182
  catch (error) {
6626
7183
  console.error(`\n❌ Error: ${error.message}`);
@@ -6654,7 +7211,11 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
6654
7211
  auditProxy = { server: geoProxy.server, username: geoProxy.username, password: geoProxy.password };
6655
7212
  }
6656
7213
  else {
6657
- console.error(`Warning: geo-region "${geoRegion}" not configured. Run without proxy.`);
7214
+ // The README promises this fails loudly; it warned, ran from the
7215
+ // local IP, and still printed "(via <region>)" (audit 2026-10-21).
7216
+ console.error(`Error: geo-region "${geoRegion}" is not configured, so the audit would run from this machine's own IP.`);
7217
+ console.error(" Configure it in ~/.cbrowser/geo-proxy.json, or run without --geo-region.");
7218
+ process.exit(1);
6658
7219
  }
6659
7220
  }
6660
7221
  console.log(`\n🤖 Running Agent-Ready Audit on ${url}...${useLightpanda ? " (with Lightpanda)" : ""}${geoRegion ? ` (via ${geoRegion})` : ""}\n`);
@@ -6662,6 +7223,10 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
6662
7223
  headless,
6663
7224
  useLightpanda,
6664
7225
  ...(auditProxy ? { proxy: auditProxy } : {}),
7226
+ // Accepted by the engine and dropped here until 20.0, so a mobile
7227
+ // audit from the CLI was silently a desktop audit.
7228
+ ...(typeof options.device === "string" ? { device: resolveDevicePreset(options.device) } : {}),
7229
+ ...(typeof options.locale === "string" ? { locale: options.locale } : {}),
6665
7230
  });
6666
7231
  // Print formatted report
6667
7232
  const report = formatAgentReadyReport(result);
@@ -6676,7 +7241,7 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
6676
7241
  if (options.html) {
6677
7242
  const fs = await import("fs");
6678
7243
  const htmlReport = generateAgentReadyHtmlReport(result);
6679
- const htmlPath = options.output?.replace(".json", ".html") || "agent-ready-report.html";
7244
+ const htmlPath = htmlReportPath(options.output, "agent-ready-report.html");
6680
7245
  fs.writeFileSync(htmlPath, htmlReport);
6681
7246
  console.log(`🌐 HTML report saved: ${htmlPath}`);
6682
7247
  }
@@ -6699,8 +7264,16 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
6699
7264
  process.exit(1);
6700
7265
  }
6701
7266
  console.log(`\n🏁 Running AI Readiness Benchmark on ${urls.length} sites...\n`);
7267
+ // --device was accepted and dropped (audit F20). The audit can emulate a
7268
+ // device but not set a bare size or another engine.
7269
+ const benchFlags = browserFlagsOrExit("ai-benchmark", options);
7270
+ refuseBrowserFlags("ai-benchmark", benchFlags, {
7271
+ viewport: "the agent-ready audit takes a device, not a bare size; use --device",
7272
+ browser: "the agent-ready audit runs in Chromium",
7273
+ });
6702
7274
  const result = await runAIReadinessBenchmark({
6703
7275
  urls,
7276
+ ...(benchFlags.device ? { device: benchFlags.device } : {}),
6704
7277
  headless: options.headless !== false,
6705
7278
  maxConcurrency: options.concurrency ? parseInt(options.concurrency) : 3,
6706
7279
  });
@@ -6713,6 +7286,9 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
6713
7286
  for (const site of result.sites) {
6714
7287
  const rank = result.ranking.find(r => r.site === site.siteName)?.rank || "-";
6715
7288
  const score = site.score !== null ? site.score.toString().padStart(3) : "ERR";
7289
+ // A site that could not be scored is a failed run, not a blank row.
7290
+ if (site.score === null)
7291
+ process.exitCode = 1;
6716
7292
  const grade = site.grade || "-";
6717
7293
  const find = site.scoreBreakdown?.findability?.toString().padStart(3) || "-";
6718
7294
  const stab = site.scoreBreakdown?.stability?.toString().padStart(3) || "-";
@@ -6755,7 +7331,7 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
6755
7331
  // Generate HTML report if requested
6756
7332
  if (options.html) {
6757
7333
  const fs = await import("fs");
6758
- const htmlPath = options.output?.replace(".json", ".html") || "ai-benchmark-report.html";
7334
+ const htmlPath = htmlReportPath(options.output, "ai-benchmark-report.html");
6759
7335
  // Simple HTML table report
6760
7336
  const html = `<!DOCTYPE html>
6761
7337
  <html lang="en">
@@ -6841,7 +7417,7 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
6841
7417
  const result = await runWebMCPReadyAudit(url, {
6842
7418
  apiKey: options["api-key"],
6843
7419
  oauthToken: options["oauth-token"],
6844
- timeout: options.timeout ? parseInt(options.timeout) : 30000,
7420
+ timeout: timeoutOption(options.timeout, 30000),
6845
7421
  });
6846
7422
  // Print formatted summary
6847
7423
  console.log(`╔══════════════════════════════════════════════════════════════╗`);
@@ -6881,7 +7457,7 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
6881
7457
  // Generate HTML report if requested
6882
7458
  if (options.html) {
6883
7459
  const fs = await import("fs");
6884
- const htmlPath = options.output?.replace(".json", ".html") || "webmcp-ready-report.html";
7460
+ const htmlPath = htmlReportPath(options.output, "webmcp-ready-report.html");
6885
7461
  const htmlReport = generateWebMCPReadyHtmlReport(result);
6886
7462
  fs.writeFileSync(htmlPath, htmlReport);
6887
7463
  console.log(`🌐 HTML report saved: ${htmlPath}`);
@@ -6905,6 +7481,11 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
6905
7481
  console.error("Usage: cbrowser competitive-benchmark --sites <urls> --goal <goal> [--persona <name>] [--html]");
6906
7482
  process.exit(1);
6907
7483
  }
7484
+ // 20.0.0: no keyless mode (its goal verdicts were not accurate).
7485
+ if (!isApiKeyConfigured()) {
7486
+ console.error("Error: competitive-benchmark needs an Anthropic API key (cbrowser config set-api-key <key>). The keyless heuristic mode was removed in 20.0.0 because its goal verdicts were not accurate.");
7487
+ process.exit(1);
7488
+ }
6908
7489
  const sites = sitesArg.split(",").map((s) => ({ url: s.trim() }));
6909
7490
  const personaName = options.persona || "first-timer";
6910
7491
  const maxSteps = options["max-steps"] ? parseInt(options["max-steps"]) : 30;
@@ -6913,6 +7494,11 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
6913
7494
  console.log(` Sites: ${sites.map((s) => s.url).join(", ")}`);
6914
7495
  console.log(` Goal: ${goal}`);
6915
7496
  console.log(` Persona: ${personaName}\n`);
7497
+ // --device and --viewport were accepted and dropped (audit F20).
7498
+ const journeyFlags = browserFlagsOrExit("competitive-benchmark", options);
7499
+ refuseBrowserFlags("competitive-benchmark", journeyFlags, {
7500
+ browser: "cognitive journeys run in Chromium",
7501
+ });
6916
7502
  const result = await runCompetitiveBenchmark({
6917
7503
  sites,
6918
7504
  goal,
@@ -6920,6 +7506,8 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
6920
7506
  maxSteps,
6921
7507
  maxTime,
6922
7508
  headless,
7509
+ ...(journeyFlags.device ? { device: journeyFlags.device } : {}),
7510
+ ...(journeyFlags.viewport ? { viewport: journeyFlags.viewport } : {}),
6923
7511
  });
6924
7512
  // Print formatted report
6925
7513
  const report = formatCompetitiveBenchmarkReport(result);
@@ -6934,7 +7522,7 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
6934
7522
  if (options.html) {
6935
7523
  const fs = await import("fs");
6936
7524
  const htmlReport = generateCompetitiveBenchmarkHtmlReport(result);
6937
- const htmlPath = options.output?.replace(".json", ".html") || "competitive-benchmark-report.html";
7525
+ const htmlPath = htmlReportPath(options.output, "competitive-benchmark-report.html");
6938
7526
  fs.writeFileSync(htmlPath, htmlReport);
6939
7527
  console.log(`🌐 HTML report saved: ${htmlPath}`);
6940
7528
  }
@@ -6943,41 +7531,22 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
6943
7531
  case "empathy-audit": {
6944
7532
  const url = args[0];
6945
7533
  const goal = options.goal;
6946
- const disabilitiesArg = options.disabilities;
7534
+ // --personas is what the help and its examples say; --disabilities is the
7535
+ // older name. Before 2026-10-21 only --disabilities was read, so the
7536
+ // documented form silently ran every accessibility persona.
7537
+ const disabilitiesArg = (typeof options.personas === "string" ? options.personas : options.disabilities);
6947
7538
  if (!url) {
6948
7539
  console.error("Error: URL required");
6949
- console.error("Usage: cbrowser empathy-audit <url> --goal <goal> [--disabilities <list>] [--html]");
6950
- process.exit(1);
6951
- }
6952
- if (!goal) {
6953
- console.error("Error: --goal required");
6954
- console.error("Usage: cbrowser empathy-audit <url> --goal <goal> [--disabilities <list>] [--html]");
7540
+ console.error("Usage: cbrowser empathy-audit <url> [--goal <goal>] [--personas <list>] [--device <name>] [--html]");
6955
7541
  process.exit(1);
6956
7542
  }
7543
+ // --goal is optional, as it is over MCP: without one the audit checks
7544
+ // the page rather than a task (it was required here only).
6957
7545
  // Parse disabilities or use all
6958
7546
  const allAccessibilityNames = listAccessibilityPersonas();
6959
- // Short alias mapping for convenience
6960
- const aliasMap = {
6961
- "motor-tremor": "motor-impairment-tremor",
6962
- "motor": "motor-impairment-tremor",
6963
- "tremor": "motor-impairment-tremor",
6964
- "low-vision": "low-vision-magnified",
6965
- "vision": "low-vision-magnified",
6966
- "magnified": "low-vision-magnified",
6967
- "adhd": "cognitive-adhd",
6968
- "cognitive": "cognitive-adhd",
6969
- "attention": "cognitive-adhd",
6970
- "dyslexia": "dyslexic-user",
6971
- "dyslexic": "dyslexic-user",
6972
- "reading": "dyslexic-user",
6973
- "deaf": "deaf-user",
6974
- "hearing": "deaf-user",
6975
- "elderly": "elderly-low-vision",
6976
- "senior": "elderly-low-vision",
6977
- "color-blind": "color-blind-deuteranopia",
6978
- "colorblind": "color-blind-deuteranopia",
6979
- "deuteranopia": "color-blind-deuteranopia",
6980
- };
7547
+ // The shared alias table (personas.ts), as MCP uses. This command had its
7548
+ // own stale copy, so --disabilities autism failed here and worked there.
7549
+ const aliasMap = PERSONA_ALIASES;
6981
7550
  const disabilityNames = disabilitiesArg
6982
7551
  ? disabilitiesArg.split(",").map((d) => {
6983
7552
  const trimmed = d.trim().toLowerCase();
@@ -6989,7 +7558,7 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
6989
7558
  if (invalidNames.length > 0) {
6990
7559
  console.error(`Error: Unknown disability persona(s): ${invalidNames.join(", ")}`);
6991
7560
  console.error(`Available: ${allAccessibilityNames.join(", ")}`);
6992
- console.error(`Aliases: motor-tremor, low-vision, adhd, dyslexia, deaf, elderly, color-blind`);
7561
+ console.error(`Aliases: ${Object.keys(aliasMap).filter((a) => allAccessibilityNames.includes(aliasMap[a]) && a !== aliasMap[a]).join(", ")}`);
6993
7562
  process.exit(1);
6994
7563
  }
6995
7564
  const wcagLevel = options["wcag-level"] || "AA";
@@ -7000,13 +7569,22 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
7000
7569
  console.log(` Goal: ${goal}`);
7001
7570
  console.log(` Disabilities: ${disabilityNames.join(", ")}`);
7002
7571
  console.log(` WCAG Level: ${wcagLevel}\n`);
7572
+ const scope = options.scope === undefined ? undefined : String(options.scope);
7573
+ if (scope !== undefined && scope !== "viewport" && scope !== "full_page") {
7574
+ console.error(`Error: --scope must be viewport or full_page, got: ${scope}`);
7575
+ process.exit(1);
7576
+ }
7003
7577
  const result = await runEmpathyAudit(url, {
7004
- goal,
7578
+ ...(goal ? { goal } : {}),
7005
7579
  disabilities: disabilityNames,
7006
7580
  wcagLevel: wcagLevel,
7007
7581
  maxSteps,
7008
7582
  maxTime,
7009
7583
  headless,
7584
+ // Accepted by the engine and dropped here until 20.0 (the MCP tool
7585
+ // passed it), so a mobile empathy audit from the CLI was a desktop one.
7586
+ ...(typeof options.device === "string" ? { device: resolveDevicePreset(options.device) } : {}),
7587
+ ...(scope ? { scope: scope } : {}),
7010
7588
  });
7011
7589
  // Print formatted report
7012
7590
  const report = formatEmpathyAuditReport(result);
@@ -7021,7 +7599,7 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
7021
7599
  if (options.html) {
7022
7600
  const fs = await import("fs");
7023
7601
  const htmlReport = generateEmpathyAuditHtmlReport(result);
7024
- const htmlPath = options.output?.replace(".json", ".html") || "empathy-audit-report.html";
7602
+ const htmlPath = htmlReportPath(options.output, "empathy-audit-report.html");
7025
7603
  fs.writeFileSync(htmlPath, htmlReport);
7026
7604
  console.log(`🌐 HTML report saved: ${htmlPath}`);
7027
7605
  }
@@ -7050,7 +7628,7 @@ Commands:
7050
7628
  persona-questionnaire lookup Look up behaviors for a specific trait value
7051
7629
 
7052
7630
  Options for 'start':
7053
- --comprehensive Include all 26 traits (default: 8 core traits)
7631
+ --comprehensive Include all 30 traits (default: 13 core traits)
7054
7632
  --name <name> Name for the persona (prompted if not provided)
7055
7633
  --output <file> Save questionnaire answers to JSON file
7056
7634