@crouton-kit/capture 1.4.2 → 1.4.10

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 (110) hide show
  1. package/README.md +178 -0
  2. package/bin/capture +31278 -7312
  3. package/package.json +3 -1
  4. package/src/capture.ts +21 -18
  5. package/src/cdp/args.ts +269 -49
  6. package/src/cdp/bridge/server.ts +6 -5
  7. package/src/cdp/bridge/spawn.ts +41 -97
  8. package/src/cdp/commands/bridge-serve.ts +11 -21
  9. package/src/cdp/commands/cdp.ts +44 -22
  10. package/src/cdp/commands/heap/census.ts +61 -0
  11. package/src/cdp/commands/heap/common.ts +77 -0
  12. package/src/cdp/commands/heap/diff.ts +92 -0
  13. package/src/cdp/commands/heap/index.ts +38 -0
  14. package/src/cdp/commands/heap/objects.ts +42 -0
  15. package/src/cdp/commands/heap/retainers.ts +52 -0
  16. package/src/cdp/commands/heap/snapshot.ts +94 -0
  17. package/src/cdp/commands/lib.ts +9 -17
  18. package/src/cdp/commands/lighthouse.ts +414 -0
  19. package/src/cdp/commands/measure/census.ts +2 -1
  20. package/src/cdp/commands/measure/check.ts +70 -16
  21. package/src/cdp/commands/measure/diff.ts +1 -2
  22. package/src/cdp/commands/measure/explain.ts +44 -7
  23. package/src/cdp/commands/measure/index.ts +24 -32
  24. package/src/cdp/commands/measure/map-ax.ts +2 -1
  25. package/src/cdp/commands/measure/map-focus.ts +2 -1
  26. package/src/cdp/commands/measure/map-layers.ts +5 -3
  27. package/src/cdp/commands/measure/map-scroll.ts +2 -1
  28. package/src/cdp/commands/measure/snap.ts +5 -4
  29. package/src/cdp/commands/measure/sweep.ts +16 -11
  30. package/src/cdp/commands/measure/text.ts +147 -0
  31. package/src/cdp/commands/motion/index.ts +10 -18
  32. package/src/cdp/commands/motion/jank.ts +13 -22
  33. package/src/cdp/commands/motion/mask.ts +10 -9
  34. package/src/cdp/commands/motion/rec.ts +228 -26
  35. package/src/cdp/commands/motion/timeline.ts +1 -1
  36. package/src/cdp/commands/page/click.ts +15 -5
  37. package/src/cdp/commands/page/elements.ts +43 -11
  38. package/src/cdp/commands/page/exec.ts +53 -18
  39. package/src/cdp/commands/page/index.ts +20 -20
  40. package/src/cdp/commands/page/inspect.ts +338 -0
  41. package/src/cdp/commands/page/navigate.ts +83 -38
  42. package/src/cdp/commands/page/repeat.ts +176 -0
  43. package/src/cdp/commands/page/scroll.ts +10 -5
  44. package/src/cdp/commands/page/shot.ts +66 -31
  45. package/src/cdp/commands/page/type.ts +6 -2
  46. package/src/cdp/commands/perf/index.ts +32 -0
  47. package/src/cdp/commands/perf/insights.ts +214 -0
  48. package/src/cdp/commands/perf/trace.ts +235 -0
  49. package/src/cdp/commands/perf/vitals.ts +76 -0
  50. package/src/cdp/commands/session/collectors.ts +54 -0
  51. package/src/cdp/commands/stub.ts +10 -0
  52. package/src/cdp/commands/tab/close.ts +2 -2
  53. package/src/cdp/commands/tab/index.ts +16 -23
  54. package/src/cdp/commands/tab/launch.ts +1 -1
  55. package/src/cdp/commands/tab/mock/index.ts +24 -0
  56. package/src/cdp/commands/tab/mock/lifecycle.ts +88 -0
  57. package/src/cdp/commands/tab/mock/start.ts +65 -0
  58. package/src/cdp/commands/tab/mock/stop.ts +39 -0
  59. package/src/cdp/commands/tab/network.ts +22 -13
  60. package/src/cdp/commands/tab/reset.ts +11 -12
  61. package/src/cdp/connection.ts +19 -39
  62. package/src/cdp/detect.ts +27 -7
  63. package/src/cdp/dispatch.ts +25 -4
  64. package/src/cdp/domains.ts +2 -2
  65. package/src/cdp/har-recorder.ts +0 -1
  66. package/src/cdp/heap-snapshot.ts +647 -0
  67. package/src/cdp/host/client.ts +22 -0
  68. package/src/cdp/host/collector.ts +74 -0
  69. package/src/cdp/host/collectors/heap.ts +79 -0
  70. package/src/cdp/host/collectors/intercept.ts +247 -0
  71. package/src/cdp/{recorder-bridge.ts → host/collectors/motion.ts} +388 -473
  72. package/src/cdp/host/collectors/trace.ts +108 -0
  73. package/src/cdp/host/core.ts +217 -0
  74. package/src/cdp/host/handle.ts +47 -0
  75. package/src/cdp/host/held-client.ts +91 -0
  76. package/src/cdp/host/kinds.ts +97 -0
  77. package/src/cdp/host/lifecycle.ts +123 -0
  78. package/src/cdp/host/server.ts +88 -0
  79. package/src/cdp/measure/check.ts +181 -16
  80. package/src/cdp/measure/collectors/hittest.ts +16 -10
  81. package/src/cdp/measure/collectors/scroll.ts +15 -7
  82. package/src/cdp/measure/collectors/style-provenance.ts +11 -0
  83. package/src/cdp/measure/collectors/styles.ts +4 -4
  84. package/src/cdp/measure/explain.ts +14 -2
  85. package/src/cdp/measure/map-layers.ts +52 -8
  86. package/src/cdp/measure/snapshot.ts +5 -3
  87. package/src/cdp/measure/sweep.ts +29 -6
  88. package/src/cdp/measure/text.ts +227 -0
  89. package/src/cdp/motion/jank.ts +29 -83
  90. package/src/cdp/motion/mask.ts +18 -9
  91. package/src/cdp/motion/recorder.ts +86 -731
  92. package/src/cdp/scope-lock.ts +9 -9
  93. package/src/cdp/screenshot.ts +51 -21
  94. package/src/cdp/timing.ts +5 -5
  95. package/src/cdp/trace-analysis.ts +320 -0
  96. package/src/cdp/types.ts +31 -1
  97. package/src/cdp.ts +4 -6
  98. package/src/har-manager.ts +2 -4
  99. package/src/interact.ts +50 -23
  100. package/src/output/artifact.ts +49 -5
  101. package/src/output/render.ts +60 -9
  102. package/src/output/selection.ts +6 -0
  103. package/src/session/artifacts.ts +122 -12
  104. package/src/session/commands.ts +348 -113
  105. package/src/session/coordinator.ts +0 -93
  106. package/src/session/har-redact.ts +29 -23
  107. package/src/session-context.ts +13 -9
  108. package/src/cdp/bridge/protocol.ts +0 -224
  109. package/src/cdp/recorder-client.ts +0 -326
  110. package/src/cdp/tab-lock.ts +0 -132
package/src/cdp/args.ts CHANGED
@@ -15,34 +15,44 @@ export function expandEqualsFlags(argv: string[]): string[] {
15
15
  }
16
16
 
17
17
  const VALUE_FLAGS = new Set([
18
- '--port', '--out', '--duration', '--settle', '--file', '--target', '--url', '--into', '--viewport', '--color-scheme', '--session',
19
- '--filter', '--name', '--filter-url', '--filter-status', '--filter-method', '--limit', '--params', '--wait-event',
18
+ '--port', '--out', '--artifact-dir', '--duration', '--settle', '--file', '--target', '--url', '--into', '--viewport', '--color-scheme', '--behavior', '--session',
19
+ '--filter', '--name', '--filter-url', '--body', '--filter-status', '--filter-method', '--limit', '--params', '--wait-event',
20
20
  '--timeout', '--socket', '--settle-timeout', '--state', '--for', '--before', '--after', '--snap', '--set-file',
21
21
  '--axis', '--from', '--to', '--viewport-height', '--rec-id', '--selector', '--do', '--element', '--prop', '--action', '--occurrence',
22
- '--state-padding', '--crop', '--crop-selector', '--pad', '--zoom',
22
+ '--state-padding', '--crop', '--crop-selector', '--pad', '--zoom', '--constructor', '--node', '--paths', '--sort', '--rules', '--categories', '--preset',
23
+ '--scope', '--event', '--steps', '--iterations',
23
24
  ]);
24
25
 
25
- function valueFor(flag: string, next: string | undefined): string {
26
- if (next === undefined || next.startsWith('--')) throw invalidInput(`${flag} requires a value.`);
26
+ function syntaxSchemaInput(command: string | undefined, received: string, expected: string, field: string): ReturnType<typeof invalidInput> {
27
+ return command
28
+ ? schemaInput(command, received, expected, field)
29
+ : invalidInput(`received: ${received}\nexpected: ${expected}\nfield: ${field}`);
30
+ }
31
+
32
+ function schemaValueFor(command: string | undefined, flag: string, next: string | undefined): string {
33
+ if (next === undefined || next.startsWith('--')) throw syntaxSchemaInput(command, `${flag}=(missing)`, `a value for ${flag}`, flag);
27
34
  return next;
28
35
  }
29
36
 
30
- function integer(token: string, flag: string, min: number, max: number, safe = false): number {
37
+ function schemaInteger(command: string | undefined, token: string, flag: string, min: number, max: number, safe = false): number {
31
38
  // Full unsigned decimal integer grammar (leading zeros allowed); no sign,
32
39
  // exponent, decimal point, partial token, or non-finite value.
33
- if (!/^\d+$/.test(token)) throw invalidInput(`Invalid ${flag}: ${token}.`);
40
+ const expected = `${safe ? 'a safe ' : ''}integer from ${min} to ${max}`;
41
+ const received = `${flag}=${token}`;
42
+ if (!/^\d+$/.test(token)) throw syntaxSchemaInput(command, received, expected, flag);
34
43
  const value = Number(token);
35
44
  if (!Number.isInteger(value) || value < min || value > max || (safe && !Number.isSafeInteger(value))) {
36
- throw invalidInput(`Invalid ${flag}: ${token}.`);
45
+ throw syntaxSchemaInput(command, received, expected, flag);
37
46
  }
38
47
  return value;
39
48
  }
40
49
 
41
- function durationMs(token: string): number {
50
+ function schemaDuration(command: string | undefined, token: string): number {
42
51
  // Full unsigned decimal-seconds grammar (`1`, `1.`, `.5`, `00.5`); no sign,
43
52
  // exponent, partial token, or non-finite value. Compare the decimal token
44
53
  // before Number conversion so precision rounding cannot admit an overflow.
45
- if (!/^(?:\d+\.?\d*|\.\d+)$/.test(token)) throw invalidInput(`Invalid --duration: ${token}.`);
54
+ const expected = 'an unsigned decimal seconds value from 0 through 2147483.647';
55
+ if (!/^(?:\d+\.?\d*|\.\d+)$/.test(token)) throw syntaxSchemaInput(command, `--duration=${token}`, expected, '--duration');
46
56
  const [whole = '', fraction = ''] = token.split('.');
47
57
  const normalizedWhole = whole.replace(/^0+/, '') || '0';
48
58
  const maxWhole = '2147483';
@@ -50,7 +60,7 @@ function durationMs(token: string): number {
50
60
  const maxFraction = '647';
51
61
  const fractionComparison = fraction.slice(0, maxFraction.length).padEnd(maxFraction.length, '0').localeCompare(maxFraction);
52
62
  if (wholeComparison > 0 || (wholeComparison === 0 && (fractionComparison > 0 || (fractionComparison === 0 && /[1-9]/.test(fraction.slice(maxFraction.length)))))) {
53
- throw invalidInput(`Invalid --duration: ${token}.`);
63
+ throw syntaxSchemaInput(command, `--duration=${token}`, expected, '--duration');
54
64
  }
55
65
  return Number(token) * 1000;
56
66
  }
@@ -59,91 +69,212 @@ function validateKnownLeaf(command: string, positional: readonly string[]): void
59
69
  const leaf = positional[0];
60
70
  if (leaf === undefined) return;
61
71
  const branches: Record<string, readonly string[]> = {
62
- session: ['start', 'stop', 'list', 'view', 'har', 'log'],
63
- page: ['click', 'type', 'scroll', 'navigate', 'exec', 'shot', 'elements'],
64
- tab: ['launch', 'quit', 'list', 'open', 'close', 'reset', 'network'],
65
- measure: ['snap', 'check', 'diff', 'census', 'explain', 'sweep', 'map'],
72
+ session: ['start', 'stop', 'list', 'view', 'har', 'log', 'collectors'],
73
+ page: ['click', 'type', 'scroll', 'navigate', 'exec', 'shot', 'elements', 'inspect', 'repeat'],
74
+ tab: ['launch', 'quit', 'list', 'open', 'close', 'reset', 'network', 'mock'],
75
+ measure: ['snap', 'check', 'diff', 'census', 'explain', 'text', 'sweep', 'map'],
66
76
  motion: ['rec', 'mask', 'timeline', 'jank', 'response'],
77
+ perf: ['trace', 'vitals', 'insights'],
78
+ heap: ['snapshot', 'census', 'objects', 'retainers', 'diff'],
67
79
  lib: ['list', 'search', 'show', 'read'],
68
80
  };
69
81
  const known = branches[command];
70
- if (known && !known.includes(leaf)) throw invalidInput(`Unknown ${command} leaf: ${leaf}.`, 'unknown_command');
82
+ if (known && !known.includes(leaf)) {
83
+ if (command === 'perf' || command === 'heap') throw schemaInput(command, leaf, `one of ${known.join(', ')}`, '<leaf>');
84
+ throw invalidInput(`Unknown ${command} leaf: ${leaf}.`, 'unknown_command');
85
+ }
86
+ if (command === 'page' && leaf === 'inspect' && positional[1] !== undefined) {
87
+ const inspectLeaf = positional[1];
88
+ if (!['listeners', 'cardinality', 'frames', 'resources'].includes(inspectLeaf)) throw invalidInput(`Unknown page inspect leaf: ${inspectLeaf}.`, 'unknown_command');
89
+ }
71
90
  if (command === 'measure' && leaf === 'map' && positional[1] !== undefined) {
72
91
  const mapLeaf = positional[1];
73
92
  if (!['focus', 'scroll', 'layers', 'ax', 'paint'].includes(mapLeaf)) throw invalidInput(`Unknown measure map leaf: ${mapLeaf}.`, 'unknown_command');
74
93
  }
94
+ if (command === 'tab' && leaf === 'mock' && positional[1] !== undefined) {
95
+ const mockLeaf = positional[1];
96
+ if (!['start', 'stop'].includes(mockLeaf)) throw schemaInput('tab mock', mockLeaf, 'start or stop', '<leaf>');
97
+ }
75
98
  }
76
99
 
77
- function requireCount(values: readonly string[], min: number, max: number, command: string): void {
100
+ function requireCount(values: readonly string[], min: number, max: number, command: string, field = '<positional>'): void {
101
+ if (values.length < min || values.length > max) {
102
+ const expected = min === max ? `exactly ${min} positional argument(s)` : `${min}..${max} positional argument(s)`;
103
+ throw schemaInput(command, `${values.length} positional argument(s)`, expected, field);
104
+ }
105
+ }
106
+
107
+ function schemaInput(command: string, received: string, expected: string, field: string): ReturnType<typeof invalidInput> {
108
+ return invalidInput(`received: ${received}\nexpected: ${expected}\nfield: ${field}\nNext: Run \`capture ${command} -h\` and read the schema before re-issuing.`);
109
+ }
110
+
111
+ function leafCommand(command: string, positional: readonly string[]): string {
112
+ if (command === 'lighthouse' || command === 'cdp') return command;
113
+ if (command === 'tab' && positional[0] === 'mock') return positional[1] ? `tab mock ${positional[1]}` : 'tab mock';
114
+ if (command === 'page' && positional[0] === 'inspect') return positional[1] ? `page inspect ${positional[1]}` : 'page inspect';
115
+ if (command === 'measure' && positional[0] === 'map') return positional[1] ? `measure map ${positional[1]}` : 'measure map';
116
+ return positional[0] ? `${command} ${positional[0]}` : command;
117
+ }
118
+
119
+ function syntaxCommand(argv: readonly string[]): string {
120
+ const command = argv[0] ?? 'capture';
121
+ const positional: string[] = [];
122
+ for (let index = 1; index < argv.length; index++) {
123
+ const token = argv[index]!;
124
+ if (VALUE_FLAGS.has(token)) { index++; continue; }
125
+ if (!token.startsWith('-')) positional.push(token);
126
+ }
127
+ return leafCommand(command, positional);
128
+ }
129
+
130
+ function requireSchemaCount(values: readonly string[], min: number, max: number, command: string, field: string): void {
78
131
  if (values.length < min || values.length > max) {
79
132
  const expected = min === max ? `exactly ${min}` : `${min}..${max}`;
80
- throw invalidInput(`${command} received ${values.length} positional argument(s); expected ${expected}.`);
133
+ throw schemaInput(command, `${values.length} positional argument(s)`, expected, field);
134
+ }
135
+ }
136
+
137
+ function assertSchemaUrl(value: string, command: string, field: string): void {
138
+ try {
139
+ assertParseableUrl(value, `${command} URL`);
140
+ } catch {
141
+ throw schemaInput(command, value, 'an absolute parseable URL', field);
81
142
  }
82
143
  }
83
144
 
145
+ function cdpMember(value: string): boolean {
146
+ return /^[A-Za-z_$][\w$]*\.[A-Za-z_$][\w$]*$/.test(value);
147
+ }
148
+
84
149
  /** Pure branch/leaf validation. It must stay before session and environment resolution. */
85
150
  export function validateCliInvocation(parsed: ParsedArgs): void {
86
151
  validateKnownLeaf(parsed.command, parsed.positional);
87
- if (parsed.help) return;
152
+ if (parsed.help && parsed.command !== 'cdp') return;
153
+ if (parsed.body !== undefined && !(parsed.command === 'session' && parsed.positional[0] === 'har')) {
154
+ throw schemaInput(leafCommand(parsed.command, parsed.positional), `--body=${parsed.body}`, '--body only with capture session har', '--body');
155
+ }
88
156
  if (parsed.command === 'cdp') {
89
157
  requireCount(parsed.positional, 0, 1, 'cdp');
90
- if (parsed.positional.length === 0 && !parsed.waitEvent) throw invalidInput('cdp requires a method or --wait-event.');
158
+ if (parsed.positional[0] !== undefined && !cdpMember(parsed.positional[0])) {
159
+ throw schemaInput('cdp', parsed.positional[0], 'a <Domain.method> member or --wait-event <Domain.event>', '<Domain.method>');
160
+ }
161
+ if (parsed.waitEvent !== undefined && !cdpMember(parsed.waitEvent)) {
162
+ throw schemaInput('cdp', parsed.waitEvent, 'a <Domain.event> member', '--wait-event');
163
+ }
164
+ if (parsed.help) return;
165
+ if (parsed.positional.length === 0 && !parsed.waitEvent) throw schemaInput('cdp', '(missing)', 'a <Domain.method> positional or --wait-event <Domain.event>', '<Domain.method> or --wait-event');
91
166
  if (parsed.params !== undefined) {
92
167
  try {
93
168
  const value = JSON.parse(parsed.params) as unknown;
94
169
  if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('not an object');
95
170
  } catch {
96
- throw invalidInput('--params must be a JSON object.');
171
+ throw schemaInput('cdp', parsed.params, 'a JSON-encoded params object', '--params');
97
172
  }
98
173
  }
99
174
  return;
100
175
  }
176
+ if (parsed.command === 'lighthouse') {
177
+ requireSchemaCount(parsed.positional, 1, 1, 'lighthouse', '<url>');
178
+ assertSchemaUrl(parsed.positional[0], 'lighthouse', '<url>');
179
+ if (parsed.categories !== undefined) {
180
+ const categories = parsed.categories.split(',');
181
+ if (categories.length === 0 || categories.some(category => !['performance', 'accessibility', 'best-practices', 'seo'].includes(category))) {
182
+ throw schemaInput('lighthouse', parsed.categories, 'a comma-separated list of performance, accessibility, best-practices, or seo', '--categories');
183
+ }
184
+ }
185
+ if (parsed.preset !== undefined && !['mobile', 'desktop'].includes(parsed.preset)) throw schemaInput('lighthouse', parsed.preset, 'mobile or desktop', '--preset');
186
+ return;
187
+ }
101
188
  if (parsed.positional[0] === undefined) return;
102
189
 
103
190
  const leaf = parsed.positional[0];
104
191
  const values = parsed.positional.slice(1);
105
192
  if (parsed.command === 'session') {
193
+ if (leaf === 'collectors') {
194
+ requireSchemaCount(values, 0, 0, 'session collectors', '(none)');
195
+ return;
196
+ }
106
197
  const ranges: Record<string, readonly [number, number]> = {
107
- start: [0, 0], stop: [1, 1], list: [0, 0], view: [1, 1], har: [0, 1], log: [1, 1],
198
+ start: [0, 0], stop: [1, 1], list: [0, 0], view: [1, 1], har: [0, 1], log: [1, 1], collectors: [0, 0],
108
199
  };
109
200
  const [min, max] = ranges[leaf];
110
201
  requireCount(values, min, max, `session ${leaf}`);
111
202
  if (leaf === 'start' && parsed.url !== undefined && parsed.target !== undefined) {
112
- throw invalidInput('session start cannot combine --url and --target.');
203
+ throw schemaInput('session start', `--url=${parsed.url}, --target=${parsed.target}`, 'at most one target source', '--url or --target');
113
204
  }
114
205
  return;
115
206
  }
116
207
 
117
208
  if (parsed.command === 'page') {
118
209
  if (leaf === 'click' || leaf === 'type' || leaf === 'navigate') requireCount(values, 1, 1, `page ${leaf}`);
119
- if (leaf === 'navigate') assertParseableUrl(values[0], 'URL');
210
+ if (leaf === 'navigate') assertSchemaUrl(values[0], 'page navigate', '<url>');
120
211
  if (leaf === 'scroll') {
121
212
  requireCount(values, 1, 1, 'page scroll');
122
- if (parsed.to === undefined) throw invalidInput('page scroll requires --to <top|bottom|px>.');
123
- assertScrollDestination(parsed.to);
213
+ if (parsed.to === undefined) throw schemaInput('page scroll', '(missing)', 'a --to <top|bottom|px> destination', '--to');
214
+ try { assertScrollDestination(parsed.to); } catch { throw schemaInput('page scroll', parsed.to, 'top, bottom, or a non-negative pixel offset', '--to'); }
124
215
  }
125
216
  if (leaf === 'exec') requireCount(values, parsed.file === undefined ? 1 : 0, parsed.file === undefined ? 1 : 0, 'page exec');
126
217
  if (leaf === 'shot' || leaf === 'elements') requireCount(values, 0, 0, `page ${leaf}`);
218
+ if (leaf === 'repeat') {
219
+ requireCount(values, 0, 0, 'page repeat');
220
+ if (parsed.iterations === undefined) throw schemaInput('page repeat', '(missing)', 'an iteration count from 1 to 100', '--iterations');
221
+ if (parsed.steps === undefined) throw schemaInput('page repeat', '(missing)', 'a JSON array of 1..20 click/type action objects', '--steps');
222
+ try {
223
+ const steps = JSON.parse(parsed.steps) as unknown;
224
+ const valid = parsed.steps.length <= 10_000 && Array.isArray(steps) && steps.length >= 1 && steps.length <= 20 && steps.every((step) => {
225
+ if (!step || typeof step !== 'object' || Array.isArray(step)) return false;
226
+ const record = step as Record<string, unknown>;
227
+ if (record.action === 'click') return Object.keys(record).length === 2 && typeof record.target === 'string' && record.target.length > 0;
228
+ if (record.action === 'type') return Object.keys(record).every(key => key === 'action' || key === 'text' || key === 'into') && typeof record.text === 'string' && (record.into === undefined || (typeof record.into === 'string' && record.into.trim().length > 0));
229
+ return false;
230
+ });
231
+ if (!valid) throw new Error('invalid steps');
232
+ } catch {
233
+ throw schemaInput('page repeat', parsed.steps, 'a JSON array of 1..20 {"action":"click","target":"…"} or {"action":"type","text":"…","into":"…"} objects', '--steps');
234
+ }
235
+ }
236
+ if (leaf === 'inspect') {
237
+ const inspectLeaf = values[0];
238
+ if (inspectLeaf === undefined) return;
239
+ requireCount(values.slice(1), 0, 0, `page inspect ${inspectLeaf}`);
240
+ if (inspectLeaf === 'listeners' && (parsed.scope ?? 'window') === 'target' && !parsed.selector?.trim()) {
241
+ throw schemaInput('page inspect listeners', '(missing)', 'a live selector/backend target when --scope target', '--selector');
242
+ }
243
+ if (inspectLeaf === 'listeners' && (parsed.scope ?? 'window') !== 'target' && parsed.selector !== undefined) {
244
+ throw schemaInput('page inspect listeners', `--scope=${parsed.scope ?? 'window'}, --selector=${parsed.selector}`, '--selector only with --scope target', '--scope or --selector');
245
+ }
246
+ if (inspectLeaf === 'frames' && parsed.duration !== undefined && parsed.duration > 60_000) {
247
+ throw schemaInput('page inspect frames', `--duration=${parsed.duration / 1000}`, 'a duration from 0 through 60 seconds', '--duration');
248
+ }
249
+ }
127
250
  if (leaf === 'shot' && parsed.viewport !== undefined) {
128
251
  const match = /^([1-9]\d*)x([1-9]\d*)$/.exec(parsed.viewport);
129
252
  if (!match || !Number.isSafeInteger(Number(match[1])) || !Number.isSafeInteger(Number(match[2]))) {
130
- throw invalidInput(`Invalid --viewport: ${parsed.viewport}.`);
253
+ throw schemaInput('page shot', parsed.viewport, '<positive-safe-int>x<positive-safe-int>', '--viewport');
131
254
  }
132
255
  }
133
256
  if (leaf === 'shot' && parsed.colorScheme !== undefined && parsed.colorScheme !== 'dark' && parsed.colorScheme !== 'light') {
134
- throw invalidInput(`Invalid --color-scheme: ${parsed.colorScheme}; expected dark or light.`);
257
+ throw schemaInput('page shot', parsed.colorScheme, 'dark or light', '--color-scheme');
135
258
  }
136
259
  return;
137
260
  }
138
261
 
139
262
  if (parsed.command === 'tab') {
263
+ if (leaf === 'mock') {
264
+ const mockLeaf = values[0];
265
+ if (mockLeaf === undefined) return;
266
+ const mockValues = values.slice(1);
267
+ requireSchemaCount(mockValues, 0, 0, `tab mock ${mockLeaf}`, '(none)');
268
+ if (mockLeaf === 'start' && !parsed.rules?.trim()) throw schemaInput('tab mock start', parsed.rules ?? '(missing)', 'a JSON rule document path', '--rules');
269
+ return;
270
+ }
140
271
  // `launch` takes no target at all (its browser does not exist yet) and
141
272
  // `quit` takes an optional port; every other tab leaf names exactly one.
142
273
  if (leaf === 'launch' || leaf === 'list') requireCount(values, 0, 0, `tab ${leaf}`);
143
274
  else if (leaf === 'quit') requireCount(values, 0, 1, 'tab quit');
144
275
  else requireCount(values, 1, 1, `tab ${leaf}`);
145
276
  if (leaf === 'network' && values[0] !== 'offline' && values[0] !== 'online') {
146
- throw invalidInput('tab network requires offline or online.');
277
+ throw schemaInput('tab network', values[0] ?? '(missing)', 'offline or online', '<mode>');
147
278
  }
148
279
  return;
149
280
  }
@@ -151,30 +282,90 @@ export function validateCliInvocation(parsed: ParsedArgs): void {
151
282
  if (parsed.command === 'measure') {
152
283
  if (leaf === 'snap' || leaf === 'check' || leaf === 'sweep') requireCount(values, 0, 1, `measure ${leaf}`);
153
284
  if (leaf === 'snap' && parsed.colorScheme !== undefined && parsed.colorScheme !== 'dark' && parsed.colorScheme !== 'light') {
154
- throw invalidInput(`Invalid --color-scheme: ${parsed.colorScheme}; expected dark or light.`);
285
+ throw schemaInput('measure snap', parsed.colorScheme, 'dark or light', '--color-scheme');
155
286
  }
156
287
  if (leaf === 'diff' || leaf === 'census') requireCount(values, 0, 0, `measure ${leaf}`);
157
- if (leaf === 'diff' && (!parsed.before || !parsed.after)) throw invalidInput('measure diff requires --before and --after.');
288
+ if (leaf === 'diff' && (!parsed.before || !parsed.after)) throw schemaInput('measure diff', `--before=${parsed.before ?? '(missing)'}, --after=${parsed.after ?? '(missing)'}`, 'both settled snapshot references', '--before and --after');
158
289
  if (leaf === 'census' && !['color', 'font', 'spacing', 'radius', 'shadow', 'animation', 'geometry', 'media', 'queries'].includes(parsed.axis ?? '')) {
159
- throw invalidInput('measure census requires a documented --axis value.');
290
+ throw schemaInput('measure census', parsed.axis ?? '(missing)', 'color, font, spacing, radius, shadow, animation, geometry, media, or queries', '--axis');
160
291
  }
161
292
  if (leaf === 'explain') {
162
293
  requireCount(values, 1, 1, 'measure explain');
163
- if (!parsed.selector?.trim()) throw invalidInput('measure explain requires --selector.');
294
+ if (!parsed.selector?.trim()) throw schemaInput('measure explain', '(missing)', 'a recorded selector input', '--selector');
295
+ }
296
+ if (leaf === 'text') {
297
+ requireCount(values, 1, 1, 'measure text');
298
+ if (!parsed.selector?.trim()) throw schemaInput('measure text', '(missing)', 'a recorded selector input', '--selector');
164
299
  }
165
300
  if (leaf === 'sweep') {
166
301
  if (!['width', 'dpr', 'zoom', 'color-scheme', 'reduced-motion'].includes(parsed.axis ?? '')) {
167
- throw invalidInput('measure sweep requires a documented --axis value.');
302
+ throw schemaInput('measure sweep', parsed.axis ?? '(missing)', 'width, dpr, zoom, color-scheme, or reduced-motion', '--axis');
303
+ }
304
+ try { assertSweepBounds(parsed.axis as SweepAxisName, parsed.from, parsed.to); } catch { throw schemaInput('measure sweep', `--from=${parsed.from ?? '(missing)'}, --to=${parsed.to ?? '(missing)'}`, `valid ${parsed.axis} bounds`, '--from and --to'); }
305
+ if (parsed.viewportHeight !== undefined) {
306
+ try { parsePositiveNumber(parsed.viewportHeight, 1, '--viewport-height'); } catch { throw schemaInput('measure sweep', parsed.viewportHeight, 'a positive number', '--viewport-height'); }
168
307
  }
169
- assertSweepBounds(parsed.axis as SweepAxisName, parsed.from, parsed.to);
170
- if (parsed.viewportHeight !== undefined) parsePositiveNumber(parsed.viewportHeight, 1, '--viewport-height');
171
308
  }
172
309
  if (leaf === 'map') {
173
310
  const mapLeaf = values[0];
174
311
  if (mapLeaf === undefined) return;
175
312
  const targets = values.slice(1);
176
313
  requireCount(targets, ['focus', 'ax', 'paint'].includes(mapLeaf) ? 1 : 0, 1, `measure map ${mapLeaf}`);
177
- if (mapLeaf === 'paint' && !parsed.selector?.trim()) throw invalidInput('measure map paint requires --selector.');
314
+ if (mapLeaf === 'paint' && !parsed.selector?.trim()) throw schemaInput('measure map paint', '(missing)', 'a recorded selector input', '--selector');
315
+ }
316
+ return;
317
+ }
318
+
319
+ if (parsed.command === 'perf') {
320
+ if (leaf === 'trace') {
321
+ requireSchemaCount(values, 0, 1, 'perf trace', '[url]');
322
+ if (parsed.start && parsed.stop) throw schemaInput('perf trace', '--start --stop', 'one lifecycle flag', '--start|--stop');
323
+ if (parsed.start || parsed.stop) {
324
+ if (values.length || parsed.do || parsed.duration !== undefined) throw schemaInput('perf trace', 'lifecycle and one-shot arguments combined', '--start or --stop alone', '[url]|--do|--duration');
325
+ } else {
326
+ if (values[0] !== undefined) assertSchemaUrl(values[0], 'perf trace', '[url]');
327
+ if (parsed.do !== undefined) {
328
+ try {
329
+ parseDoAction(parsed.do);
330
+ } catch {
331
+ throw schemaInput('perf trace', parsed.do, 'the motion rec --do action grammar', '--do');
332
+ }
333
+ }
334
+ }
335
+ } else {
336
+ requireSchemaCount(values, 1, 1, `perf ${leaf}`, '<trace>');
337
+ if (leaf === 'insights' && parsed.full && !parsed.name) throw schemaInput('perf insights', '(missing --name)', 'one engine insight name before --full event detail', '--name');
338
+ }
339
+ return;
340
+ }
341
+
342
+ if (parsed.command === 'heap') {
343
+ if (leaf === 'snapshot') {
344
+ requireSchemaCount(values, 0, 1, 'heap snapshot', '[url]');
345
+ if (values[0] !== undefined) assertSchemaUrl(values[0], 'heap snapshot', '[url]');
346
+ }
347
+ if (leaf === 'census') {
348
+ requireSchemaCount(values, 1, 1, 'heap census', '<snapshot>');
349
+ if (parsed.axis !== undefined && !['constructor', 'string'].includes(parsed.axis)) throw schemaInput('heap census', parsed.axis, 'constructor or string', '--axis');
350
+ }
351
+ if (leaf === 'objects') {
352
+ requireSchemaCount(values, 1, 1, 'heap objects', '<snapshot>');
353
+ const constructor = typeof parsed.constructor === 'string' ? parsed.constructor.trim() : '';
354
+ if (!constructor) throw schemaInput('heap objects', '(missing)', 'an exact constructor name reported by capture heap census', '--constructor');
355
+ if (parsed.sort !== undefined && !['retained', 'self'].includes(parsed.sort)) throw schemaInput('heap objects', parsed.sort, 'retained or self', '--sort');
356
+ }
357
+ if (leaf === 'retainers') {
358
+ requireSchemaCount(values, 1, 1, 'heap retainers', '<snapshot>');
359
+ if (parsed.node === undefined) throw schemaInput('heap retainers', '(missing)', 'a Chrome snapshot object id', '--node');
360
+ try {
361
+ schemaInteger(undefined, parsed.node, '--node', 1, Number.MAX_SAFE_INTEGER, true);
362
+ } catch {
363
+ throw schemaInput('heap retainers', parsed.node, 'a positive safe-integer Chrome snapshot object id', '--node');
364
+ }
365
+ }
366
+ if (leaf === 'diff') {
367
+ requireSchemaCount(values, 0, 0, 'heap diff', '(none)');
368
+ if (!parsed.before || !parsed.after) throw schemaInput('heap diff', `--before=${parsed.before ?? '(missing)'}, --after=${parsed.after ?? '(missing)'}`, 'both heap snapshot references', '--before and --after');
178
369
  }
179
370
  return;
180
371
  }
@@ -183,29 +374,29 @@ export function validateCliInvocation(parsed: ParsedArgs): void {
183
374
  if (leaf === 'mask' || leaf === 'jank' || leaf === 'response') requireCount(values, 1, 1, `motion ${leaf}`);
184
375
  if (leaf === 'timeline') {
185
376
  requireCount(values, 1, 1, 'motion timeline');
186
- if (!parsed.element?.trim()) throw invalidInput('motion timeline requires --element.');
377
+ if (!parsed.element?.trim()) throw schemaInput('motion timeline', '(missing)', 'an element selector input', '--element');
187
378
  }
188
379
  if (leaf === 'rec') {
189
- if (parsed.start && parsed.stop) throw invalidInput('motion rec cannot combine --start and --stop.');
190
- if (!parsed.stop && parsed.recId) throw invalidInput('motion rec accepts --rec-id only with --stop.');
380
+ if (parsed.start && parsed.stop) throw schemaInput('motion rec', '--start --stop', 'one lifecycle flag', '--start or --stop');
381
+ if (!parsed.stop && parsed.recId) throw schemaInput('motion rec', `--rec-id=${parsed.recId}`, '--rec-id only with --stop', '--rec-id');
191
382
  if (parsed.start || parsed.stop) {
192
383
  requireCount(values, 0, 0, 'motion rec lifecycle');
193
384
  if (parsed.do || parsed.duration !== undefined || (parsed.stop && parsed.viewport !== undefined)) {
194
- throw invalidInput('motion rec lifecycle flags cannot be combined with one-shot flags.');
385
+ throw schemaInput('motion rec', 'lifecycle and one-shot arguments combined', '--start or --stop alone', '[url]|--do|--duration|--viewport');
195
386
  }
196
387
  } else {
197
388
  // The URL is optional: omitting it records the active session tab
198
389
  // (enforced downstream once session state is known — this pure
199
390
  // validator only bounds cardinality at 0..1 and checks a supplied URL).
200
391
  requireCount(values, 0, 1, 'motion rec');
201
- if (!parsed.do) throw invalidInput('motion rec one-shot requires --do.');
202
- if (values[0] !== undefined) assertParseableUrl(values[0], 'recording URL');
203
- parseDoAction(parsed.do);
392
+ if (!parsed.do) throw schemaInput('motion rec', '(missing)', 'a --do action for one-shot recording', '--do');
393
+ if (values[0] !== undefined) assertSchemaUrl(values[0], 'motion rec', '[url]');
394
+ try { parseDoAction(parsed.do); } catch { throw schemaInput('motion rec', parsed.do, 'the documented --do action grammar', '--do'); }
204
395
  }
205
396
  if (parsed.viewport !== undefined) {
206
397
  const match = /^([1-9]\d*)x([1-9]\d*)$/.exec(parsed.viewport);
207
398
  if (!match || !Number.isSafeInteger(Number(match[1])) || !Number.isSafeInteger(Number(match[2]))) {
208
- throw invalidInput(`Invalid --viewport: ${parsed.viewport}.`);
399
+ throw schemaInput('motion rec', parsed.viewport, '<positive-safe-int>x<positive-safe-int>', '--viewport');
209
400
  }
210
401
  }
211
402
  }
@@ -225,14 +416,19 @@ export function parseCliSyntax(argv: string[]): ParsedArgs {
225
416
  const rest = expandEqualsFlags(argv.slice(1));
226
417
  const positional: string[] = [];
227
418
  const parsed: Partial<ParsedArgs> = { command, positional };
419
+ const commandPath = syntaxCommand(argv);
420
+ const valueFor = (flag: string, next: string | undefined) => schemaValueFor(commandPath, flag, next);
421
+ const integer = (token: string, flag: string, min: number, max: number, safe = false) => schemaInteger(commandPath, token, flag, min, max, safe);
422
+ const durationMs = (token: string) => schemaDuration(commandPath, token);
228
423
 
229
424
  for (let i = 0; i < rest.length; i++) {
230
425
  const arg = rest[i];
231
426
  const next = rest[i + 1];
232
- if (VALUE_FLAGS.has(arg)) valueFor(arg, next);
427
+ if (VALUE_FLAGS.has(arg) && !(arg === '--crop' && commandPath === 'measure text')) valueFor(arg, next);
233
428
 
234
429
  if (arg === '--port') { parsed.port = integer(valueFor(arg, next), '--port', 1, 65535); parsed.portSource = 'flag'; i++; }
235
430
  else if (arg === '--out') { parsed.out = valueFor(arg, next); i++; }
431
+ else if (arg === '--artifact-dir') { parsed.artifactDir = valueFor(arg, next); i++; }
236
432
  else if (arg === '--json') parsed.json = true;
237
433
  else if (arg === '--duration') { parsed.duration = durationMs(valueFor(arg, next)); i++; }
238
434
  else if (arg === '--settle') { parsed.settle = integer(valueFor(arg, next), '--settle', 0, 2_147_483_647); i++; }
@@ -245,6 +441,12 @@ export function parseCliSyntax(argv: string[]): ParsedArgs {
245
441
  else if (arg === '--no-screenshot') parsed.noScreenshot = true;
246
442
  else if (arg === '--viewport') { const value = valueFor(arg, next); parsed.viewport = value; (parsed.viewports ??= []).push(value); i++; }
247
443
  else if (arg === '--color-scheme') { parsed.colorScheme = valueFor(arg, next); i++; }
444
+ else if (arg === '--behavior') {
445
+ const behavior = valueFor(arg, next);
446
+ if (behavior !== 'instant' && behavior !== 'smooth') throw syntaxSchemaInput(commandPath, `--behavior=${behavior}`, 'instant or smooth', '--behavior');
447
+ parsed.behavior = behavior;
448
+ i++;
449
+ }
248
450
  else if (arg === '--full-page') parsed.fullPage = true;
249
451
  else if (arg === '--all') parsed.all = true;
250
452
  else if (arg === '--session') { parsed.session = valueFor(arg, next); i++; }
@@ -252,6 +454,7 @@ export function parseCliSyntax(argv: string[]): ParsedArgs {
252
454
  else if (arg === '--filter') { parsed.filter = valueFor(arg, next); i++; }
253
455
  else if (arg === '--name') { parsed.name = valueFor(arg, next); i++; }
254
456
  else if (arg === '--filter-url') { parsed.filterUrl = valueFor(arg, next); i++; }
457
+ else if (arg === '--body') { parsed.body = valueFor(arg, next); i++; }
255
458
  else if (arg === '--filter-status') { parsed.filterStatus = valueFor(arg, next); i++; }
256
459
  else if (arg === '--filter-method') { parsed.filterMethod = valueFor(arg, next).toUpperCase(); i++; }
257
460
  else if (arg === '--limit') { parsed.limit = integer(valueFor(arg, next), '--limit', 1, Number.MAX_SAFE_INTEGER, true); i++; }
@@ -268,6 +471,7 @@ export function parseCliSyntax(argv: string[]): ParsedArgs {
268
471
  else if (arg === '--state') { (parsed.state ??= []).push(valueFor(arg, next)); i++; }
269
472
  else if (arg === '--state-padding') { parsed.statePadding = integer(valueFor(arg, next), '--state-padding', 0, 16_384); i++; }
270
473
  else if (arg === '--list-crops') parsed.listCrops = true;
474
+ else if (arg === '--crop' && commandPath === 'measure text') parsed.textCrop = true;
271
475
  else if (arg === '--crop') { parsed.crop = valueFor(arg, next); i++; }
272
476
  else if (arg === '--crop-selector') { parsed.cropSelector = valueFor(arg, next); i++; }
273
477
  else if (arg === '--pad') { parsed.pad = integer(valueFor(arg, next), '--pad', 0, 16_384); i++; }
@@ -295,7 +499,23 @@ export function parseCliSyntax(argv: string[]): ParsedArgs {
295
499
  else if (arg === '--prop') { parsed.prop = valueFor(arg, next); i++; }
296
500
  else if (arg === '--action') { parsed.action = valueFor(arg, next); i++; }
297
501
  else if (arg === '--occurrence') { parsed.occurrence = integer(valueFor(arg, next), '--occurrence', 1, Number.MAX_SAFE_INTEGER, true); i++; }
298
- else if (arg === '-h') parsed.help = true;
502
+ else if (arg === '--constructor') { parsed.constructor = valueFor(arg, next); i++; }
503
+ else if (arg === '--node') { parsed.node = valueFor(arg, next); i++; }
504
+ else if (arg === '--paths') { parsed.paths = integer(valueFor(arg, next), '--paths', 1, Number.MAX_SAFE_INTEGER, true); i++; }
505
+ else if (arg === '--sort') { parsed.sort = valueFor(arg, next); i++; }
506
+ else if (arg === '--rules') { parsed.rules = valueFor(arg, next); i++; }
507
+ else if (arg === '--categories') { parsed.categories = valueFor(arg, next); i++; }
508
+ else if (arg === '--preset') { parsed.preset = valueFor(arg, next); i++; }
509
+ else if (arg === '--scope') {
510
+ const scope = valueFor(arg, next);
511
+ if (scope !== 'window' && scope !== 'document' && scope !== 'target') throw syntaxSchemaInput(commandPath, `--scope=${scope}`, 'window, document, or target', '--scope');
512
+ parsed.scope = scope;
513
+ i++;
514
+ }
515
+ else if (arg === '--event') { parsed.event = valueFor(arg, next); i++; }
516
+ else if (arg === '--steps') { parsed.steps = valueFor(arg, next); i++; }
517
+ else if (arg === '--iterations') { parsed.iterations = integer(valueFor(arg, next), '--iterations', 1, 100, true); i++; }
518
+ else if (arg === '-h' || arg === '--help') parsed.help = true;
299
519
  else if (arg.startsWith('--')) throw invalidInput(`Unknown flag: ${arg}.`, 'unknown_flag');
300
520
  else positional.push(arg);
301
521
  }
@@ -314,7 +534,7 @@ export function parseCliSyntax(argv: string[]): ParsedArgs {
314
534
  */
315
535
  function needsEndpointResolution(parsed: ParsedArgs): boolean {
316
536
  if (parsed.help || parsed.command === 'lib') return false;
317
- const branchOnly = ['session', 'page', 'tab', 'measure', 'motion'].includes(parsed.command)
537
+ const branchOnly = ['session', 'page', 'tab', 'measure', 'motion', 'perf', 'heap'].includes(parsed.command)
318
538
  && (parsed.positional.length === 0 || (parsed.command === 'measure' && parsed.positional.length === 1 && parsed.positional[0] === 'map'));
319
539
  if (branchOnly) return false;
320
540
  if (parsed.command === 'session' && parsed.positional[0] !== 'start') return false;
@@ -353,7 +573,7 @@ export function resolveCliContext(parsed: ParsedArgs): ParsedArgs {
353
573
  if (parsed.port === undefined && session.port !== undefined && session.port !== null) { parsed.port = session.port; parsed.portSource = 'session'; }
354
574
  }
355
575
  if (parsed.port === undefined && process.env.CDP_PORT !== undefined) {
356
- parsed.port = integer(process.env.CDP_PORT, 'CDP_PORT', 1, 65535);
576
+ parsed.port = schemaInteger(leafCommand(parsed.command, parsed.positional), process.env.CDP_PORT, 'CDP_PORT', 1, 65535);
357
577
  parsed.portSource = 'env';
358
578
  }
359
579
  // Preserve historical stale-index cleanup once endpoint validation has
@@ -218,17 +218,18 @@ export async function runBridgeServer(socketPath: string, port?: number): Promis
218
218
  export function prepareSocketPath(socketPath: string): void {
219
219
  fs.mkdirSync(path.dirname(socketPath), { recursive: true });
220
220
  try {
221
+ if (!fs.lstatSync(socketPath).isSocket()) throw new Error(`socket path is not a Unix socket: ${socketPath}`);
221
222
  fs.unlinkSync(socketPath);
222
- } catch {
223
- // No stale socket to remove.
223
+ } catch (error) {
224
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
224
225
  }
225
226
  }
226
227
 
227
228
  /**
228
229
  * Binds a Unix-domain `net.Server` at `socketPath` that frames each
229
230
  * connection's input as newline-delimited JSON, invoking `handleLine` once
230
- * per complete line. This is the wire framing every bridge mode (plain
231
- * browser-level bridge, recorder bridge) shares — "one request per
231
+ * per complete line. This is the wire framing both bridge modes (plain
232
+ * browser-level bridge and collector host) share — "one request per
232
233
  * connection, one response, then the socket closes" is a convention the
233
234
  * caller's `handleLine` implements by writing exactly one response per
234
235
  * line and letting the client end the connection; the server itself is
@@ -269,7 +270,7 @@ export function closeNdjsonSocket(server: net.Server, socketPath: string): void
269
270
  // Already closed.
270
271
  }
271
272
  try {
272
- fs.unlinkSync(socketPath);
273
+ if (fs.lstatSync(socketPath).isSocket()) fs.unlinkSync(socketPath);
273
274
  } catch {
274
275
  // Already gone.
275
276
  }