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