@crouton-kit/capture 1.3.8 → 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 (136) hide show
  1. package/bin/capture +29841 -8539
  2. package/package.json +6 -3
  3. package/src/capture.ts +86 -173
  4. package/src/cdp/a11y.ts +113 -14
  5. package/src/cdp/args.ts +300 -205
  6. package/src/cdp/bridge/protocol.ts +93 -6
  7. package/src/cdp/bridge/server.ts +118 -53
  8. package/src/cdp/bridge/spawn.ts +53 -6
  9. package/src/cdp/client.ts +13 -3
  10. package/src/cdp/commands/bridge-serve.ts +20 -14
  11. package/src/cdp/commands/cdp.ts +259 -90
  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 +76 -20
  15. package/src/cdp/commands/measure/check.ts +100 -26
  16. package/src/cdp/commands/measure/diff.ts +109 -21
  17. package/src/cdp/commands/measure/explain.ts +121 -19
  18. package/src/cdp/commands/measure/index.ts +31 -39
  19. package/src/cdp/commands/measure/map-ax.ts +52 -0
  20. package/src/cdp/commands/measure/map-focus.ts +41 -13
  21. package/src/cdp/commands/measure/map-layers.ts +49 -15
  22. package/src/cdp/commands/measure/map-scroll.ts +85 -15
  23. package/src/cdp/commands/measure/snap.ts +405 -25
  24. package/src/cdp/commands/measure/sweep.ts +217 -20
  25. package/src/cdp/commands/motion/index.ts +18 -17
  26. package/src/cdp/commands/motion/jank.ts +119 -9
  27. package/src/cdp/commands/motion/mask.ts +81 -12
  28. package/src/cdp/commands/motion/rec.ts +520 -19
  29. package/src/cdp/commands/motion/response.ts +65 -15
  30. package/src/cdp/commands/motion/timeline.ts +139 -14
  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/detect.ts +42 -13
  46. package/src/cdp/dispatch.ts +74 -28
  47. package/src/cdp/har-recorder.ts +648 -115
  48. package/src/cdp/leaf-grammar.ts +99 -0
  49. package/src/cdp/measure/census.ts +205 -0
  50. package/src/cdp/measure/check.ts +318 -0
  51. package/src/cdp/measure/collectors/animation.ts +385 -0
  52. package/src/cdp/measure/collectors/ax.ts +278 -0
  53. package/src/cdp/measure/collectors/focus.ts +1041 -0
  54. package/src/cdp/measure/collectors/forms.ts +905 -0
  55. package/src/cdp/measure/collectors/geometry.ts +1038 -0
  56. package/src/cdp/measure/collectors/hittest.ts +835 -0
  57. package/src/cdp/measure/collectors/layers.ts +697 -0
  58. package/src/cdp/measure/collectors/media.ts +522 -0
  59. package/src/cdp/measure/collectors/pixels.ts +1584 -0
  60. package/src/cdp/measure/collectors/queries.ts +464 -0
  61. package/src/cdp/measure/collectors/scroll.ts +599 -0
  62. package/src/cdp/measure/collectors/states.ts +992 -0
  63. package/src/cdp/measure/collectors/style-provenance.ts +643 -0
  64. package/src/cdp/measure/collectors/styles.ts +355 -0
  65. package/src/cdp/measure/collectors/text.ts +713 -0
  66. package/src/cdp/measure/diff.ts +470 -0
  67. package/src/cdp/measure/explain.ts +498 -0
  68. package/src/cdp/measure/map-ax.ts +633 -0
  69. package/src/cdp/measure/map-focus.ts +188 -0
  70. package/src/cdp/measure/map-layers.ts +177 -0
  71. package/src/cdp/measure/map-scroll.ts +317 -0
  72. package/src/cdp/measure/redaction.ts +51 -0
  73. package/src/cdp/measure/settle.ts +1051 -0
  74. package/src/cdp/measure/snapshot.ts +390 -0
  75. package/src/cdp/measure/sweep.ts +330 -0
  76. package/src/cdp/measure/types.ts +220 -0
  77. package/src/cdp/measure/v2/collectors.ts +19 -0
  78. package/src/cdp/measure/v2/orchestrator.ts +46 -0
  79. package/src/cdp/measure/v2/snap-selection.ts +33 -0
  80. package/src/cdp/measure/v2/target-lock.ts +15 -0
  81. package/src/cdp/measure/v2/target.ts +16 -0
  82. package/src/cdp/motion/jank.ts +433 -0
  83. package/src/cdp/motion/mask.ts +421 -0
  84. package/src/cdp/motion/recorder.ts +764 -0
  85. package/src/cdp/motion/response.ts +356 -0
  86. package/src/cdp/motion/timeline.ts +215 -0
  87. package/src/cdp/record.ts +5 -212
  88. package/src/cdp/recorder-bridge.ts +1478 -105
  89. package/src/cdp/recorder-client.ts +326 -0
  90. package/src/cdp/scope-lock.ts +73 -0
  91. package/src/cdp/screenshot.ts +199 -66
  92. package/src/cdp/targets.ts +21 -1
  93. package/src/cdp/timing.ts +15 -10
  94. package/src/cdp/types.ts +22 -7
  95. package/src/cdp/viewport.ts +21 -0
  96. package/src/cdp.ts +12 -58
  97. package/src/command-help.ts +29 -0
  98. package/src/command-resolution.ts +22 -0
  99. package/src/command-walker.ts +35 -0
  100. package/src/contracts/a11y.ts +33 -0
  101. package/src/contracts/cursor.ts +35 -0
  102. package/src/contracts/index.ts +15 -0
  103. package/src/contracts/motion.ts +19 -0
  104. package/src/contracts/neutral.ts +56 -0
  105. package/src/contracts/primitives.ts +205 -0
  106. package/src/contracts/release.ts +17 -0
  107. package/src/contracts/results.ts +208 -0
  108. package/src/contracts/routes.ts +140 -0
  109. package/src/contracts/snapshot.ts +92 -0
  110. package/src/cursor/index.ts +13 -0
  111. package/src/cursor/service.ts +310 -0
  112. package/src/errors.ts +71 -0
  113. package/src/har-manager.ts +992 -41
  114. package/src/interact.ts +410 -98
  115. package/src/output/artifact-lifecycle.ts +371 -0
  116. package/src/output/artifact.ts +6 -0
  117. package/src/output/exact-raw.ts +114 -0
  118. package/src/output/render.ts +396 -2
  119. package/src/output/structured.ts +211 -0
  120. package/src/registry.ts +208 -0
  121. package/src/session/artifacts.ts +452 -200
  122. package/src/session/commands.ts +1162 -397
  123. package/src/session/coordinator.ts +251 -0
  124. package/src/session/har-filter.ts +57 -0
  125. package/src/session/log-tailer.ts +653 -0
  126. package/src/session-context.ts +279 -51
  127. package/src/vault/bundle.ts +32 -17
  128. package/src/cdp/commands/exec.ts +0 -105
  129. package/src/cdp/commands/har.ts +0 -128
  130. package/src/cdp/commands/tabs.ts +0 -183
  131. package/src/cdp/commands/traffic.ts +0 -121
  132. package/src/cdp/commands/ui.ts +0 -172
  133. package/src/cdp/execute.ts +0 -113
  134. package/src/cdp/har-output.ts +0 -47
  135. package/src/cdp/usage.ts +0 -79
  136. 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,209 +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
- // Kept last-wins for every existing command's single-target semantics.
68
- // Also accumulated into `urls` so census/sweep-style leaves can accept
69
- // the flag repeated for a multi-page set without disturbing that.
70
- parsed.url = next;
71
- (parsed.urls ??= []).push(next);
72
- i++;
73
- } else if (arg === '--role' && next) {
74
- parsed.role = next;
75
- i++;
76
- } else if (arg === '--into' && next) {
77
- parsed.into = next;
78
- i++;
79
- } else if (arg === '--no-screenshot') {
80
- parsed.noScreenshot = true;
81
- } else if (arg === '--viewport' && next) {
82
- // Kept last-wins for every existing command's single-value semantics.
83
- // Also accumulated into `viewports` so check/snap/census-style leaves
84
- // can accept the flag repeated for a multi-viewport sweep, mirroring
85
- // the `--url` → { url, urls[] } pattern above.
86
- parsed.viewport = next;
87
- (parsed.viewports ??= []).push(next);
88
- i++;
89
- } else if (arg === '--full-page') {
90
- parsed.fullPage = true;
91
- } else if (arg === '--height' && next) {
92
- parsed.height = parseInt(next, 10);
93
- i++;
94
- } else if (arg === '--filter-url' && next) {
95
- parsed.filterUrl = next;
96
- i++;
97
- } else if (arg === '--filter-status' && next) {
98
- parsed.filterStatus = next;
99
- i++;
100
- } else if (arg === '--filter-method' && next) {
101
- parsed.filterMethod = next.toUpperCase();
102
- i++;
103
- } else if (arg === '--limit' && next) {
104
- parsed.limit = parseInt(next, 10);
105
- i++;
106
- } else if (arg === '--browser') {
107
- parsed.browser = true;
108
- } else if (arg === '--params' && next) {
109
- parsed.params = next;
110
- i++;
111
- } else if (arg === '--wait-event' && next) {
112
- parsed.waitEvent = next;
113
- i++;
114
- } else if (arg === '--timeout' && next) {
115
- parsed.timeoutMs = parseInt(next, 10);
116
- i++;
117
- } else if (arg === '--socket' && next) {
118
- parsed.socket = next;
119
- i++;
120
- } else if (arg === '--freeze-animations') {
121
- parsed.freezeAnimations = true;
122
- } else if (arg === '--settle-timeout' && next) {
123
- parsed.settleTimeout = parseInt(next, 10);
124
- i++;
125
- } else if (arg === '--capture-unsettled') {
126
- parsed.captureUnsettled = true;
127
- } else if (arg === '--pixels') {
128
- parsed.pixels = true;
129
- } else if (arg === '--state' && next) {
130
- (parsed.state ??= []).push(next);
131
- i++;
132
- } else if (arg === '--for' && next) {
133
- parsed.for = next;
134
- i++;
135
- } else if (arg === '--before' && next) {
136
- parsed.before = next;
137
- i++;
138
- } else if (arg === '--after' && next) {
139
- parsed.after = next;
140
- i++;
141
- } else if (arg === '--full') {
142
- parsed.full = true;
143
- } else if (arg === '--gate') {
144
- parsed.gate = true;
145
- } else if (arg === '--snap' && next) {
146
- (parsed.snap ??= []).push(next);
147
- i++;
148
- } else if (arg === '--set-file' && next) {
149
- parsed.setFile = next;
150
- i++;
151
- } else if (arg === '--axis' && next) {
152
- parsed.axis = next;
153
- i++;
154
- } else if (arg === '--from' && next) {
155
- // Raw string — axis units vary (width px / dpr float / zoom); parseInt
156
- // would lossily truncate a non-integer axis value.
157
- parsed.from = next;
158
- i++;
159
- } else if (arg === '--to' && next) {
160
- parsed.to = next;
161
- i++;
162
- } else if (arg === '--viewport-height' && next) {
163
- parsed.viewportHeight = next;
164
- i++;
165
- } else if (arg === '--rec-id' && next) {
166
- parsed.recId = next;
167
- i++;
168
- } else if (arg === '--selector' && next) {
169
- parsed.selector = next;
170
- i++;
171
- } else if (arg === '--size') {
172
- parsed.size = true;
173
- } else if (arg === '--text') {
174
- parsed.text = true;
175
- } else if (arg === '--form') {
176
- parsed.form = true;
177
- } else if (arg === '--start') {
178
- parsed.start = true;
179
- } else if (arg === '--stop') {
180
- parsed.stop = true;
181
- } else if (arg === '--do' && next) {
182
- parsed.do = next;
183
- i++;
184
- } else if (arg === '--element' && next) {
185
- parsed.element = next;
186
- i++;
187
- } else if (arg === '--prop' && next) {
188
- parsed.prop = next;
189
- i++;
190
- } else if (arg === '--action' && next) {
191
- parsed.action = next;
192
- i++;
193
- } else if (arg === '--help' || arg === '-h') {
194
- parsed.help = true;
195
- } else if (arg.startsWith('--')) {
196
- console.error(`Unknown flag: ${arg}`);
197
- process.exit(1);
198
- } else {
199
- positional.push(arg);
200
- }
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);
201
274
  }
202
275
 
203
- // Fill gaps from the active session first. A session is an explicit,
204
- // scoped choice the caller made (`capture session start`), so its
205
- // target/har must win over ambient CDP_PORT/CDP_TARGET/CDP_HAR_ID env vars
206
- // — those are meant for orchestrators only when NOT in a session (see
207
- // `capture --help` TARGETING section). Letting a stale/inherited env var
208
- // outrank an active session is how a command silently ends up on the
209
- // wrong tab instead of the session's own.
210
- const session = getActiveSession();
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
+ }
296
+
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 });
211
308
  if (session) {
212
309
  if (!parsed.har && session.harId) parsed.har = session.harId;
213
310
  // An explicit --url picks a different (parallel) tab than the session's
214
311
  // own — don't let the session's targetId clobber that choice.
215
- 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;
216
317
  }
217
-
218
- // Fill any still-empty gaps from environment variables (set by pipeline
219
- // orchestrators that aren't using the session concept).
220
- if (!parsed.port && process.env.CDP_PORT) {
221
- const envPort = Number.parseInt(process.env.CDP_PORT, 10);
222
- if (Number.isNaN(envPort)) {
223
- console.error(`Invalid CDP_PORT: ${process.env.CDP_PORT}`);
224
- process.exit(1);
225
- }
226
- parsed.port = envPort;
227
- }
228
- if (!parsed.target && !parsed.url && process.env.CDP_TARGET) {
229
- parsed.target = process.env.CDP_TARGET;
230
- }
231
- if (!parsed.har && process.env.CDP_HAR_ID) {
232
- parsed.har = process.env.CDP_HAR_ID;
318
+ if (parsed.port === undefined && process.env.CDP_PORT !== undefined) {
319
+ parsed.port = integer(process.env.CDP_PORT, 'CDP_PORT', 1, 65535);
233
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
+ }
234
327
 
235
- 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);
236
331
  }