cbrowser 19.5.0 → 20.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (187) hide show
  1. package/README.md +292 -232
  2. package/THIRD_PARTY_LICENSES.md +455 -7
  3. package/dist/analysis/accessibility-empathy.d.ts.map +1 -1
  4. package/dist/analysis/accessibility-empathy.js +2 -0
  5. package/dist/analysis/accessibility-empathy.js.map +1 -1
  6. package/dist/analysis/competitive-benchmark.d.ts.map +1 -1
  7. package/dist/analysis/competitive-benchmark.js +70 -558
  8. package/dist/analysis/competitive-benchmark.js.map +1 -1
  9. package/dist/analysis/natural-language.d.ts.map +1 -1
  10. package/dist/analysis/natural-language.js +43 -0
  11. package/dist/analysis/natural-language.js.map +1 -1
  12. package/dist/analysis/persona-comparison.d.ts +7 -0
  13. package/dist/analysis/persona-comparison.d.ts.map +1 -1
  14. package/dist/analysis/persona-comparison.js +8 -1
  15. package/dist/analysis/persona-comparison.js.map +1 -1
  16. package/dist/browser-options.d.ts +23 -0
  17. package/dist/browser-options.d.ts.map +1 -0
  18. package/dist/browser-options.js +24 -0
  19. package/dist/browser-options.js.map +1 -0
  20. package/dist/browser.d.ts +36 -14
  21. package/dist/browser.d.ts.map +1 -1
  22. package/dist/browser.js +172 -202
  23. package/dist/browser.js.map +1 -1
  24. package/dist/cli-args.d.ts +9 -1
  25. package/dist/cli-args.d.ts.map +1 -1
  26. package/dist/cli-args.js +35 -7
  27. package/dist/cli-args.js.map +1 -1
  28. package/dist/cli-browser-flags.d.ts +33 -0
  29. package/dist/cli-browser-flags.d.ts.map +1 -0
  30. package/dist/cli-browser-flags.js +89 -0
  31. package/dist/cli-browser-flags.js.map +1 -0
  32. package/dist/cli.js +1058 -478
  33. package/dist/cli.js.map +1 -1
  34. package/dist/cognitive/index.d.ts +5 -13
  35. package/dist/cognitive/index.d.ts.map +1 -1
  36. package/dist/cognitive/index.js +44 -15
  37. package/dist/cognitive/index.js.map +1 -1
  38. package/dist/config.d.ts +9 -0
  39. package/dist/config.d.ts.map +1 -1
  40. package/dist/config.js +16 -6
  41. package/dist/config.js.map +1 -1
  42. package/dist/daemon.d.ts +9 -5
  43. package/dist/daemon.d.ts.map +1 -1
  44. package/dist/daemon.js +102 -36
  45. package/dist/daemon.js.map +1 -1
  46. package/dist/device-names.d.ts +10 -0
  47. package/dist/device-names.d.ts.map +1 -0
  48. package/dist/device-names.js +16 -0
  49. package/dist/device-names.js.map +1 -0
  50. package/dist/device-presets-devtools.d.ts +3 -0
  51. package/dist/device-presets-devtools.d.ts.map +1 -0
  52. package/dist/device-presets-devtools.js +823 -0
  53. package/dist/device-presets-devtools.js.map +1 -0
  54. package/dist/devices.d.ts +35 -0
  55. package/dist/devices.d.ts.map +1 -0
  56. package/dist/devices.js +214 -0
  57. package/dist/devices.js.map +1 -0
  58. package/dist/eval-script.d.ts +29 -0
  59. package/dist/eval-script.d.ts.map +1 -0
  60. package/dist/eval-script.js +46 -0
  61. package/dist/eval-script.js.map +1 -0
  62. package/dist/help-client.d.ts +48 -0
  63. package/dist/help-client.d.ts.map +1 -0
  64. package/dist/help-client.js +96 -0
  65. package/dist/help-client.js.map +1 -0
  66. package/dist/index.d.ts +1 -0
  67. package/dist/index.d.ts.map +1 -1
  68. package/dist/index.js +1 -0
  69. package/dist/index.js.map +1 -1
  70. package/dist/mcp-server.d.ts.map +1 -1
  71. package/dist/mcp-server.js +165 -2281
  72. package/dist/mcp-server.js.map +1 -1
  73. package/dist/mcp-tools/base/advanced-interaction-tools.d.ts.map +1 -1
  74. package/dist/mcp-tools/base/advanced-interaction-tools.js +21 -21
  75. package/dist/mcp-tools/base/advanced-interaction-tools.js.map +1 -1
  76. package/dist/mcp-tools/base/assertion-tools.d.ts.map +1 -1
  77. package/dist/mcp-tools/base/assertion-tools.js +6 -3
  78. package/dist/mcp-tools/base/assertion-tools.js.map +1 -1
  79. package/dist/mcp-tools/base/audit-tools.d.ts.map +1 -1
  80. package/dist/mcp-tools/base/audit-tools.js +15 -4
  81. package/dist/mcp-tools/base/audit-tools.js.map +1 -1
  82. package/dist/mcp-tools/base/browser-state-tools.d.ts.map +1 -1
  83. package/dist/mcp-tools/base/browser-state-tools.js +33 -27
  84. package/dist/mcp-tools/base/browser-state-tools.js.map +1 -1
  85. package/dist/mcp-tools/base/bug-analysis-tools.d.ts.map +1 -1
  86. package/dist/mcp-tools/base/bug-analysis-tools.js +9 -2
  87. package/dist/mcp-tools/base/bug-analysis-tools.js.map +1 -1
  88. package/dist/mcp-tools/base/capture-tools.d.ts +60 -4
  89. package/dist/mcp-tools/base/capture-tools.d.ts.map +1 -1
  90. package/dist/mcp-tools/base/capture-tools.js +118 -14
  91. package/dist/mcp-tools/base/capture-tools.js.map +1 -1
  92. package/dist/mcp-tools/base/index.d.ts +6 -5
  93. package/dist/mcp-tools/base/index.d.ts.map +1 -1
  94. package/dist/mcp-tools/base/index.js +7 -6
  95. package/dist/mcp-tools/base/index.js.map +1 -1
  96. package/dist/mcp-tools/base/interaction-tools.d.ts.map +1 -1
  97. package/dist/mcp-tools/base/interaction-tools.js +20 -6
  98. package/dist/mcp-tools/base/interaction-tools.js.map +1 -1
  99. package/dist/mcp-tools/base/navigation-tools.d.ts +1 -1
  100. package/dist/mcp-tools/base/navigation-tools.d.ts.map +1 -1
  101. package/dist/mcp-tools/base/navigation-tools.js +83 -3
  102. package/dist/mcp-tools/base/navigation-tools.js.map +1 -1
  103. package/dist/mcp-tools/base/site-knowledge-tools.d.ts.map +1 -1
  104. package/dist/mcp-tools/base/site-knowledge-tools.js +11 -23
  105. package/dist/mcp-tools/base/site-knowledge-tools.js.map +1 -1
  106. package/dist/mcp-tools/base/visual-testing-tools.d.ts.map +1 -1
  107. package/dist/mcp-tools/base/visual-testing-tools.js +13 -3
  108. package/dist/mcp-tools/base/visual-testing-tools.js.map +1 -1
  109. package/dist/mcp-tools/enterprise-stubs.d.ts +2 -2
  110. package/dist/mcp-tools/enterprise-stubs.d.ts.map +1 -1
  111. package/dist/mcp-tools/enterprise-stubs.js +29 -16
  112. package/dist/mcp-tools/enterprise-stubs.js.map +1 -1
  113. package/dist/mcp-tools/index.d.ts +19 -14
  114. package/dist/mcp-tools/index.d.ts.map +1 -1
  115. package/dist/mcp-tools/index.js +2 -2
  116. package/dist/mcp-tools/index.js.map +1 -1
  117. package/dist/mcp-tools/red-zone-refusal.d.ts +33 -0
  118. package/dist/mcp-tools/red-zone-refusal.d.ts.map +1 -0
  119. package/dist/mcp-tools/red-zone-refusal.js +43 -0
  120. package/dist/mcp-tools/red-zone-refusal.js.map +1 -0
  121. package/dist/mcp-tools/tier-gate.js +1 -1
  122. package/dist/mcp-tools/tier-gate.js.map +1 -1
  123. package/dist/mcp-tools/tool-categories.d.ts.map +1 -1
  124. package/dist/mcp-tools/tool-categories.js +2 -0
  125. package/dist/mcp-tools/tool-categories.js.map +1 -1
  126. package/dist/output.d.ts.map +1 -1
  127. package/dist/output.js +18 -2
  128. package/dist/output.js.map +1 -1
  129. package/dist/performance/metrics.d.ts +8 -3
  130. package/dist/performance/metrics.d.ts.map +1 -1
  131. package/dist/performance/metrics.js +16 -5
  132. package/dist/performance/metrics.js.map +1 -1
  133. package/dist/recording/contact-sheet.js.map +1 -1
  134. package/dist/security/tool-permissions.d.ts.map +1 -1
  135. package/dist/security/tool-permissions.js +1 -0
  136. package/dist/security/tool-permissions.js.map +1 -1
  137. package/dist/testing/coverage.d.ts +2 -2
  138. package/dist/testing/coverage.d.ts.map +1 -1
  139. package/dist/testing/coverage.js +6 -2
  140. package/dist/testing/coverage.js.map +1 -1
  141. package/dist/testing/flaky-detection.d.ts +2 -2
  142. package/dist/testing/flaky-detection.d.ts.map +1 -1
  143. package/dist/testing/flaky-detection.js +11 -3
  144. package/dist/testing/flaky-detection.js.map +1 -1
  145. package/dist/testing/nl-test-suite.d.ts +20 -0
  146. package/dist/testing/nl-test-suite.d.ts.map +1 -1
  147. package/dist/testing/nl-test-suite.js +7 -1
  148. package/dist/testing/nl-test-suite.js.map +1 -1
  149. package/dist/testing/suite-results.d.ts +27 -0
  150. package/dist/testing/suite-results.d.ts.map +1 -0
  151. package/dist/testing/suite-results.js +54 -0
  152. package/dist/testing/suite-results.js.map +1 -0
  153. package/dist/testing/test-repair.d.ts +2 -2
  154. package/dist/testing/test-repair.d.ts.map +1 -1
  155. package/dist/testing/test-repair.js +6 -2
  156. package/dist/testing/test-repair.js.map +1 -1
  157. package/dist/types.d.ts +61 -8
  158. package/dist/types.d.ts.map +1 -1
  159. package/dist/types.js +17 -103
  160. package/dist/types.js.map +1 -1
  161. package/dist/visual/ab-comparison.d.ts.map +1 -1
  162. package/dist/visual/ab-comparison.js +7 -2
  163. package/dist/visual/ab-comparison.js.map +1 -1
  164. package/dist/visual/cross-browser.d.ts +7 -2
  165. package/dist/visual/cross-browser.d.ts.map +1 -1
  166. package/dist/visual/cross-browser.js +33 -5
  167. package/dist/visual/cross-browser.js.map +1 -1
  168. package/dist/visual/distance-metrics.d.ts +7 -0
  169. package/dist/visual/distance-metrics.d.ts.map +1 -1
  170. package/dist/visual/distance-metrics.js.map +1 -1
  171. package/dist/visual/heatmap-overlay.js.map +1 -1
  172. package/dist/visual/llm-relevance.d.ts.map +1 -1
  173. package/dist/visual/llm-relevance.js.map +1 -1
  174. package/dist/visual/regression.d.ts +17 -7
  175. package/dist/visual/regression.d.ts.map +1 -1
  176. package/dist/visual/regression.js +77 -38
  177. package/dist/visual/regression.js.map +1 -1
  178. package/dist/visual/responsive.d.ts +22 -0
  179. package/dist/visual/responsive.d.ts.map +1 -1
  180. package/dist/visual/responsive.js +44 -7
  181. package/dist/visual/responsive.js.map +1 -1
  182. package/dist/visual/visual-overlays.js.map +1 -1
  183. package/dist/webhooks.d.ts +67 -0
  184. package/dist/webhooks.d.ts.map +1 -0
  185. package/dist/webhooks.js +128 -0
  186. package/dist/webhooks.js.map +1 -0
  187. package/package.json +23 -24
package/dist/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,9 +22,11 @@ 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";
23
- import { DEVICE_PRESETS, LOCATION_PRESETS } from "./types.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";
28
+ import { LOCATION_PRESETS } from "./types.js";
29
+ import { CBROWSER_DEVICE_KEYS, DEVICE_PRESETS, resolveDevice } from "./devices.js";
24
30
  import { startMcpServer } from "./mcp-server.js";
25
31
  import { startRemoteMcpServer } from "./mcp-server-remote.js";
26
32
  import { startDaemon, stopDaemon, getDaemonStatus, isDaemonRunning, sendToDaemon, runDaemonServer } from "./daemon.js";
@@ -30,6 +36,7 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, unlinkSync,
30
36
  import { resolveCookieDomain, hostFromUrl } from "./utils.js";
31
37
  import { dirname, join, resolve } from "node:path";
32
38
  import { guardConsole, guardTerminalOutput } from "./terminal-safe.js";
39
+ import { evaluateScript } from "./eval-script.js";
33
40
  import { VideoCaptureSession } from "./recording/engine.js";
34
41
  import { changeSignal, parseManifest } from "./recording/types.js";
35
42
  import { printEnterpriseStatus } from "./stealth/index.js";
@@ -81,7 +88,7 @@ function helpText() {
81
88
 
82
89
  NAVIGATION
83
90
  navigate <url> Navigate and take screenshot
84
- screenshot [path] Take screenshot of current page
91
+ screenshot [url] [path] Take a screenshot (of <url> when given, else the current page)
85
92
  --no-restore Don't reload saved URL (capture current state after interactions)
86
93
 
87
94
  INTERACTION
@@ -116,6 +123,7 @@ SCRIPTING
116
123
  --raw Print the raw value, unquoted
117
124
  --wait-for <selector> Wait for the selector before evaluating
118
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)
119
127
  Examples:
120
128
  cbrowser evaluate "document.title" --force
121
129
  cbrowser evaluate --file check.js --arg '"#main"' --expect-truthy --force
@@ -128,6 +136,7 @@ KEYBOARD
128
136
  --hold <mod> Hold a modifier across the whole sequence
129
137
  --repeat <n> Repeat the whole sequence n times
130
138
  --text Treat every token as literal text (for "+" in text)
139
+ --url <url> Navigate to URL first
131
140
  A token with modifiers is a chord (Control+A); a known key name is that key
132
141
  (Enter, Tab, ArrowLeft); anything else is typed as literal text. Unknown
133
142
  key names are rejected, not typed. For a single key use 'press', for a
@@ -137,12 +146,12 @@ KEYBOARD
137
146
  cbrowser keyboard "ArrowRight" --repeat 5 --hold Shift
138
147
  cbrowser keyboard "Tab" "Tab" "Enter" --selector "#email" --delay 120
139
148
 
140
- SITE EXPLORATION (Free, heuristic-based)
141
- 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)
142
151
  --start <url> Starting URL (required)
143
152
  --goal <goal> What to accomplish
144
153
  --record-video Record exploration as video
145
- 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.
146
155
 
147
156
  COGNITIVE JOURNEY (API-powered, realistic user simulation)
148
157
  cognitive-journey Simulate a real user with emotions, patience, and abandonment
@@ -158,10 +167,13 @@ COGNITIVE JOURNEY (API-powered, realistic user simulation)
158
167
  --verbose Show step-by-step narration and internal monologue
159
168
  --no-vision Disable vision mode (text-only, less accurate but faster)
160
169
  --output <file> Save JSON report to file
161
- --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)
162
174
  --timezone <tz> Override persona timezone (e.g., "America/New_York", "Europe/London")
163
175
  --locale <loc> Override persona locale (e.g., "en-US", "de-DE")
164
- --geolocation <lat,lng> Override persona geolocation (e.g., "40.7128,-74.0060")
176
+ --geo <preset|lat,lon> Override persona location (also --geolocation <lat,lng>)
165
177
 
166
178
  Why use this over 'explore'?
167
179
  • Tracks cognitive state: patience, frustration, confusion
@@ -177,12 +189,14 @@ COGNITIVE JOURNEY (API-powered, realistic user simulation)
177
189
 
178
190
  Note: Requires Anthropic API key. Set with: cbrowser config set-api-key <key>
179
191
 
180
- MULTI-PERSONA COMPARISON (v6.0.0)
192
+ MULTI-PERSONA COMPARISON
181
193
  compare-personas Compare multiple personas on the same journey
182
194
  --start <url> Starting URL (required)
183
195
  --goal <goal> What to accomplish (required)
184
196
  --personas <list> Comma-separated persona names
185
197
  --concurrency <n> Max parallel browsers (default: 3)
198
+ --device <name> Device every journey emulates
199
+ --viewport <WxH> Page size for every journey
186
200
  --output <file> Save JSON report to file
187
201
  --html Generate HTML report
188
202
  Examples:
@@ -190,8 +204,10 @@ MULTI-PERSONA COMPARISON (v6.0.0)
190
204
  --goal "Complete checkout" \\
191
205
  --personas power-user,first-timer,elderly-user,mobile-user
192
206
 
193
- NATURAL LANGUAGE TEST SUITES (v6.1.0)
207
+ NATURAL LANGUAGE TEST SUITES
194
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')
195
211
  --continue-on-failure Keep running after a test fails
196
212
  --screenshot-on-failure Take screenshots on failure (default: true)
197
213
  --output <file> Save JSON report to file
@@ -226,29 +242,31 @@ NATURAL LANGUAGE TEST SUITES (v6.1.0)
226
242
  click search button
227
243
  verify page contains "results"
228
244
 
229
- AI TEST REPAIR (v6.2.0)
245
+ AI TEST REPAIR
230
246
  repair-tests <file.txt> Analyze failing tests and suggest/apply repairs
231
247
  --auto-apply Automatically apply the best repair
232
248
  --verify Re-run tests after repair to verify they pass
233
249
  --output <file> Save repaired tests to new file
234
- --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
235
252
  Examples:
236
253
  cbrowser repair-tests broken-test.txt
237
254
  cbrowser repair-tests tests.txt --auto-apply --verify
238
255
  cbrowser repair-tests tests.txt --auto-apply --output fixed-tests.txt
239
256
 
240
- FLAKY TEST DETECTION (v6.3.0)
257
+ FLAKY TEST DETECTION
241
258
  flaky-check <file.txt> Run tests multiple times to detect flaky tests
242
259
  --runs <n> Number of runs per test (default: 5)
243
260
  --threshold <n> Flakiness % threshold to flag (default: 20)
244
261
  --delay <ms> Delay between runs (default: 500)
245
262
  --output <file> Save JSON report to file
263
+ --device, --viewport, --browser, --no-headless The browser every run uses
246
264
  Examples:
247
265
  cbrowser flaky-check tests.txt
248
266
  cbrowser flaky-check tests.txt --runs 10
249
267
  cbrowser flaky-check tests.txt --runs 5 --threshold 30 --output flaky-report.json
250
268
 
251
- TEST COVERAGE MAP (v6.5.0)
269
+ TEST COVERAGE MAP
252
270
  coverage <url> Generate test coverage map for a site
253
271
  --tests <glob> Test files to analyze (default: tests/*.txt)
254
272
  --sitemap <url> Use sitemap.xml instead of crawling
@@ -257,7 +275,8 @@ TEST COVERAGE MAP (v6.5.0)
257
275
  --exclude <pattern> Exclude paths matching pattern
258
276
  --min-coverage <n> Min coverage % to not flag (default: 50)
259
277
  --html Generate HTML report
260
- --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)
261
280
  Examples:
262
281
  cbrowser coverage "https://example.com" --tests "tests/*.txt"
263
282
  cbrowser coverage "https://example.com" --sitemap "https://example.com/sitemap.xml"
@@ -280,6 +299,8 @@ PERSONAS
280
299
  persona delete <name> Delete a custom persona
281
300
  persona export <name> Export persona to JSON file
282
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
283
304
 
284
305
  SESSION MANAGEMENT
285
306
  session save <name> Save browser session (cookies, storage, URL)
@@ -287,6 +308,12 @@ SESSION MANAGEMENT
287
308
  session load <name> Load a saved session
288
309
  session list List all saved sessions
289
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
290
317
 
291
318
  COOKIE MANAGEMENT
292
319
  cookie list List all cookies for current page
@@ -299,7 +326,7 @@ COOKIE MANAGEMENT
299
326
  cookie clear Clear all cookies
300
327
 
301
328
  DEVICE EMULATION
302
- device list List available device presets
329
+ device list [filter] List device presets, one per line (filter by name)
303
330
  device set <name> Set device emulation for session
304
331
  Example: cbrowser device set iphone-15
305
332
 
@@ -317,10 +344,12 @@ PERFORMANCE
317
344
  --budget-fcp <ms> FCP budget (default: 1800)
318
345
  --budget-cls <score> CLS budget (default: 0.1)
319
346
 
320
- PERFORMANCE REGRESSION (v6.4.0)
347
+ PERFORMANCE REGRESSION
321
348
  perf-baseline save <url> Capture and save performance baseline
322
349
  --name <name> Human-readable name for baseline
323
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
324
353
  Examples:
325
354
  cbrowser perf-baseline save "https://example.com" --name "homepage"
326
355
  cbrowser perf-baseline save "https://example.com/checkout" --runs 5
@@ -334,6 +363,9 @@ PERFORMANCE REGRESSION (v6.4.0)
334
363
  --threshold-lcp <n> Max LCP increase % (default: 20)
335
364
  --threshold-cls <n> Max CLS increase (default: 0.1)
336
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 %
337
369
  --output <file> Save JSON report to file
338
370
  Examples:
339
371
  cbrowser perf-regression "https://example.com" homepage
@@ -344,14 +376,17 @@ NETWORK / HAR (need the daemon: each command is its own process)
344
376
  daemon start Start the daemon first; it holds the browser between commands
345
377
  har start Start recording HAR in the daemon's browser
346
378
  har stop [output] Stop and save HAR file
347
- 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
348
383
  network clear Clear that list
349
384
  console List the daemon tab's console messages (errors, warnings, logs)
350
385
  --filter <type> all | error | warning | log | info (default all)
351
386
  --clear Empty the console buffer after reading
352
387
  --json Print the raw messages as JSON
353
388
 
354
- VISUAL REGRESSION (v2.5.0)
389
+ VISUAL REGRESSION
355
390
  visual save <name> Save baseline screenshot
356
391
  --url <url> Navigate to URL first
357
392
  visual compare <name> Compare current page against baseline
@@ -359,19 +394,22 @@ VISUAL REGRESSION (v2.5.0)
359
394
  visual list List all saved baselines
360
395
  visual delete <name> Delete a baseline
361
396
 
362
- AI VISUAL REGRESSION (v7.0.0)
397
+ AI VISUAL REGRESSION
363
398
  ai-visual capture <url> Capture AI-analyzed visual baseline
364
399
  --name <name> Baseline name (required)
365
400
  --selector <sel> Capture specific element instead of viewport
366
401
  --device <device> Device emulation (iphone-15, pixel-7, etc.)
402
+ --viewport <WxH> Page size (default: 1920x1080)
367
403
  --width <n> Viewport width (default: 1920)
368
404
  --height <n> Viewport height (default: 1080)
405
+ --browser <engine> chromium, firefox or webkit; the baseline keeps it
369
406
  --wait <ms|selector> Wait before capture (ms or CSS selector)
370
407
  Examples:
371
408
  cbrowser ai-visual capture "https://example.com" --name homepage
372
409
  cbrowser ai-visual capture "https://example.com/dashboard" --name dashboard --device iphone-15
373
410
 
374
411
  ai-visual test <url> <baseline> Test current page against baseline using AI
412
+ Renders with the baseline's engine, device and size
375
413
  --threshold <n> Similarity threshold 0-1 (default: 0.9)
376
414
  --sensitivity <level> Detection sensitivity: low, medium, high (default: medium)
377
415
  --html Generate HTML report
@@ -389,41 +427,13 @@ AI VISUAL REGRESSION (v7.0.0)
389
427
  ai-visual show <name> Show baseline details
390
428
  ai-visual delete <name> Delete a baseline
391
429
 
392
- SMART BASELINES & TRANSPORT MAP (v18.0.0)
393
- smart-baseline capture <url> Capture consensus baseline from multiple screenshots
394
- --name <name> Baseline name (required)
395
- --captures <n> Number of screenshots to take (default: 5)
396
- --delay <ms> Delay between captures (default: 1500)
397
- --selector <sel> Capture specific element
398
- --device <device> Device emulation
399
- --outlier-threshold <n> Outlier rejection threshold in stddev (default: 2.0)
400
- Examples:
401
- cbrowser smart-baseline capture "https://example.com" --name homepage
402
- cbrowser smart-baseline capture "https://example.com" --name hero --captures 10
403
-
404
- smart-baseline test <url> <name> Test against smart baseline
405
- --threshold <n> Override adaptive threshold (0-1)
406
- --transport-map Also generate visual transport map
407
- Examples:
408
- cbrowser smart-baseline test "https://staging.example.com" homepage
409
- cbrowser smart-baseline test "https://staging.example.com" homepage --transport-map
410
-
411
- smart-baseline list List all smart baselines
412
- smart-baseline delete <name> Delete a smart baseline
413
-
414
- transport-map <baseline> <current> Generate visual transport map between two screenshots
415
- --cell-size <n> Grid cell size in pixels (default: 32)
416
- --output <file.svg> Save SVG to file
417
- --hotspots <n> Number of hotspots to identify (default: 5)
418
- Examples:
419
- cbrowser transport-map baseline.png current.png
420
- cbrowser transport-map baseline.png current.png --cell-size 16 --output changes.svg
421
-
422
- CROSS-BROWSER VISUAL TESTING (v7.3.0)
430
+ CROSS-BROWSER VISUAL TESTING
423
431
  cross-browser <url> Compare visual rendering across browsers
424
432
  --browsers <list> Browsers to test: chromium,firefox,webkit (default: all)
433
+ --viewport <WxH> Page size (default: 1920x1080)
425
434
  --width <n> Viewport width (default: 1920)
426
435
  --height <n> Viewport height (default: 1080)
436
+ --device <name> Device preset (firefox cannot emulate a mobile one)
427
437
  --wait <ms> Wait before screenshot (ms)
428
438
  --wait-for <selector> Wait for selector before screenshot
429
439
  --sensitivity <level> Comparison sensitivity: low, medium, high
@@ -445,9 +455,11 @@ CROSS-BROWSER VISUAL TESTING (v7.3.0)
445
455
  "options": { "browsers": ["chromium", "firefox"] }
446
456
  }
447
457
 
448
- RESPONSIVE VISUAL TESTING (v7.3.0)
458
+ RESPONSIVE VISUAL TESTING
449
459
  responsive <url> Test visual rendering across viewport sizes
450
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)
451
463
  --wait <ms> Wait before screenshot (ms)
452
464
  --wait-for <selector> Wait for selector before screenshot
453
465
  --sensitivity <level> Comparison sensitivity: low, medium, high
@@ -471,18 +483,19 @@ RESPONSIVE VISUAL TESTING (v7.3.0)
471
483
  "options": { "viewports": ["mobile", "tablet", "desktop"] }
472
484
  }
473
485
 
474
- Available viewport presets:
475
- mobile-sm (320x568) mobile (375x667) mobile-lg (414x896)
476
- mobile-xl (428x926) tablet (768x1024) tablet-lg (1024x1366)
477
- desktop-sm (1280x800) desktop (1440x900) desktop-lg (1920x1080)
478
- desktop-xl (2560x1440)
486
+ Available viewport presets (mobile and tablet emulate their device: touch,
487
+ user agent, pixel ratio):
488
+ ${formatViewportPresetList(" ")}
479
489
 
480
- A/B VISUAL COMPARISON (v7.3.0)
490
+ A/B VISUAL COMPARISON
481
491
  ab <urlA> <urlB> Compare two URLs visually
482
492
  --label-a <name> Label for URL A (default: "Version A")
483
493
  --label-b <name> Label for URL B (default: "Version B")
494
+ --viewport <WxH> Page size (default: 1920x1080)
484
495
  --width <n> Viewport width (default: 1920)
485
496
  --height <n> Viewport height (default: 1080)
497
+ --device <name> Device preset
498
+ --browser <engine> chromium, firefox or webkit
486
499
  --wait <ms> Wait before screenshot (ms)
487
500
  --wait-for <selector> Wait for selector before screenshot
488
501
  --sensitivity <level> Comparison sensitivity: low, medium, high
@@ -512,6 +525,9 @@ AI FRIENDLINESS - Audit sites for AI agent compatibility
512
525
 
513
526
  agent-ready-audit <url> Audit site for AI-agent friendliness (A-F grade)
514
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)
515
531
  --output <file> Save JSON report to file
516
532
  --html Generate HTML report
517
533
  Grades sites on: Findability (35%), Stability (30%),
@@ -522,6 +538,7 @@ AI FRIENDLINESS - Audit sites for AI agent compatibility
522
538
 
523
539
  ai-benchmark Compare AI-friendliness across multiple sites
524
540
  --urls <list> Comma-separated URLs to benchmark (required)
541
+ --concurrency <n> Sites audited at once (default: 3)
525
542
  --output <file> Save JSON report to file
526
543
  --html Generate HTML report
527
544
  Runs agent-ready-audit on each URL and produces ranked comparison.
@@ -530,14 +547,17 @@ AI FRIENDLINESS - Audit sites for AI agent compatibility
530
547
  cbrowser ai-benchmark --urls "https://amazon.com,https://ebay.com" --html
531
548
 
532
549
  empathy-audit <url> Simulate disability experience on site
533
- --goal <goal> Task goal (required)
534
- --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
535
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
536
557
  --output <file> Save JSON report to file
537
558
  --html Generate HTML report
538
559
  Available personas:
539
- motor-impairment-tremor, low-vision-magnified, cognitive-adhd,
540
- dyslexic-user, deaf-user, elderly-low-vision, color-blind-deuteranopia
560
+ ${listAccessibilityPersonas().join(", ")}
541
561
  Examples:
542
562
  cbrowser empathy-audit "https://example.com" \\
543
563
  --goal "complete checkout" \\
@@ -555,13 +575,13 @@ AI FRIENDLINESS - Audit sites for AI agent compatibility
555
575
  cbrowser webmcp-ready "https://demo.cbrowser.ai/mcp"
556
576
  cbrowser webmcp-ready "https://your-server.com/mcp" --api-key KEY --html
557
577
 
558
- hunt-bugs <url> Automatically find UX bugs on a page
578
+ hunt-bugs <url> Automatically find UX bugs on a page (alias: hunt)
559
579
  --max-pages <n> Max pages to crawl (default: 10)
560
580
  --timeout <ms> Timeout in ms (default: 60000)
561
581
  Examples:
562
582
  cbrowser hunt-bugs "https://example.com"
563
583
 
564
- competitive-benchmark Compare UX across competitor sites
584
+ competitive-benchmark Compare UX across competitor sites (needs an Anthropic API key)
565
585
  --sites <list> Comma-separated site URLs (required)
566
586
  --goal <goal> Task goal (required)
567
587
  --persona <name> Persona to use (default: first-timer)
@@ -574,12 +594,12 @@ AI FRIENDLINESS - Audit sites for AI agent compatibility
574
594
  --sites "https://yoursite.com,https://competitor.com" \\
575
595
  --goal "sign up for free trial" --html
576
596
 
577
- ACCESSIBILITY (v2.5.0)
597
+ ACCESSIBILITY
578
598
  a11y audit Run WCAG accessibility audit
579
599
  --url <url> Navigate to URL first
580
600
  a11y audit [url] Audit a specific URL
581
601
 
582
- TEST RECORDING (v2.5.0)
602
+ TEST RECORDING
583
603
  record start Start recording interactions
584
604
  --url <url> Navigate to URL to begin recording
585
605
  record stop Stop recording and show actions
@@ -591,7 +611,7 @@ SCREEN CAPTURE (video/GIF — distinct from 'record', which captures actions)
591
611
  capture start <url> Capture the screen to GIF/WebP/video
592
612
  --fps <n> Target frames per second (default: 10)
593
613
  --duration <5s> Auto-stop after this long (5s, 2m, 1500ms)
594
- --viewport <WxH> Viewport size, e.g. 1280x720
614
+ --viewport <WxH> Viewport size, e.g. 1280x720, or a size name (mobile, tablet, desktop...)
595
615
  --region <x,y,w,h> Capture a fixed region instead of the viewport
596
616
  --element <selector> Track an element; crop follows it as it moves
597
617
  --element-padding <n> Expand the element box by n CSS px per side
@@ -625,13 +645,16 @@ SCREEN CAPTURE (video/GIF — distinct from 'record', which captures actions)
625
645
  and the still stretches are reported as frame_gaps rather than padded out.
626
646
  mp4 needs a full ffmpeg (CBROWSER_FFMPEG_PATH); the bundled one does webm.
627
647
 
628
- TEST EXPORT (v2.5.0)
648
+ TEST EXPORT
629
649
  export junit <name> [output] Export test results as JUnit XML
630
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
631
652
 
632
- WEBHOOKS (v2.5.0)
633
- webhook add <name> <url> Add webhook notification
634
- --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
635
658
  --format <format> slack, discord, or generic
636
659
  webhook list List configured webhooks
637
660
  webhook delete <name> Delete a webhook
@@ -639,30 +662,37 @@ WEBHOOKS (v2.5.0)
639
662
 
640
663
  PERSONA QUESTIONNAIRE
641
664
  persona-questionnaire start Build a persona from a behavioural questionnaire
642
- --comprehensive All 26 traits (default: 8 core traits)
665
+ --comprehensive All 30 traits (default: 13 core traits)
643
666
  --name <name> Persona name (prompted if omitted)
644
667
  --output <file> Save answers to JSON
645
668
  persona-questionnaire list-traits List available traits with descriptions
646
669
  persona-questionnaire lookup Look up behaviours for a trait value
647
670
 
648
- PARALLEL EXECUTION (v2.5.0)
671
+ PARALLEL EXECUTION
649
672
  parallel devices <url> Run same URL across multiple devices
650
673
  --devices <list> Comma-separated device names (default: all)
651
674
  --concurrency <n> Max parallel browsers (default: 3)
675
+ --browser, --no-headless The browser for every device
652
676
  parallel urls <urls> Run same task across multiple URLs
653
677
  --concurrency <n> Max parallel browsers (default: 3)
678
+ --device, --viewport, --browser, --no-headless The browser for every URL
654
679
  parallel perf <urls> Performance audit multiple URLs in parallel
655
680
  --concurrency <n> Max parallel browsers (default: 3)
681
+ --device, --viewport, --browser, --no-headless The browser for every URL
656
682
 
657
- NATURAL LANGUAGE (v3.0.0)
683
+ NATURAL LANGUAGE
658
684
  run "<command>" Execute natural language command
659
685
  Examples:
660
686
  cbrowser run "go to https://example.com"
661
687
  cbrowser run "click the login button"
662
688
  cbrowser run "type 'hello' in the search box"
663
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.
664
694
 
665
- VISUAL AI (v4.0.0)
695
+ VISUAL AI
666
696
  ai find "<intent>" Find element by semantic intent
667
697
  Examples:
668
698
  cbrowser ai find "the cheapest product"
@@ -670,23 +700,20 @@ VISUAL AI (v4.0.0)
670
700
  cbrowser ai find "search box"
671
701
  ai click "<intent>" Find and click element by intent
672
702
 
673
- BUG HUNTER (v4.0.0)
674
- hunt <url> Automatically find bugs on a page
675
- --max-pages <n> Max pages to crawl (default: 10)
676
- --timeout <ms> Timeout in ms (default: 60000)
677
-
678
- CROSS-BROWSER (v4.0.0)
703
+ BROWSER DIFF
679
704
  diff <url> Compare page across browsers
680
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)
681
708
 
682
- CHAOS ENGINEERING (v4.0.0)
709
+ CHAOS ENGINEERING
683
710
  chaos <url> Test app resilience
684
711
  --latency <ms> Add network latency
685
712
  --offline Simulate offline mode
686
713
  --block <patterns> Block URL patterns (comma-separated)
687
714
  --fail-api <pattern:status> Fail specific API calls
688
715
 
689
- SMART RETRY & ASSERTIONS (v5.0.0)
716
+ SMART RETRY & ASSERTIONS
690
717
  smart-click <selector> Click with auto-retry and alternative selectors
691
718
  --url <url> Navigate to URL first
692
719
  --max-retries <n> Maximum retry attempts (default: 3)
@@ -705,7 +732,7 @@ SMART RETRY & ASSERTIONS (v5.0.0)
705
732
  cbrowser assert "'Login' button exists"
706
733
  cbrowser assert "page has 3 items"
707
734
 
708
- SELF-HEALING SELECTORS (v5.0.0)
735
+ SELF-HEALING SELECTORS
709
736
  heal stats Show selector cache statistics
710
737
  heal list List all cached selector mappings
711
738
  --domain <domain> Filter by domain
@@ -714,18 +741,22 @@ SELF-HEALING SELECTORS (v5.0.0)
714
741
  heal test <selector> Test if a selector would be healed
715
742
  --url <url> Navigate to URL first
716
743
 
717
- AI TEST GENERATION (v5.0.0)
744
+ AI TEST GENERATION
718
745
  generate <url> Analyze page and generate test scenarios
719
746
  --format <format> Output: summary, cbrowser, playwright, all (default: summary)
720
747
  --output <file> Save to file instead of stdout
721
748
  analyze <url> Show page analysis without generating tests
749
+ --output <file> Save the analysis as JSON
722
750
 
723
- MCP SERVER (v5.0.0)
751
+ MCP SERVER
724
752
  mcp-server Start CBrowser as MCP server for Claude integration
725
753
  Use with Claude Desktop or other MCP-compatible clients
726
754
  mcp-remote Start remote HTTP MCP server for claude.ai connectors
727
755
  --port <port> Port to listen on (default: 3000)
728
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.
729
760
 
730
761
  UNINSTALL
731
762
  uninstall Remove CBrowser data: credentials, sessions, captures
@@ -740,47 +771,58 @@ CLAUDE SKILL INSTALLATION & SYNC
740
771
  Downloads latest skill files from GitHub
741
772
  Run after 'npm update -g cbrowser' to stay in sync
742
773
  Add to skill-index.json after first installation
743
- --stateful Use stateful session mode
744
774
 
745
- DAEMON MODE (v6.4.0)
775
+ DAEMON MODE
746
776
  daemon start Start background daemon (keeps browser running)
747
777
  --port <port> Daemon port (default: 9222)
748
- --timeout <min> Idle timeout in minutes (default: 30)
778
+ --device <name> Device the daemon's browser emulates
779
+ --viewport <WxH> Page size of the daemon's browser
780
+ --timeout <min> Idle timeout: minutes, or 90s / 45m / 2h (default: 30)
749
781
  daemon stop Stop the running daemon
750
- daemon status Check if daemon is running
782
+ daemon status Check if daemon is running (exits 1 when it is not)
751
783
  daemon run Run daemon in foreground (internal use)
752
- Note: When daemon is running, all commands automatically connect to it
753
- 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.
754
789
 
755
- API CONFIGURATION (v8.0.0)
790
+ API CONFIGURATION
756
791
  config set-api-key <key> Set Anthropic API key for cognitive journeys
757
792
  config show-api-key Show configured API key (masked)
758
793
  config remove-api-key Remove stored API key
759
794
  config set-model <model> Set Claude model (default: claude-sonnet-5)
760
795
 
761
- LIGHTPANDA (v18.20.0) - High-Performance Headless Browser (OPT-IN ONLY)
796
+ LIGHTPANDA - High-Performance Headless Browser (OPT-IN ONLY)
762
797
  lightpanda-status Check Lightpanda availability and configuration
763
798
  lightpanda-setup Show setup instructions with security warnings
764
799
  ⚠️ SECURITY NOTICE:
765
800
  • Lightpanda is BETA software with no security audit
766
801
  • Cloud mode exposes traffic to lightpanda.io servers
767
802
  • NEVER use for auth, credentials, or payments
768
- • Requires explicit --lightpanda flag (not automatic)
803
+ • Used only by agent-ready-audit, and only with --lightpanda
769
804
  Environment Variables:
770
805
  LIGHTPANDA_ENDPOINT WebSocket endpoint (e.g., ws://127.0.0.1:9222)
771
806
  LIGHTPANDA_TOKEN Cloud API token (⚠️ data visible to lightpanda.io)
772
807
  Examples:
773
808
  cbrowser lightpanda-status
774
- cbrowser screenshot https://example.com --lightpanda
775
809
  cbrowser agent-ready-audit https://example.com --lightpanda
776
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
+
777
819
  DIAGNOSTICS
778
820
  version, -v, --version Show version number
779
821
  status Show environment status and diagnostics
780
822
  Displays data directories, browsers, config, heal cache
781
823
  doctor Check environment health (Node, Playwright, API key)
782
824
 
783
- ACCESSIBILITY
825
+ OUTPUT MODES (accessibility)
784
826
  --no-color Disable all color output (also respects NO_COLOR env var)
785
827
  --plain Disable emoji, box-drawing, and unicode decorations
786
828
  --json-output Output structured JSON (for scripts and screen readers)
@@ -792,11 +834,16 @@ STORAGE & CLEANUP
792
834
  --older-than <days> Delete files older than N days (default: 7)
793
835
  --keep-screenshots <n> Keep at least N screenshots (default: 10)
794
836
  --keep-journeys <n> Keep at least N journeys (default: 5)
837
+ --keep-sessions <n> Keep at least N sessions (default: 3)
795
838
  reset Clear persistent browser state (cookies, storage)
796
839
 
797
840
  OPTIONS
798
841
  --browser <type> Browser: chromium, firefox, webkit (default: chromium)
799
- --device <name> Device preset: iphone-15, pixel-8, ipad-pro-12, etc.
842
+ --device <name> Device preset: iphone-16, pixel-9, ipad-pro-13, ... ('device list')
843
+ --viewport <WxH> Page size, e.g. 390x844; with --device it sets the size and
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
800
847
  --geo <location> Location preset or lat,lon coordinates
801
848
  --locale <locale> Browser locale (e.g., en-US, fr-FR)
802
849
  --timezone <tz> Timezone (e.g., America/New_York)
@@ -864,8 +911,27 @@ const COMMAND_ALIASES = {
864
911
  "chaos-test": "chaos",
865
912
  "generate-tests": "generate",
866
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",
867
920
  };
868
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
+ }
869
935
  function escapeForRegExp(value) {
870
936
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
871
937
  }
@@ -970,15 +1036,35 @@ function showCommandHelp(command) {
970
1036
  * against a second browser while the daemon holds the first. Anything listed
971
1037
  * here must either be daemon-routed or refuse to run.
972
1038
  */
1039
+ /** What `extract` can return (browser.extract). */
1040
+ const EXTRACT_KINDS = ["text", "links", "headings", "images", "forms"];
973
1041
  const DRIVES_THE_PAGE = [
974
1042
  "navigate", "click", "fill", "hover", "screenshot", "extract", "run",
975
1043
  "keyboard", "press", "type", "evaluate", "eval", "drag", "upload",
976
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",
977
1051
  ];
978
- const GLOBAL_FLAGS = [
979
- "url", "browser", "device", "headless", "persistent", "restore",
980
- "locale", "timezone", "geo", "verbose", "help", "no-color", "quiet", "json",
1052
+ /**
1053
+ * Flags the shared CBrowser construction consumes, for every command that uses
1054
+ * it. One list, spread into each command's set below: when this list was copied
1055
+ * into each set, --viewport (2026-10-21) reached some commands and was refused
1056
+ * by evaluate and keyboard.
1057
+ */
1058
+ const SHARED_BROWSER_FLAGS = [
1059
+ "url", "browser", "device", "viewport", "headless", "persistent", "restore",
1060
+ "locale", "timezone", "geo", "verbose",
981
1061
  ];
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"];
982
1068
  /**
983
1069
  * Declared options per command.
984
1070
  *
@@ -1009,25 +1095,19 @@ const COMMAND_FLAGS = {
1009
1095
  evaluate: ["file", "arg", "json", "raw", "wait-for", "timeout", "expect-truthy", "force"],
1010
1096
  eval: ["file", "arg", "json", "raw", "wait-for", "timeout", "expect-truthy", "force"],
1011
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"],
1012
1102
  "test-suite": [
1013
1103
  "capture", "capture-fps", "capture-format", "capture-out",
1014
- "api-key", "auto-apply", "comprehensive", "concurrency", "continue-on-failure",
1015
- "delay", "disabilities", "dry-run", "exclude", "fuzzy-match", "goal",
1016
- "html", "include", "inline", "length", "lightpanda", "max-pages", "max-steps",
1017
- "max-time", "min-coverage", "name", "no-vision", "oauth-token", "output",
1018
- "persona", "retries", "runs", "screenshot-on-failure", "sensitivity",
1019
- "sitemap", "sites", "start", "step-through", "tests", "threshold",
1020
- "threshold-cls", "threshold-fcp", "threshold-lcp", "threshold-tbt",
1021
- "threshold-ttfb", "threshold-tti", "timeout", "trait", "urls", "value",
1022
- "verify", "wcag-level", "geolocation", "geo-region",
1104
+ "continue-on-failure", "dry-run", "fuzzy-match", "html", "inline", "output",
1105
+ "screenshot-on-failure", "step-through", "timeout",
1023
1106
  ],
1024
1107
  "cognitive-journey": [
1025
1108
  "capture", "capture-fps", "capture-format", "capture-out",
1026
- "api-key", "comprehensive", "concurrency", "disabilities", "goal", "html",
1027
- "length", "lightpanda", "max-steps", "max-time", "name", "no-vision",
1028
- "oauth-token", "output", "persona", "sites", "start", "timeout", "trait",
1029
- "urls", "value", "wcag-level", "geolocation", "geo-region", "vision",
1030
- "trace", "replay-trace",
1109
+ "goal", "persona", "start", "max-steps", "max-time", "no-vision", "output",
1110
+ "geolocation", "trace", "replay-trace",
1031
1111
  ],
1032
1112
  };
1033
1113
  /**
@@ -1044,11 +1124,29 @@ const UNVALIDATED_REASON = "not yet declared - see COMMAND_FLAGS in src/cli.ts";
1044
1124
  * Fails on the FIRST invalid flag with the command's full accepted set, so a
1045
1125
  * typo is a one-command fix rather than a guessing game.
1046
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
+ }
1047
1145
  function validateCommandFlags(command, options) {
1048
1146
  const declared = COMMAND_FLAGS[command];
1049
1147
  if (!declared)
1050
1148
  return; // UNVALIDATED_REASON
1051
- const allowed = new Set([...declared, ...GLOBAL_FLAGS]);
1149
+ const allowed = allowedFlags(command);
1052
1150
  const unknown = Object.keys(options).filter((flag) => !allowed.has(flag));
1053
1151
  if (unknown.length === 0)
1054
1152
  return;
@@ -1073,14 +1171,66 @@ function captureStatePath() {
1073
1171
  return join(getPaths().videosDir, "active.json");
1074
1172
  }
1075
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
+ }
1076
1226
  function parseDurationMs(input) {
1077
- 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());
1078
1228
  if (!match) {
1079
- 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`);
1080
1230
  }
1081
1231
  const value = parseFloat(match[1]);
1082
1232
  const unit = (match[2] || "ms").toLowerCase();
1083
- 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;
1084
1234
  if (ms <= 0)
1085
1235
  throw new Error(`Invalid duration "${input}" - must be greater than zero`);
1086
1236
  return Math.round(ms);
@@ -1099,9 +1249,13 @@ function parseFormats(input) {
1099
1249
  }
1100
1250
  /** Parse "1280x720". */
1101
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 };
1102
1255
  const match = /^(\d+)x(\d+)$/i.exec(input.trim());
1103
- if (!match)
1104
- 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
+ }
1105
1259
  const width = parseInt(match[1], 10);
1106
1260
  const height = parseInt(match[2], 10);
1107
1261
  if (width <= 0 || height <= 0) {
@@ -1139,36 +1293,23 @@ function parseRegion(input) {
1139
1293
  * confident "✓" for a capture the user did not ask for, which is worse than
1140
1294
  * an error. The globals are the ones the CLI itself consumes before dispatch.
1141
1295
  */
1142
- const CAPTURE_FLAGS = new Set([
1143
- // capture-specific
1144
- "fps", "duration", "format", "out", "name", "quality", "max-frames",
1145
- "viewport", "region", "element", "element-padding",
1146
- "contact-sheet", "attention-overlay", "overlay-persona", "overlay-goal",
1147
- "after", "after-element", "after-delay",
1148
- "until-element", "until-element-gone", "until-idle", "timeout",
1149
- // globals honoured by the shared CBrowser construction
1150
- "url", "browser", "device", "headless", "persistent", "restore",
1151
- "locale", "timezone", "geo", "verbose",
1152
- ]);
1296
+ const CAPTURE_FLAGS = allowedFlags("capture");
1153
1297
  /**
1154
1298
  * Resolve `--device` to a real preset key, or exit naming the bad value.
1155
1299
  *
1156
- * CBrowser silently ignores an unknown device name (browser.ts: the preset
1157
- * lookup is inside the `if`), which would hand back a 1280x800 desktop capture
1158
- * for `--device "iPhone 14"` and call it success. Spaces and case are
1159
- * normalised so "iPhone 15" finds "iphone-15".
1300
+ * `capture start` checks here, before it builds anything. Every other command
1301
+ * gets the same check from CBrowser.launch() (src/devices.ts resolveDevice);
1302
+ * before 2026-10-21 the browser skipped names it did not know and rendered
1303
+ * 1280x800 desktop. Case and spaces do not matter: "iPhone 15" is "iphone-15".
1160
1304
  */
1161
1305
  function resolveDevicePreset(value) {
1162
- const normalised = value.trim().toLowerCase().replace(/\s+/g, "-");
1163
- if (DEVICE_PRESETS[normalised])
1164
- return normalised;
1165
- const known = Object.keys(DEVICE_PRESETS);
1166
- const stem = normalised.replace(/-?\d+.*$/, "");
1167
- const close = known.filter((k) => k.startsWith(stem) && stem.length > 0);
1306
+ const resolved = resolveDevice(value);
1307
+ if ("key" in resolved)
1308
+ return resolved.key;
1168
1309
  console.error(`Error: unknown --device "${value}"`);
1169
- if (close.length > 0)
1170
- console.error(` Did you mean: ${close.join(", ")}?`);
1171
- console.error(` Available presets: ${known.join(", ")}`);
1310
+ if (resolved.suggestions.length > 0)
1311
+ console.error(` Did you mean: ${resolved.suggestions.join(", ")}?`);
1312
+ console.error(` 'cbrowser device list' shows all ${Object.keys(DEVICE_PRESETS).length} presets; --viewport WIDTHxHEIGHT sets any size.`);
1172
1313
  process.exit(1);
1173
1314
  }
1174
1315
  /** Reject any flag `capture start` cannot honour, naming it. */
@@ -1505,12 +1646,19 @@ function failCaptureCommand(message) {
1505
1646
  * later `cbrowser click` or `cbrowser fill` appear in the recording.
1506
1647
  */
1507
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
+ }
1508
1657
  const target = buildCaptureTarget(options);
1509
1658
  const triggers = buildCaptureTriggers(options);
1510
1659
  const slug = slugFor(options);
1511
1660
  const response = await sendToDaemon("captureStart", {
1512
1661
  url,
1513
- engine: options.browser === "firefox" ? "firefox" : options.browser === "webkit" ? "webkit" : "chromium",
1514
1662
  options: {
1515
1663
  fps: options.fps !== undefined ? Number(options.fps) : undefined,
1516
1664
  quality: options.quality !== undefined ? Number(options.quality) : undefined,
@@ -1520,6 +1668,16 @@ async function runCaptureStartViaDaemon(url, options) {
1520
1668
  contactSheet: options["contact-sheet"] === true,
1521
1669
  slug,
1522
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
+ : {}),
1523
1681
  ...triggers,
1524
1682
  },
1525
1683
  });
@@ -1750,11 +1908,7 @@ function reportAutoCapture(capture) {
1750
1908
  // evaluate — run JavaScript in the page
1751
1909
  // =========================================================================
1752
1910
  /** Flags `evaluate` understands; anything else is rejected rather than ignored. */
1753
- const EVALUATE_FLAGS = new Set([
1754
- "file", "arg", "json", "raw", "wait-for", "timeout", "expect-truthy", "force",
1755
- "url", "browser", "device", "headless", "persistent", "restore",
1756
- "locale", "timezone", "geo", "verbose",
1757
- ]);
1911
+ const EVALUATE_FLAGS = allowedFlags("evaluate");
1758
1912
  /**
1759
1913
  * `--force` is a bare flag, but the parser gives any flag the next non-flag
1760
1914
  * word as its value, so `evaluate --force "document.title"` reads the SCRIPT as
@@ -1787,61 +1941,14 @@ function repeatedFlag(name) {
1787
1941
  return values;
1788
1942
  }
1789
1943
  /**
1790
- * Evaluate a script in the page, accepting all three shapes people write.
1791
- *
1792
- * `--file` exists because multi-line scripts die in shell quoting, so it has to
1793
- * accept real scripts - `const`, `let`, an explicit `return` - not only a
1794
- * single expression. The source is compiled inside the page as a function
1795
- * body: a bare expression gets an implicit return, a statement body keeps its
1796
- * own, and a function/arrow literal is called with the arguments.
1797
- */
1798
- async function evaluateScript(page, script, args) {
1799
- const source = script.trim();
1800
- // A function or arrow literal is called with the arguments directly.
1801
- const isCallable = /^(async\s+)?function\b/.test(source) || /=>/.test(source);
1802
- return page.evaluate((payload) => {
1803
- if (payload.callable) {
1804
- const fn = (0, eval)(`(${payload.src})`);
1805
- return fn(...payload.a);
1806
- }
1807
- const compile = (src) => {
1808
- // 1. A bare expression: implicit return.
1809
- try {
1810
- return new Function("args", `return (${src});`);
1811
- }
1812
- catch { /* not an expression */ }
1813
- // 2. Statements with no explicit return: return the final expression,
1814
- // which is what a REPL and the old one-arg form both did.
1815
- if (!/\breturn\b/.test(src)) {
1816
- const trimmed = src.replace(/;\s*$/, "");
1817
- const split = Math.max(trimmed.lastIndexOf(";"), trimmed.lastIndexOf("\n"));
1818
- if (split > -1) {
1819
- const head = trimmed.slice(0, split + 1);
1820
- const tail = trimmed.slice(split + 1).trim();
1821
- if (tail) {
1822
- try {
1823
- return new Function("args", `${head}\nreturn (${tail});`);
1824
- }
1825
- catch { /* the tail is not an expression either */ }
1826
- }
1827
- }
1828
- }
1829
- // 3. Statements as written. Any syntax error surfaces from here, which
1830
- // is the error the user should actually see.
1831
- return new Function("args", src);
1832
- };
1833
- return compile(payload.src)(payload.a);
1834
- }, { src: source, a: args, callable: isCallable });
1835
- }
1836
- /**
1837
- * Run JavaScript in the page.
1838
- *
1839
- * The bare `evaluate "<js>"` form is unchanged. Everything else is additive:
1840
- * a script from a file (shell quoting mangles multi-line scripts), JSON
1841
- * arguments (so a script is reusable instead of string-interpolated), an
1842
- * 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.
1843
1950
  */
1844
- async function runEvaluate(browser, args, options) {
1951
+ async function prepareEvaluate(args, options) {
1845
1952
  const unknown = Object.keys(options).filter((f) => !EVALUATE_FLAGS.has(f));
1846
1953
  if (unknown.length > 0) {
1847
1954
  console.error(`Error: 'evaluate' does not support ${unknown.map((f) => `--${f}`).join(", ")}`);
@@ -1910,45 +2017,53 @@ async function runEvaluate(browser, args, options) {
1910
2017
  }
1911
2018
  // Said on stderr so stdout stays exactly the result (--raw / --json pipes).
1912
2019
  console.error("⚠ Red zone: running caller-supplied page script (--force)");
1913
- if (options.url)
1914
- await browser.navigate(options.url);
1915
- const page = await browser.getPage();
1916
- if (typeof options["wait-for"] === "string") {
1917
- // Bounded by the caller, not by a constant.
1918
- //
1919
- // This was a hardcoded 30s, which is two defects wearing one number. For a
1920
- // user it meant no way to fail fast on a page that renders in 200ms and no
1921
- // way to wait longer on one that does not. For the suite it meant the test
1922
- // asserting that a never-matching selector fails loudly spent 30 of its 60
1923
- // second budget deliberately waiting, so browser launch plus teardown had
1924
- // to fit in the rest -- which held when the test ran alone and did not when
1925
- // the full suite ran browsers in parallel. The test read as flaky and was
1926
- // not: it was structurally tight against a constant it could not reach.
1927
- // (2026-08-02)
1928
- // Falls back to the configured default rather than a fresh literal.
1929
- // `CBROWSER_TIMEOUT` is documented in --help as "Default timeout in ms" and
1930
- // config.ts already resolves it; a second hardcoded 30_000 here would mean
1931
- // 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) {
1932
2023
  const { getDefaultConfig } = await import("./config.js");
1933
2024
  const configuredMs = getDefaultConfig().timeout ?? 30_000;
1934
- const waitMs = typeof options.timeout === "string" ? Number(options.timeout)
1935
- : typeof options.timeout === "number" ? options.timeout
1936
- : configuredMs;
2025
+ waitMs = typeof options.timeout === "number" ? options.timeout : timeoutOption(options.timeout, configuredMs);
1937
2026
  if (!Number.isFinite(waitMs) || waitMs <= 0) {
1938
2027
  console.error(`Error: --timeout must be a positive number of milliseconds, got: ${options.timeout}`);
1939
2028
  process.exit(1);
1940
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) {
1941
2056
  try {
1942
- await page.waitForSelector(options["wait-for"], { timeout: waitMs });
2057
+ await page.waitForSelector(prep.waitFor, { timeout: prep.waitMs });
1943
2058
  }
1944
2059
  catch {
1945
- 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)`);
1946
2061
  process.exit(1);
1947
2062
  }
1948
2063
  }
1949
2064
  let result;
1950
2065
  try {
1951
- result = await evaluateScript(page, script, evalArgs);
2066
+ result = await evaluateScript(page, prep.script, prep.evalArgs);
1952
2067
  }
1953
2068
  catch (e) {
1954
2069
  const error = e;
@@ -1958,20 +2073,7 @@ async function runEvaluate(browser, args, options) {
1958
2073
  console.error(error.stack.split("\n").slice(1).join("\n"));
1959
2074
  process.exit(1);
1960
2075
  }
1961
- if (options.raw === true) {
1962
- console.log(String(result));
1963
- }
1964
- else if (options.json === true) {
1965
- console.log(JSON.stringify(result, null, 2));
1966
- }
1967
- else {
1968
- // Legacy default, kept so the existing one-arg form behaves identically.
1969
- console.log(typeof result === "string" ? result : JSON.stringify(result, null, 2));
1970
- }
1971
- if (options["expect-truthy"] === true && !result) {
1972
- console.error(`✗ Expected a truthy result, got ${JSON.stringify(result)}`);
1973
- process.exit(1);
1974
- }
2076
+ printEvaluateResult(result, options);
1975
2077
  }
1976
2078
  // =========================================================================
1977
2079
  // keyboard — sequences, chords and held modifiers
@@ -2069,12 +2171,26 @@ function parseKeyboardToken(token, literal = false) {
2069
2171
  }
2070
2172
  return { kind: "text", text: token };
2071
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
+ }
2072
2192
  /** Flags `keyboard` understands. */
2073
- const KEYBOARD_FLAGS = new Set([
2074
- "delay", "selector", "hold", "repeat", "text", "force",
2075
- "url", "browser", "device", "headless", "persistent", "restore",
2076
- "locale", "timezone", "geo", "verbose",
2077
- ]);
2193
+ const KEYBOARD_FLAGS = allowedFlags("keyboard");
2078
2194
  /**
2079
2195
  * Drive a keyboard sequence.
2080
2196
  *
@@ -2094,16 +2210,7 @@ async function runKeyboard(browser, args, options) {
2094
2210
  console.error(" Run 'cbrowser help keyboard' for options");
2095
2211
  process.exit(1);
2096
2212
  }
2097
- const delay = options.delay !== undefined ? Number(options.delay) : 50;
2098
- if (!Number.isFinite(delay) || delay < 0) {
2099
- console.error(`Error: --delay must be a non-negative number (got "${String(options.delay)}")`);
2100
- process.exit(1);
2101
- }
2102
- const repeat = options.repeat !== undefined ? Number(options.repeat) : 1;
2103
- if (!Number.isInteger(repeat) || repeat < 1) {
2104
- console.error(`Error: --repeat must be a positive integer (got "${String(options.repeat)}")`);
2105
- process.exit(1);
2106
- }
2213
+ const { delay, repeat } = parseKeyboardNumbers(options);
2107
2214
  const hold = typeof options.hold === "string"
2108
2215
  ? options.hold.split("+").map((m) => m.trim()).filter(Boolean)
2109
2216
  : [];
@@ -2613,8 +2720,29 @@ function parseGeoLocation(location) {
2613
2720
  }
2614
2721
  return null;
2615
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
+ }
2616
2744
  async function main() {
2617
- const { command, args, options } = parseArgs(process.argv.slice(2));
2745
+ const { command, args, options } = rewriteScroll(parseArgs(process.argv.slice(2)));
2618
2746
  // Initialize accessible output system (v18.54.0)
2619
2747
  const outputOpts = detectOutputOptions(options);
2620
2748
  setOutputOptions(outputOpts);
@@ -2656,6 +2784,35 @@ async function main() {
2656
2784
  // path unreachable from a shell no matter what the checks found.
2657
2785
  process.exit(process.exitCode ?? 0);
2658
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
+ }
2659
2816
  // MCP Server mode - runs before browser instantiation
2660
2817
  if (command === "mcp-server") {
2661
2818
  console.error("🔌 Starting CBrowser MCP Server...");
@@ -2799,17 +2956,10 @@ async function main() {
2799
2956
  console.log("Creating ~/.claude/skills directory...");
2800
2957
  fs.mkdirSync(skillsDir, { recursive: true });
2801
2958
  }
2802
- // Check if skill exists
2803
- if (fs.existsSync(skillDir)) {
2804
- console.log("CBrowser skill already exists. Updating...");
2805
- fs.rmSync(skillDir, { recursive: true });
2806
- }
2807
- // Create directories
2808
- console.log("Creating skill directory structure...");
2809
- fs.mkdirSync(path.join(skillDir, "Workflows"), { recursive: true });
2810
- fs.mkdirSync(path.join(skillDir, "Tools"), { recursive: true });
2811
- fs.mkdirSync(path.join(skillDir, ".memory", "sessions"), { recursive: true });
2812
- // 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).
2813
2963
  console.log("Downloading skill files...");
2814
2964
  const files = [
2815
2965
  "SKILL.md",
@@ -2826,22 +2976,35 @@ async function main() {
2826
2976
  "Workflows/Journey.md",
2827
2977
  "Tools/CBrowser.ts",
2828
2978
  ];
2979
+ const downloaded = new Map();
2980
+ const failed = [];
2829
2981
  for (const file of files) {
2830
2982
  try {
2831
2983
  const url = `${repoUrl}/skill/${file}`;
2832
- const dest = path.join(skillDir, file);
2833
2984
  console.log(` - ${file}`);
2834
- const response = await fetch(url);
2985
+ const response = await fetch(url, { signal: AbortSignal.timeout(30_000) });
2835
2986
  if (!response.ok) {
2836
2987
  throw new Error(`HTTP ${response.status}`);
2837
2988
  }
2838
- const content = await response.text();
2839
- fs.writeFileSync(dest, content, "utf-8");
2989
+ downloaded.set(file, await response.text());
2840
2990
  }
2841
2991
  catch (e) {
2842
- console.log(` Warning: Could not download ${file} (${e.message})`);
2992
+ failed.push(`${file} (${e.message})`);
2843
2993
  }
2844
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
+ }
2845
3008
  console.log(`
2846
3009
  ╔═══════════════════════════════════════════════════════════════╗
2847
3010
  ║ CBrowser Skill Installed Successfully! ║
@@ -2868,9 +3031,69 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
2868
3031
  const port = parseInt(options.port) || 9222;
2869
3032
  switch (subCommand) {
2870
3033
  case "start": {
3034
+ // The daemon's browser takes its device and size from here. Both are
3035
+ // checked now: a bad value inside the detached daemon would only show
3036
+ // up as a daemon that failed to start.
3037
+ const launchArgs = [];
3038
+ if (typeof options.device === "string")
3039
+ launchArgs.push("--device", resolveDevicePreset(options.device));
3040
+ if (typeof options.viewport === "string") {
3041
+ try {
3042
+ parseViewportList(options.viewport);
3043
+ }
3044
+ catch (e) {
3045
+ console.error(`Error: ${e.message}`);
3046
+ process.exit(1);
3047
+ }
3048
+ launchArgs.push("--viewport", options.viewport);
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
+ }
2871
3091
  console.log("🚀 Starting CBrowser daemon...");
2872
- const result = await startDaemon(port);
3092
+ const result = await startDaemon(port, launchArgs);
2873
3093
  console.log(result.success ? `✓ ${result.message}` : `✗ ${result.message}`);
3094
+ if (result.alreadyRunning && launchArgs.length > 0) {
3095
+ console.error("⚠ The browser flags were not applied: the daemon was already running. Run cbrowser daemon stop first.");
3096
+ }
2874
3097
  process.exit(result.success ? 0 : 1);
2875
3098
  break;
2876
3099
  }
@@ -2878,13 +3101,14 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
2878
3101
  console.log("🛑 Stopping CBrowser daemon...");
2879
3102
  const result = await stopDaemon();
2880
3103
  console.log(result.success ? `✓ ${result.message}` : `✗ ${result.message}`);
2881
- process.exit(0);
3104
+ process.exit(result.success ? 0 : 1);
2882
3105
  break;
2883
3106
  }
2884
3107
  case "status": {
2885
3108
  const status = await getDaemonStatus();
2886
3109
  console.log(status);
2887
- process.exit(0);
3110
+ // Non-zero when nothing is running, so `cbrowser daemon status && ...` works.
3111
+ process.exit((await isDaemonRunning()) ? 0 : 1);
2888
3112
  break;
2889
3113
  }
2890
3114
  case "run": {
@@ -2894,10 +3118,16 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
2894
3118
  : options.browser === "webkit" ? "webkit"
2895
3119
  : "chromium";
2896
3120
  // runDaemonServer will merge with defaults internally
3121
+ const runViewport = typeof options.viewport === "string" ? parseViewportList(options.viewport)[0] : undefined;
2897
3122
  await runDaemonServer({
2898
3123
  browser: browserType,
2899
3124
  headless: options.headless !== false && options.headless !== "false",
2900
- }, port);
3125
+ ...(typeof options.device === "string" ? { device: options.device } : {}),
3126
+ ...(runViewport ? { viewportWidth: runViewport.width, viewportHeight: runViewport.height, viewportOverride: runViewport } : {}),
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);
2901
3131
  return;
2902
3132
  }
2903
3133
  default:
@@ -2918,21 +3148,35 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
2918
3148
  "keyboard", "press", "type", "evaluate", "eval",
2919
3149
  ];
2920
3150
  if (daemonRunning && INTERACTION_COMMANDS.includes(command)) {
2921
- console.log("🔌 Connected to running daemon");
2922
- const daemonCommand = command;
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");
3161
+ }
3162
+ // The daemon knows "evaluate"; "eval" reached it unchanged and failed.
3163
+ const daemonCommand = command === "eval" ? "evaluate" : command;
2923
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 } : {};
2924
3168
  switch (command) {
2925
3169
  case "navigate":
2926
3170
  daemonArgs = { url: args[0] || options.url };
2927
3171
  break;
2928
3172
  case "click":
2929
- daemonArgs = { selector: args[0], force: options.force === true };
3173
+ daemonArgs = { selector: args[0], force: options.force === true, ...urlArg };
2930
3174
  break;
2931
3175
  case "hover":
2932
- daemonArgs = { selector: args[0] };
3176
+ daemonArgs = { selector: args[0], ...urlArg };
2933
3177
  break;
2934
3178
  case "fill":
2935
- daemonArgs = { selector: args[0], value: args[1] };
3179
+ daemonArgs = { selector: args[0], value: args[1], ...urlArg };
2936
3180
  break;
2937
3181
  case "keyboard": {
2938
3182
  validateCommandFlags("keyboard", options);
@@ -2954,42 +3198,45 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
2954
3198
  process.exit(1);
2955
3199
  }
2956
3200
  }
3201
+ const { delay, repeat } = parseKeyboardNumbers(options);
2957
3202
  daemonArgs = {
2958
3203
  steps,
2959
3204
  hold,
2960
- delay: options.delay !== undefined ? Number(options.delay) : 50,
2961
- repeat: options.repeat !== undefined ? Number(options.repeat) : 1,
3205
+ delay,
3206
+ repeat,
2962
3207
  selector: typeof options.selector === "string" ? options.selector : undefined,
2963
3208
  force: options.force === true,
3209
+ ...urlArg,
2964
3210
  };
2965
3211
  break;
2966
3212
  }
2967
3213
  case "press":
2968
- daemonArgs = { key: args[0], force: options.force === true };
3214
+ daemonArgs = { key: args[0], force: options.force === true, ...urlArg };
2969
3215
  break;
2970
- case "type":
2971
- 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 };
2972
3223
  break;
3224
+ }
2973
3225
  case "eval":
2974
3226
  case "evaluate": {
2975
3227
  validateCommandFlags(command, options);
2976
- rejectForceValue(options);
2977
- const script = typeof options.file === "string"
2978
- ? readFileSync(resolve(options.file), "utf-8")
2979
- : args[0];
2980
- if (!script) {
2981
- console.error('Usage: cbrowser evaluate "<javascript>" | --file <path>');
2982
- process.exit(1);
2983
- }
2984
- // Refused here, before the request leaves, and again daemon-side
2985
- // (anything on this machine can POST to the daemon).
2986
- const refusal = evaluateScriptRefusal(options.force === true);
2987
- if (refusal) {
2988
- console.error(`✗ ${refusal}`);
2989
- console.error(" Re-run with --force to run it anyway.");
2990
- process.exit(1);
2991
- }
2992
- daemonArgs = { body: `return (function(){ ${/\breturn\b/.test(script) ? script : `return (${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
+ };
2993
3240
  break;
2994
3241
  }
2995
3242
  case "screenshot": {
@@ -3005,7 +3252,9 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
3005
3252
  ssPath = args[0];
3006
3253
  }
3007
3254
  }
3008
- 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 };
3009
3258
  break;
3010
3259
  }
3011
3260
  case "extract": {
@@ -3020,17 +3269,30 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
3020
3269
  extractWhat = args[0];
3021
3270
  }
3022
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
+ }
3023
3276
  daemonArgs = { what: extractWhat, url: extractUrl || options.url };
3024
3277
  break;
3025
3278
  }
3026
3279
  case "run":
3027
- daemonArgs = { command: args.join(" ") };
3280
+ daemonArgs = { command: args.join(" "), ...urlArg };
3028
3281
  break;
3029
3282
  }
3030
3283
  const result = await sendToDaemon(daemonCommand, daemonArgs);
3031
3284
  if (result.success) {
3032
- console.log("✓ Command executed via daemon");
3033
- if (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) {
3034
3296
  console.log(JSON.stringify(result.result, null, 2));
3035
3297
  }
3036
3298
  }
@@ -3092,15 +3354,17 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
3092
3354
  process.exit(1);
3093
3355
  }
3094
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).
3095
3359
  const browser = new CBrowser({
3096
- browser: browserType,
3097
- headless,
3360
+ ...(options.browser !== undefined ? { browser: browserType } : {}),
3361
+ ...(options.headless !== undefined ? { headless } : {}),
3098
3362
  device: options.device,
3099
- ...(viewportOverride ? { viewportWidth: viewportOverride.width, viewportHeight: viewportOverride.height } : {}),
3363
+ ...(viewportOverride ? { viewportWidth: viewportOverride.width, viewportHeight: viewportOverride.height, viewportOverride } : {}),
3100
3364
  geolocation,
3101
3365
  locale: options.locale,
3102
3366
  timezone: options.timezone,
3103
- recordVideo: options["record-video"] === true,
3367
+ ...(options["record-video"] !== undefined ? { recordVideo: options["record-video"] === true } : {}),
3104
3368
  persistent: persistentMode,
3105
3369
  skipSessionRestore: skipRestore,
3106
3370
  verbose: true,
@@ -3114,7 +3378,14 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
3114
3378
  process.exit(1);
3115
3379
  }
3116
3380
  const result = await browser.navigate(url);
3117
- 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}`);
3118
3389
  console.log(` Title: ${result.title}`);
3119
3390
  console.log(` Load time: ${result.loadTime}ms`);
3120
3391
  console.log(` Screenshot: ${result.screenshot}`);
@@ -3185,7 +3456,13 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
3185
3456
  if (options.url)
3186
3457
  await browser.navigate(options.url);
3187
3458
  const page = await browser.getPage();
3188
- 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
+ }
3189
3466
  const refusal = await browser.keystrokeRedZone({ text }, options.force === true);
3190
3467
  if (refusal) {
3191
3468
  console.error(`✗ ${refusal}`);
@@ -3305,8 +3582,15 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
3305
3582
  if (url) {
3306
3583
  await browser.navigate(url);
3307
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
+ }
3308
3592
  const result = await browser.extract(what);
3309
- console.log(JSON.stringify(result.data, null, 2));
3593
+ printData("extract", result.data);
3310
3594
  break;
3311
3595
  }
3312
3596
  // =========================================================================
@@ -3344,7 +3628,7 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
3344
3628
  else {
3345
3629
  console.error(`\n✗ ${result.message}`);
3346
3630
  if (result.aiSuggestion) {
3347
- console.log(`\n💡 Suggestion:\n${result.aiSuggestion}`);
3631
+ console.error(`\n💡 Suggestion:\n${result.aiSuggestion}`);
3348
3632
  }
3349
3633
  process.exit(1);
3350
3634
  }
@@ -3511,6 +3795,11 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
3511
3795
  console.log(`\n🧪 Analyzing page and generating tests...\n`);
3512
3796
  const result = await browser.generateTests(url);
3513
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
+ }
3514
3803
  const outputFile = options.output;
3515
3804
  let output = "";
3516
3805
  if (format === "summary" || format === "all") {
@@ -3562,6 +3851,17 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
3562
3851
  }
3563
3852
  await browser.navigate(url);
3564
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
+ }
3565
3865
  console.log(`\n📊 Page Analysis: ${analysis.url}\n`);
3566
3866
  console.log(`Title: ${analysis.title}`);
3567
3867
  console.log(`\n📝 Forms (${analysis.forms.length}):`);
@@ -3613,20 +3913,28 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
3613
3913
  console.warn("⚠️ 'journey' is deprecated. Use 'explore' instead.\n");
3614
3914
  }
3615
3915
  const persona = args[0] || "first-timer";
3616
- const startUrl = options.start;
3916
+ // --url works too (audit F25: four spellings of "start URL").
3917
+ const startUrl = (options.start ?? options.url);
3617
3918
  const goal = options.goal || "Explore the site";
3618
3919
  if (!startUrl) {
3619
3920
  console.error("Error: --start URL required");
3620
3921
  process.exit(1);
3621
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
+ }
3622
3929
  console.log(`🔍 Starting exploration as "${persona}"...`);
3623
3930
  console.log(` Goal: ${goal}`);
3624
- console.log(` Mode: Heuristic (free, no API)`);
3625
3931
  console.log("");
3626
3932
  const result = await browser.journey({ persona, startUrl, goal });
3627
3933
  console.log("");
3628
3934
  console.log(`📊 Journey Results`);
3629
3935
  console.log(` Success: ${result.success ? "✓" : "✗"}`);
3936
+ if (!result.success)
3937
+ process.exitCode = 1;
3630
3938
  console.log(` Steps: ${result.steps.length}`);
3631
3939
  console.log(` Time: ${result.totalTime}ms`);
3632
3940
  console.log(` Friction points: ${result.frictionPoints.length}`);
@@ -3661,12 +3969,18 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
3661
3969
  const concurrency = options.concurrency
3662
3970
  ? parseInt(options.concurrency)
3663
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
+ });
3664
3976
  const comparison = await comparePersonas({
3665
3977
  startUrl,
3666
3978
  goal,
3667
3979
  personas: personaNames,
3668
3980
  maxConcurrency: concurrency,
3669
3981
  headless,
3982
+ device: personaFlags.device,
3983
+ viewport: personaFlags.viewport,
3670
3984
  });
3671
3985
  // Print formatted report
3672
3986
  const report = formatComparisonReport(comparison);
@@ -3681,7 +3995,7 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
3681
3995
  if (options.html) {
3682
3996
  const fs = await import("fs");
3683
3997
  const htmlReport = generateHtmlReport(comparison);
3684
- const htmlPath = options.output?.replace(".json", ".html") || "comparison-report.html";
3998
+ const htmlPath = htmlReportPath(options.output, "comparison-report.html");
3685
3999
  fs.writeFileSync(htmlPath, htmlReport);
3686
4000
  console.log(`\n🌐 HTML report saved: ${htmlPath}`);
3687
4001
  }
@@ -3780,7 +4094,7 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
3780
4094
  // Save the persona
3781
4095
  const filepath = saveCustomPersona(persona);
3782
4096
  console.log(`\n✓ Persona saved: ${filepath}`);
3783
- 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>"`);
3784
4098
  break;
3785
4099
  }
3786
4100
  case "show": {
@@ -3923,6 +4237,7 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
3923
4237
  console.error(" cbrowser persona delete my-custom-persona");
3924
4238
  console.error(" cbrowser persona cleanup --dry-run # Preview invalid personas");
3925
4239
  console.error(" cbrowser persona cleanup # Remove invalid personas");
4240
+ process.exit(1);
3926
4241
  }
3927
4242
  break;
3928
4243
  }
@@ -4103,6 +4418,7 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
4103
4418
  }
4104
4419
  else {
4105
4420
  console.error(`✗ Session not found: ${name}`);
4421
+ process.exit(1);
4106
4422
  }
4107
4423
  break;
4108
4424
  }
@@ -4116,6 +4432,7 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
4116
4432
  console.error(" cleanup --older-than <days> Delete old sessions");
4117
4433
  console.error(" export <name> --output <file> Export session to file");
4118
4434
  console.error(" import <file> --name <name> Import session from file");
4435
+ process.exit(1);
4119
4436
  }
4120
4437
  break;
4121
4438
  }
@@ -4231,6 +4548,7 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
4231
4548
  }
4232
4549
  default:
4233
4550
  console.error("Usage: cbrowser cookie [list|set|delete|clear]");
4551
+ process.exit(1);
4234
4552
  }
4235
4553
  break;
4236
4554
  }
@@ -4241,14 +4559,18 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
4241
4559
  const subcommand = args[0];
4242
4560
  switch (subcommand) {
4243
4561
  case "list": {
4244
- console.log("\n📱 Available Device Presets:\n");
4245
- for (const [name, device] of Object.entries(DEVICE_PRESETS)) {
4246
- console.log(` ${name}`);
4247
- console.log(` ${device.name}`);
4248
- console.log(` ${device.viewport.width}x${device.viewport.height} @${device.deviceScaleFactor}x`);
4249
- console.log(` Mobile: ${device.isMobile} | Touch: ${device.hasTouch}`);
4250
- console.log("");
4251
- }
4562
+ // One line per preset: there are a few hundred now. An optional
4563
+ // filter matches the key or the display name.
4564
+ const filter = (args[1] ?? "").toLowerCase();
4565
+ const rows = Object.entries(DEVICE_PRESETS).filter(([key, d]) => !filter || key.includes(filter) || d.name.toLowerCase().includes(filter));
4566
+ const width = Math.max(...rows.map(([key]) => key.length), 4);
4567
+ console.log(`\n📱 Device presets${filter ? ` matching "${filter}"` : ""}: ${rows.length}\n`);
4568
+ for (const [key, d] of rows) {
4569
+ const kind = d.isMobile ? "mobile" : "desktop";
4570
+ const touch = d.hasTouch ? ", touch" : "";
4571
+ console.log(` ${key.padEnd(width)} ${d.viewport.width}x${d.viewport.height} @${d.deviceScaleFactor}x ${kind}${touch} (${d.name})`);
4572
+ }
4573
+ console.log("\n Case and spaces do not matter: --device \"iPhone 16\" is iphone-16.");
4252
4574
  break;
4253
4575
  }
4254
4576
  case "set": {
@@ -4257,20 +4579,17 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
4257
4579
  console.error("Usage: cbrowser device set <name>");
4258
4580
  process.exit(1);
4259
4581
  }
4260
- if (!DEVICE_PRESETS[deviceName]) {
4261
- console.error(`Unknown device: ${deviceName}`);
4262
- console.error("Run 'cbrowser device list' to see available devices");
4263
- process.exit(1);
4264
- }
4582
+ const presetKey = resolveDevicePreset(deviceName);
4265
4583
  // Save device setting to persistent session state
4266
4584
  const deviceBrowser = new CBrowser({ persistent: true, headless: true, verbose: false });
4267
- deviceBrowser.saveDeviceSetting(deviceName);
4268
- console.log(`✓ Device set: ${deviceName}`);
4585
+ deviceBrowser.saveDeviceSetting(presetKey);
4586
+ console.log(`✓ Device set: ${presetKey}`);
4269
4587
  console.log(" Device emulation will apply to all subsequent commands");
4270
4588
  break;
4271
4589
  }
4272
4590
  default:
4273
4591
  console.error("Usage: cbrowser device [list|set]");
4592
+ process.exit(1);
4274
4593
  }
4275
4594
  break;
4276
4595
  }
@@ -4301,12 +4620,18 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
4301
4620
  console.error(`Invalid location: ${location}`);
4302
4621
  process.exit(1);
4303
4622
  }
4304
- 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);
4305
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)");
4306
4630
  break;
4307
4631
  }
4308
4632
  default:
4309
4633
  console.error("Usage: cbrowser geo [list|set]");
4634
+ process.exit(1);
4310
4635
  }
4311
4636
  break;
4312
4637
  }
@@ -4335,6 +4660,8 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
4335
4660
  console.log("\n📊 Performance Audit:\n");
4336
4661
  console.log(` URL: ${result.url}`);
4337
4662
  console.log(` Result: ${result.passed ? "✓ PASSED" : "✗ FAILED"}`);
4663
+ if (!result.passed)
4664
+ process.exitCode = 1;
4338
4665
  console.log("");
4339
4666
  console.log(" Metrics:");
4340
4667
  if (result.metrics.lcp)
@@ -4396,7 +4723,7 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
4396
4723
  const subcommand = args[0];
4397
4724
  if (subcommand !== "start" && subcommand !== "stop") {
4398
4725
  console.error("Usage: cbrowser har [start|stop]");
4399
- break;
4726
+ process.exit(1);
4400
4727
  }
4401
4728
  // A recording has to span several commands, and each cbrowser command is
4402
4729
  // its own process: only the daemon's browser outlives them. Recorded here,
@@ -4481,7 +4808,7 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
4481
4808
  const subcommand = args[0];
4482
4809
  if (subcommand !== "list" && subcommand !== "clear") {
4483
4810
  console.error("Usage: cbrowser network [list|clear]");
4484
- break;
4811
+ process.exit(1);
4485
4812
  }
4486
4813
  // Same reason as har: the request log lives in a browser, and only the
4487
4814
  // daemon's browser outlives a command. A fresh process always listed
@@ -4509,19 +4836,32 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
4509
4836
  }
4510
4837
  switch (subcommand) {
4511
4838
  case "list": {
4512
- const requests = listed.result.requests;
4513
- if (requests.length === 0) {
4514
- console.log("No network requests captured");
4515
- 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");
4516
4855
  }
4517
4856
  else {
4518
- console.log(`\n🌐 Network Requests (${requests.length}):\n`);
4519
- for (const req of requests.slice(-20)) {
4520
- 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 ? "..." : ""}`);
4521
4861
  console.log(` Type: ${req.resourceType} | Time: ${req.timestamp}`);
4522
4862
  }
4523
- if (requests.length > 20) {
4524
- 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)`);
4525
4865
  }
4526
4866
  }
4527
4867
  break;
@@ -4558,13 +4898,15 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
4558
4898
  if (options.url) {
4559
4899
  await browser.navigate(options.url);
4560
4900
  }
4561
- const threshold = options.threshold ? parseFloat(options.threshold) : 0.1;
4901
+ const threshold = numberOption("threshold", options.threshold, 0.1, { min: 0, max: 1 });
4562
4902
  const result = await browser.compareBaseline(name, threshold);
4563
4903
  console.log("\n🔍 Visual Comparison:\n");
4564
4904
  console.log(` Baseline: ${name}`);
4565
4905
  console.log(` Difference: ${(result.diffPercentage * 100).toFixed(2)}%`);
4566
4906
  console.log(` Threshold: ${(threshold * 100).toFixed(0)}%`);
4567
4907
  console.log(` Result: ${result.passed ? "✓ PASSED" : "✗ FAILED"}`);
4908
+ if (!result.passed)
4909
+ process.exitCode = 1;
4568
4910
  if (result.diffPath) {
4569
4911
  console.log(` Diff image: ${result.diffPath}`);
4570
4912
  }
@@ -4608,6 +4950,7 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
4608
4950
  }
4609
4951
  default:
4610
4952
  console.error("Usage: cbrowser visual [save|compare|list|delete]");
4953
+ process.exit(1);
4611
4954
  }
4612
4955
  break;
4613
4956
  }
@@ -4616,6 +4959,12 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
4616
4959
  // =========================================================================
4617
4960
  case "ai-visual": {
4618
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
+ };
4619
4968
  switch (subcommand) {
4620
4969
  case "capture": {
4621
4970
  const url = args[1];
@@ -4623,20 +4972,18 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
4623
4972
  console.error("Usage: cbrowser ai-visual capture <url> --name <name> [options]");
4624
4973
  console.error(" --selector <sel> Capture specific element");
4625
4974
  console.error(" --device <device> Device emulation");
4626
- console.error(" --width <n> Viewport width");
4627
- 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");
4628
4978
  console.error(" --wait <ms|sel> Wait before capture");
4629
4979
  process.exit(1);
4630
4980
  }
4981
+ const flags = browserFlagsOrExit("ai-visual capture", options);
4631
4982
  console.log(`📸 Capturing visual baseline: ${options.name}`);
4632
4983
  console.log(` URL: ${url}`);
4633
4984
  const baseline = await captureVisualBaseline(url, options.name, {
4634
4985
  selector: options.selector,
4635
- device: options.device,
4636
- viewport: options.width || options.height ? {
4637
- width: parseInt(options.width) || 1920,
4638
- height: parseInt(options.height) || 1080,
4639
- } : undefined,
4986
+ ...flags,
4640
4987
  waitFor: options.wait ? (/^\d+$/.test(options.wait)
4641
4988
  ? parseInt(options.wait)
4642
4989
  : options.wait) : undefined,
@@ -4644,6 +4991,10 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
4644
4991
  console.log(`\n✅ Baseline captured successfully!`);
4645
4992
  console.log(` ID: ${baseline.id}`);
4646
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}`);
4647
4998
  console.log(` Screenshot: ${baseline.screenshotPath}`);
4648
4999
  break;
4649
5000
  }
@@ -4658,13 +5009,16 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
4658
5009
  console.error(" --output <file> Save JSON report");
4659
5010
  process.exit(1);
4660
5011
  }
5012
+ const flags = browserFlagsOrExit("ai-visual test", options);
5013
+ refuseBrowserFlags("ai-visual test", flags, BASELINE_SHAPE_FLAGS);
4661
5014
  console.log(`\n🔍 Running AI visual regression test...`);
4662
5015
  console.log(` URL: ${url}`);
4663
5016
  console.log(` Baseline: ${baselineName}\n`);
4664
5017
  const result = await runVisualRegression(url, baselineName, {
4665
- threshold: options.threshold ? parseFloat(options.threshold) : 0.9,
4666
- 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",
4667
5020
  generateDiff: true,
5021
+ headless: flags.headless,
4668
5022
  });
4669
5023
  // Print report
4670
5024
  console.log(formatVisualRegressionReport(result));
@@ -4716,10 +5070,13 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
4716
5070
  console.error(`Suite file not found: ${suiteFile}`);
4717
5071
  process.exit(1);
4718
5072
  }
5073
+ const flags = browserFlagsOrExit("ai-visual suite", options);
5074
+ refuseBrowserFlags("ai-visual suite", flags, BASELINE_SHAPE_FLAGS);
4719
5075
  const suite = JSON.parse(fs.readFileSync(suiteFile, "utf-8"));
4720
5076
  console.log(`\n🔍 Running visual regression suite: ${suite.name}`);
4721
5077
  const result = await runVisualRegressionSuite(suite, {
4722
- threshold: options.threshold ? parseFloat(options.threshold) : 0.9,
5078
+ threshold: numberOption("threshold", options.threshold, 0.9, { min: 0, max: 1 }),
5079
+ headless: flags.headless,
4723
5080
  });
4724
5081
  // Save outputs
4725
5082
  if (options.output && !options.html) {
@@ -4810,6 +5167,7 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
4810
5167
  console.error(" cbrowser ai-visual list");
4811
5168
  console.error(" cbrowser ai-visual show <name>");
4812
5169
  console.error(" cbrowser ai-visual delete <name>");
5170
+ process.exit(1);
4813
5171
  }
4814
5172
  break;
4815
5173
  }
@@ -4818,6 +5176,10 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
4818
5176
  // =========================================================================
4819
5177
  case "cross-browser": {
4820
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
+ });
4821
5183
  if (subcommand === "suite") {
4822
5184
  // Cross-browser suite
4823
5185
  const suiteFile = args[1];
@@ -4833,7 +5195,8 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
4833
5195
  process.exit(1);
4834
5196
  }
4835
5197
  const suite = JSON.parse(fs.readFileSync(suiteFile, "utf-8"));
4836
- 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 } });
4837
5200
  // Save outputs
4838
5201
  if (options.output && !options.html) {
4839
5202
  fs.writeFileSync(options.output, JSON.stringify(result, null, 2));
@@ -4861,8 +5224,9 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
4861
5224
  console.error(" cbrowser cross-browser suite <file.json>");
4862
5225
  console.error("\nOptions:");
4863
5226
  console.error(" --browsers <list> chromium,firefox,webkit (default: all)");
4864
- console.error(" --width <n> Viewport width (default: 1920)");
4865
- 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)");
4866
5230
  console.error(" --wait <ms> Wait before screenshot");
4867
5231
  console.error(" --wait-for <sel> Wait for selector");
4868
5232
  console.error(" --sensitivity <l> low, medium, high");
@@ -4875,13 +5239,10 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
4875
5239
  : undefined;
4876
5240
  const result = await runCrossBrowserTest(url, {
4877
5241
  browsers,
4878
- viewport: options.width || options.height ? {
4879
- width: parseInt(options.width) || 1920,
4880
- height: parseInt(options.height) || 1080,
4881
- } : undefined,
5242
+ ...flags,
4882
5243
  waitBeforeCapture: options.wait ? parseInt(options.wait) : undefined,
4883
5244
  waitForSelector: options["wait-for"],
4884
- sensitivity: options.sensitivity,
5245
+ sensitivity: enumOption("sensitivity", options.sensitivity, ["low", "medium", "high"]),
4885
5246
  });
4886
5247
  // Print report
4887
5248
  console.log("\n" + formatCrossBrowserReport(result));
@@ -4920,6 +5281,19 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
4920
5281
  // =========================================================================
4921
5282
  case "responsive": {
4922
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
+ };
4923
5297
  if (subcommand === "viewports") {
4924
5298
  // List viewport presets
4925
5299
  const presets = listViewportPresets();
@@ -4945,6 +5319,7 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
4945
5319
  }
4946
5320
  else if (subcommand === "suite") {
4947
5321
  // Responsive suite
5322
+ const flags = responsiveFlags();
4948
5323
  const suiteFile = args[1];
4949
5324
  if (!suiteFile) {
4950
5325
  console.error("Usage: cbrowser responsive suite <file.json> [options]");
@@ -4958,7 +5333,8 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
4958
5333
  process.exit(1);
4959
5334
  }
4960
5335
  const suite = JSON.parse(fs.readFileSync(suiteFile, "utf-8"));
4961
- 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 } });
4962
5338
  // Save outputs
4963
5339
  if (options.output && !options.html) {
4964
5340
  fs.writeFileSync(options.output, JSON.stringify(result, null, 2));
@@ -4993,19 +5369,20 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
4993
5369
  console.error(" --sensitivity <l> low, medium, high");
4994
5370
  console.error(" --html Generate HTML report");
4995
5371
  console.error(" --output <file> Save report");
4996
- console.error("\nViewport presets: mobile-sm, mobile, mobile-lg, mobile-xl,");
4997
- console.error(" tablet, tablet-lg, desktop-sm, desktop,");
4998
- console.error(" desktop-lg, desktop-xl");
5372
+ console.error("\nViewport presets:");
5373
+ console.error(formatViewportPresetList(" "));
4999
5374
  process.exit(1);
5000
5375
  }
5376
+ const flags = responsiveFlags();
5001
5377
  const viewports = options.viewports
5002
5378
  ? options.viewports.split(",")
5003
5379
  : undefined;
5004
5380
  const result = await runResponsiveTest(url, {
5005
5381
  viewports,
5382
+ ...flags,
5006
5383
  waitBeforeCapture: options.wait ? parseInt(options.wait) : undefined,
5007
5384
  waitForSelector: options["wait-for"],
5008
- sensitivity: options.sensitivity,
5385
+ sensitivity: enumOption("sensitivity", options.sensitivity, ["low", "medium", "high"]),
5009
5386
  });
5010
5387
  // Print report
5011
5388
  console.log("\n" + formatResponsiveReport(result));
@@ -5046,6 +5423,7 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
5046
5423
  // =========================================================================
5047
5424
  case "ab": {
5048
5425
  const subcommand = args[0];
5426
+ const flags = browserFlagsOrExit("ab", options);
5049
5427
  if (subcommand === "suite") {
5050
5428
  // A/B suite
5051
5429
  const suiteFile = args[1];
@@ -5061,7 +5439,8 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
5061
5439
  process.exit(1);
5062
5440
  }
5063
5441
  const suite = JSON.parse(fs.readFileSync(suiteFile, "utf-8"));
5064
- 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 } });
5065
5444
  // Save outputs
5066
5445
  if (options.output && !options.html) {
5067
5446
  fs.writeFileSync(options.output, JSON.stringify(result, null, 2));
@@ -5091,8 +5470,9 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
5091
5470
  console.error("\nOptions:");
5092
5471
  console.error(" --label-a <name> Label for URL A (default: 'Version A')");
5093
5472
  console.error(" --label-b <name> Label for URL B (default: 'Version B')");
5094
- console.error(" --width <n> Viewport width (default: 1920)");
5095
- 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");
5096
5476
  console.error(" --wait <ms> Wait before screenshot");
5097
5477
  console.error(" --wait-for <sel> Wait for selector");
5098
5478
  console.error(" --sensitivity <l> low, medium, high");
@@ -5105,13 +5485,10 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
5105
5485
  a: options["label-a"] || "Version A",
5106
5486
  b: options["label-b"] || "Version B",
5107
5487
  } : undefined,
5108
- viewport: options.width || options.height ? {
5109
- width: parseInt(options.width) || 1920,
5110
- height: parseInt(options.height) || 1080,
5111
- } : undefined,
5488
+ ...flags,
5112
5489
  waitBeforeCapture: options.wait ? parseInt(options.wait) : undefined,
5113
5490
  waitForSelector: options["wait-for"],
5114
- sensitivity: options.sensitivity,
5491
+ sensitivity: enumOption("sensitivity", options.sensitivity, ["low", "medium", "high"]),
5115
5492
  });
5116
5493
  // Print report
5117
5494
  console.log("\n" + formatABReport(result));
@@ -5177,6 +5554,7 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
5177
5554
  }
5178
5555
  else {
5179
5556
  console.error("Usage: cbrowser a11y audit [url]");
5557
+ process.exit(1);
5180
5558
  }
5181
5559
  break;
5182
5560
  }
@@ -5185,6 +5563,17 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
5185
5563
  // =========================================================================
5186
5564
  case "record": {
5187
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
+ }
5188
5577
  switch (subcommand) {
5189
5578
  case "start": {
5190
5579
  const url = options.url;
@@ -5261,6 +5650,7 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
5261
5650
  }
5262
5651
  default:
5263
5652
  console.error("Usage: cbrowser record [start|stop|save|list|generate]");
5653
+ process.exit(1);
5264
5654
  }
5265
5655
  break;
5266
5656
  }
@@ -5298,31 +5688,25 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
5298
5688
  console.error("Usage: cbrowser export [junit|tap] <name> [output]");
5299
5689
  process.exit(1);
5300
5690
  }
5301
- // Load test results (for now, create a mock suite)
5302
- const fs = await import("fs");
5303
- const path = await import("path");
5304
- const resultsPath = path.join(browser.getDataDir(), "results", `${name}.json`);
5305
- let suite;
5306
- if (fs.existsSync(resultsPath)) {
5307
- 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);
5308
5694
  }
5309
- else {
5695
+ // The last `test-suite` run of that suite (it saves every run).
5696
+ const suite = loadSuiteForExport(browser.getDataDir(), name);
5697
+ if (!suite) {
5310
5698
  console.error(`Test results not found: ${name}`);
5311
- 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")}`);
5312
5700
  process.exit(1);
5313
5701
  }
5314
5702
  if (format === "junit") {
5315
5703
  const exportPath = browser.exportJUnit(suite, output);
5316
5704
  console.log(`✓ JUnit XML exported: ${exportPath}`);
5317
5705
  }
5318
- else if (format === "tap") {
5706
+ else {
5319
5707
  const exportPath = browser.exportTAP(suite, output);
5320
5708
  console.log(`✓ TAP exported: ${exportPath}`);
5321
5709
  }
5322
- else {
5323
- console.error("Unknown export format. Use 'junit' or 'tap'");
5324
- process.exit(1);
5325
- }
5326
5710
  break;
5327
5711
  }
5328
5712
  // =========================================================================
@@ -5331,13 +5715,9 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
5331
5715
  case "webhook": {
5332
5716
  const subcommand = args[0];
5333
5717
  const fs = await import("fs");
5334
- const path = await import("path");
5335
- const webhooksPath = path.join(browser.getDataDir(), "webhooks.json");
5718
+ const hooksFile = webhooksPath(browser.getDataDir());
5336
5719
  // Load existing webhooks
5337
- let webhooks = [];
5338
- if (fs.existsSync(webhooksPath)) {
5339
- webhooks = JSON.parse(fs.readFileSync(webhooksPath, "utf-8"));
5340
- }
5720
+ let webhooks = loadWebhooks(browser.getDataDir());
5341
5721
  switch (subcommand) {
5342
5722
  case "add": {
5343
5723
  const name = args[1];
@@ -5346,14 +5726,30 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
5346
5726
  console.error("Usage: cbrowser webhook add <name> <url> [--events <events>] [--format <format>]");
5347
5727
  process.exit(1);
5348
5728
  }
5349
- const events = options.events
5350
- ? options.events.split(",")
5351
- : ["test.fail", "journey.complete"];
5352
- 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
+ }
5353
5749
  // Remove existing webhook with same name
5354
5750
  webhooks = webhooks.filter(w => w.name !== name);
5355
5751
  webhooks.push({ name, url, events, format });
5356
- fs.writeFileSync(webhooksPath, JSON.stringify(webhooks, null, 2));
5752
+ fs.writeFileSync(hooksFile, JSON.stringify(webhooks, null, 2));
5357
5753
  console.log(`✓ Webhook added: ${name}`);
5358
5754
  console.log(` URL: ${url}`);
5359
5755
  console.log(` Events: ${events.join(", ")}`);
@@ -5367,9 +5763,11 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
5367
5763
  else {
5368
5764
  console.log("\n🔔 Configured Webhooks:\n");
5369
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));
5370
5768
  console.log(` ${w.name}`);
5371
5769
  console.log(` URL: ${w.url}`);
5372
- console.log(` Events: ${w.events.join(", ")}`);
5770
+ console.log(` Events: ${w.events.join(", ")}${unsent.length > 0 ? ` (never sent: ${unsent.join(", ")})` : ""}`);
5373
5771
  console.log(` Format: ${w.format}`);
5374
5772
  console.log("");
5375
5773
  }
@@ -5385,7 +5783,7 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
5385
5783
  const originalLength = webhooks.length;
5386
5784
  webhooks = webhooks.filter(w => w.name !== name);
5387
5785
  if (webhooks.length < originalLength) {
5388
- fs.writeFileSync(webhooksPath, JSON.stringify(webhooks, null, 2));
5786
+ fs.writeFileSync(hooksFile, JSON.stringify(webhooks, null, 2));
5389
5787
  console.log(`✓ Webhook deleted: ${name}`);
5390
5788
  }
5391
5789
  else {
@@ -5405,6 +5803,11 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
5405
5803
  console.error(`✗ Webhook not found: ${name}`);
5406
5804
  process.exit(1);
5407
5805
  }
5806
+ const urlProblem = webhookUrlProblem(webhook.url);
5807
+ if (urlProblem) {
5808
+ console.error(`✗ ${urlProblem}`);
5809
+ process.exit(1);
5810
+ }
5408
5811
  // Send test notification
5409
5812
  const testPayload = webhook.format === "slack"
5410
5813
  ? { text: "🔔 CBrowser test notification" }
@@ -5416,6 +5819,8 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
5416
5819
  method: "POST",
5417
5820
  headers: { "Content-Type": "application/json" },
5418
5821
  body: JSON.stringify(testPayload),
5822
+ redirect: "error",
5823
+ signal: AbortSignal.timeout(WEBHOOK_TIMEOUT_MS),
5419
5824
  });
5420
5825
  if (response.ok) {
5421
5826
  console.log(`✓ Test notification sent to: ${name}`);
@@ -5433,6 +5838,7 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
5433
5838
  }
5434
5839
  default:
5435
5840
  console.error("Usage: cbrowser webhook [add|list|delete|test]");
5841
+ process.exit(1);
5436
5842
  }
5437
5843
  break;
5438
5844
  }
@@ -5441,6 +5847,7 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
5441
5847
  // =========================================================================
5442
5848
  case "parallel": {
5443
5849
  const subcommand = args[0];
5850
+ const flags = browserFlagsOrExit("parallel", options);
5444
5851
  switch (subcommand) {
5445
5852
  case "devices": {
5446
5853
  const url = args[1];
@@ -5448,9 +5855,14 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
5448
5855
  console.error("Usage: cbrowser parallel devices <url> [--devices <list>] [--concurrency <n>]");
5449
5856
  process.exit(1);
5450
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
+ });
5862
+ // Without --devices, cbrowser's own presets, not all few hundred.
5451
5863
  const deviceList = options.devices
5452
- ? options.devices.split(",")
5453
- : Object.keys(DEVICE_PRESETS);
5864
+ ? options.devices.split(",").map((d) => resolveDevicePreset(d))
5865
+ : [...CBROWSER_DEVICE_KEYS];
5454
5866
  const concurrency = options.concurrency ? parseInt(options.concurrency) : 3;
5455
5867
  console.log(`\n🚀 Running parallel device tests...`);
5456
5868
  console.log(` URL: ${url}`);
@@ -5460,11 +5872,12 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
5460
5872
  const nav = await b.navigate(url);
5461
5873
  const screenshot = await b.screenshot();
5462
5874
  return { title: nav.title, loadTime: nav.loadTime, screenshot };
5463
- }, { maxConcurrency: concurrency });
5875
+ }, { maxConcurrency: concurrency, config: browserLaunchConfig(flags) });
5464
5876
  console.log("📊 Results:\n");
5465
5877
  for (const r of results) {
5466
5878
  if (r.error) {
5467
5879
  console.log(` ✗ ${r.device}: ${r.error} (${r.duration}ms)`);
5880
+ process.exitCode = 1;
5468
5881
  }
5469
5882
  else {
5470
5883
  console.log(` ✓ ${r.device}: ${r.result?.title} - ${r.result?.loadTime}ms (${r.duration}ms total)`);
@@ -5487,11 +5900,12 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
5487
5900
  const results = await CBrowser.parallelUrls(urls, async (b, url) => {
5488
5901
  const nav = await b.navigate(url);
5489
5902
  return { title: nav.title, loadTime: nav.loadTime };
5490
- }, { maxConcurrency: concurrency });
5903
+ }, { maxConcurrency: concurrency, config: browserLaunchConfig(flags) });
5491
5904
  console.log("📊 Results:\n");
5492
5905
  for (const r of results) {
5493
5906
  if (r.error) {
5494
5907
  console.log(` ✗ ${r.url}: ${r.error}`);
5908
+ process.exitCode = 1;
5495
5909
  }
5496
5910
  else {
5497
5911
  console.log(` ✓ ${r.url}: ${r.result?.title} (${r.result?.loadTime}ms)`);
@@ -5512,11 +5926,12 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
5512
5926
  const results = await CBrowser.parallelUrls(urls, async (b, url) => {
5513
5927
  await b.navigate(url);
5514
5928
  return await b.getPerformanceMetrics();
5515
- }, { maxConcurrency: concurrency });
5929
+ }, { maxConcurrency: concurrency, config: browserLaunchConfig(flags) });
5516
5930
  console.log("📊 Performance Results:\n");
5517
5931
  for (const r of results) {
5518
5932
  if (r.error) {
5519
5933
  console.log(` ✗ ${r.url}: ${r.error}`);
5934
+ process.exitCode = 1;
5520
5935
  }
5521
5936
  else {
5522
5937
  const m = r.result;
@@ -5533,6 +5948,7 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
5533
5948
  }
5534
5949
  default:
5535
5950
  console.error("Usage: cbrowser parallel [devices|urls|perf]");
5951
+ process.exit(1);
5536
5952
  }
5537
5953
  break;
5538
5954
  }
@@ -5550,6 +5966,8 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
5550
5966
  process.exit(1);
5551
5967
  }
5552
5968
  console.log(`\n🗣️ Executing: "${nlCommand}"\n`);
5969
+ if (typeof options.url === "string")
5970
+ await browser.navigate(options.url);
5553
5971
  const result = await executeNaturalLanguage(browser, nlCommand);
5554
5972
  if (result.success) {
5555
5973
  console.log(`✓ Action: ${result.action}`);
@@ -5563,6 +5981,8 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
5563
5981
  console.log(` ${r.message}`);
5564
5982
  if (r.screenshot)
5565
5983
  console.log(` Screenshot: ${r.screenshot}`);
5984
+ if (typeof r.scrollY === "number")
5985
+ console.log(` scrollY: ${r.scrollY}`);
5566
5986
  }
5567
5987
  }
5568
5988
  else {
@@ -5593,7 +6013,8 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
5593
6013
  }
5594
6014
  else {
5595
6015
  console.log(`✗ ${r.command}`);
5596
- console.log(` Error: ${r.error}`);
6016
+ process.exitCode = 1;
6017
+ console.error(` Error: ${r.error}`);
5597
6018
  }
5598
6019
  }
5599
6020
  const passed = results.filter(r => r.success).length;
@@ -5630,7 +6051,7 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
5630
6051
  console.log(` Description: ${result.description}`);
5631
6052
  }
5632
6053
  else {
5633
- console.log(`✗ Could not find element matching: "${intent}"`);
6054
+ console.error(`✗ Could not find element matching: "${intent}"`);
5634
6055
  process.exit(1);
5635
6056
  }
5636
6057
  break;
@@ -5640,17 +6061,24 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
5640
6061
  const result = await findElementByIntent(browser, intent);
5641
6062
  if (result) {
5642
6063
  console.log(`✓ Found: ${result.description}`);
5643
- 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
+ }
5644
6071
  console.log(`✓ Clicked element`);
5645
6072
  }
5646
6073
  else {
5647
- console.log(`✗ Could not find element matching: "${intent}"`);
6074
+ console.error(`✗ Could not find element matching: "${intent}"`);
5648
6075
  process.exit(1);
5649
6076
  }
5650
6077
  break;
5651
6078
  }
5652
6079
  default:
5653
6080
  console.error("Usage: cbrowser ai [find|click] \"<intent>\"");
6081
+ process.exit(1);
5654
6082
  }
5655
6083
  break;
5656
6084
  }
@@ -5665,7 +6093,7 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
5665
6093
  process.exit(1);
5666
6094
  }
5667
6095
  const maxPages = options["max-pages"] ? parseInt(options["max-pages"]) : 10;
5668
- const timeout = options.timeout ? parseInt(options.timeout) : 60000;
6096
+ const timeout = timeoutOption(options.timeout, 60000);
5669
6097
  console.log(`\n🔍 Bug Hunter starting...`);
5670
6098
  console.log(` URL: ${url}`);
5671
6099
  console.log(` Max pages: ${maxPages}`);
@@ -5700,16 +6128,24 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
5700
6128
  case "diff": {
5701
6129
  const url = args[0];
5702
6130
  if (!url) {
5703
- console.error("Usage: cbrowser diff <url> [--browsers <list>]");
6131
+ console.error("Usage: cbrowser diff <url> [--browsers <list>] [--viewport <WxH>] [--device <name>]");
5704
6132
  process.exit(1);
5705
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
+ });
5706
6138
  const browserList = options.browsers
5707
6139
  ? options.browsers.split(",")
5708
6140
  : ["chromium", "firefox", "webkit"];
5709
6141
  console.log(`\n🔀 Cross-Browser Diff`);
5710
6142
  console.log(` URL: ${url}`);
5711
6143
  console.log(` Browsers: ${browserList.join(", ")}\n`);
5712
- 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
+ });
5713
6149
  console.log(`📊 Results:\n`);
5714
6150
  console.log(` Metrics:`);
5715
6151
  for (const [browser, metrics] of Object.entries(result.metrics)) {
@@ -5761,6 +6197,8 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
5761
6197
  const result = await runChaosTest(browser, url, chaosConfig);
5762
6198
  console.log(`📊 Results:\n`);
5763
6199
  console.log(` Passed: ${result.passed ? "✅ Yes" : "❌ No"}`);
6200
+ if (!result.passed)
6201
+ process.exitCode = 1;
5764
6202
  console.log(` Duration: ${result.duration}ms`);
5765
6203
  if (result.errors.length > 0) {
5766
6204
  console.log(`\n Errors:`);
@@ -5807,10 +6245,9 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
5807
6245
  console.log("");
5808
6246
  }
5809
6247
  console.log("Usage (OPT-IN REQUIRED):");
5810
- console.log(" cbrowser screenshot https://example.com --lightpanda");
5811
6248
  console.log(" cbrowser agent-ready-audit https://example.com --lightpanda");
5812
6249
  console.log("");
5813
- 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.");
5814
6251
  }
5815
6252
  else {
5816
6253
  console.log("⚠️ Configured but unavailable");
@@ -5912,7 +6349,11 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
5912
6349
  });
5913
6350
  console.log(`\n🔍 Step-through mode enabled. Press Enter to execute each step.\n`);
5914
6351
  const { CBrowser } = await import("./browser.js");
5915
- const stepBrowser = new CBrowser({ headless });
6352
+ const stepBrowser = new CBrowser({
6353
+ headless,
6354
+ ...(typeof options.device === "string" ? { device: options.device } : {}),
6355
+ ...(viewportOverride ? { viewportWidth: viewportOverride.width, viewportHeight: viewportOverride.height, viewportOverride } : {}),
6356
+ });
5916
6357
  await stepBrowser.launch();
5917
6358
  try {
5918
6359
  const totalSteps = suite.tests.reduce((sum, t) => sum + t.steps.length, 0);
@@ -5977,6 +6418,7 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
5977
6418
  }
5978
6419
  catch (e) {
5979
6420
  console.log(` ✗ Failed: ${e.message} (${Date.now() - start}ms)`);
6421
+ process.exitCode = 1;
5980
6422
  }
5981
6423
  }
5982
6424
  }
@@ -5989,10 +6431,16 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
5989
6431
  }
5990
6432
  assertCaptureNamespaceFlags("test-suite", options);
5991
6433
  const suiteOptions = {
5992
- stepTimeout: options.timeout ? parseInt(options.timeout) : 30000,
6434
+ stepTimeout: timeoutOption(options.timeout, 30000),
5993
6435
  continueOnFailure: options["continue-on-failure"] === true,
5994
6436
  screenshotOnFailure: options["screenshot-on-failure"] !== false,
5995
6437
  headless,
6438
+ device: typeof options.device === "string" ? options.device : undefined,
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)
@@ -6567,6 +7101,14 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
6567
7101
  maxTime,
6568
7102
  verbose,
6569
7103
  headless,
7104
+ device: typeof options.device === "string" ? options.device : undefined,
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,
6570
7112
  vision,
6571
7113
  customTraits, // Pass custom traits from env var
6572
7114
  location: locationOverride, // Pass location override
@@ -6577,6 +7119,10 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
6577
7119
  });
6578
7120
  console.log("\n");
6579
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
+ }
6580
7126
  if (result.goalAchieved) {
6581
7127
  console.log(`✅ GOAL ACHIEVED in ${result.stepCount} steps (${result.totalTime.toFixed(1)}s)`);
6582
7128
  }
@@ -6618,7 +7164,20 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
6618
7164
  // goalAchieved genuinely means the persona reached the goal — unlike
6619
7165
  // the empathy audit's field of the same old name, which measured
6620
7166
  // barriers and was renamed to noBlockingBarriers.
6621
- 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);
6622
7181
  }
6623
7182
  catch (error) {
6624
7183
  console.error(`\n❌ Error: ${error.message}`);
@@ -6652,7 +7211,11 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
6652
7211
  auditProxy = { server: geoProxy.server, username: geoProxy.username, password: geoProxy.password };
6653
7212
  }
6654
7213
  else {
6655
- 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);
6656
7219
  }
6657
7220
  }
6658
7221
  console.log(`\n🤖 Running Agent-Ready Audit on ${url}...${useLightpanda ? " (with Lightpanda)" : ""}${geoRegion ? ` (via ${geoRegion})` : ""}\n`);
@@ -6660,6 +7223,10 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
6660
7223
  headless,
6661
7224
  useLightpanda,
6662
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 } : {}),
6663
7230
  });
6664
7231
  // Print formatted report
6665
7232
  const report = formatAgentReadyReport(result);
@@ -6674,7 +7241,7 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
6674
7241
  if (options.html) {
6675
7242
  const fs = await import("fs");
6676
7243
  const htmlReport = generateAgentReadyHtmlReport(result);
6677
- const htmlPath = options.output?.replace(".json", ".html") || "agent-ready-report.html";
7244
+ const htmlPath = htmlReportPath(options.output, "agent-ready-report.html");
6678
7245
  fs.writeFileSync(htmlPath, htmlReport);
6679
7246
  console.log(`🌐 HTML report saved: ${htmlPath}`);
6680
7247
  }
@@ -6697,8 +7264,16 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
6697
7264
  process.exit(1);
6698
7265
  }
6699
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
+ });
6700
7274
  const result = await runAIReadinessBenchmark({
6701
7275
  urls,
7276
+ ...(benchFlags.device ? { device: benchFlags.device } : {}),
6702
7277
  headless: options.headless !== false,
6703
7278
  maxConcurrency: options.concurrency ? parseInt(options.concurrency) : 3,
6704
7279
  });
@@ -6711,6 +7286,9 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
6711
7286
  for (const site of result.sites) {
6712
7287
  const rank = result.ranking.find(r => r.site === site.siteName)?.rank || "-";
6713
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;
6714
7292
  const grade = site.grade || "-";
6715
7293
  const find = site.scoreBreakdown?.findability?.toString().padStart(3) || "-";
6716
7294
  const stab = site.scoreBreakdown?.stability?.toString().padStart(3) || "-";
@@ -6753,7 +7331,7 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
6753
7331
  // Generate HTML report if requested
6754
7332
  if (options.html) {
6755
7333
  const fs = await import("fs");
6756
- const htmlPath = options.output?.replace(".json", ".html") || "ai-benchmark-report.html";
7334
+ const htmlPath = htmlReportPath(options.output, "ai-benchmark-report.html");
6757
7335
  // Simple HTML table report
6758
7336
  const html = `<!DOCTYPE html>
6759
7337
  <html lang="en">
@@ -6839,7 +7417,7 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
6839
7417
  const result = await runWebMCPReadyAudit(url, {
6840
7418
  apiKey: options["api-key"],
6841
7419
  oauthToken: options["oauth-token"],
6842
- timeout: options.timeout ? parseInt(options.timeout) : 30000,
7420
+ timeout: timeoutOption(options.timeout, 30000),
6843
7421
  });
6844
7422
  // Print formatted summary
6845
7423
  console.log(`╔══════════════════════════════════════════════════════════════╗`);
@@ -6879,7 +7457,7 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
6879
7457
  // Generate HTML report if requested
6880
7458
  if (options.html) {
6881
7459
  const fs = await import("fs");
6882
- const htmlPath = options.output?.replace(".json", ".html") || "webmcp-ready-report.html";
7460
+ const htmlPath = htmlReportPath(options.output, "webmcp-ready-report.html");
6883
7461
  const htmlReport = generateWebMCPReadyHtmlReport(result);
6884
7462
  fs.writeFileSync(htmlPath, htmlReport);
6885
7463
  console.log(`🌐 HTML report saved: ${htmlPath}`);
@@ -6903,6 +7481,11 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
6903
7481
  console.error("Usage: cbrowser competitive-benchmark --sites <urls> --goal <goal> [--persona <name>] [--html]");
6904
7482
  process.exit(1);
6905
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
+ }
6906
7489
  const sites = sitesArg.split(",").map((s) => ({ url: s.trim() }));
6907
7490
  const personaName = options.persona || "first-timer";
6908
7491
  const maxSteps = options["max-steps"] ? parseInt(options["max-steps"]) : 30;
@@ -6911,6 +7494,11 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
6911
7494
  console.log(` Sites: ${sites.map((s) => s.url).join(", ")}`);
6912
7495
  console.log(` Goal: ${goal}`);
6913
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
+ });
6914
7502
  const result = await runCompetitiveBenchmark({
6915
7503
  sites,
6916
7504
  goal,
@@ -6918,6 +7506,8 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
6918
7506
  maxSteps,
6919
7507
  maxTime,
6920
7508
  headless,
7509
+ ...(journeyFlags.device ? { device: journeyFlags.device } : {}),
7510
+ ...(journeyFlags.viewport ? { viewport: journeyFlags.viewport } : {}),
6921
7511
  });
6922
7512
  // Print formatted report
6923
7513
  const report = formatCompetitiveBenchmarkReport(result);
@@ -6932,7 +7522,7 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
6932
7522
  if (options.html) {
6933
7523
  const fs = await import("fs");
6934
7524
  const htmlReport = generateCompetitiveBenchmarkHtmlReport(result);
6935
- const htmlPath = options.output?.replace(".json", ".html") || "competitive-benchmark-report.html";
7525
+ const htmlPath = htmlReportPath(options.output, "competitive-benchmark-report.html");
6936
7526
  fs.writeFileSync(htmlPath, htmlReport);
6937
7527
  console.log(`🌐 HTML report saved: ${htmlPath}`);
6938
7528
  }
@@ -6941,41 +7531,22 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
6941
7531
  case "empathy-audit": {
6942
7532
  const url = args[0];
6943
7533
  const goal = options.goal;
6944
- 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);
6945
7538
  if (!url) {
6946
7539
  console.error("Error: URL required");
6947
- console.error("Usage: cbrowser empathy-audit <url> --goal <goal> [--disabilities <list>] [--html]");
6948
- process.exit(1);
6949
- }
6950
- if (!goal) {
6951
- console.error("Error: --goal required");
6952
- 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]");
6953
7541
  process.exit(1);
6954
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).
6955
7545
  // Parse disabilities or use all
6956
7546
  const allAccessibilityNames = listAccessibilityPersonas();
6957
- // Short alias mapping for convenience
6958
- const aliasMap = {
6959
- "motor-tremor": "motor-impairment-tremor",
6960
- "motor": "motor-impairment-tremor",
6961
- "tremor": "motor-impairment-tremor",
6962
- "low-vision": "low-vision-magnified",
6963
- "vision": "low-vision-magnified",
6964
- "magnified": "low-vision-magnified",
6965
- "adhd": "cognitive-adhd",
6966
- "cognitive": "cognitive-adhd",
6967
- "attention": "cognitive-adhd",
6968
- "dyslexia": "dyslexic-user",
6969
- "dyslexic": "dyslexic-user",
6970
- "reading": "dyslexic-user",
6971
- "deaf": "deaf-user",
6972
- "hearing": "deaf-user",
6973
- "elderly": "elderly-low-vision",
6974
- "senior": "elderly-low-vision",
6975
- "color-blind": "color-blind-deuteranopia",
6976
- "colorblind": "color-blind-deuteranopia",
6977
- "deuteranopia": "color-blind-deuteranopia",
6978
- };
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;
6979
7550
  const disabilityNames = disabilitiesArg
6980
7551
  ? disabilitiesArg.split(",").map((d) => {
6981
7552
  const trimmed = d.trim().toLowerCase();
@@ -6987,7 +7558,7 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
6987
7558
  if (invalidNames.length > 0) {
6988
7559
  console.error(`Error: Unknown disability persona(s): ${invalidNames.join(", ")}`);
6989
7560
  console.error(`Available: ${allAccessibilityNames.join(", ")}`);
6990
- 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(", ")}`);
6991
7562
  process.exit(1);
6992
7563
  }
6993
7564
  const wcagLevel = options["wcag-level"] || "AA";
@@ -6998,13 +7569,22 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
6998
7569
  console.log(` Goal: ${goal}`);
6999
7570
  console.log(` Disabilities: ${disabilityNames.join(", ")}`);
7000
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
+ }
7001
7577
  const result = await runEmpathyAudit(url, {
7002
- goal,
7578
+ ...(goal ? { goal } : {}),
7003
7579
  disabilities: disabilityNames,
7004
7580
  wcagLevel: wcagLevel,
7005
7581
  maxSteps,
7006
7582
  maxTime,
7007
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 } : {}),
7008
7588
  });
7009
7589
  // Print formatted report
7010
7590
  const report = formatEmpathyAuditReport(result);
@@ -7019,7 +7599,7 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
7019
7599
  if (options.html) {
7020
7600
  const fs = await import("fs");
7021
7601
  const htmlReport = generateEmpathyAuditHtmlReport(result);
7022
- const htmlPath = options.output?.replace(".json", ".html") || "empathy-audit-report.html";
7602
+ const htmlPath = htmlReportPath(options.output, "empathy-audit-report.html");
7023
7603
  fs.writeFileSync(htmlPath, htmlReport);
7024
7604
  console.log(`🌐 HTML report saved: ${htmlPath}`);
7025
7605
  }
@@ -7048,7 +7628,7 @@ Commands:
7048
7628
  persona-questionnaire lookup Look up behaviors for a specific trait value
7049
7629
 
7050
7630
  Options for 'start':
7051
- --comprehensive Include all 26 traits (default: 8 core traits)
7631
+ --comprehensive Include all 30 traits (default: 13 core traits)
7052
7632
  --name <name> Name for the persona (prompted if not provided)
7053
7633
  --output <file> Save questionnaire answers to JSON file
7054
7634