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.
- package/dist/browser.d.ts +32 -0
- package/dist/browser.d.ts.map +1 -1
- package/dist/browser.js +46 -2
- package/dist/browser.js.map +1 -1
- package/dist/cli.js +1465 -24
- package/dist/cli.js.map +1 -1
- package/dist/cognitive/index.d.ts +10 -0
- package/dist/cognitive/index.d.ts.map +1 -1
- package/dist/cognitive/index.js +8 -0
- package/dist/cognitive/index.js.map +1 -1
- package/dist/daemon.d.ts.map +1 -1
- package/dist/daemon.js +151 -3
- package/dist/daemon.js.map +1 -1
- package/dist/mcp-server.d.ts.map +1 -1
- package/dist/mcp-server.js +67 -0
- package/dist/mcp-server.js.map +1 -1
- package/dist/mcp-tools/base/capture-tools.d.ts +189 -0
- package/dist/mcp-tools/base/capture-tools.d.ts.map +1 -0
- package/dist/mcp-tools/base/capture-tools.js +667 -0
- package/dist/mcp-tools/base/capture-tools.js.map +1 -0
- package/dist/mcp-tools/base/gif-tools.d.ts +19 -0
- package/dist/mcp-tools/base/gif-tools.d.ts.map +1 -1
- package/dist/mcp-tools/base/gif-tools.js +45 -27
- package/dist/mcp-tools/base/gif-tools.js.map +1 -1
- package/dist/mcp-tools/base/index.d.ts +2 -1
- package/dist/mcp-tools/base/index.d.ts.map +1 -1
- package/dist/mcp-tools/base/index.js +5 -1
- package/dist/mcp-tools/base/index.js.map +1 -1
- package/dist/mcp-tools/tool-categories.d.ts.map +1 -1
- package/dist/mcp-tools/tool-categories.js +11 -0
- package/dist/mcp-tools/tool-categories.js.map +1 -1
- package/dist/recording/auto-capture.d.ts +72 -0
- package/dist/recording/auto-capture.d.ts.map +1 -0
- package/dist/recording/auto-capture.js +155 -0
- package/dist/recording/auto-capture.js.map +1 -0
- package/dist/recording/contact-sheet.d.ts +65 -0
- package/dist/recording/contact-sheet.d.ts.map +1 -0
- package/dist/recording/contact-sheet.js +204 -0
- package/dist/recording/contact-sheet.js.map +1 -0
- package/dist/recording/encoders.d.ts +101 -0
- package/dist/recording/encoders.d.ts.map +1 -0
- package/dist/recording/encoders.js +304 -0
- package/dist/recording/encoders.js.map +1 -0
- package/dist/recording/engine.d.ts +263 -0
- package/dist/recording/engine.d.ts.map +1 -0
- package/dist/recording/engine.js +890 -0
- package/dist/recording/engine.js.map +1 -0
- package/dist/recording/ssim.d.ts +35 -0
- package/dist/recording/ssim.d.ts.map +1 -0
- package/dist/recording/ssim.js +113 -0
- package/dist/recording/ssim.js.map +1 -0
- package/dist/recording/timing.d.ts +124 -0
- package/dist/recording/timing.d.ts.map +1 -0
- package/dist/recording/timing.js +251 -0
- package/dist/recording/timing.js.map +1 -0
- package/dist/recording/types.d.ts +213 -0
- package/dist/recording/types.d.ts.map +1 -0
- package/dist/recording/types.js +171 -0
- package/dist/recording/types.js.map +1 -0
- package/dist/testing/nl-test-suite.d.ts +10 -0
- package/dist/testing/nl-test-suite.d.ts.map +1 -1
- package/dist/testing/nl-test-suite.js +13 -1
- package/dist/testing/nl-test-suite.js.map +1 -1
- package/dist/types.d.ts +22 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
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.
|
|
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();
|