cbrowser 18.70.0 → 18.72.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 (67) hide show
  1. package/dist/browser.d.ts +32 -0
  2. package/dist/browser.d.ts.map +1 -1
  3. package/dist/browser.js +46 -2
  4. package/dist/browser.js.map +1 -1
  5. package/dist/cli.js +1475 -24
  6. package/dist/cli.js.map +1 -1
  7. package/dist/cognitive/index.d.ts +10 -0
  8. package/dist/cognitive/index.d.ts.map +1 -1
  9. package/dist/cognitive/index.js +8 -0
  10. package/dist/cognitive/index.js.map +1 -1
  11. package/dist/daemon.d.ts.map +1 -1
  12. package/dist/daemon.js +151 -3
  13. package/dist/daemon.js.map +1 -1
  14. package/dist/mcp-server.d.ts.map +1 -1
  15. package/dist/mcp-server.js +67 -0
  16. package/dist/mcp-server.js.map +1 -1
  17. package/dist/mcp-tools/base/capture-tools.d.ts +189 -0
  18. package/dist/mcp-tools/base/capture-tools.d.ts.map +1 -0
  19. package/dist/mcp-tools/base/capture-tools.js +667 -0
  20. package/dist/mcp-tools/base/capture-tools.js.map +1 -0
  21. package/dist/mcp-tools/base/gif-tools.d.ts +19 -0
  22. package/dist/mcp-tools/base/gif-tools.d.ts.map +1 -1
  23. package/dist/mcp-tools/base/gif-tools.js +45 -27
  24. package/dist/mcp-tools/base/gif-tools.js.map +1 -1
  25. package/dist/mcp-tools/base/index.d.ts +2 -1
  26. package/dist/mcp-tools/base/index.d.ts.map +1 -1
  27. package/dist/mcp-tools/base/index.js +5 -1
  28. package/dist/mcp-tools/base/index.js.map +1 -1
  29. package/dist/mcp-tools/tool-categories.d.ts.map +1 -1
  30. package/dist/mcp-tools/tool-categories.js +11 -0
  31. package/dist/mcp-tools/tool-categories.js.map +1 -1
  32. package/dist/recording/auto-capture.d.ts +72 -0
  33. package/dist/recording/auto-capture.d.ts.map +1 -0
  34. package/dist/recording/auto-capture.js +155 -0
  35. package/dist/recording/auto-capture.js.map +1 -0
  36. package/dist/recording/contact-sheet.d.ts +65 -0
  37. package/dist/recording/contact-sheet.d.ts.map +1 -0
  38. package/dist/recording/contact-sheet.js +204 -0
  39. package/dist/recording/contact-sheet.js.map +1 -0
  40. package/dist/recording/encoders.d.ts +101 -0
  41. package/dist/recording/encoders.d.ts.map +1 -0
  42. package/dist/recording/encoders.js +304 -0
  43. package/dist/recording/encoders.js.map +1 -0
  44. package/dist/recording/engine.d.ts +263 -0
  45. package/dist/recording/engine.d.ts.map +1 -0
  46. package/dist/recording/engine.js +896 -0
  47. package/dist/recording/engine.js.map +1 -0
  48. package/dist/recording/ssim.d.ts +78 -0
  49. package/dist/recording/ssim.d.ts.map +1 -0
  50. package/dist/recording/ssim.js +209 -0
  51. package/dist/recording/ssim.js.map +1 -0
  52. package/dist/recording/timing.d.ts +124 -0
  53. package/dist/recording/timing.d.ts.map +1 -0
  54. package/dist/recording/timing.js +251 -0
  55. package/dist/recording/timing.js.map +1 -0
  56. package/dist/recording/types.d.ts +316 -0
  57. package/dist/recording/types.d.ts.map +1 -0
  58. package/dist/recording/types.js +337 -0
  59. package/dist/recording/types.js.map +1 -0
  60. package/dist/testing/nl-test-suite.d.ts +10 -0
  61. package/dist/testing/nl-test-suite.d.ts.map +1 -1
  62. package/dist/testing/nl-test-suite.js +13 -1
  63. package/dist/testing/nl-test-suite.js.map +1 -1
  64. package/dist/types.d.ts +22 -0
  65. package/dist/types.d.ts.map +1 -1
  66. package/dist/types.js.map +1 -1
  67. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -23,7 +23,11 @@ import { DEVICE_PRESETS, LOCATION_PRESETS } from "./types.js";
23
23
  import { startMcpServer } from "./mcp-server.js";
24
24
  import { startRemoteMcpServer } from "./mcp-server-remote.js";
25
25
  import { startDaemon, stopDaemon, getDaemonStatus, isDaemonRunning, sendToDaemon, runDaemonServer } from "./daemon.js";
26
- import { getStatusInfo, formatStatus, getDataDir } from "./config.js";
26
+ import { getStatusInfo, formatStatus, getDataDir, getPaths } from "./config.js";
27
+ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, unlinkSync, writeFileSync } from "node:fs";
28
+ import { dirname, join, resolve } from "node:path";
29
+ import { VideoCaptureSession } from "./recording/engine.js";
30
+ import { changeSignal, parseManifest } from "./recording/types.js";
27
31
  import { printEnterpriseStatus } from "./stealth/index.js";
28
32
  import { runCognitiveJourney, getAnthropicApiKey, setAnthropicApiKey, removeAnthropicApiKey, isApiKeyConfigured, } from "./cognitive/index.js";
29
33
  // Lightpanda integration (v18.20.0) - OPT-IN ONLY with security guardrails
@@ -50,12 +54,19 @@ function readlineQuestion(prompt) {
50
54
  });
51
55
  });
52
56
  }
53
- function showHelp() {
57
+ /**
58
+ * The full help text.
59
+ *
60
+ * Returned rather than printed so `help <command>` can slice a single section
61
+ * out of it - one source of truth for both the global dump and per-command
62
+ * help, which is what stops the two drifting apart.
63
+ */
64
+ function helpText() {
54
65
  // Pad version string to maintain banner alignment
55
66
  const versionStr = `CBrowser CLI v${VERSION}`;
56
67
  const padding = ' '.repeat(Math.max(0, (80 - 2 - versionStr.length) / 2));
57
68
  const rightPadding = ' '.repeat(Math.max(0, 80 - 2 - padding.length - versionStr.length));
58
- console.log(`
69
+ return (`
59
70
  ╔══════════════════════════════════════════════════════════════════════════════╗
60
71
  ║${padding}${versionStr}${rightPadding}║
61
72
  ║ AI-powered browser automation with cross-browser visual testing ║
@@ -78,10 +89,47 @@ INTERACTION
78
89
  --url <url> Navigate to URL first, then fill
79
90
  --verbose Show available inputs and AI suggestions on failure
80
91
  --debug-dir <dir> Save debug screenshots to directory
92
+ hover <selector> Hover over an element
93
+ press <key> Press a single key (Enter, Tab, Escape, Control+a)
94
+ type "<text>" Type a string into the focused element
95
+ --delay <ms> Per-character delay (default: 50)
96
+ drag <source> <target> Drag one element onto another
97
+ upload <selector> <file> Set a file input's value
98
+ --url <url> Navigate to URL first
99
+ For sequences, chords or held modifiers, see 'cbrowser help keyboard'.
81
100
 
82
101
  EXTRACTION
83
102
  extract <what> Extract data (links, images, headings, forms)
84
103
 
104
+ SCRIPTING
105
+ evaluate "<js>" Run JavaScript in the page and print the result
106
+ --file <path> Read the script from a file instead of argv
107
+ --arg <json> Argument for the script (repeatable, JSON-typed)
108
+ --json Always print JSON
109
+ --raw Print the raw value, unquoted
110
+ --wait-for <selector> Wait for the selector before evaluating
111
+ --expect-truthy Exit 1 when the result is falsy (CI assertion)
112
+ Examples:
113
+ cbrowser evaluate "document.title"
114
+ cbrowser evaluate --file check.js --arg '"#main"' --expect-truthy
115
+ cbrowser evaluate "(sel) => !!document.querySelector(sel)" --arg '"nav"'
116
+
117
+ KEYBOARD
118
+ keyboard <token> [token...] Send a key sequence: chords, keys and text
119
+ --selector <sel> Focus this element first
120
+ --delay <ms> Delay between steps (default: 50)
121
+ --hold <mod> Hold a modifier across the whole sequence
122
+ --repeat <n> Repeat the whole sequence n times
123
+ --text Treat every token as literal text (for "+" in text)
124
+ A token with modifiers is a chord (Control+A); a known key name is that key
125
+ (Enter, Tab, ArrowLeft); anything else is typed as literal text. Unknown
126
+ key names are rejected, not typed. For a single key use 'press', for a
127
+ single string use 'type'.
128
+ Examples:
129
+ cbrowser keyboard "Control+A" "Delete" "Hello world"
130
+ cbrowser keyboard "ArrowRight" --repeat 5 --hold Shift
131
+ cbrowser keyboard "Tab" "Tab" "Enter" --selector "#email" --delay 120
132
+
85
133
  SITE EXPLORATION (Free, heuristic-based)
86
134
  explore <persona> Quick autonomous exploration using built-in heuristics
87
135
  --start <url> Starting URL (required)
@@ -91,6 +139,10 @@ SITE EXPLORATION (Free, heuristic-based)
91
139
 
92
140
  COGNITIVE JOURNEY (API-powered, realistic user simulation)
93
141
  cognitive-journey Simulate a real user with emotions, patience, and abandonment
142
+ --capture Record the run to GIF/WebP/video
143
+ --capture-fps <n> Capture frame rate (default: 10)
144
+ --capture-format <list> gif,webp,webm,mp4 (default: gif)
145
+ --capture-out <dir> Capture output directory
94
146
  --persona <name> Persona name or description (default: first-timer)
95
147
  --start <url> Starting URL (required)
96
148
  --goal <goal> Goal statement (required)
@@ -142,6 +194,10 @@ NATURAL LANGUAGE TEST SUITES (v6.1.0)
142
194
  --fuzzy-match Use case-insensitive fuzzy matching for assertions
143
195
  --step-through Pause before each step for interactive execution
144
196
  test-suite --inline "..." Run inline test (semicolon-separated steps)
197
+ --capture Record the run to GIF/WebP/video
198
+ --capture-fps <n> Capture frame rate (default: 10)
199
+ --capture-format <list> gif,webp,webm,mp4 (default: gif)
200
+ --capture-out <dir> Capture output directory
145
201
  Examples:
146
202
  cbrowser test-suite login-flow.txt --html
147
203
  cbrowser test-suite --inline "go to https://example.com ; click login ; verify url contains /dashboard"
@@ -518,6 +574,40 @@ TEST RECORDING (v2.5.0)
518
574
  record list List saved recordings
519
575
  record generate <name> Generate Playwright test code
520
576
 
577
+ SCREEN CAPTURE (video/GIF — distinct from 'record', which captures actions)
578
+ capture start <url> Capture the screen to GIF/WebP/video
579
+ --fps <n> Target frames per second (default: 10)
580
+ --duration <5s> Auto-stop after this long (5s, 2m, 1500ms)
581
+ --viewport <WxH> Viewport size, e.g. 1280x720
582
+ --region <x,y,w,h> Capture a fixed region instead of the viewport
583
+ --element <selector> Track an element; crop follows it as it moves
584
+ --element-padding <n> Expand the element box by n CSS px per side
585
+ --device <name> Device preset, e.g. iphone-15 ('capture status'
586
+ lists them on a bad value)
587
+ --format <list> gif,webp,webm,mp4 (default: gif)
588
+ --quality <1-100> JPEG quality of captured frames (default: 80)
589
+ --max-frames <n> Stop retaining frames after n (default: 3000)
590
+ --contact-sheet Also write one JPEG summarising the whole capture
591
+ --after <event> Start on load|domcontentloaded|networkidle
592
+ --after-element <sel> Start when the element is present and visible
593
+ --after-delay <500ms> Extra delay after the start trigger
594
+ --until-element <sel> Stop when the element appears
595
+ --until-element-gone <sel> Stop when the element disappears
596
+ --until-idle <800ms> Stop after this long with NO part of the page
597
+ changing. A page with a clock or ticker is always
598
+ changing and will run to --timeout instead.
599
+ --timeout <30s> Trigger wait budget (default: 30s); a trigger that
600
+ never fires stops the capture, writes a manifest
601
+ with trigger_timeout, and exits non-zero
602
+ --out <dir> Output directory
603
+ (default: ~/.cbrowser/videos/<session>/<name>)
604
+ --name <slug> Capture name
605
+ capture stop Stop the running screen capture
606
+ capture status Show the running or most recent capture
607
+ Note: capture is event-driven — a page that never repaints emits no frames,
608
+ and the still stretches are reported as frame_gaps rather than padded out.
609
+ mp4 needs a full ffmpeg (CBROWSER_FFMPEG_PATH); the bundled one does webm.
610
+
521
611
  TEST EXPORT (v2.5.0)
522
612
  export junit <name> [output] Export test results as JUnit XML
523
613
  export tap <name> [output] Export test results as TAP format
@@ -530,6 +620,14 @@ WEBHOOKS (v2.5.0)
530
620
  webhook delete <name> Delete a webhook
531
621
  webhook test <name> Send test notification
532
622
 
623
+ PERSONA QUESTIONNAIRE
624
+ persona-questionnaire start Build a persona from a behavioural questionnaire
625
+ --comprehensive All 25 traits (default: 8 core traits)
626
+ --name <name> Persona name (prompted if omitted)
627
+ --output <file> Save answers to JSON
628
+ persona-questionnaire list-traits List available traits with descriptions
629
+ persona-questionnaire lookup Look up behaviours for a trait value
630
+
533
631
  PARALLEL EXECUTION (v2.5.0)
534
632
  parallel devices <url> Run same URL across multiple devices
535
633
  --devices <list> Comma-separated device names (default: all)
@@ -724,6 +822,115 @@ EXAMPLES
724
822
  npx cbrowser cookie list --url "https://example.com"
725
823
  `);
726
824
  }
825
+ function showHelp() {
826
+ console.log(helpText());
827
+ }
828
+ /**
829
+ * Commands that are aliases of another command in the dispatch switch.
830
+ *
831
+ * `help <alias>` resolves to the target's section: an alias that denies having
832
+ * help is the worst answer of all, because the user has no way to learn it IS
833
+ * an alias. Kept honest by the test that walks every `case` label in the
834
+ * dispatch switch and asserts it resolves.
835
+ */
836
+ const COMMAND_ALIASES = {
837
+ eval: "evaluate",
838
+ "chaos-test": "chaos",
839
+ "generate-tests": "generate",
840
+ journey: "explore",
841
+ };
842
+ /** Escape a string for safe use inside a RegExp. */
843
+ function escapeForRegExp(value) {
844
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
845
+ }
846
+ /**
847
+ * Split the help text into its top-level sections.
848
+ *
849
+ * A heading is an unindented line in caps; everything up to the next heading
850
+ * belongs to it. Command names are the first token of each two-space-indented
851
+ * entry, which is how `help <command>` finds the section to print.
852
+ */
853
+ function parseHelpSections() {
854
+ const sections = [];
855
+ let current = null;
856
+ for (const line of helpText().split("\n")) {
857
+ // Headings are the only unindented prose in the help text: entries are
858
+ // indented at least two spaces, and the banner and rule lines are drawn
859
+ // with box characters. Matching on "looks uppercase" instead would miss
860
+ // headings like "VISUAL REGRESSION (v2.5.0)".
861
+ const isHeading = /^[A-Za-z]/.test(line) &&
862
+ line.trim().length > 0 &&
863
+ !/^[\u2550-\u256c]+$/.test(line.trim());
864
+ if (isHeading) {
865
+ current = { heading: line.trim(), lines: [line], commands: new Set() };
866
+ sections.push(current);
867
+ continue;
868
+ }
869
+ if (!current)
870
+ continue;
871
+ current.lines.push(line);
872
+ const entry = /^ {2}(\S+)/.exec(line);
873
+ if (entry)
874
+ current.commands.add(entry[1]);
875
+ }
876
+ return sections;
877
+ }
878
+ /**
879
+ * Print help for a single command.
880
+ *
881
+ * Returns false when nothing matches, so the caller can exit non-zero - an
882
+ * unknown command that silently printed something would be the same
883
+ * accepted-and-ignored failure mode as a silently dropped flag.
884
+ */
885
+ function showCommandHelp(command) {
886
+ const sections = parseHelpSections();
887
+ const target = COMMAND_ALIASES[command];
888
+ if (target) {
889
+ console.log(`'${command}' is an alias for '${target}'.\n`);
890
+ return showCommandHelp(target);
891
+ }
892
+ const matches = sections.filter((s) => s.commands.has(command));
893
+ if (matches.length === 0) {
894
+ // A command can be real but documented inside another command's entry (an
895
+ // example line, a "see also"), so before declaring it unknown, look for it
896
+ // anywhere in the help text and point at the section that mentions it.
897
+ // Saying "no help section" about a command that exists is its own small
898
+ // version of lying to the user.
899
+ const mentions = sections.filter((s) => s.lines.some((line) => new RegExp(`(^|\\s|')${escapeForRegExp(command)}(\\s|$|'|"|,)`).test(line)));
900
+ if (mentions.length > 0) {
901
+ console.log(`'${command}' has no section of its own; it is documented under:`);
902
+ for (const section of mentions.slice(0, 3)) {
903
+ console.log(`\n${section.heading}`);
904
+ for (const line of section.lines.slice(1)) {
905
+ if (new RegExp(`(^|\\s|')${escapeForRegExp(command)}(\\s|$|'|"|,)`).test(line)) {
906
+ console.log(line);
907
+ }
908
+ }
909
+ }
910
+ console.log(`\nFull section: cbrowser help ${[...mentions[0].commands][0] ?? ""}`.trimEnd());
911
+ return true;
912
+ }
913
+ const known = [...new Set(sections.flatMap((s) => [...s.commands]))].sort();
914
+ const close = known.filter((c) => c.startsWith(command) || command.startsWith(c) || c.includes(command));
915
+ console.error(`No help section for '${command}'.`);
916
+ if (close.length > 0) {
917
+ console.error(`Did you mean: ${close.slice(0, 8).join(", ")}?`);
918
+ }
919
+ else {
920
+ console.error(`Run 'cbrowser help' for the full command list.`);
921
+ }
922
+ return false;
923
+ }
924
+ for (const section of matches) {
925
+ // Trailing blank lines belong to the gap before the next section.
926
+ const lines = [...section.lines];
927
+ while (lines.length > 0 && lines[lines.length - 1].trim() === "")
928
+ lines.pop();
929
+ console.log(lines.join("\n"));
930
+ console.log("");
931
+ }
932
+ return true;
933
+ }
727
934
  function parseArgs(args) {
728
935
  const command = args[0] || "help";
729
936
  const restArgs = [];
@@ -746,6 +953,1132 @@ function parseArgs(args) {
746
953
  }
747
954
  return { command, args: restArgs, options };
748
955
  }
956
+ // =========================================================================
957
+ // Flag validation — one gate, before dispatch
958
+ // =========================================================================
959
+ /**
960
+ * Flags accepted by every command, consumed by the shared browser setup in
961
+ * main() rather than by any individual case.
962
+ */
963
+ /**
964
+ * Commands that act on the live page and would therefore be wrong to run
965
+ * against a second browser while the daemon holds the first. Anything listed
966
+ * here must either be daemon-routed or refuse to run.
967
+ */
968
+ const DRIVES_THE_PAGE = [
969
+ "navigate", "click", "fill", "hover", "screenshot", "extract", "run",
970
+ "keyboard", "press", "type", "evaluate", "eval", "drag", "upload",
971
+ "smart-click", "dismiss-overlay", "assert",
972
+ ];
973
+ const GLOBAL_FLAGS = [
974
+ "url", "browser", "device", "headless", "persistent", "restore",
975
+ "locale", "timezone", "geo", "verbose", "help", "no-color", "quiet", "json",
976
+ ];
977
+ /**
978
+ * Declared options per command.
979
+ *
980
+ * This exists because "accepted and ignored" showed up eight times in this
981
+ * build - each time a flag reached a code path that never read it, and the
982
+ * command cheerfully exited 0. Per-command vigilance did not hold, so the rule
983
+ * moved here: argv is validated against the command's declared set BEFORE
984
+ * dispatch, and an undeclared flag never reaches an execution path.
985
+ *
986
+ * Commands absent from this map are not validated yet; see UNVALIDATED_REASON.
987
+ * Lists for the large pre-existing commands are supersets derived from the
988
+ * flags their case blocks actually read - deliberately permissive, since a
989
+ * false rejection of a working flag would be a worse defect than the one being
990
+ * fixed. They are still tight enough to catch a typo or an invented flag.
991
+ */
992
+ const COMMAND_FLAGS = {
993
+ capture: [
994
+ "fps", "duration", "format", "out", "name", "quality", "max-frames",
995
+ "viewport", "region", "element", "element-padding", "contact-sheet",
996
+ "after", "after-element", "after-delay",
997
+ "until-element", "until-element-gone", "until-idle", "timeout",
998
+ ],
999
+ evaluate: ["file", "arg", "json", "raw", "wait-for", "expect-truthy"],
1000
+ eval: ["file", "arg", "json", "raw", "wait-for", "expect-truthy"],
1001
+ keyboard: ["delay", "selector", "hold", "repeat", "text"],
1002
+ "test-suite": [
1003
+ "capture", "capture-fps", "capture-format", "capture-out",
1004
+ "api-key", "auto-apply", "comprehensive", "concurrency", "continue-on-failure",
1005
+ "delay", "disabilities", "dry-run", "exclude", "fuzzy-match", "goal",
1006
+ "html", "include", "inline", "length", "lightpanda", "max-pages", "max-steps",
1007
+ "max-time", "min-coverage", "name", "no-vision", "oauth-token", "output",
1008
+ "persona", "retries", "runs", "screenshot-on-failure", "sensitivity",
1009
+ "sitemap", "sites", "start", "step-through", "tests", "threshold",
1010
+ "threshold-cls", "threshold-fcp", "threshold-lcp", "threshold-tbt",
1011
+ "threshold-ttfb", "threshold-tti", "timeout", "trait", "urls", "value",
1012
+ "verify", "wcag-level", "geolocation", "geo-region",
1013
+ ],
1014
+ "cognitive-journey": [
1015
+ "capture", "capture-fps", "capture-format", "capture-out",
1016
+ "api-key", "comprehensive", "concurrency", "disabilities", "goal", "html",
1017
+ "length", "lightpanda", "max-steps", "max-time", "name", "no-vision",
1018
+ "oauth-token", "output", "persona", "sites", "start", "timeout", "trait",
1019
+ "urls", "value", "wcag-level", "geolocation", "geo-region", "vision",
1020
+ "trace", "replay-trace",
1021
+ ],
1022
+ };
1023
+ /**
1024
+ * Why the rest are not covered yet: cbrowser has ~90 commands whose flag sets
1025
+ * are not declared anywhere machine-readable, and inventing a list for a
1026
+ * command I have not exercised risks rejecting a flag that works today. Each
1027
+ * entry above was derived from that command's own code and then exercised.
1028
+ * Extending coverage is one line per command plus a run to confirm it.
1029
+ */
1030
+ const UNVALIDATED_REASON = "not yet declared - see COMMAND_FLAGS in src/cli.ts";
1031
+ /**
1032
+ * Reject undeclared flags before the command runs.
1033
+ *
1034
+ * Fails on the FIRST invalid flag with the command's full accepted set, so a
1035
+ * typo is a one-command fix rather than a guessing game.
1036
+ */
1037
+ function validateCommandFlags(command, options) {
1038
+ const declared = COMMAND_FLAGS[command];
1039
+ if (!declared)
1040
+ return; // UNVALIDATED_REASON
1041
+ const allowed = new Set([...declared, ...GLOBAL_FLAGS]);
1042
+ const unknown = Object.keys(options).filter((flag) => !allowed.has(flag));
1043
+ if (unknown.length === 0)
1044
+ return;
1045
+ for (const flag of unknown) {
1046
+ const hint = flag.startsWith("record")
1047
+ ? " (did you mean --capture?)"
1048
+ : flag.startsWith("capture") ? " (capture options are --capture, --capture-fps, --capture-format, --capture-out)" : "";
1049
+ console.error(`Error: '${command}' does not support --${flag}${hint}`);
1050
+ }
1051
+ console.error(` Accepted: ${[...declared].sort().map((f) => `--${f}`).join(" ")}`);
1052
+ console.error(` Plus globals: ${GLOBAL_FLAGS.map((f) => `--${f}`).join(" ")}`);
1053
+ process.exit(1);
1054
+ }
1055
+ // =========================================================================
1056
+ // Screen capture (capture start/stop/status)
1057
+ //
1058
+ // Distinct from the `record` command, which records user ACTIONS for test
1059
+ // generation. This captures PIXELS into a GIF/WebP/video.
1060
+ // =========================================================================
1061
+ /** Where the active-capture pointer lives, so `capture stop` can find it. */
1062
+ function captureStatePath() {
1063
+ return join(getPaths().videosDir, "active.json");
1064
+ }
1065
+ /** Parse "5s", "2m", "1500ms" or a bare millisecond count. */
1066
+ function parseDurationMs(input) {
1067
+ const match = /^(\d+(?:\.\d+)?)\s*(ms|s|m)?$/i.exec(input.trim());
1068
+ if (!match) {
1069
+ throw new Error(`Invalid duration "${input}" - use 5s, 2m, 1500ms or a millisecond count`);
1070
+ }
1071
+ const value = parseFloat(match[1]);
1072
+ const unit = (match[2] || "ms").toLowerCase();
1073
+ const ms = unit === "s" ? value * 1000 : unit === "m" ? value * 60_000 : value;
1074
+ if (ms <= 0)
1075
+ throw new Error(`Invalid duration "${input}" - must be greater than zero`);
1076
+ return Math.round(ms);
1077
+ }
1078
+ /** Parse "gif,webp,mp4" into validated formats. */
1079
+ function parseFormats(input) {
1080
+ const allowed = ["gif", "webp", "mp4", "webm"];
1081
+ const parsed = input.split(",").map((f) => f.trim().toLowerCase()).filter(Boolean);
1082
+ const bad = parsed.filter((f) => !allowed.includes(f));
1083
+ if (bad.length > 0) {
1084
+ throw new Error(`Unknown format(s): ${bad.join(", ")}. Supported: ${allowed.join(", ")}`);
1085
+ }
1086
+ if (parsed.length === 0)
1087
+ throw new Error("--format requires at least one format");
1088
+ return parsed;
1089
+ }
1090
+ /** Parse "1280x720". */
1091
+ function parseViewportSize(input) {
1092
+ const match = /^(\d+)x(\d+)$/i.exec(input.trim());
1093
+ if (!match)
1094
+ throw new Error(`Invalid viewport "${input}" - use WIDTHxHEIGHT, e.g. 1280x720`);
1095
+ const width = parseInt(match[1], 10);
1096
+ const height = parseInt(match[2], 10);
1097
+ if (width <= 0 || height <= 0) {
1098
+ throw new Error(`Invalid viewport "${input}" - width and height must be positive`);
1099
+ }
1100
+ return { width, height };
1101
+ }
1102
+ /** Parse "390x844,1280x800" into one or more viewport sizes. */
1103
+ function parseViewportList(input) {
1104
+ const parts = input.split(",").map((p) => p.trim()).filter(Boolean);
1105
+ if (parts.length === 0)
1106
+ throw new Error("--viewport requires at least one WIDTHxHEIGHT");
1107
+ return parts.map(parseViewportSize);
1108
+ }
1109
+ /** Parse "x,y,width,height" in CSS pixels. */
1110
+ function parseRegion(input) {
1111
+ const parts = input.split(",").map((p) => p.trim());
1112
+ if (parts.length !== 4) {
1113
+ throw new Error(`Invalid --region "${input}" - use x,y,width,height (4 comma-separated numbers)`);
1114
+ }
1115
+ const [x, y, width, height] = parts.map(Number);
1116
+ for (const [name, value] of [["x", x], ["y", y], ["width", width], ["height", height]]) {
1117
+ if (!Number.isFinite(value)) {
1118
+ throw new Error(`Invalid --region "${input}" - ${name} is not a number ("${parts[["x", "y", "width", "height"].indexOf(name)]}")`);
1119
+ }
1120
+ }
1121
+ if (width <= 0 || height <= 0) {
1122
+ throw new Error(`Invalid --region "${input}" - width and height must be positive (got ${width}x${height})`);
1123
+ }
1124
+ return { x, y, width, height };
1125
+ }
1126
+ /**
1127
+ * Flags `capture start` understands. Anything outside this set is rejected
1128
+ * rather than ignored: a flag that is accepted and silently dropped returns a
1129
+ * confident "✓" for a capture the user did not ask for, which is worse than
1130
+ * an error. The globals are the ones the CLI itself consumes before dispatch.
1131
+ */
1132
+ const CAPTURE_FLAGS = new Set([
1133
+ // capture-specific
1134
+ "fps", "duration", "format", "out", "name", "quality", "max-frames",
1135
+ "viewport", "region", "element", "element-padding",
1136
+ "contact-sheet",
1137
+ "after", "after-element", "after-delay",
1138
+ "until-element", "until-element-gone", "until-idle", "timeout",
1139
+ // globals honoured by the shared CBrowser construction
1140
+ "url", "browser", "device", "headless", "persistent", "restore",
1141
+ "locale", "timezone", "geo", "verbose",
1142
+ ]);
1143
+ /**
1144
+ * Resolve `--device` to a real preset key, or exit naming the bad value.
1145
+ *
1146
+ * CBrowser silently ignores an unknown device name (browser.ts: the preset
1147
+ * lookup is inside the `if`), which would hand back a 1280x800 desktop capture
1148
+ * for `--device "iPhone 14"` and call it success. Spaces and case are
1149
+ * normalised so "iPhone 15" finds "iphone-15".
1150
+ */
1151
+ function resolveDevicePreset(value) {
1152
+ const normalised = value.trim().toLowerCase().replace(/\s+/g, "-");
1153
+ if (DEVICE_PRESETS[normalised])
1154
+ return normalised;
1155
+ const known = Object.keys(DEVICE_PRESETS);
1156
+ const stem = normalised.replace(/-?\d+.*$/, "");
1157
+ const close = known.filter((k) => k.startsWith(stem) && stem.length > 0);
1158
+ console.error(`Error: unknown --device "${value}"`);
1159
+ if (close.length > 0)
1160
+ console.error(` Did you mean: ${close.join(", ")}?`);
1161
+ console.error(` Available presets: ${known.join(", ")}`);
1162
+ process.exit(1);
1163
+ }
1164
+ /** Reject any flag `capture start` cannot honour, naming it. */
1165
+ function assertCaptureFlagsSupported(options) {
1166
+ const unknown = Object.keys(options).filter((flag) => !CAPTURE_FLAGS.has(flag));
1167
+ if (unknown.length === 0)
1168
+ return;
1169
+ console.error(`Error: 'capture start' does not support ${unknown.map((f) => `--${f}`).join(", ")}`);
1170
+ console.error(` Supported: ${[...CAPTURE_FLAGS].map((f) => `--${f}`).join(" ")}`);
1171
+ process.exit(1);
1172
+ }
1173
+ /**
1174
+ * Build the start/stop triggers from the trigger flags.
1175
+ *
1176
+ * Triggers compose: `--after-element .modal --after-delay 200ms --duration 3s`
1177
+ * waits for the modal, waits another 200ms, then records for three seconds.
1178
+ */
1179
+ function buildCaptureTriggers(options) {
1180
+ const LIFECYCLE = ["load", "domcontentloaded", "networkidle"];
1181
+ let startTrigger;
1182
+ if (typeof options.after === "string" && typeof options["after-element"] === "string") {
1183
+ console.error("Error: --after and --after-element are mutually exclusive - pick one");
1184
+ process.exit(1);
1185
+ }
1186
+ if (typeof options.after === "string") {
1187
+ const event = options.after.trim().toLowerCase();
1188
+ if (!LIFECYCLE.includes(event)) {
1189
+ console.error(`Error: --after must be one of ${LIFECYCLE.join(", ")} (got "${options.after}")`);
1190
+ process.exit(1);
1191
+ }
1192
+ startTrigger = { kind: "lifecycle", event: event };
1193
+ }
1194
+ else if (typeof options["after-element"] === "string") {
1195
+ startTrigger = { kind: "element", selector: options["after-element"] };
1196
+ }
1197
+ else if (options.after !== undefined || options["after-element"] !== undefined) {
1198
+ console.error("Error: --after / --after-element require a value");
1199
+ process.exit(1);
1200
+ }
1201
+ const startDelayMs = typeof options["after-delay"] === "string"
1202
+ ? parseDurationMs(options["after-delay"])
1203
+ : undefined;
1204
+ const stopFlags = ["until-element", "until-element-gone", "until-idle"].filter((f) => options[f] !== undefined);
1205
+ if (stopFlags.length > 1) {
1206
+ console.error(`Error: ${stopFlags.map((f) => `--${f}`).join(" and ")} are mutually exclusive - pick one`);
1207
+ process.exit(1);
1208
+ }
1209
+ let stopTrigger;
1210
+ if (typeof options["until-element"] === "string") {
1211
+ stopTrigger = { kind: "element-appears", selector: options["until-element"] };
1212
+ }
1213
+ else if (typeof options["until-element-gone"] === "string") {
1214
+ stopTrigger = { kind: "element-disappears", selector: options["until-element-gone"] };
1215
+ }
1216
+ else if (typeof options["until-idle"] === "string") {
1217
+ stopTrigger = { kind: "idle", idleMs: parseDurationMs(options["until-idle"]) };
1218
+ }
1219
+ else if (stopFlags.length === 1) {
1220
+ console.error(`Error: --${stopFlags[0]} requires a value`);
1221
+ process.exit(1);
1222
+ }
1223
+ const triggerTimeoutMs = typeof options.timeout === "string"
1224
+ ? parseDurationMs(options.timeout)
1225
+ : undefined;
1226
+ return { startTrigger, startDelayMs, stopTrigger, triggerTimeoutMs };
1227
+ }
1228
+ /**
1229
+ * Build the capture target from the target flags.
1230
+ *
1231
+ * The three targets are mutually exclusive; asking for two is a contradiction
1232
+ * the CLI cannot silently resolve, so it errors instead of picking one.
1233
+ */
1234
+ function buildCaptureTarget(options) {
1235
+ const hasRegion = typeof options.region === "string";
1236
+ const hasElement = typeof options.element === "string";
1237
+ if (hasRegion && hasElement) {
1238
+ console.error("Error: --region and --element are mutually exclusive - pick one");
1239
+ process.exit(1);
1240
+ }
1241
+ if (typeof options["element-padding"] !== "undefined" && !hasElement) {
1242
+ console.error("Error: --element-padding only applies with --element");
1243
+ process.exit(1);
1244
+ }
1245
+ if (hasRegion) {
1246
+ return { kind: "region", rect: parseRegion(options.region) };
1247
+ }
1248
+ if (hasElement) {
1249
+ const selector = options.element;
1250
+ if (selector.trim() === "") {
1251
+ console.error("Error: --element requires a selector");
1252
+ process.exit(1);
1253
+ }
1254
+ const padding = options["element-padding"] !== undefined ? Number(options["element-padding"]) : 0;
1255
+ if (!Number.isFinite(padding) || padding < 0) {
1256
+ console.error(`Error: --element-padding must be a non-negative number (got "${String(options["element-padding"])}")`);
1257
+ process.exit(1);
1258
+ }
1259
+ return { kind: "element", selector, padding };
1260
+ }
1261
+ return { kind: "viewport" };
1262
+ }
1263
+ /**
1264
+ * Run a screencast capture to completion in this process.
1265
+ *
1266
+ * Capture is single-process by necessity: the browser profile is locked by
1267
+ * whoever launched it, so a second CLI invocation cannot attach to a running
1268
+ * capture's page. `capture stop` therefore signals this process rather than
1269
+ * doing any capture work of its own.
1270
+ */
1271
+ async function runCaptureStart(browser, url, options) {
1272
+ assertCaptureFlagsSupported(options);
1273
+ // A capture with no end condition needs somewhere to live that outlasts this
1274
+ // process. The daemon owns the browser, so the capture runs there and later
1275
+ // commands drive the same page into the same recording.
1276
+ const openEnded = options.duration === undefined
1277
+ && options["until-element"] === undefined
1278
+ && options["until-element-gone"] === undefined
1279
+ && options["until-idle"] === undefined;
1280
+ if (openEnded) {
1281
+ if (await isDaemonRunning()) {
1282
+ await runCaptureStartViaDaemon(url, options);
1283
+ return;
1284
+ }
1285
+ console.error("Error: an open-ended capture needs the daemon, which is not running.");
1286
+ console.error(" Without it the capture would live and die inside this one process, so");
1287
+ console.error(" 'capture stop' from another shell and any later click/fill/navigate");
1288
+ console.error(" would never reach it.");
1289
+ console.error("");
1290
+ console.error(" Start it: cbrowser daemon start");
1291
+ console.error(" Or bound it: --duration 5s | --until-element <sel> | --until-idle 800ms");
1292
+ process.exit(1);
1293
+ }
1294
+ const fps = options.fps !== undefined ? Number(options.fps) : undefined;
1295
+ if (fps !== undefined && (!Number.isFinite(fps) || fps <= 0)) {
1296
+ console.error(`Error: --fps must be a positive number (got "${String(options.fps)}")`);
1297
+ process.exit(1);
1298
+ }
1299
+ const quality = options.quality !== undefined ? Number(options.quality) : undefined;
1300
+ if (quality !== undefined && (!Number.isInteger(quality) || quality < 1 || quality > 100)) {
1301
+ console.error(`Error: --quality must be an integer from 1 to 100 (got "${String(options.quality)}")`);
1302
+ process.exit(1);
1303
+ }
1304
+ const maxFrames = options["max-frames"] !== undefined ? Number(options["max-frames"]) : undefined;
1305
+ if (maxFrames !== undefined && (!Number.isInteger(maxFrames) || maxFrames < 1)) {
1306
+ console.error(`Error: --max-frames must be a positive integer (got "${String(options["max-frames"])}")`);
1307
+ process.exit(1);
1308
+ }
1309
+ // Normalise in place so the browser built below gets a key it recognises.
1310
+ if (typeof options.device === "string")
1311
+ options.device = resolveDevicePreset(options.device);
1312
+ const durationMs = typeof options.duration === "string" ? parseDurationMs(options.duration) : undefined;
1313
+ const format = typeof options.format === "string" ? parseFormats(options.format) : undefined;
1314
+ const outDir = typeof options.out === "string" ? resolve(options.out) : undefined;
1315
+ if (url)
1316
+ await browser.navigate(url);
1317
+ const page = await browser.getPage();
1318
+ // Multiple viewports run as separate, sequential captures. Sequential rather
1319
+ // than concurrent by design: one page cannot be two sizes at once, so
1320
+ // overlapping them would interleave frames from different viewports into one
1321
+ // stream. Each run gets its own slug, directory and manifest.
1322
+ const viewports = typeof options.viewport === "string" ? parseViewportList(options.viewport) : [];
1323
+ if (viewports.length > 1) {
1324
+ if (durationMs === undefined && !options["until-element"] && !options["until-element-gone"] && !options["until-idle"]) {
1325
+ console.error("Error: --viewport with more than one size needs --duration or a stop trigger, so each run can end on its own");
1326
+ process.exit(1);
1327
+ }
1328
+ for (const viewport of viewports) {
1329
+ await page.setViewportSize(viewport);
1330
+ console.log(`\n── viewport ${viewport.width}x${viewport.height} ──`);
1331
+ // Each viewport gets its OWN directory. Sharing one --out would let the
1332
+ // second run overwrite the first run's manifest.json and frames/ while
1333
+ // both GIFs survived - keeping the human artifact and destroying the AI
1334
+ // artifact, which inverts this feature's whole premise.
1335
+ await runOneCapture(browser, page, options, `${slugFor(options)}-${viewport.width}x${viewport.height}`, typeof options.out === "string"
1336
+ ? join(resolve(options.out), `${viewport.width}x${viewport.height}`)
1337
+ : undefined);
1338
+ }
1339
+ return;
1340
+ }
1341
+ await runOneCapture(browser, page, options, slugFor(options));
1342
+ }
1343
+ /** The capture name, defaulted to a timestamp slug. */
1344
+ function slugFor(options) {
1345
+ return typeof options.name === "string" ? options.name : `capture-${Date.now()}`;
1346
+ }
1347
+ /** Run exactly one capture to completion and report it. */
1348
+ async function runOneCapture(browser, page, options, slug, outDirOverride) {
1349
+ const fps = options.fps !== undefined ? Number(options.fps) : undefined;
1350
+ const quality = options.quality !== undefined ? Number(options.quality) : undefined;
1351
+ const maxFrames = options["max-frames"] !== undefined ? Number(options["max-frames"]) : undefined;
1352
+ const durationMs = typeof options.duration === "string" ? parseDurationMs(options.duration) : undefined;
1353
+ const format = typeof options.format === "string" ? parseFormats(options.format) : undefined;
1354
+ const outDir = outDirOverride ?? (typeof options.out === "string" ? resolve(options.out) : undefined);
1355
+ const target = buildCaptureTarget(options);
1356
+ const triggers = buildCaptureTriggers(options);
1357
+ const engine = options.browser === "firefox" ? "firefox" : options.browser === "webkit" ? "webkit" : "chromium";
1358
+ // Output lives under paths.videosDir, session-scoped.
1359
+ //
1360
+ // videosDir over recordingsDir: `recordings/` is the legacy action-recording
1361
+ // store (`record save` writes *.json there, and status counts them), so
1362
+ // video artifacts in the same tree would be two features sharing a folder.
1363
+ // videosDir over screenshotsDir: screenshot retention deletes whole session
1364
+ // directories after an hour by default, which would silently delete the
1365
+ // user's GIF. The session segment is what stops concurrent captures from
1366
+ // colliding, the same isolation rule screenshots already use.
1367
+ const paths = getPaths();
1368
+ const session = new VideoCaptureSession(page, engine, join(paths.videosDir, paths.sessionId));
1369
+ await session.start({
1370
+ fps, durationMs, outDir, format, slug, target, quality, maxFrames,
1371
+ contactSheet: options["contact-sheet"] === true || typeof options["contact-sheet"] === "string",
1372
+ ...triggers,
1373
+ // Element tracking resolves through the browser's existing nine-strategy
1374
+ // resolver rather than a second one living in the capture engine.
1375
+ resolveElement: (selector) => browser.resolveElementLocator(selector),
1376
+ });
1377
+ const status = session.status();
1378
+ mkdirSync(dirname(captureStatePath()), { recursive: true });
1379
+ const active = {
1380
+ pid: process.pid,
1381
+ slug: status.slug,
1382
+ outDir: status.outDir,
1383
+ url: typeof options.url === "string" ? options.url : undefined,
1384
+ startedAt: new Date().toISOString(),
1385
+ };
1386
+ writeFileSync(captureStatePath(), JSON.stringify(active, null, 2));
1387
+ console.log(`🎥 Capturing ${status.captureMethod === "cdp-screencast" ? "via CDP screencast" : "via screenshot loop"}`);
1388
+ console.log(` Output: ${status.outDir}`);
1389
+ if (triggers.startTrigger) {
1390
+ console.log(` Waited for: ${triggers.startTrigger.kind === "lifecycle" ? triggers.startTrigger.event : triggers.startTrigger.selector}`);
1391
+ }
1392
+ if (target.kind === "region") {
1393
+ const r = target.rect;
1394
+ console.log(` Region: ${r.width}x${r.height} at (${r.x}, ${r.y})`);
1395
+ }
1396
+ else if (target.kind === "element") {
1397
+ console.log(` Element: ${target.selector}${target.padding ? ` +${target.padding}px padding` : ""}`);
1398
+ }
1399
+ if (durationMs) {
1400
+ console.log(` Duration: ${durationMs}ms (auto-stops)`);
1401
+ }
1402
+ else {
1403
+ console.log(` Stop with 'cbrowser capture stop' (or Ctrl-C in this terminal)`);
1404
+ }
1405
+ // A signal from `capture stop`, or an interactive Ctrl-C, finalises the same
1406
+ // way the duration timer does; stop() is idempotent so they cannot collide.
1407
+ let finished = false;
1408
+ const finish = async () => {
1409
+ if (finished)
1410
+ return;
1411
+ finished = true;
1412
+ const result = await session.stop();
1413
+ if (existsSync(captureStatePath()))
1414
+ unlinkSync(captureStatePath());
1415
+ finishCaptureCommand(result);
1416
+ };
1417
+ process.on("SIGINT", () => { void finish().then(() => process.exit(process.exitCode ?? 0)); });
1418
+ process.on("SIGTERM", () => { void finish().then(() => process.exit(process.exitCode ?? 0)); });
1419
+ // Wait for whichever ends the capture first: the duration timer, a stop
1420
+ // trigger, an explicit `capture stop` signal, or the safety cap. Waiting on
1421
+ // the duration alone would sleep straight through a stop trigger.
1422
+ const capMs = 10 * 60_000;
1423
+ let hitCap = false;
1424
+ await Promise.race([
1425
+ session.finished,
1426
+ new Promise((r) => setTimeout(r, durationMs !== undefined ? durationMs + 500 : capMs))
1427
+ .then(() => { hitCap = durationMs === undefined; }),
1428
+ ]);
1429
+ if (hitCap)
1430
+ console.log(`\n⚠️ Reached the ${capMs / 60_000}-minute capture cap`);
1431
+ await finish();
1432
+ }
1433
+ /**
1434
+ * The one place a capture command ends.
1435
+ *
1436
+ * Printing the artifacts, reporting encoder failures and setting the exit code
1437
+ * were duplicated across the direct and daemon paths, and the duplicate drifted
1438
+ * immediately: the daemon path printed a "✗" and exited 0. Every capture path
1439
+ * funnels through here so "how a capture command ends" has a single definition.
1440
+ */
1441
+ function finishCaptureCommand(result) {
1442
+ const m = result.manifest;
1443
+ if (m.trigger_timeout) {
1444
+ console.error(`\n✗ Capture trigger timed out`);
1445
+ }
1446
+ else {
1447
+ console.log(`\n✓ Capture stopped`);
1448
+ }
1449
+ if (m.start_trigger)
1450
+ console.log(` Start trigger: ${m.start_trigger}`);
1451
+ if (m.stop_trigger)
1452
+ console.log(` Stop trigger: ${m.stop_trigger}`);
1453
+ console.log(` Frames: ${m.frames.length} (${m.actual_fps.toFixed(1)} fps actual vs ${m.target_fps} requested)`);
1454
+ console.log(` Duration: ${m.duration_ms}ms`);
1455
+ if (m.frame_gaps && m.frame_gaps.length > 0) {
1456
+ const worst = Math.max(...m.frame_gaps.map((g) => g.gap_ms));
1457
+ console.log(` Frame gaps: ${m.frame_gaps.length} (largest ${Math.round(worst)}ms - the page was not repainting)`);
1458
+ }
1459
+ console.log(` Manifest: ${result.manifestPath}`);
1460
+ for (const [fmt, rel] of Object.entries(m.artifacts)) {
1461
+ console.log(` ${fmt.toUpperCase()}: ${join(dirname(result.manifestPath), rel)}`);
1462
+ }
1463
+ for (const [fmt, message] of Object.entries(result.encodeErrors)) {
1464
+ console.error(` ✗ ${fmt}: ${message}`);
1465
+ }
1466
+ // A requested format that could not be encoded, or a trigger that never
1467
+ // fired, is a failed run even though the frames and manifest survive.
1468
+ if (Object.keys(result.encodeErrors).length > 0 || m.trigger_timeout)
1469
+ process.exitCode = 1;
1470
+ }
1471
+ /**
1472
+ * Fail a capture command with a message. Centralised for the same reason as
1473
+ * finishCaptureCommand: a "✗" printed with a zero exit is a lie a script
1474
+ * cannot detect.
1475
+ */
1476
+ function failCaptureCommand(message) {
1477
+ console.error(`✗ ${message}`);
1478
+ process.exit(1);
1479
+ }
1480
+ /**
1481
+ * Start a capture inside the daemon and return immediately.
1482
+ *
1483
+ * The capture keeps running after this process exits, which is what lets a
1484
+ * later `cbrowser click` or `cbrowser fill` appear in the recording.
1485
+ */
1486
+ async function runCaptureStartViaDaemon(url, options) {
1487
+ const target = buildCaptureTarget(options);
1488
+ const triggers = buildCaptureTriggers(options);
1489
+ const slug = slugFor(options);
1490
+ const response = await sendToDaemon("captureStart", {
1491
+ url,
1492
+ engine: options.browser === "firefox" ? "firefox" : options.browser === "webkit" ? "webkit" : "chromium",
1493
+ options: {
1494
+ fps: options.fps !== undefined ? Number(options.fps) : undefined,
1495
+ quality: options.quality !== undefined ? Number(options.quality) : undefined,
1496
+ maxFrames: options["max-frames"] !== undefined ? Number(options["max-frames"]) : undefined,
1497
+ format: typeof options.format === "string" ? parseFormats(options.format) : undefined,
1498
+ outDir: typeof options.out === "string" ? resolve(options.out) : undefined,
1499
+ contactSheet: options["contact-sheet"] === true,
1500
+ slug,
1501
+ target,
1502
+ ...triggers,
1503
+ },
1504
+ });
1505
+ if (!response.success)
1506
+ failCaptureCommand(response.error ?? "daemon refused the capture");
1507
+ const status = response.result;
1508
+ console.log(`🎥 Capturing in the daemon (${status.captureMethod})`);
1509
+ console.log(` Name: ${status.slug}`);
1510
+ console.log(` Output: ${status.outDir}`);
1511
+ console.log("");
1512
+ console.log(" The capture is live and this shell is free. Commands you run now drive");
1513
+ console.log(" the same page and appear in the recording:");
1514
+ console.log(" cbrowser navigate <url> / click <sel> / fill <sel> <value>");
1515
+ console.log(" Finish with: cbrowser capture stop");
1516
+ }
1517
+ /** Stop the daemon's capture and report it. */
1518
+ async function runCaptureStopViaDaemon() {
1519
+ const response = await sendToDaemon("captureStop", {});
1520
+ if (!response.success)
1521
+ failCaptureCommand(response.error ?? "daemon could not stop the capture");
1522
+ finishCaptureCommand(response.result);
1523
+ }
1524
+ /** Signal the running capture process and wait for its manifest to land. */
1525
+ async function runCaptureStop() {
1526
+ // The daemon owns any open-ended capture, so ask it first.
1527
+ if (await isDaemonRunning()) {
1528
+ const status = await sendToDaemon("captureStatus", {});
1529
+ const state = status.result?.state;
1530
+ if (status.success && state === "recording") {
1531
+ await runCaptureStopViaDaemon();
1532
+ return;
1533
+ }
1534
+ }
1535
+ const statePath = captureStatePath();
1536
+ if (!existsSync(statePath)) {
1537
+ console.error("No active capture. A capture is stopped from the process that started it.");
1538
+ process.exit(1);
1539
+ }
1540
+ const active = JSON.parse(readFileSync(statePath, "utf-8"));
1541
+ try {
1542
+ // SIGTERM rather than SIGINT: a capture launched into the background by a
1543
+ // non-interactive shell inherits SIGINT as ignored, so an interrupt never
1544
+ // reaches the handler and the capture dies without writing its manifest.
1545
+ process.kill(active.pid, "SIGTERM");
1546
+ }
1547
+ catch {
1548
+ console.error(`Capture process ${active.pid} is gone; clearing stale state.`);
1549
+ unlinkSync(statePath);
1550
+ process.exit(1);
1551
+ }
1552
+ const manifestPath = join(active.outDir, "manifest.json");
1553
+ const deadline = Date.now() + 60_000;
1554
+ while (Date.now() < deadline) {
1555
+ if (existsSync(manifestPath)) {
1556
+ console.log(`✓ Capture stopped: ${active.slug}`);
1557
+ console.log(` Manifest: ${manifestPath}`);
1558
+ return;
1559
+ }
1560
+ await new Promise((r) => setTimeout(r, 200));
1561
+ }
1562
+ console.error(`Signalled process ${active.pid} but no manifest appeared at ${manifestPath} within 60s`);
1563
+ process.exit(1);
1564
+ }
1565
+ /** Report the active capture, or the most recent finished one. */
1566
+ async function runCaptureStatus() {
1567
+ if (await isDaemonRunning()) {
1568
+ const response = await sendToDaemon("captureStatus", {});
1569
+ const status = response.result;
1570
+ if (response.success && status && status.state === "recording") {
1571
+ console.log("🎥 Capture in progress (in the daemon)");
1572
+ console.log(` Name: ${status.slug}`);
1573
+ console.log(` Frames so far: ${status.frames}`);
1574
+ console.log(` Elapsed: ${status.elapsedMs}ms`);
1575
+ console.log(` Method: ${status.captureMethod}`);
1576
+ console.log(` Output: ${status.outDir}`);
1577
+ console.log(" Stop with: cbrowser capture stop");
1578
+ return;
1579
+ }
1580
+ if (response.success && status && status.state === "stopped" && status.manifestPath) {
1581
+ console.log("No capture in progress. The daemon's most recent:");
1582
+ console.log(` Name: ${status.slug}`);
1583
+ console.log(` Frames: ${status.frames}`);
1584
+ console.log(` Manifest: ${status.manifestPath}`);
1585
+ return;
1586
+ }
1587
+ }
1588
+ const statePath = captureStatePath();
1589
+ if (existsSync(statePath)) {
1590
+ const active = JSON.parse(readFileSync(statePath, "utf-8"));
1591
+ let alive = true;
1592
+ try {
1593
+ process.kill(active.pid, 0);
1594
+ }
1595
+ catch {
1596
+ alive = false;
1597
+ }
1598
+ if (alive) {
1599
+ const frames = existsSync(join(active.outDir, "frames"))
1600
+ ? readdirSync(join(active.outDir, "frames")).length
1601
+ : 0;
1602
+ console.log(`🎥 Capture in progress`);
1603
+ console.log(` Name: ${active.slug}`);
1604
+ console.log(` PID: ${active.pid}`);
1605
+ console.log(` Started: ${active.startedAt}`);
1606
+ console.log(` Frames so far: ${frames}`);
1607
+ console.log(` Output: ${active.outDir}`);
1608
+ return;
1609
+ }
1610
+ console.log(`⚠️ Stale state file for a dead process (${active.pid}); last output: ${active.outDir}`);
1611
+ return;
1612
+ }
1613
+ const recordingsDir = getPaths().videosDir;
1614
+ if (!existsSync(recordingsDir)) {
1615
+ console.log("No captures yet");
1616
+ return;
1617
+ }
1618
+ // Captures live at videos/<session>/<slug>; earlier builds wrote
1619
+ // videos/<slug> and recordings/<slug>, so look one level either way.
1620
+ const candidates = [];
1621
+ for (const entry of readdirSync(recordingsDir)) {
1622
+ const entryPath = join(recordingsDir, entry);
1623
+ if (!statSync(entryPath).isDirectory())
1624
+ continue;
1625
+ candidates.push(join(entryPath, "manifest.json"));
1626
+ for (const nested of readdirSync(entryPath)) {
1627
+ const nestedPath = join(entryPath, nested);
1628
+ if (statSync(nestedPath).isDirectory())
1629
+ candidates.push(join(nestedPath, "manifest.json"));
1630
+ }
1631
+ }
1632
+ const manifests = candidates
1633
+ .filter((p) => existsSync(p))
1634
+ .sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs);
1635
+ if (manifests.length === 0) {
1636
+ console.log("No captures yet");
1637
+ return;
1638
+ }
1639
+ const manifest = parseManifest(readFileSync(manifests[0], "utf-8"));
1640
+ console.log(`No capture in progress. Most recent:`);
1641
+ console.log(` Name: ${manifest.slug}`);
1642
+ console.log(` Started: ${manifest.started_at}`);
1643
+ console.log(` Frames: ${manifest.frames.length} over ${manifest.duration_ms}ms (${manifest.actual_fps.toFixed(1)} fps)`);
1644
+ console.log(` Capture: ${manifest.capture_method}`);
1645
+ // changeSignal() rather than the optional fields directly: it maps a v1
1646
+ // manifest's change_points onto keyFrames, which is exact rather than a
1647
+ // guess (v1 computed them at the 0.9 bar that v2 calls key_frames). Reading
1648
+ // the raw field would report "no key frames" for a file that has them.
1649
+ const signal = changeSignal(manifest);
1650
+ console.log(` Key frames: ${signal.keyFrames.length}${signal.keyFrames.length > 0 ? ` (${signal.keyFrames.slice(0, 8).join(", ")}${signal.keyFrames.length > 8 ? ", ..." : ""})` : ""}`);
1651
+ console.log(` Change points: ${signal.changePoints.length}` +
1652
+ (signal.saturated ? ` — saturated, use key frames instead` : ""));
1653
+ console.log(` Artifacts: ${Object.entries(manifest.artifacts).map(([k, v]) => `${k} -> ${v}`).join(", ") || "none"}`);
1654
+ console.log(` Manifest: ${manifests[0]}`);
1655
+ }
1656
+ /**
1657
+ * Reject capture-namespace flags a wrapped run cannot honour.
1658
+ *
1659
+ * Deliberately narrower than the allowlists on `capture`/`evaluate`/`keyboard`:
1660
+ * `test-suite` and `cognitive-journey` have large, long-established flag sets
1661
+ * that I cannot enumerate with confidence, and a wrong allowlist would reject
1662
+ * flags that work today. So this rejects only what it can be sure about - any
1663
+ * unrecognised `--capture*` flag, plus the two spellings people actually
1664
+ * reached for before `--capture` existed.
1665
+ */
1666
+ function assertCaptureNamespaceFlags(command, options) {
1667
+ const valid = new Set(["capture", "capture-fps", "capture-format", "capture-out"]);
1668
+ const wrong = Object.keys(options).filter((f) => f.startsWith("capture") && !valid.has(f));
1669
+ // `--record-video` is a real flag on `explore` (an older, separate feature
1670
+ // that saves a Playwright video). It is NOT a flag here, and silently
1671
+ // ignoring it is what let this whole class survive.
1672
+ const aliases = ["record", "record-video"].filter((f) => options[f] !== undefined);
1673
+ if (wrong.length === 0 && aliases.length === 0)
1674
+ return;
1675
+ for (const flag of wrong) {
1676
+ console.error(`Error: '${command}' does not support --${flag}`);
1677
+ }
1678
+ for (const flag of aliases) {
1679
+ console.error(`Error: '${command}' does not support --${flag}; use --capture`);
1680
+ }
1681
+ console.error(` Capture flags: ${[...valid].map((f) => `--${f}`).join(" ")}`);
1682
+ process.exit(1);
1683
+ }
1684
+ /**
1685
+ * Build the `AutoCaptureSetting` for a wrapped run (test-suite, journey).
1686
+ *
1687
+ * Returns undefined when no capture flag was given, so a run that does not ask
1688
+ * for a recording behaves exactly as before.
1689
+ */
1690
+ function buildAutoCaptureSetting(options, defaultName) {
1691
+ const asked = options.capture === true || typeof options.capture === "string";
1692
+ const detailFlags = ["capture-fps", "capture-format", "capture-out"].filter((f) => options[f] !== undefined);
1693
+ if (!asked) {
1694
+ if (detailFlags.length > 0) {
1695
+ console.error(`Error: ${detailFlags.map((f) => `--${f}`).join(", ")} require --capture`);
1696
+ process.exit(1);
1697
+ }
1698
+ return undefined;
1699
+ }
1700
+ const fps = options["capture-fps"] !== undefined ? Number(options["capture-fps"]) : undefined;
1701
+ if (fps !== undefined && (!Number.isFinite(fps) || fps <= 0)) {
1702
+ console.error(`Error: --capture-fps must be a positive number (got "${String(options["capture-fps"])}")`);
1703
+ process.exit(1);
1704
+ }
1705
+ return {
1706
+ name: defaultName,
1707
+ fps,
1708
+ format: typeof options["capture-format"] === "string" ? parseFormats(options["capture-format"]) : undefined,
1709
+ outDir: typeof options["capture-out"] === "string" ? resolve(options["capture-out"]) : undefined,
1710
+ };
1711
+ }
1712
+ /** Report where a wrapped run's recording landed, or why it failed. */
1713
+ function reportAutoCapture(capture) {
1714
+ if (!capture)
1715
+ return;
1716
+ if (capture.error) {
1717
+ console.error(`\n🎥 Capture failed: ${capture.error}`);
1718
+ process.exitCode = 1;
1719
+ return;
1720
+ }
1721
+ console.log(`\n🎥 Capture: ${capture.frames} frames${capture.durationMs ? ` over ${capture.durationMs}ms` : ""}${capture.actualFps ? ` (${capture.actualFps.toFixed(1)} fps)` : ""}`);
1722
+ if (capture.manifestPath)
1723
+ console.log(` Manifest: ${capture.manifestPath}`);
1724
+ for (const [fmt, rel] of Object.entries(capture.artifacts ?? {})) {
1725
+ console.log(` ${fmt.toUpperCase()}: ${join(capture.outDir, rel)}`);
1726
+ }
1727
+ }
1728
+ // =========================================================================
1729
+ // evaluate — run JavaScript in the page
1730
+ // =========================================================================
1731
+ /** Flags `evaluate` understands; anything else is rejected rather than ignored. */
1732
+ const EVALUATE_FLAGS = new Set([
1733
+ "file", "arg", "json", "raw", "wait-for", "expect-truthy",
1734
+ "url", "browser", "device", "headless", "persistent", "restore",
1735
+ "locale", "timezone", "geo", "verbose",
1736
+ ]);
1737
+ /**
1738
+ * Collect a repeatable flag's values.
1739
+ *
1740
+ * parseArgs keeps only the last value for a repeated flag, so `--arg` is
1741
+ * re-read from argv here. Without this, `--arg 1 --arg 2` would silently pass
1742
+ * a single argument.
1743
+ */
1744
+ function repeatedFlag(name) {
1745
+ const values = [];
1746
+ const argv = process.argv.slice(2);
1747
+ for (let i = 0; i < argv.length; i++) {
1748
+ if (argv[i] === `--${name}` && argv[i + 1] !== undefined && !argv[i + 1].startsWith("--")) {
1749
+ values.push(argv[i + 1]);
1750
+ i++;
1751
+ }
1752
+ }
1753
+ return values;
1754
+ }
1755
+ /**
1756
+ * Evaluate a script in the page, accepting all three shapes people write.
1757
+ *
1758
+ * `--file` exists because multi-line scripts die in shell quoting, so it has to
1759
+ * accept real scripts - `const`, `let`, an explicit `return` - not only a
1760
+ * single expression. The source is compiled inside the page as a function
1761
+ * body: a bare expression gets an implicit return, a statement body keeps its
1762
+ * own, and a function/arrow literal is called with the arguments.
1763
+ */
1764
+ async function evaluateScript(page, script, args) {
1765
+ const source = script.trim();
1766
+ // A function or arrow literal is called with the arguments directly.
1767
+ const isCallable = /^(async\s+)?function\b/.test(source) || /=>/.test(source);
1768
+ return page.evaluate((payload) => {
1769
+ if (payload.callable) {
1770
+ const fn = (0, eval)(`(${payload.src})`);
1771
+ return fn(...payload.a);
1772
+ }
1773
+ // Compile in the page, trying the three shapes people write, in order.
1774
+ // Deciding by regex was wrong twice: `({a:1}).a` starts with "(" but is
1775
+ // not a function, and a statement body with no `return` silently produced
1776
+ // undefined. Letting the JS parser decide is the only reliable test.
1777
+ const compile = (src) => {
1778
+ // 1. A bare expression: implicit return.
1779
+ try {
1780
+ return new Function("args", `return (${src});`);
1781
+ }
1782
+ catch { /* not an expression */ }
1783
+ // 2. Statements with no explicit return: return the final expression,
1784
+ // which is what a REPL and the old one-arg form both did.
1785
+ if (!/\breturn\b/.test(src)) {
1786
+ const trimmed = src.replace(/;\s*$/, "");
1787
+ const split = Math.max(trimmed.lastIndexOf(";"), trimmed.lastIndexOf("\n"));
1788
+ if (split > -1) {
1789
+ const head = trimmed.slice(0, split + 1);
1790
+ const tail = trimmed.slice(split + 1).trim();
1791
+ if (tail) {
1792
+ try {
1793
+ return new Function("args", `${head}\nreturn (${tail});`);
1794
+ }
1795
+ catch { /* the tail is not an expression either */ }
1796
+ }
1797
+ }
1798
+ }
1799
+ // 3. Statements as written. Any syntax error surfaces from here, which
1800
+ // is the error the user should actually see.
1801
+ return new Function("args", src);
1802
+ };
1803
+ return compile(payload.src)(payload.a);
1804
+ }, { src: source, a: args, callable: isCallable });
1805
+ }
1806
+ /**
1807
+ * Run JavaScript in the page.
1808
+ *
1809
+ * The bare `evaluate "<js>"` form is unchanged. Everything else is additive:
1810
+ * a script from a file (shell quoting mangles multi-line scripts), JSON
1811
+ * arguments (so a script is reusable instead of string-interpolated), an
1812
+ * explicit output mode, and an assertion mode for CI.
1813
+ */
1814
+ async function runEvaluate(browser, args, options) {
1815
+ const unknown = Object.keys(options).filter((f) => !EVALUATE_FLAGS.has(f));
1816
+ if (unknown.length > 0) {
1817
+ console.error(`Error: 'evaluate' does not support ${unknown.map((f) => `--${f}`).join(", ")}`);
1818
+ console.error(` Supported: ${[...EVALUATE_FLAGS].map((f) => `--${f}`).join(" ")}`);
1819
+ process.exit(1);
1820
+ }
1821
+ if (options.json === true && options.raw === true) {
1822
+ console.error("Error: --json and --raw are mutually exclusive - pick one");
1823
+ process.exit(1);
1824
+ }
1825
+ const fromFile = typeof options.file === "string";
1826
+ if (fromFile && args[0]) {
1827
+ console.error("Error: pass the script as an argument OR --file, not both");
1828
+ process.exit(1);
1829
+ }
1830
+ let script;
1831
+ if (fromFile) {
1832
+ const path = resolve(options.file);
1833
+ if (!existsSync(path)) {
1834
+ console.error(`Error: script file not found: ${path}`);
1835
+ process.exit(1);
1836
+ }
1837
+ script = readFileSync(path, "utf-8");
1838
+ }
1839
+ else if (args[0]) {
1840
+ script = args[0];
1841
+ }
1842
+ else {
1843
+ console.error('Usage: cbrowser evaluate "<javascript>" | --file <path>');
1844
+ console.error(" Run 'cbrowser help evaluate' for options");
1845
+ process.exit(1);
1846
+ }
1847
+ // Arguments arrive as JSON so types survive: --arg 3 is a number, --arg '"3"'
1848
+ // is a string. A malformed value names itself rather than reaching the page.
1849
+ const rawArgs = repeatedFlag("arg");
1850
+ const evalArgs = rawArgs.map((raw, i) => {
1851
+ try {
1852
+ return JSON.parse(raw);
1853
+ }
1854
+ catch {
1855
+ console.error(`Error: --arg #${i + 1} is not valid JSON: ${raw}`);
1856
+ console.error(` Strings need quotes the shell will keep, e.g. --arg '"text"'`);
1857
+ process.exit(1);
1858
+ }
1859
+ });
1860
+ if (options.url)
1861
+ await browser.navigate(options.url);
1862
+ const page = await browser.getPage();
1863
+ if (typeof options["wait-for"] === "string") {
1864
+ try {
1865
+ await page.waitForSelector(options["wait-for"], { timeout: 30_000 });
1866
+ }
1867
+ catch {
1868
+ console.error(`✗ Timed out waiting for selector: ${options["wait-for"]}`);
1869
+ process.exit(1);
1870
+ }
1871
+ }
1872
+ let result;
1873
+ try {
1874
+ result = await evaluateScript(page, script, evalArgs);
1875
+ }
1876
+ catch (e) {
1877
+ const error = e;
1878
+ console.error(`✗ ${error.message}`);
1879
+ // The page-side stack is the part that says WHERE in the script it broke.
1880
+ if (error.stack)
1881
+ console.error(error.stack.split("\n").slice(1).join("\n"));
1882
+ process.exit(1);
1883
+ }
1884
+ if (options.raw === true) {
1885
+ console.log(String(result));
1886
+ }
1887
+ else if (options.json === true) {
1888
+ console.log(JSON.stringify(result, null, 2));
1889
+ }
1890
+ else {
1891
+ // Legacy default, kept so the existing one-arg form behaves identically.
1892
+ console.log(typeof result === "string" ? result : JSON.stringify(result, null, 2));
1893
+ }
1894
+ if (options["expect-truthy"] === true && !result) {
1895
+ console.error(`✗ Expected a truthy result, got ${JSON.stringify(result)}`);
1896
+ process.exit(1);
1897
+ }
1898
+ }
1899
+ // =========================================================================
1900
+ // keyboard — sequences, chords and held modifiers
1901
+ // =========================================================================
1902
+ /** Modifier names Playwright accepts in a chord. */
1903
+ const KEYBOARD_MODIFIERS = ["Control", "Shift", "Alt", "Meta", "ControlOrMeta"];
1904
+ /**
1905
+ * Named keys Playwright accepts. Anything not here and not a single character
1906
+ * is rejected: silently typing an unrecognised key name as literal text is how
1907
+ * `--element` and `--device` used to fail.
1908
+ */
1909
+ const KEYBOARD_NAMED_KEYS = [
1910
+ "Enter", "Tab", "Escape", "Backspace", "Delete", "Insert", "Space",
1911
+ "ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown",
1912
+ "Home", "End", "PageUp", "PageDown", "CapsLock", "ContextMenu",
1913
+ ...Array.from({ length: 12 }, (_, i) => `F${i + 1}`),
1914
+ ...KEYBOARD_MODIFIERS,
1915
+ ];
1916
+ /** Levenshtein distance, capped early since only small distances matter here. */
1917
+ function editDistance(a, b) {
1918
+ if (Math.abs(a.length - b.length) > 1)
1919
+ return 99;
1920
+ const rows = Array.from({ length: a.length + 1 }, (_, i) => Array.from({ length: b.length + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0)));
1921
+ for (let i = 1; i <= a.length; i++) {
1922
+ for (let j = 1; j <= b.length; j++) {
1923
+ rows[i][j] = a[i - 1] === b[j - 1]
1924
+ ? rows[i - 1][j - 1]
1925
+ : 1 + Math.min(rows[i - 1][j - 1], rows[i - 1][j], rows[i][j - 1]);
1926
+ }
1927
+ }
1928
+ return rows[a.length][b.length];
1929
+ }
1930
+ /**
1931
+ * Classify a token as a chord, a named key, or literal text.
1932
+ *
1933
+ * A token is a chord only when every `+`-separated part but the last is a known
1934
+ * modifier - so "Control+A" is a chord and "a+b" or "1+1" is text.
1935
+ */
1936
+ function parseKeyboardToken(token, literal = false) {
1937
+ // --text forces literal typing, which is how a string containing "+" is
1938
+ // expressed now that "+" always means a chord.
1939
+ if (literal)
1940
+ return { kind: "text", text: token };
1941
+ // ANY interior "+" means the user intended a chord, so every segment is
1942
+ // validated. The previous rule - chord only if every modifier was already
1943
+ // valid - meant a typo like "Ctrl+NotAKey" failed the test and fell through
1944
+ // to the literal-text branch, silently TYPING the typo into the page.
1945
+ if (token.includes("+") && token.length > 1 && !token.startsWith("+") && !token.endsWith("+")) {
1946
+ const parts = token.split("+");
1947
+ const mods = parts.slice(0, -1);
1948
+ const key = parts[parts.length - 1];
1949
+ const badMods = mods.filter((m) => !KEYBOARD_MODIFIERS.includes(m));
1950
+ if (badMods.length > 0) {
1951
+ console.error(`Error: unknown modifier ${badMods.map((m) => `"${m}"`).join(", ")} in chord "${token}"`);
1952
+ // The names people actually reach for. Suggested, never silently
1953
+ // accepted - a wrong modifier that "worked" would hide the typo.
1954
+ const ALIASES = {
1955
+ ctrl: "Control", cmd: "Meta", command: "Meta", win: "Meta",
1956
+ super: "Meta", option: "Alt", opt: "Alt", altgr: "Alt", ctl: "Control",
1957
+ };
1958
+ const suggestions = badMods
1959
+ .map((m) => ALIASES[m.toLowerCase()] ?? KEYBOARD_MODIFIERS.find((k) => k.toLowerCase().startsWith(m.toLowerCase())))
1960
+ .filter(Boolean);
1961
+ if (suggestions.length > 0)
1962
+ console.error(` Did you mean: ${suggestions.join(", ")}?`);
1963
+ console.error(` Valid modifiers: ${KEYBOARD_MODIFIERS.join(", ")}`);
1964
+ console.error(` To type this as literal text, use --text.`);
1965
+ process.exit(1);
1966
+ }
1967
+ if (key.length !== 1 && !KEYBOARD_NAMED_KEYS.includes(key)) {
1968
+ console.error(`Error: unknown key "${key}" in chord "${token}"`);
1969
+ console.error(` Modifiers: ${KEYBOARD_MODIFIERS.join(", ")}`);
1970
+ console.error(` Named keys: ${KEYBOARD_NAMED_KEYS.filter((k) => !KEYBOARD_MODIFIERS.includes(k)).join(", ")}`);
1971
+ console.error(` To type this as literal text, use --text.`);
1972
+ process.exit(1);
1973
+ }
1974
+ return { kind: "chord", key: token };
1975
+ }
1976
+ if (KEYBOARD_NAMED_KEYS.includes(token))
1977
+ return { kind: "key", key: token };
1978
+ // Case-insensitive matches are intent, not typos: "enter" means Enter.
1979
+ const cased = KEYBOARD_NAMED_KEYS.find((k) => k.toLowerCase() === token.toLowerCase());
1980
+ if (cased)
1981
+ return { kind: "key", key: cased };
1982
+ // A near-miss on a real key name is a typo worth rejecting ("Delet",
1983
+ // "ArrowLef"). Distance is measured against the actual key list rather than
1984
+ // guessed from shape, so ordinary words to be typed ("Hello", "Replaced")
1985
+ // are nowhere near a key name and pass through as text.
1986
+ const near = KEYBOARD_NAMED_KEYS.filter((k) => editDistance(k.toLowerCase(), token.toLowerCase()) <= 1);
1987
+ if (near.length > 0) {
1988
+ console.error(`Error: unknown key name "${token}"`);
1989
+ console.error(` Did you mean: ${near.join(", ")}?`);
1990
+ console.error(` Valid modifiers: ${KEYBOARD_MODIFIERS.join(", ")}`);
1991
+ process.exit(1);
1992
+ }
1993
+ return { kind: "text", text: token };
1994
+ }
1995
+ /** Flags `keyboard` understands. */
1996
+ const KEYBOARD_FLAGS = new Set([
1997
+ "delay", "selector", "hold", "repeat", "text",
1998
+ "url", "browser", "device", "headless", "persistent", "restore",
1999
+ "locale", "timezone", "geo", "verbose",
2000
+ ]);
2001
+ /**
2002
+ * Drive a keyboard sequence.
2003
+ *
2004
+ * Complements `press` (one key) and `type` (one string) rather than replacing
2005
+ * them: this is for ordered sequences, chords, and modifiers held across
2006
+ * several steps, which neither of those can express.
2007
+ */
2008
+ async function runKeyboard(browser, args, options) {
2009
+ const unknown = Object.keys(options).filter((f) => !KEYBOARD_FLAGS.has(f));
2010
+ if (unknown.length > 0) {
2011
+ console.error(`Error: 'keyboard' does not support ${unknown.map((f) => `--${f}`).join(", ")}`);
2012
+ console.error(` Supported: ${[...KEYBOARD_FLAGS].map((f) => `--${f}`).join(" ")}`);
2013
+ process.exit(1);
2014
+ }
2015
+ if (args.length === 0) {
2016
+ console.error('Usage: cbrowser keyboard "<key|chord|text>" ["<key|chord|text>" ...]');
2017
+ console.error(" Run 'cbrowser help keyboard' for options");
2018
+ process.exit(1);
2019
+ }
2020
+ const delay = options.delay !== undefined ? Number(options.delay) : 50;
2021
+ if (!Number.isFinite(delay) || delay < 0) {
2022
+ console.error(`Error: --delay must be a non-negative number (got "${String(options.delay)}")`);
2023
+ process.exit(1);
2024
+ }
2025
+ const repeat = options.repeat !== undefined ? Number(options.repeat) : 1;
2026
+ if (!Number.isInteger(repeat) || repeat < 1) {
2027
+ console.error(`Error: --repeat must be a positive integer (got "${String(options.repeat)}")`);
2028
+ process.exit(1);
2029
+ }
2030
+ const hold = typeof options.hold === "string"
2031
+ ? options.hold.split("+").map((m) => m.trim()).filter(Boolean)
2032
+ : [];
2033
+ for (const modifier of hold) {
2034
+ if (!KEYBOARD_MODIFIERS.includes(modifier)) {
2035
+ console.error(`Error: unknown --hold modifier "${modifier}"`);
2036
+ console.error(` Valid modifiers: ${KEYBOARD_MODIFIERS.join(", ")}`);
2037
+ process.exit(1);
2038
+ }
2039
+ }
2040
+ // Parsed up-front so a bad token fails before any key reaches the page and
2041
+ // leaves the form half-filled.
2042
+ const literal = options.text === true;
2043
+ const steps = args.map((token) => parseKeyboardToken(token, literal));
2044
+ if (options.url)
2045
+ await browser.navigate(options.url);
2046
+ const page = await browser.getPage();
2047
+ if (typeof options.selector === "string") {
2048
+ const locator = await browser.resolveElementLocator(options.selector);
2049
+ if (!locator) {
2050
+ console.error(`✗ No element matched selector: ${options.selector}`);
2051
+ process.exit(1);
2052
+ }
2053
+ await locator.focus();
2054
+ }
2055
+ for (const modifier of hold)
2056
+ await page.keyboard.down(modifier);
2057
+ try {
2058
+ for (let pass = 0; pass < repeat; pass++) {
2059
+ for (const step of steps) {
2060
+ if (step.kind === "text") {
2061
+ await page.keyboard.type(step.text, { delay: Math.min(delay, 50) });
2062
+ }
2063
+ else {
2064
+ await page.keyboard.press(step.key);
2065
+ }
2066
+ if (delay > 0)
2067
+ await new Promise((r) => setTimeout(r, delay));
2068
+ }
2069
+ }
2070
+ }
2071
+ finally {
2072
+ // Released even if a step throws: a stuck modifier would corrupt every
2073
+ // later command in the same persistent session.
2074
+ for (const modifier of [...hold].reverse())
2075
+ await page.keyboard.up(modifier);
2076
+ }
2077
+ const summary = steps
2078
+ .map((s) => (s.kind === "text" ? `"${s.text}"` : s.key))
2079
+ .join(" → ");
2080
+ console.log(`✓ Keyboard: ${summary}${repeat > 1 ? ` (x${repeat})` : ""}${hold.length > 0 ? ` holding ${hold.join("+")}` : ""}`);
2081
+ }
749
2082
  function formatBytes(bytes) {
750
2083
  if (bytes < 1024)
751
2084
  return `${bytes} B`;
@@ -1175,10 +2508,22 @@ async function main() {
1175
2508
  const outputOpts = detectOutputOptions(options);
1176
2509
  setOutputOptions(outputOpts);
1177
2510
  installOutputWrappers();
1178
- if (command === "help" || options.help) {
2511
+ // `--help`/`-h` are aliases for `help`, matching how `--version`/`-v` alias
2512
+ // `version` below. `help <command>` and `<command> --help` both narrow to a
2513
+ // single section rather than dumping the whole manual.
2514
+ const isHelpCommand = command === "help" || command === "--help" || command === "-h";
2515
+ if (isHelpCommand || options.help) {
2516
+ const topic = isHelpCommand
2517
+ ? args[0] ?? (typeof options.help === "string" ? options.help : undefined)
2518
+ : command;
2519
+ if (topic) {
2520
+ process.exit(showCommandHelp(topic) ? 0 : 1);
2521
+ }
1179
2522
  showHelp();
1180
2523
  process.exit(0);
1181
2524
  }
2525
+ // One gate for every command in the registry, before anything executes.
2526
+ validateCommandFlags(command, options);
1182
2527
  // Doctor command - environment health check (v18.54.0)
1183
2528
  if (command === "doctor") {
1184
2529
  await runDoctor(outputOpts);
@@ -1409,7 +2754,15 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
1409
2754
  }
1410
2755
  // Check if daemon is running and use it for supported commands
1411
2756
  const daemonRunning = await isDaemonRunning();
1412
- if (daemonRunning && ["navigate", "click", "fill", "hover", "screenshot", "extract", "run"].includes(command)) {
2757
+ // Commands that drive the page. When a daemon holds the browser these MUST
2758
+ // go through it: run locally and they open a second browser, drive a
2759
+ // different page, and report success for work the user cannot see - which is
2760
+ // exactly what `keyboard` did during a live capture.
2761
+ const INTERACTION_COMMANDS = [
2762
+ "navigate", "click", "fill", "hover", "screenshot", "extract", "run",
2763
+ "keyboard", "press", "type", "evaluate", "eval",
2764
+ ];
2765
+ if (daemonRunning && INTERACTION_COMMANDS.includes(command)) {
1413
2766
  console.log("🔌 Connected to running daemon");
1414
2767
  const daemonCommand = command;
1415
2768
  let daemonArgs = {};
@@ -1426,6 +2779,54 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
1426
2779
  case "fill":
1427
2780
  daemonArgs = { selector: args[0], value: args[1] };
1428
2781
  break;
2782
+ case "keyboard": {
2783
+ validateCommandFlags("keyboard", options);
2784
+ if (args.length === 0) {
2785
+ console.error('Usage: cbrowser keyboard "<key|chord|text>" ["<key|chord|text>" ...]');
2786
+ process.exit(1);
2787
+ }
2788
+ // Tokens are validated HERE, before the request leaves: an unknown key
2789
+ // must never reach an execution path, daemon-side or not.
2790
+ const literal = options.text === true;
2791
+ const steps = args.map((token) => parseKeyboardToken(token, literal));
2792
+ const hold = typeof options.hold === "string"
2793
+ ? options.hold.split("+").map((m) => m.trim()).filter(Boolean)
2794
+ : [];
2795
+ for (const modifier of hold) {
2796
+ if (!KEYBOARD_MODIFIERS.includes(modifier)) {
2797
+ console.error(`Error: unknown --hold modifier "${modifier}"`);
2798
+ console.error(` Valid modifiers: ${KEYBOARD_MODIFIERS.join(", ")}`);
2799
+ process.exit(1);
2800
+ }
2801
+ }
2802
+ daemonArgs = {
2803
+ steps,
2804
+ hold,
2805
+ delay: options.delay !== undefined ? Number(options.delay) : 50,
2806
+ repeat: options.repeat !== undefined ? Number(options.repeat) : 1,
2807
+ selector: typeof options.selector === "string" ? options.selector : undefined,
2808
+ };
2809
+ break;
2810
+ }
2811
+ case "press":
2812
+ daemonArgs = { key: args[0] };
2813
+ break;
2814
+ case "type":
2815
+ daemonArgs = { text: args[0], delay: options.delay !== undefined ? Number(options.delay) : 50 };
2816
+ break;
2817
+ case "eval":
2818
+ case "evaluate": {
2819
+ validateCommandFlags(command, options);
2820
+ const script = typeof options.file === "string"
2821
+ ? readFileSync(resolve(options.file), "utf-8")
2822
+ : args[0];
2823
+ if (!script) {
2824
+ console.error('Usage: cbrowser evaluate "<javascript>" | --file <path>');
2825
+ process.exit(1);
2826
+ }
2827
+ daemonArgs = { body: `return (function(){ ${/\breturn\b/.test(script) ? script : `return (${script});`} })();`, args: [] };
2828
+ break;
2829
+ }
1429
2830
  case "screenshot": {
1430
2831
  let ssUrl;
1431
2832
  let ssPath = typeof options.output === "string" ? options.output : undefined;
@@ -1472,7 +2873,19 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
1472
2873
  console.error(`✗ Daemon error: ${result.error}`);
1473
2874
  process.exit(1);
1474
2875
  }
1475
- return;
2876
+ // Every other command path ends with an explicit exit; this one returned,
2877
+ // so a keep-alive socket to a busy daemon held the process open forever.
2878
+ // That is what looked like `fill` deadlocking against a live capture: the
2879
+ // command had already completed and printed its result.
2880
+ process.exit(process.exitCode ?? 0);
2881
+ }
2882
+ // An interaction command that is NOT routed would drive a second browser.
2883
+ if (daemonRunning && !INTERACTION_COMMANDS.includes(command) && DRIVES_THE_PAGE.includes(command)) {
2884
+ console.error(`Error: '${command}' cannot run while the daemon holds the browser.`);
2885
+ console.error(` It would launch a second browser and drive a different page,`);
2886
+ console.error(` so its effects would not appear in a running capture.`);
2887
+ console.error(` Stop the daemon first: cbrowser daemon stop`);
2888
+ process.exit(1);
1476
2889
  }
1477
2890
  // Parse browser type
1478
2891
  const browserType = options.browser === "firefox" ? "firefox"
@@ -1498,10 +2911,27 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
1498
2911
  const headless = options.headless !== false && options.headless !== "false";
1499
2912
  // --no-restore: skip session URL restoration (preserves page state after interactions)
1500
2913
  const skipRestore = options.restore === false || options.restore === "false";
2914
+ // Device presets are validated before the browser is built, since that is
2915
+ // where the name is consumed - an unknown name is silently dropped there.
2916
+ if (command === "capture" && typeof options.device === "string") {
2917
+ options.device = resolveDevicePreset(options.device);
2918
+ }
2919
+ // --viewport WxH overrides the configured viewport for this invocation.
2920
+ let viewportOverride;
2921
+ if (typeof options.viewport === "string") {
2922
+ try {
2923
+ viewportOverride = parseViewportList(options.viewport)[0];
2924
+ }
2925
+ catch (e) {
2926
+ console.error(`Error: ${e.message}`);
2927
+ process.exit(1);
2928
+ }
2929
+ }
1501
2930
  const browser = new CBrowser({
1502
2931
  browser: browserType,
1503
2932
  headless,
1504
2933
  device: options.device,
2934
+ ...(viewportOverride ? { viewportWidth: viewportOverride.width, viewportHeight: viewportOverride.height } : {}),
1505
2935
  geolocation,
1506
2936
  locale: options.locale,
1507
2937
  timezone: options.timezone,
@@ -1624,22 +3054,11 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
1624
3054
  }
1625
3055
  case "eval":
1626
3056
  case "evaluate": {
1627
- const script = args[0];
1628
- if (!script) {
1629
- console.error("Usage: cbrowser eval \"<javascript>\"");
1630
- process.exit(1);
1631
- }
1632
- if (options.url)
1633
- await browser.navigate(options.url);
1634
- const page = await browser.getPage();
1635
- try {
1636
- const evalResult = await page.evaluate(script);
1637
- console.log(typeof evalResult === "string" ? evalResult : JSON.stringify(evalResult, null, 2));
1638
- }
1639
- catch (e) {
1640
- console.error(`✗ ${e.message}`);
1641
- process.exit(1);
1642
- }
3057
+ await runEvaluate(browser, args, options);
3058
+ break;
3059
+ }
3060
+ case "keyboard": {
3061
+ await runKeyboard(browser, args, options);
1643
3062
  break;
1644
3063
  }
1645
3064
  case "upload": {
@@ -3578,6 +4997,29 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
3578
4997
  break;
3579
4998
  }
3580
4999
  // =========================================================================
5000
+ // Screen Capture (video/GIF)
5001
+ // =========================================================================
5002
+ // Deliberately separate from `record` above: `record` captures user
5003
+ // ACTIONS for test generation, `capture` captures PIXELS for video/GIF.
5004
+ // They share nothing but a rough English synonym - do not unify them.
5005
+ case "capture": {
5006
+ switch (args[0]) {
5007
+ case "start":
5008
+ await runCaptureStart(browser, args[1] || (typeof options.url === "string" ? options.url : undefined), options);
5009
+ break;
5010
+ case "stop":
5011
+ await runCaptureStop();
5012
+ break;
5013
+ case "status":
5014
+ await runCaptureStatus();
5015
+ break;
5016
+ default:
5017
+ console.error("Usage: cbrowser capture [start <url>|stop|status]");
5018
+ process.exit(1);
5019
+ }
5020
+ break;
5021
+ }
5022
+ // =========================================================================
3581
5023
  // Test Export (Tier 2)
3582
5024
  // =========================================================================
3583
5025
  case "export": {
@@ -4276,17 +5718,20 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
4276
5718
  }
4277
5719
  break;
4278
5720
  }
5721
+ assertCaptureNamespaceFlags("test-suite", options);
4279
5722
  const suiteOptions = {
4280
5723
  stepTimeout: options.timeout ? parseInt(options.timeout) : 30000,
4281
5724
  continueOnFailure: options["continue-on-failure"] === true,
4282
5725
  screenshotOnFailure: options["screenshot-on-failure"] !== false,
4283
5726
  headless,
4284
5727
  fuzzyMatch: options["fuzzy-match"] === true,
5728
+ capture: buildAutoCaptureSetting(options, `test-suite-${suite.name.replace(/[^a-zA-Z0-9-_]+/g, "-").toLowerCase()}-${Date.now()}`),
4285
5729
  };
4286
5730
  const result = await runNLTestSuite(suite, suiteOptions);
4287
5731
  // Print formatted report
4288
5732
  const report = formatNLTestReport(result);
4289
5733
  console.log(report);
5734
+ reportAutoCapture(result.capture);
4290
5735
  // Save JSON output if requested
4291
5736
  if (options.output) {
4292
5737
  const fs = await import("fs");
@@ -4844,6 +6289,7 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
4844
6289
  }
4845
6290
  console.log("");
4846
6291
  try {
6292
+ assertCaptureNamespaceFlags("cognitive-journey", options);
4847
6293
  const result = await runCognitiveJourney({
4848
6294
  persona: personaName,
4849
6295
  goal,
@@ -4855,11 +6301,13 @@ Documentation: https://github.com/alexandriashai/cbrowser/wiki
4855
6301
  vision,
4856
6302
  customTraits, // Pass custom traits from env var
4857
6303
  location: locationOverride, // Pass location override
6304
+ capture: buildAutoCaptureSetting(options, `journey-${personaName.replace(/[^a-zA-Z0-9-_]+/g, "-").toLowerCase()}-${Date.now()}`),
4858
6305
  onStep: verbose ? undefined : (step) => {
4859
6306
  process.stdout.write(`\r Step ${step.step}: ${step.phase} (${step.state.currentMood})`);
4860
6307
  },
4861
6308
  });
4862
6309
  console.log("\n");
6310
+ reportAutoCapture(result.capture);
4863
6311
  if (result.goalAchieved) {
4864
6312
  console.log(`✅ GOAL ACHIEVED in ${result.stepCount} steps (${result.totalTime.toFixed(1)}s)`);
4865
6313
  }
@@ -5470,8 +6918,11 @@ Examples:
5470
6918
  await browser.close();
5471
6919
  }
5472
6920
  // Explicitly exit after successful browser commands
5473
- // Persistent context may keep handles open that prevent natural exit
5474
- process.exit(0);
6921
+ // Persistent context may keep handles open that prevent natural exit.
6922
+ // Honour process.exitCode: a command that completed its work but had a part
6923
+ // fail (a requested encoder missing, say) sets it, and a hardcoded exit(0)
6924
+ // would report success for a run the caller can't detect as broken.
6925
+ process.exit(process.exitCode ?? 0);
5475
6926
  }
5476
6927
  main().catch((error) => {
5477
6928
  const opts = getOutputOptions();