@crouton-kit/capture 1.3.7 → 1.3.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/bin/capture +32786 -9989
  2. package/package.json +9 -3
  3. package/src/capture.ts +86 -163
  4. package/src/cdp/a11y.ts +113 -14
  5. package/src/cdp/args.ts +300 -116
  6. package/src/cdp/bridge/protocol.ts +187 -1
  7. package/src/cdp/bridge/server.ts +211 -84
  8. package/src/cdp/bridge/spawn.ts +110 -1
  9. package/src/cdp/client.ts +13 -3
  10. package/src/cdp/commands/bridge-serve.ts +35 -4
  11. package/src/cdp/commands/cdp.ts +266 -69
  12. package/src/cdp/commands/gate-guard.ts +43 -0
  13. package/src/cdp/commands/lib.ts +339 -213
  14. package/src/cdp/commands/measure/census.ts +87 -0
  15. package/src/cdp/commands/measure/check.ts +109 -0
  16. package/src/cdp/commands/measure/diff.ts +120 -0
  17. package/src/cdp/commands/measure/explain.ts +133 -0
  18. package/src/cdp/commands/measure/index.ts +106 -0
  19. package/src/cdp/commands/measure/map-ax.ts +52 -0
  20. package/src/cdp/commands/measure/map-focus.ts +52 -0
  21. package/src/cdp/commands/measure/map-layers.ts +59 -0
  22. package/src/cdp/commands/measure/map-scroll.ts +94 -0
  23. package/src/cdp/commands/measure/snap.ts +416 -0
  24. package/src/cdp/commands/measure/sweep.ts +227 -0
  25. package/src/cdp/commands/motion/index.ts +61 -0
  26. package/src/cdp/commands/motion/jank.ts +132 -0
  27. package/src/cdp/commands/motion/mask.ts +92 -0
  28. package/src/cdp/commands/motion/rec.ts +534 -0
  29. package/src/cdp/commands/motion/response.ts +76 -0
  30. package/src/cdp/commands/motion/timeline.ts +152 -0
  31. package/src/cdp/commands/page/click.ts +194 -0
  32. package/src/cdp/commands/page/elements.ts +154 -0
  33. package/src/cdp/commands/page/exec.ts +317 -0
  34. package/src/cdp/commands/page/index.ts +72 -0
  35. package/src/cdp/commands/page/navigate.ts +330 -0
  36. package/src/cdp/commands/page/scroll.ts +107 -0
  37. package/src/cdp/commands/page/shot.ts +208 -0
  38. package/src/cdp/commands/page/type.ts +104 -0
  39. package/src/cdp/commands/tab/index.ts +59 -0
  40. package/src/cdp/commands/tab/list.ts +167 -0
  41. package/src/cdp/commands/tab/network.ts +162 -0
  42. package/src/cdp/commands/tab/open.ts +90 -0
  43. package/src/cdp/commands/tab/reset.ts +160 -0
  44. package/src/cdp/connection.ts +368 -37
  45. package/src/cdp/coordinates.ts +158 -0
  46. package/src/cdp/detect.ts +42 -13
  47. package/src/cdp/dispatch.ts +76 -26
  48. package/src/cdp/domains.ts +75 -0
  49. package/src/cdp/har-recorder.ts +648 -115
  50. package/src/cdp/leaf-grammar.ts +99 -0
  51. package/src/cdp/measure/census.ts +205 -0
  52. package/src/cdp/measure/check.ts +318 -0
  53. package/src/cdp/measure/collectors/animation.ts +385 -0
  54. package/src/cdp/measure/collectors/ax.ts +278 -0
  55. package/src/cdp/measure/collectors/focus.ts +1041 -0
  56. package/src/cdp/measure/collectors/forms.ts +905 -0
  57. package/src/cdp/measure/collectors/geometry.ts +1038 -0
  58. package/src/cdp/measure/collectors/hittest.ts +835 -0
  59. package/src/cdp/measure/collectors/layers.ts +697 -0
  60. package/src/cdp/measure/collectors/media.ts +522 -0
  61. package/src/cdp/measure/collectors/pixels.ts +1584 -0
  62. package/src/cdp/measure/collectors/queries.ts +464 -0
  63. package/src/cdp/measure/collectors/scroll.ts +599 -0
  64. package/src/cdp/measure/collectors/states.ts +992 -0
  65. package/src/cdp/measure/collectors/style-provenance.ts +643 -0
  66. package/src/cdp/measure/collectors/styles.ts +355 -0
  67. package/src/cdp/measure/collectors/text.ts +713 -0
  68. package/src/cdp/measure/diff.ts +470 -0
  69. package/src/cdp/measure/explain.ts +498 -0
  70. package/src/cdp/measure/map-ax.ts +633 -0
  71. package/src/cdp/measure/map-focus.ts +188 -0
  72. package/src/cdp/measure/map-layers.ts +177 -0
  73. package/src/cdp/measure/map-scroll.ts +317 -0
  74. package/src/cdp/measure/redaction.ts +51 -0
  75. package/src/cdp/measure/settle.ts +1051 -0
  76. package/src/cdp/measure/snapshot.ts +390 -0
  77. package/src/cdp/measure/sweep.ts +330 -0
  78. package/src/cdp/measure/types.ts +220 -0
  79. package/src/cdp/measure/v2/collectors.ts +19 -0
  80. package/src/cdp/measure/v2/orchestrator.ts +46 -0
  81. package/src/cdp/measure/v2/snap-selection.ts +33 -0
  82. package/src/cdp/measure/v2/target-lock.ts +15 -0
  83. package/src/cdp/measure/v2/target.ts +16 -0
  84. package/src/cdp/motion/jank.ts +433 -0
  85. package/src/cdp/motion/mask.ts +421 -0
  86. package/src/cdp/motion/recorder.ts +764 -0
  87. package/src/cdp/motion/response.ts +356 -0
  88. package/src/cdp/motion/timeline.ts +215 -0
  89. package/src/cdp/record.ts +5 -212
  90. package/src/cdp/recorder-bridge.ts +1894 -0
  91. package/src/cdp/recorder-client.ts +326 -0
  92. package/src/cdp/scope-lock.ts +73 -0
  93. package/src/cdp/screenshot.ts +199 -66
  94. package/src/cdp/source-map.ts +309 -0
  95. package/src/cdp/targets.ts +21 -1
  96. package/src/cdp/timing.ts +87 -0
  97. package/src/cdp/types.ts +87 -6
  98. package/src/cdp/viewport.ts +21 -0
  99. package/src/cdp.ts +12 -58
  100. package/src/command-help.ts +29 -0
  101. package/src/command-resolution.ts +22 -0
  102. package/src/command-walker.ts +35 -0
  103. package/src/contracts/a11y.ts +33 -0
  104. package/src/contracts/cursor.ts +35 -0
  105. package/src/contracts/index.ts +15 -0
  106. package/src/contracts/motion.ts +19 -0
  107. package/src/contracts/neutral.ts +56 -0
  108. package/src/contracts/primitives.ts +205 -0
  109. package/src/contracts/release.ts +17 -0
  110. package/src/contracts/results.ts +208 -0
  111. package/src/contracts/routes.ts +140 -0
  112. package/src/contracts/snapshot.ts +92 -0
  113. package/src/cursor/index.ts +13 -0
  114. package/src/cursor/service.ts +310 -0
  115. package/src/errors.ts +71 -0
  116. package/src/har-manager.ts +992 -41
  117. package/src/interact.ts +410 -98
  118. package/src/output/artifact-lifecycle.ts +371 -0
  119. package/src/output/artifact.ts +526 -0
  120. package/src/output/diff.ts +111 -0
  121. package/src/output/exact-raw.ts +114 -0
  122. package/src/output/render.ts +987 -0
  123. package/src/output/selector.ts +185 -0
  124. package/src/output/structured.ts +211 -0
  125. package/src/registry.ts +208 -0
  126. package/src/session/artifacts.ts +481 -0
  127. package/src/session/commands.ts +1237 -368
  128. package/src/session/coordinator.ts +251 -0
  129. package/src/session/har-filter.ts +57 -0
  130. package/src/session/log-tailer.ts +653 -0
  131. package/src/session-context.ts +302 -34
  132. package/src/vault/bundle.ts +32 -17
  133. package/src/cdp/commands/exec.ts +0 -105
  134. package/src/cdp/commands/har.ts +0 -128
  135. package/src/cdp/commands/tabs.ts +0 -183
  136. package/src/cdp/commands/traffic.ts +0 -121
  137. package/src/cdp/commands/ui.ts +0 -172
  138. package/src/cdp/execute.ts +0 -113
  139. package/src/cdp/har-output.ts +0 -47
  140. package/src/cdp/usage.ts +0 -77
  141. package/src/tab-session.ts +0 -91
package/src/cdp/args.ts CHANGED
@@ -1,25 +1,208 @@
1
- import { type ParsedArgs } from './types.js';
1
+ import { invalidInput } from '../errors.js';
2
2
  import { getActiveSession } from '../session-context.js';
3
+ import { type ParsedArgs } from './types.js';
4
+ import { assertParseableUrl, assertScrollDestination, assertSweepBounds, parsePositiveNumber, parseDoAction, type SweepAxisName } from './leaf-grammar.js';
3
5
 
4
- // Every flag below is matched as `--flag` followed by a separate `next`
5
- // token (space-separated). To accept the equally-common `--flag=value` form
6
- // without duplicating every branch, split any `--flag=value` token into two
7
- // tokens up front so the rest of the parser only ever has to handle one
8
- // convention.
9
6
  export function expandEqualsFlags(argv: string[]): string[] {
10
7
  const out: string[] = [];
11
8
  for (const arg of argv) {
12
9
  if (arg.startsWith('--') && arg.includes('=')) {
13
10
  const eq = arg.indexOf('=');
14
11
  out.push(arg.slice(0, eq), arg.slice(eq + 1));
15
- } else {
16
- out.push(arg);
17
- }
12
+ } else out.push(arg);
18
13
  }
19
14
  return out;
20
15
  }
21
16
 
22
- export function parseCliArgs(argv: string[]): ParsedArgs {
17
+ const VALUE_FLAGS = new Set([
18
+ '--port', '--out', '--duration', '--settle', '--file', '--target', '--url', '--into', '--viewport', '--session',
19
+ '--filter', '--name', '--filter-url', '--filter-status', '--filter-method', '--limit', '--params', '--wait-event',
20
+ '--timeout', '--socket', '--settle-timeout', '--state', '--for', '--before', '--after', '--snap', '--set-file',
21
+ '--axis', '--from', '--to', '--viewport-height', '--rec-id', '--selector', '--do', '--element', '--prop', '--action', '--occurrence',
22
+ ]);
23
+
24
+ function valueFor(flag: string, next: string | undefined): string {
25
+ if (next === undefined || next.startsWith('--')) throw invalidInput(`${flag} requires a value.`);
26
+ return next;
27
+ }
28
+
29
+ function integer(token: string, flag: string, min: number, max: number, safe = false): number {
30
+ // Full unsigned decimal integer grammar (leading zeros allowed); no sign,
31
+ // exponent, decimal point, partial token, or non-finite value.
32
+ if (!/^\d+$/.test(token)) throw invalidInput(`Invalid ${flag}: ${token}.`);
33
+ const value = Number(token);
34
+ if (!Number.isInteger(value) || value < min || value > max || (safe && !Number.isSafeInteger(value))) {
35
+ throw invalidInput(`Invalid ${flag}: ${token}.`);
36
+ }
37
+ return value;
38
+ }
39
+
40
+ function durationMs(token: string): number {
41
+ // Full unsigned decimal-seconds grammar (`1`, `1.`, `.5`, `00.5`); no sign,
42
+ // exponent, partial token, or non-finite value. Compare the decimal token
43
+ // before Number conversion so precision rounding cannot admit an overflow.
44
+ if (!/^(?:\d+\.?\d*|\.\d+)$/.test(token)) throw invalidInput(`Invalid --duration: ${token}.`);
45
+ const [whole = '', fraction = ''] = token.split('.');
46
+ const normalizedWhole = whole.replace(/^0+/, '') || '0';
47
+ const maxWhole = '2147483';
48
+ const wholeComparison = normalizedWhole.length - maxWhole.length || normalizedWhole.localeCompare(maxWhole);
49
+ const maxFraction = '647';
50
+ const fractionComparison = fraction.slice(0, maxFraction.length).padEnd(maxFraction.length, '0').localeCompare(maxFraction);
51
+ if (wholeComparison > 0 || (wholeComparison === 0 && (fractionComparison > 0 || (fractionComparison === 0 && /[1-9]/.test(fraction.slice(maxFraction.length)))))) {
52
+ throw invalidInput(`Invalid --duration: ${token}.`);
53
+ }
54
+ return Number(token) * 1000;
55
+ }
56
+
57
+ function validateKnownLeaf(command: string, positional: readonly string[]): void {
58
+ const leaf = positional[0];
59
+ if (leaf === undefined) return;
60
+ const branches: Record<string, readonly string[]> = {
61
+ session: ['start', 'stop', 'list', 'view', 'har', 'log'],
62
+ page: ['click', 'type', 'scroll', 'navigate', 'exec', 'shot', 'elements'],
63
+ tab: ['list', 'open', 'reset', 'network'],
64
+ measure: ['snap', 'check', 'diff', 'census', 'explain', 'sweep', 'map'],
65
+ motion: ['rec', 'mask', 'timeline', 'jank', 'response'],
66
+ lib: ['list', 'search', 'show', 'read'],
67
+ };
68
+ const known = branches[command];
69
+ if (known && !known.includes(leaf)) throw invalidInput(`Unknown ${command} leaf: ${leaf}.`, 'unknown_command');
70
+ if (command === 'measure' && leaf === 'map' && positional[1] !== undefined) {
71
+ const mapLeaf = positional[1];
72
+ if (!['focus', 'scroll', 'layers', 'ax'].includes(mapLeaf)) throw invalidInput(`Unknown measure map leaf: ${mapLeaf}.`, 'unknown_command');
73
+ }
74
+ }
75
+
76
+ function requireCount(values: readonly string[], min: number, max: number, command: string): void {
77
+ if (values.length < min || values.length > max) {
78
+ const expected = min === max ? `exactly ${min}` : `${min}..${max}`;
79
+ throw invalidInput(`${command} received ${values.length} positional argument(s); expected ${expected}.`);
80
+ }
81
+ }
82
+
83
+ /** Pure branch/leaf validation. It must stay before session and environment resolution. */
84
+ export function validateCliInvocation(parsed: ParsedArgs): void {
85
+ validateKnownLeaf(parsed.command, parsed.positional);
86
+ if (parsed.help) return;
87
+ if (parsed.command === 'cdp') {
88
+ requireCount(parsed.positional, 0, 1, 'cdp');
89
+ if (parsed.positional.length === 0 && !parsed.waitEvent) throw invalidInput('cdp requires a method or --wait-event.');
90
+ if (parsed.params !== undefined) {
91
+ try {
92
+ const value = JSON.parse(parsed.params) as unknown;
93
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('not an object');
94
+ } catch {
95
+ throw invalidInput('--params must be a JSON object.');
96
+ }
97
+ }
98
+ return;
99
+ }
100
+ if (parsed.positional[0] === undefined) return;
101
+
102
+ const leaf = parsed.positional[0];
103
+ const values = parsed.positional.slice(1);
104
+ if (parsed.command === 'session') {
105
+ const ranges: Record<string, readonly [number, number]> = {
106
+ start: [0, 0], stop: [1, 1], list: [0, 0], view: [1, 1], har: [0, 1], log: [1, 1],
107
+ };
108
+ const [min, max] = ranges[leaf];
109
+ requireCount(values, min, max, `session ${leaf}`);
110
+ return;
111
+ }
112
+
113
+ if (parsed.command === 'page') {
114
+ if (leaf === 'click' || leaf === 'type' || leaf === 'navigate') requireCount(values, 1, 1, `page ${leaf}`);
115
+ if (leaf === 'navigate') assertParseableUrl(values[0], 'URL');
116
+ if (leaf === 'scroll') {
117
+ requireCount(values, 1, 1, 'page scroll');
118
+ if (parsed.to === undefined) throw invalidInput('page scroll requires --to <top|bottom|px>.');
119
+ assertScrollDestination(parsed.to);
120
+ }
121
+ if (leaf === 'exec') requireCount(values, parsed.file === undefined ? 1 : 0, parsed.file === undefined ? 1 : 0, 'page exec');
122
+ if (leaf === 'shot' || leaf === 'elements') requireCount(values, 0, 0, `page ${leaf}`);
123
+ if (leaf === 'shot' && parsed.viewport !== undefined) {
124
+ const match = /^([1-9]\d*)x([1-9]\d*)$/.exec(parsed.viewport);
125
+ if (!match || !Number.isSafeInteger(Number(match[1])) || !Number.isSafeInteger(Number(match[2]))) {
126
+ throw invalidInput(`Invalid --viewport: ${parsed.viewport}.`);
127
+ }
128
+ }
129
+ return;
130
+ }
131
+
132
+ if (parsed.command === 'tab') {
133
+ requireCount(values, leaf === 'list' ? 0 : 1, leaf === 'list' ? 0 : 1, `tab ${leaf}`);
134
+ if (leaf === 'network' && values[0] !== 'offline' && values[0] !== 'online') {
135
+ throw invalidInput('tab network requires offline or online.');
136
+ }
137
+ return;
138
+ }
139
+
140
+ if (parsed.command === 'measure') {
141
+ if (leaf === 'snap' || leaf === 'check' || leaf === 'sweep') requireCount(values, 0, 1, `measure ${leaf}`);
142
+ if (leaf === 'diff' || leaf === 'census') requireCount(values, 0, 0, `measure ${leaf}`);
143
+ if (leaf === 'diff' && (!parsed.before || !parsed.after)) throw invalidInput('measure diff requires --before and --after.');
144
+ if (leaf === 'census' && !['color', 'font', 'spacing', 'radius', 'shadow', 'animation', 'geometry', 'media', 'queries'].includes(parsed.axis ?? '')) {
145
+ throw invalidInput('measure census requires a documented --axis value.');
146
+ }
147
+ if (leaf === 'explain') {
148
+ requireCount(values, 1, 1, 'measure explain');
149
+ if (!parsed.selector?.trim()) throw invalidInput('measure explain requires --selector.');
150
+ }
151
+ if (leaf === 'sweep') {
152
+ if (!['width', 'dpr', 'zoom', 'color-scheme', 'reduced-motion'].includes(parsed.axis ?? '')) {
153
+ throw invalidInput('measure sweep requires a documented --axis value.');
154
+ }
155
+ assertSweepBounds(parsed.axis as SweepAxisName, parsed.from, parsed.to);
156
+ if (parsed.viewportHeight !== undefined) parsePositiveNumber(parsed.viewportHeight, 1, '--viewport-height');
157
+ }
158
+ if (leaf === 'map') {
159
+ const mapLeaf = values[0];
160
+ if (mapLeaf === undefined) return;
161
+ const targets = values.slice(1);
162
+ requireCount(targets, mapLeaf === 'focus' || mapLeaf === 'ax' ? 1 : 0, 1, `measure map ${mapLeaf}`);
163
+ }
164
+ return;
165
+ }
166
+
167
+ if (parsed.command === 'motion') {
168
+ if (leaf === 'mask' || leaf === 'jank' || leaf === 'response') requireCount(values, 1, 1, `motion ${leaf}`);
169
+ if (leaf === 'timeline') {
170
+ requireCount(values, 1, 1, 'motion timeline');
171
+ if (!parsed.element?.trim()) throw invalidInput('motion timeline requires --element.');
172
+ }
173
+ if (leaf === 'rec') {
174
+ if (parsed.start && parsed.stop) throw invalidInput('motion rec cannot combine --start and --stop.');
175
+ if (!parsed.stop && parsed.recId) throw invalidInput('motion rec accepts --rec-id only with --stop.');
176
+ if (parsed.start || parsed.stop) {
177
+ requireCount(values, 0, 0, 'motion rec lifecycle');
178
+ if (parsed.do || parsed.duration !== undefined || (parsed.stop && parsed.viewport !== undefined)) {
179
+ throw invalidInput('motion rec lifecycle flags cannot be combined with one-shot flags.');
180
+ }
181
+ } else {
182
+ requireCount(values, 1, 1, 'motion rec');
183
+ if (!parsed.do) throw invalidInput('motion rec one-shot requires --do.');
184
+ assertParseableUrl(values[0], 'recording URL');
185
+ parseDoAction(parsed.do);
186
+ }
187
+ if (parsed.viewport !== undefined) {
188
+ const match = /^([1-9]\d*)x([1-9]\d*)$/.exec(parsed.viewport);
189
+ if (!match || !Number.isSafeInteger(Number(match[1])) || !Number.isSafeInteger(Number(match[2]))) {
190
+ throw invalidInput(`Invalid --viewport: ${parsed.viewport}.`);
191
+ }
192
+ }
193
+ }
194
+ return;
195
+ }
196
+
197
+ if (parsed.command === 'lib') {
198
+ if (leaf === 'list') requireCount(values, 0, 0, 'lib list');
199
+ if (leaf === 'search' || leaf === 'show') requireCount(values, 1, 1, `lib ${leaf}`);
200
+ if (leaf === 'read') requireCount(values, 1, Number.MAX_SAFE_INTEGER, 'lib read');
201
+ }
202
+ }
203
+
204
+ /** Pure syntax/provenance parsing: no session or environment reads. */
205
+ export function parseCliSyntax(argv: string[]): ParsedArgs {
23
206
  const command = argv[0] ?? '';
24
207
  const rest = expandEqualsFlags(argv.slice(1));
25
208
  const positional: string[] = [];
@@ -28,120 +211,121 @@ export function parseCliArgs(argv: string[]): ParsedArgs {
28
211
  for (let i = 0; i < rest.length; i++) {
29
212
  const arg = rest[i];
30
213
  const next = rest[i + 1];
214
+ if (VALUE_FLAGS.has(arg)) valueFor(arg, next);
31
215
 
32
- if (arg === '--port' && next) {
33
- parsed.port = parseInt(next, 10);
34
- i++;
35
- } else if (arg === '--out' && next) {
36
- parsed.out = next;
37
- i++;
38
- } else if (arg === '--json') {
39
- parsed.json = true;
40
- } else if (arg === '--interactive') {
41
- parsed.interactive = true;
42
- } else if (arg === '--har-out' && next) {
43
- parsed.harOut = next;
44
- i++;
45
- } else if (arg === '--record') {
46
- parsed.record = true;
47
- } else if (arg === '--duration' && next) {
48
- parsed.duration = parseInt(next, 10);
49
- i++;
50
- } else if (arg === '--settle' && next) {
51
- parsed.settle = parseInt(next, 10);
52
- i++;
53
- } else if (arg === '--file' && next) {
54
- parsed.file = next;
55
- i++;
56
- } else if (arg === '--nested') {
57
- parsed.nested = true;
58
- } else if (arg === '--har' && next) {
59
- parsed.har = next;
60
- i++;
61
- } else if (arg === '--new') {
62
- parsed.new = true;
63
- } else if (arg === '--target' && next) {
64
- parsed.target = next;
65
- i++;
66
- } else if (arg === '--url' && next) {
67
- parsed.url = next;
68
- i++;
69
- } else if (arg === '--role' && next) {
70
- parsed.role = next;
71
- i++;
72
- } else if (arg === '--into' && next) {
73
- parsed.into = next;
74
- i++;
75
- } else if (arg === '--no-screenshot') {
76
- parsed.noScreenshot = true;
77
- } else if (arg === '--viewport' && next) {
78
- parsed.viewport = next;
79
- i++;
80
- } else if (arg === '--full-page') {
81
- parsed.fullPage = true;
82
- } else if (arg === '--height' && next) {
83
- parsed.height = parseInt(next, 10);
84
- i++;
85
- } else if (arg === '--filter-url' && next) {
86
- parsed.filterUrl = next;
87
- i++;
88
- } else if (arg === '--filter-status' && next) {
89
- parsed.filterStatus = next;
90
- i++;
91
- } else if (arg === '--filter-method' && next) {
92
- parsed.filterMethod = next.toUpperCase();
93
- i++;
94
- } else if (arg === '--limit' && next) {
95
- parsed.limit = parseInt(next, 10);
96
- i++;
97
- } else if (arg === '--browser') {
98
- parsed.browser = true;
99
- } else if (arg === '--params' && next) {
100
- parsed.params = next;
101
- i++;
102
- } else if (arg === '--wait-event' && next) {
103
- parsed.waitEvent = next;
104
- i++;
105
- } else if (arg === '--timeout' && next) {
106
- parsed.timeoutMs = parseInt(next, 10);
107
- i++;
108
- } else if (arg === '--socket' && next) {
109
- parsed.socket = next;
110
- i++;
111
- } else if (arg === '--help' || arg === '-h') {
112
- parsed.help = true;
113
- } else if (arg.startsWith('--')) {
114
- console.error(`Unknown flag: ${arg}`);
115
- process.exit(1);
116
- } else {
117
- positional.push(arg);
118
- }
216
+ if (arg === '--port') { parsed.port = integer(valueFor(arg, next), '--port', 1, 65535); i++; }
217
+ else if (arg === '--out') { parsed.out = valueFor(arg, next); i++; }
218
+ else if (arg === '--json') parsed.json = true;
219
+ else if (arg === '--duration') { parsed.duration = durationMs(valueFor(arg, next)); i++; }
220
+ else if (arg === '--settle') { parsed.settle = integer(valueFor(arg, next), '--settle', 0, 2_147_483_647); i++; }
221
+ else if (arg === '--file') { parsed.file = valueFor(arg, next); i++; }
222
+ else if (arg === '--new') parsed.new = true;
223
+ else if (arg === '--target') { parsed.target = valueFor(arg, next); parsed.targetSource = 'flag'; i++; }
224
+ else if (arg === '--url') { const value = valueFor(arg, next); parsed.url = value; (parsed.urls ??= []).push(value); i++; }
225
+ else if (arg === '--into') { parsed.into = valueFor(arg, next); i++; }
226
+ else if (arg === '--no-screenshot') parsed.noScreenshot = true;
227
+ else if (arg === '--viewport') { const value = valueFor(arg, next); parsed.viewport = value; (parsed.viewports ??= []).push(value); i++; }
228
+ else if (arg === '--full-page') parsed.fullPage = true;
229
+ else if (arg === '--all') parsed.all = true;
230
+ else if (arg === '--session') { parsed.session = valueFor(arg, next); i++; }
231
+ else if (arg === '--hold') parsed.hold = true;
232
+ else if (arg === '--filter') { parsed.filter = valueFor(arg, next); i++; }
233
+ else if (arg === '--name') { parsed.name = valueFor(arg, next); i++; }
234
+ else if (arg === '--filter-url') { parsed.filterUrl = valueFor(arg, next); i++; }
235
+ else if (arg === '--filter-status') { parsed.filterStatus = valueFor(arg, next); i++; }
236
+ else if (arg === '--filter-method') { parsed.filterMethod = valueFor(arg, next).toUpperCase(); i++; }
237
+ else if (arg === '--limit') { parsed.limit = integer(valueFor(arg, next), '--limit', 1, Number.MAX_SAFE_INTEGER, true); i++; }
238
+ else if (arg === '--browser') parsed.browser = true;
239
+ else if (arg === '--params') { parsed.params = valueFor(arg, next); i++; }
240
+ else if (arg === '--wait-event') { parsed.waitEvent = valueFor(arg, next); i++; }
241
+ else if (arg === '--timeout') { parsed.timeoutMs = integer(valueFor(arg, next), '--timeout', 1, 2_147_483_647); i++; }
242
+ else if (arg === '--socket') { parsed.socket = valueFor(arg, next); i++; }
243
+ else if (arg === '--freeze-animations') parsed.freezeAnimations = true;
244
+ else if (arg === '--settle-timeout') { parsed.settleTimeout = integer(valueFor(arg, next), '--settle-timeout', 1, 2_147_483_647); i++; }
245
+ else if (arg === '--capture-unsettled') parsed.captureUnsettled = true;
246
+ else if (arg === '--pixels') parsed.pixels = true;
247
+ else if (arg === '--state') { (parsed.state ??= []).push(valueFor(arg, next)); i++; }
248
+ else if (arg === '--for') { parsed.for = valueFor(arg, next); i++; }
249
+ else if (arg === '--before') { parsed.before = valueFor(arg, next); i++; }
250
+ else if (arg === '--after') { parsed.after = valueFor(arg, next); i++; }
251
+ else if (arg === '--full') parsed.full = true;
252
+ else if (arg === '--gate') parsed.gate = true;
253
+ else if (arg === '--snap') { (parsed.snap ??= []).push(valueFor(arg, next)); i++; }
254
+ else if (arg === '--set-file') { parsed.setFile = valueFor(arg, next); i++; }
255
+ else if (arg === '--axis') { parsed.axis = valueFor(arg, next); i++; }
256
+ else if (arg === '--from') { parsed.from = valueFor(arg, next); i++; }
257
+ else if (arg === '--to') { parsed.to = valueFor(arg, next); i++; }
258
+ else if (arg === '--viewport-height') { parsed.viewportHeight = valueFor(arg, next); i++; }
259
+ else if (arg === '--rec-id') { parsed.recId = valueFor(arg, next); i++; }
260
+ else if (arg === '--selector') { parsed.selector = valueFor(arg, next); i++; }
261
+ else if (arg === '--size') parsed.size = true;
262
+ else if (arg === '--text') parsed.text = true;
263
+ else if (arg === '--form') parsed.form = true;
264
+ else if (arg === '--start') parsed.start = true;
265
+ else if (arg === '--stop') parsed.stop = true;
266
+ else if (arg === '--do') { parsed.do = valueFor(arg, next); i++; }
267
+ else if (arg === '--element') { parsed.element = valueFor(arg, next); i++; }
268
+ else if (arg === '--prop') { parsed.prop = valueFor(arg, next); i++; }
269
+ else if (arg === '--action') { parsed.action = valueFor(arg, next); i++; }
270
+ else if (arg === '--occurrence') { parsed.occurrence = integer(valueFor(arg, next), '--occurrence', 1, Number.MAX_SAFE_INTEGER, true); i++; }
271
+ else if (arg === '-h') parsed.help = true;
272
+ else if (arg.startsWith('--')) throw invalidInput(`Unknown flag: ${arg}.`, 'unknown_flag');
273
+ else positional.push(arg);
119
274
  }
120
275
 
121
- // Fill gaps from environment variables (set by pipeline orchestrators)
122
- if (!parsed.port && process.env.CDP_PORT) {
123
- const envPort = Number.parseInt(process.env.CDP_PORT, 10);
124
- if (Number.isNaN(envPort)) {
125
- console.error(`Invalid CDP_PORT: ${process.env.CDP_PORT}`);
126
- process.exit(1);
127
- }
128
- parsed.port = envPort;
129
- }
130
- if (!parsed.target && !parsed.url && process.env.CDP_TARGET) {
131
- parsed.target = process.env.CDP_TARGET;
132
- }
133
- if (!parsed.har && process.env.CDP_HAR_ID) {
134
- parsed.har = process.env.CDP_HAR_ID;
135
- }
276
+ return parsed as ParsedArgs;
277
+ }
278
+
279
+ /**
280
+ * Decides whether an invocation needs CDP endpoint (port/target) resolution.
281
+ * Bare branches (`session`, `page`, ... with no leaf) and `measure map` with no
282
+ * sub-leaf only render help. `lib` never touches a browser. Of the `session`
283
+ * lifecycle leaves, only `start` connects to CDP — `stop`, `list`, `view`,
284
+ * `har`, and `log` finalize/read session state and must NOT parse `CDP_PORT`
285
+ * or read/clean the active-session index (a malformed ambient `CDP_PORT` must
286
+ * never block `session stop <id>` from finalizing).
287
+ */
288
+ function needsEndpointResolution(parsed: ParsedArgs): boolean {
289
+ if (parsed.help || parsed.command === 'lib') return false;
290
+ const branchOnly = ['session', 'page', 'tab', 'measure', 'motion'].includes(parsed.command)
291
+ && (parsed.positional.length === 0 || (parsed.command === 'measure' && parsed.positional.length === 1 && parsed.positional[0] === 'map'));
292
+ if (branchOnly) return false;
293
+ if (parsed.command === 'session' && parsed.positional[0] !== 'start') return false;
294
+ return true;
295
+ }
136
296
 
137
- // Fill remaining gaps from active session context (explicit flags always win)
138
- const session = getActiveSession();
297
+ /**
298
+ * Hydrates endpoint/session defaults only after pure invocation validation
299
+ * succeeds. Endpoint precedence is `explicit flag > session endpoint > env`:
300
+ * the active session is hydrated FIRST, and the ambient `CDP_PORT` token is
301
+ * parsed only when neither an explicit `--port` nor a session port supplies
302
+ * one — so a malformed `CDP_PORT` can never reject a command whose session
303
+ * already holds the authoritative port.
304
+ */
305
+ export function resolveCliContext(parsed: ParsedArgs): ParsedArgs {
306
+ if (!needsEndpointResolution(parsed)) return parsed;
307
+ const session = getActiveSession({ cleanStale: false });
139
308
  if (session) {
140
309
  if (!parsed.har && session.harId) parsed.har = session.harId;
141
310
  // An explicit --url picks a different (parallel) tab than the session's
142
311
  // own — don't let the session's targetId clobber that choice.
143
- if (!parsed.target && !parsed.url && session.targetId) parsed.target = session.targetId;
312
+ if (!parsed.target && !parsed.url && session.targetId) { parsed.target = session.targetId; parsed.targetSource = 'session'; }
313
+ // A session target is inseparable from the endpoint that created it.
314
+ // Prefer that endpoint over ambient CDP_PORT, but retain an explicit
315
+ // --port override for deliberate multi-browser work.
316
+ if (parsed.port === undefined && session.port !== undefined && session.port !== null) parsed.port = session.port;
317
+ }
318
+ if (parsed.port === undefined && process.env.CDP_PORT !== undefined) {
319
+ parsed.port = integer(process.env.CDP_PORT, 'CDP_PORT', 1, 65535);
144
320
  }
321
+ // Preserve historical stale-index cleanup once endpoint validation has
322
+ // succeeded; only the malformed-env throw path deliberately leaves it alone.
323
+ if (!session) getActiveSession();
324
+ if (!parsed.target && !parsed.url && process.env.CDP_TARGET) { parsed.target = process.env.CDP_TARGET; parsed.targetSource = 'env'; }
325
+ return parsed;
326
+ }
145
327
 
146
- return parsed as ParsedArgs;
328
+ /** Public pure parser used by command-level tests and programmatic branch callers. */
329
+ export function parseCliArgs(argv: string[]): ParsedArgs {
330
+ return parseCliSyntax(argv);
147
331
  }
@@ -17,7 +17,7 @@ export interface BridgeRequest {
17
17
  * domains (`Browser.*`, `Target.*`).
18
18
  */
19
19
  targetId?: string;
20
- /** CDP event name to wait for (consumes the next occurrence, FIFO, buffered if it already fired). */
20
+ /** CDP event name to observe after this request is armed. Observation is future-only and scoped to this request's actual flattened target session (`undefined` without `targetId`); one event broadcasts to all already-armed same-key requests. */
21
21
  waitEvent?: string;
22
22
  timeoutMs?: number;
23
23
  }
@@ -36,3 +36,189 @@ export interface BridgeResponseErr {
36
36
  }
37
37
 
38
38
  export type BridgeResponse = BridgeResponseOk | BridgeResponseErr;
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // Recorder-mode protocol — the SAME NDJSON-over-unix-socket wire format as
42
+ // above, specialized for `capture motion rec` (see ../recorder-bridge.ts).
43
+ // The generic `BridgeRequest`/`BridgeResponse` are unchanged and still
44
+ // govern `capture cdp --browser` / `session start --hold`; a recorder-mode
45
+ // bridge process (spawned in recorder mode, see ../bridge/spawn.ts) speaks
46
+ // this union instead, one request per connection, one response, same as
47
+ // the plain bridge.
48
+ // ---------------------------------------------------------------------------
49
+
50
+ /**
51
+ * The three-way clock baseline the design's "Recorder timing model" requires to convert
52
+ * screencast-frame and Tracing timestamps into the authoritative `performance.now()` domain.
53
+ * `performanceNowMs`/`wallClockMs` are read synchronously when the recorder arms (`rec-start`);
54
+ * `firstScreencastTimestampSec`/`firstTraceEventTsUs` cannot be — no frame or trace batch has
55
+ * necessarily arrived yet at that instant — so they are filled in opportunistically as the
56
+ * recorder's own screencast/tracing handlers observe the first occurrence of each, and stay
57
+ * `null` (with `baselinesPending: true`) until then. `rec-stop`'s response carries the same
58
+ * object read again at stop time — the flush path a caller uses to get the completed baselines
59
+ * for `markers.json` even when `rec-start`'s own response returned it still pending.
60
+ */
61
+ export interface RecorderClockBaselines {
62
+ performanceNowMs: number;
63
+ wallClockMs: number;
64
+ /** First `Page.screencastFrame`'s `metadata.timestamp` (wall-clock seconds), or `null` if none had arrived yet at the moment this snapshot was taken. */
65
+ firstScreencastTimestampSec: number | null;
66
+ /** First `Tracing.dataCollected` batch's earliest event `ts` (trace-clock microseconds), or `null` if none had arrived yet at the moment this snapshot was taken. */
67
+ firstTraceEventTsUs: number | null;
68
+ /** `true` while either timestamp above is still `null`. */
69
+ baselinesPending: boolean;
70
+ }
71
+
72
+ /**
73
+ * Arms the recorder on its held tab connection: enables the motion-rec CDP
74
+ * domains, starts `Page.startScreencast` + `Tracing`, injects the
75
+ * Mutation/Resize/Performance observers, and captures the clock baseline.
76
+ * A recorder process only accepts one `rec-start` while `state==="idle"`.
77
+ *
78
+ * `nonce` (here and on every other recorder request shape) is the
79
+ * per-recording control-socket admission token: the recorder bridge process
80
+ * generates it at boot (64 lowercase hex chars, 256 bits) and hands it back
81
+ * to its starter over a private boot file; every request line must carry it
82
+ * verbatim, and the server constant-time-compares it BEFORE any handler
83
+ * runs. There is no unauthenticated request shape and no compatibility
84
+ * lane — a missing or mismatched nonce is answered `ok:false error:
85
+ * 'unauthorized'` with no side effects.
86
+ */
87
+ export interface RecStartRequest {
88
+ reqId: number;
89
+ type: 'rec-start';
90
+ /** Per-recording control-socket admission token — see the doc comment above. */
91
+ nonce: string;
92
+ }
93
+
94
+ export interface RecStartResponseOk {
95
+ reqId: number;
96
+ ok: true;
97
+ type: 'rec-start';
98
+ /** Returned for the caller to persist into `markers.json` — the recorder itself does not write that file. */
99
+ markers: RecorderClockBaselines;
100
+ }
101
+
102
+ /**
103
+ * Stops screencast + tracing, flushes the injected observers, and tears
104
+ * down the recorder's subscriptions (not the socket — the caller closes
105
+ * the bridge process once it has read the response). Returns counts for
106
+ * the caller's `meta.json`; the recorder itself does not write that file.
107
+ */
108
+ export interface RecStopRequest {
109
+ reqId: number;
110
+ type: 'rec-stop';
111
+ /** Per-recording control-socket admission token — see `RecStartRequest`. */
112
+ nonce: string;
113
+ }
114
+
115
+ export interface RecStopResponseOk {
116
+ reqId: number;
117
+ ok: true;
118
+ type: 'rec-stop';
119
+ frameCount: number;
120
+ eventCount: number;
121
+ durationMs: number;
122
+ /** The clock baselines re-read at stop time — the flush path for a `rec-start` response whose baselines were still pending. */
123
+ markers: RecorderClockBaselines;
124
+ }
125
+
126
+ /**
127
+ * A CDP request routed through the recorder's held tab connection — the
128
+ * mechanism intervening session commands (`click`, `type`, `navigate`, ...)
129
+ * use during a composed recording. Setting `mark` brackets the dispatch
130
+ * with two performance.now() reads taken in the page's execution context
131
+ * (`../timing.ts`'s `withDocumentPerformanceNow`) and appends a labeled
132
+ * input-landmark record to `events.jsonl`, tying the action to the frame
133
+ * timeline — this is the "marked CDP request message" the protocol
134
+ * supports. Omit `mark` for a plain passthrough call (still observed
135
+ * natively by the recorder's own event subscriptions, just not logged as a
136
+ * distinct input landmark). Omit `method` for a wait-event-only request;
137
+ * the bridge arms a future-only wait on the recorder tab websocket's
138
+ * unscoped event envelope and returns the matched event in one response.
139
+ */
140
+ export interface RecCdpDispatchRequest {
141
+ reqId: number;
142
+ type: 'cdp';
143
+ /** Per-recording control-socket admission token — see `RecStartRequest`. */
144
+ nonce: string;
145
+ method: string;
146
+ params?: Record<string, unknown>;
147
+ mark?: string;
148
+ waitEvent?: string;
149
+ timeoutMs?: number;
150
+ }
151
+
152
+ export interface RecCdpWaitEventRequest {
153
+ reqId: number;
154
+ type: 'cdp';
155
+ /** Per-recording control-socket admission token — see `RecStartRequest`. */
156
+ nonce: string;
157
+ method?: undefined;
158
+ waitEvent: string;
159
+ timeoutMs?: number;
160
+ }
161
+
162
+ export type RecCdpRequest = RecCdpDispatchRequest | RecCdpWaitEventRequest;
163
+
164
+ export interface RecCdpResponseOk {
165
+ reqId: number;
166
+ ok: true;
167
+ type: 'cdp';
168
+ result?: unknown;
169
+ event?: unknown;
170
+ /**
171
+ * Present iff this request armed a `waitEvent` AND carried a `method`. It
172
+ * reports the load-wait outcome SEPARATELY from the method result: a method
173
+ * dispatch that succeeds is always `ok:true` with its `result` preserved,
174
+ * regardless of whether the paired wait observed its event
175
+ * (`'observed'`, with `event` set) or the wait's own bounded timer elapsed
176
+ * first (`'bounded-timeout'`, no `event`). A wait-event-ONLY request (no
177
+ * `method`) whose wait times out is still `ok:false` — that path has no
178
+ * method result to preserve, so its timeout is a genuine failure.
179
+ */
180
+ waitOutcome?: 'observed' | 'bounded-timeout';
181
+ }
182
+
183
+ /**
184
+ * The action-return HAR flush/health barrier. A recorder-routed page action
185
+ * (`click`, `type`, `scroll`, `exec`, `navigate`) sends this after its settle
186
+ * window and before claiming success, so every HAR entry/body/append the
187
+ * recorder's streaming collector had already completed by then is durably in
188
+ * the owning session's live HAR first. Deliberately NON-attributing: the
189
+ * response reports store health only — `ok:true` means "all work completed at
190
+ * request time has appended and no fatal store error is latched"; it never
191
+ * says which entries belong to which action and carries no counts (the
192
+ * collector cannot truthfully attribute concurrent traffic to one action).
193
+ * Once the terminal `rec-stop` latch is claimed, HAR finalization belongs to
194
+ * that one owner and a later `har-flush` is answered with a deterministic
195
+ * `ok:false` rejection instead of sharing or re-driving the drain.
196
+ */
197
+ export interface RecHarFlushRequest {
198
+ reqId: number;
199
+ type: 'har-flush';
200
+ /** Per-recording control-socket admission token — see `RecStartRequest`. */
201
+ nonce: string;
202
+ }
203
+
204
+ /** Health-only: success carries no result fields beyond the envelope — see `RecHarFlushRequest`. */
205
+ export interface RecHarFlushResponseOk {
206
+ reqId: number;
207
+ ok: true;
208
+ type: 'har-flush';
209
+ }
210
+
211
+ export type RecorderRequest = RecStartRequest | RecStopRequest | RecCdpRequest | RecHarFlushRequest;
212
+
213
+ export interface RecorderResponseErr {
214
+ reqId: number;
215
+ ok: false;
216
+ type: RecorderRequest['type'];
217
+ error: string;
218
+ }
219
+
220
+ export type RecStartResponse = RecStartResponseOk | RecorderResponseErr;
221
+ export type RecStopResponse = RecStopResponseOk | RecorderResponseErr;
222
+ export type RecCdpResponse = RecCdpResponseOk | RecorderResponseErr;
223
+ export type RecHarFlushResponse = RecHarFlushResponseOk | RecorderResponseErr;
224
+ export type RecorderResponse = RecStartResponse | RecStopResponse | RecCdpResponse | RecHarFlushResponse;