@crouton-kit/capture 1.3.21 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/capture +2830 -1730
- package/package.json +1 -1
- package/src/capture.ts +3 -2
- package/src/cdp/args.ts +18 -3
- package/src/cdp/commands/measure/explain.ts +22 -7
- package/src/cdp/commands/measure/index.ts +8 -4
- package/src/cdp/commands/measure/map-paint.ts +273 -0
- package/src/cdp/commands/measure/snap.ts +34 -7
- package/src/cdp/commands/page/exec.ts +1 -1
- package/src/cdp/commands/page/index.ts +2 -2
- package/src/cdp/commands/page/shot.ts +118 -6
- package/src/cdp/connection.ts +19 -12
- package/src/cdp/measure/collectors/geometry.ts +66 -9
- package/src/cdp/measure/collectors/pixels.ts +9 -1
- package/src/cdp/measure/collectors/states.ts +243 -4
- package/src/cdp/measure/explain.ts +123 -23
- package/src/cdp/measure/ink-source.ts +116 -0
- package/src/cdp/measure/ink.ts +180 -0
- package/src/cdp/measure/snapshot.ts +3 -0
- package/src/cdp/screenshot.ts +43 -11
- package/src/cdp/targets.ts +24 -0
- package/src/cdp/types.ts +15 -0
- package/src/contracts/index.ts +0 -2
- package/src/output/render.ts +1 -0
- package/src/output/selector.ts +238 -5
- package/src/session/commands.ts +47 -32
- package/src/command-help.ts +0 -29
- package/src/command-resolution.ts +0 -22
- package/src/command-walker.ts +0 -35
- package/src/contracts/results.ts +0 -208
- package/src/contracts/routes.ts +0 -140
- package/src/output/exact-raw.ts +0 -114
- package/src/registry.ts +0 -208
package/src/output/selector.ts
CHANGED
|
@@ -30,6 +30,8 @@ export interface ElementRecord {
|
|
|
30
30
|
readonly axName?: string;
|
|
31
31
|
/** Text content associated with the element, when known. */
|
|
32
32
|
readonly text?: string;
|
|
33
|
+
/** The element's own captured HTML attributes, keyed by attribute name. */
|
|
34
|
+
readonly attributes?: Readonly<Record<string, string>>;
|
|
33
35
|
readonly [key: string]: unknown;
|
|
34
36
|
}
|
|
35
37
|
|
|
@@ -37,9 +39,240 @@ export interface ElementRecord {
|
|
|
37
39
|
// Direct lookups
|
|
38
40
|
// ============================================================================
|
|
39
41
|
|
|
40
|
-
|
|
42
|
+
interface AttributeMatcher {
|
|
43
|
+
readonly name: string;
|
|
44
|
+
readonly value?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface CompoundMatcher {
|
|
48
|
+
readonly tag?: string;
|
|
49
|
+
readonly id?: string;
|
|
50
|
+
readonly classes: readonly string[];
|
|
51
|
+
readonly attributes: readonly AttributeMatcher[];
|
|
52
|
+
readonly nthOfType?: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
type Combinator = 'child' | 'descendant';
|
|
56
|
+
|
|
57
|
+
interface CssMatcher {
|
|
58
|
+
readonly compounds: readonly CompoundMatcher[];
|
|
59
|
+
/** The relation between compound i and compound i + 1. */
|
|
60
|
+
readonly combinators: readonly Combinator[];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function isNameStart(char: string | undefined): boolean {
|
|
64
|
+
return char !== undefined && /[A-Za-z_*]/.test(char);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function isNameChar(char: string | undefined): boolean {
|
|
68
|
+
return char !== undefined && /[A-Za-z0-9_-]/.test(char);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function readName(value: string, start: number): { value: string; end: number } | undefined {
|
|
72
|
+
if (!isNameStart(value[start])) return undefined;
|
|
73
|
+
let end = start + 1;
|
|
74
|
+
while (isNameChar(value[end])) end += 1;
|
|
75
|
+
return { value: value.slice(start, end), end };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function unquoteCssValue(value: string): string | undefined {
|
|
79
|
+
const trimmed = value.trim();
|
|
80
|
+
if (!trimmed) return undefined;
|
|
81
|
+
if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
|
|
82
|
+
return trimmed.slice(1, -1).replace(/\\(.)/g, '$1');
|
|
83
|
+
}
|
|
84
|
+
return /\s/.test(trimmed) ? undefined : trimmed;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function parseAttribute(value: string): AttributeMatcher | undefined {
|
|
88
|
+
const equals = value.indexOf('=');
|
|
89
|
+
const name = (equals === -1 ? value : value.slice(0, equals)).trim();
|
|
90
|
+
if (!/^[A-Za-z_][A-Za-z0-9_:-]*$/.test(name)) return undefined;
|
|
91
|
+
if (equals === -1) return { name: name.toLowerCase() };
|
|
92
|
+
const attributeValue = unquoteCssValue(value.slice(equals + 1));
|
|
93
|
+
return attributeValue === undefined ? undefined : { name: name.toLowerCase(), value: attributeValue };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function parseCompound(value: string): CompoundMatcher | undefined {
|
|
97
|
+
let index = 0;
|
|
98
|
+
let tag: string | undefined;
|
|
99
|
+
const first = readName(value, index);
|
|
100
|
+
if (first) {
|
|
101
|
+
tag = first.value.toLowerCase();
|
|
102
|
+
index = first.end;
|
|
103
|
+
}
|
|
104
|
+
const classes: string[] = [];
|
|
105
|
+
const attributes: AttributeMatcher[] = [];
|
|
106
|
+
let id: string | undefined;
|
|
107
|
+
let nthOfType: number | undefined;
|
|
108
|
+
while (index < value.length) {
|
|
109
|
+
const marker = value[index]!;
|
|
110
|
+
if (marker === '.' || marker === '#') {
|
|
111
|
+
const name = readName(value, index + 1);
|
|
112
|
+
if (!name) return undefined;
|
|
113
|
+
if (marker === '.') classes.push(name.value);
|
|
114
|
+
else if (id === undefined) id = name.value;
|
|
115
|
+
else return undefined;
|
|
116
|
+
index = name.end;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (marker === '[') {
|
|
120
|
+
let end = index + 1;
|
|
121
|
+
let quote: string | undefined;
|
|
122
|
+
while (end < value.length) {
|
|
123
|
+
const char = value[end]!;
|
|
124
|
+
if (quote) {
|
|
125
|
+
if (char === '\\') end += 2;
|
|
126
|
+
else {
|
|
127
|
+
if (char === quote) quote = undefined;
|
|
128
|
+
end += 1;
|
|
129
|
+
}
|
|
130
|
+
} else if (char === '"' || char === "'") {
|
|
131
|
+
quote = char;
|
|
132
|
+
end += 1;
|
|
133
|
+
} else if (char === ']') break;
|
|
134
|
+
else end += 1;
|
|
135
|
+
}
|
|
136
|
+
if (quote || value[end] !== ']') return undefined;
|
|
137
|
+
const attribute = parseAttribute(value.slice(index + 1, end));
|
|
138
|
+
if (!attribute) return undefined;
|
|
139
|
+
attributes.push(attribute);
|
|
140
|
+
index = end + 1;
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (value.startsWith(':nth-of-type(', index)) {
|
|
144
|
+
const end = value.indexOf(')', index);
|
|
145
|
+
const rawIndex = value.slice(index + ':nth-of-type('.length, end);
|
|
146
|
+
if (end === -1 || !/^\d+$/.test(rawIndex) || nthOfType !== undefined) return undefined;
|
|
147
|
+
nthOfType = Number(rawIndex);
|
|
148
|
+
index = end + 1;
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
153
|
+
return tag || id || classes.length || attributes.length || nthOfType !== undefined ? { tag, id, classes, attributes, nthOfType } : undefined;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function parseCssMatcher(rawInput: string): CssMatcher | undefined {
|
|
157
|
+
const input = rawInput.trim();
|
|
158
|
+
const compounds: CompoundMatcher[] = [];
|
|
159
|
+
const combinators: Combinator[] = [];
|
|
160
|
+
let index = 0;
|
|
161
|
+
let pendingCombinator: Combinator | undefined;
|
|
162
|
+
while (index < input.length) {
|
|
163
|
+
let sawWhitespace = false;
|
|
164
|
+
while (/\s/.test(input[index] ?? '')) {
|
|
165
|
+
sawWhitespace = true;
|
|
166
|
+
index += 1;
|
|
167
|
+
}
|
|
168
|
+
if (index >= input.length) return undefined;
|
|
169
|
+
if (input[index] === '>') {
|
|
170
|
+
if (!compounds.length || pendingCombinator) return undefined;
|
|
171
|
+
pendingCombinator = 'child';
|
|
172
|
+
index += 1;
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
if (compounds.length && !pendingCombinator) pendingCombinator = sawWhitespace ? 'descendant' : undefined;
|
|
176
|
+
if (compounds.length && !pendingCombinator) return undefined;
|
|
177
|
+
const start = index;
|
|
178
|
+
let bracketDepth = 0;
|
|
179
|
+
let quote: string | undefined;
|
|
180
|
+
while (index < input.length) {
|
|
181
|
+
const char = input[index]!;
|
|
182
|
+
if (quote) {
|
|
183
|
+
if (char === '\\') index += 2;
|
|
184
|
+
else {
|
|
185
|
+
if (char === quote) quote = undefined;
|
|
186
|
+
index += 1;
|
|
187
|
+
}
|
|
188
|
+
} else if (char === '"' || char === "'") {
|
|
189
|
+
quote = char;
|
|
190
|
+
index += 1;
|
|
191
|
+
} else if (char === '[') {
|
|
192
|
+
bracketDepth += 1;
|
|
193
|
+
index += 1;
|
|
194
|
+
} else if (char === ']') {
|
|
195
|
+
bracketDepth -= 1;
|
|
196
|
+
if (bracketDepth < 0) return undefined;
|
|
197
|
+
index += 1;
|
|
198
|
+
} else if (bracketDepth === 0 && (char === '>' || /\s/.test(char))) break;
|
|
199
|
+
else index += 1;
|
|
200
|
+
}
|
|
201
|
+
if (quote || bracketDepth !== 0) return undefined;
|
|
202
|
+
const compound = parseCompound(input.slice(start, index));
|
|
203
|
+
if (!compound) return undefined;
|
|
204
|
+
if (compounds.length) combinators.push(pendingCombinator!);
|
|
205
|
+
compounds.push(compound);
|
|
206
|
+
pendingCombinator = undefined;
|
|
207
|
+
}
|
|
208
|
+
return compounds.length && !pendingCombinator ? { compounds, combinators } : undefined;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function elementAttributes(element: ElementRecord): Readonly<Record<string, string>> {
|
|
212
|
+
return element.attributes ?? {};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function terminalGeneratedCompound(element: ElementRecord): CompoundMatcher | undefined {
|
|
216
|
+
const selector = element.selector;
|
|
217
|
+
if (!selector) return undefined;
|
|
218
|
+
const parts = parseCssMatcher(selector)?.compounds;
|
|
219
|
+
return parts?.at(-1);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function matchesCompound(element: ElementRecord, compound: CompoundMatcher): boolean {
|
|
223
|
+
const tag = element.tag;
|
|
224
|
+
if (compound.tag && compound.tag !== '*' && (typeof tag !== 'string' || tag.toLowerCase() !== compound.tag)) return false;
|
|
225
|
+
const attributes = elementAttributes(element);
|
|
226
|
+
const generated = terminalGeneratedCompound(element);
|
|
227
|
+
const actualId = attributes.id ?? generated?.id;
|
|
228
|
+
if (compound.id && actualId !== compound.id) return false;
|
|
229
|
+
const classNames = new Set((attributes.class ?? '').split(/\s+/).filter(Boolean));
|
|
230
|
+
for (const className of generated?.classes ?? []) classNames.add(className);
|
|
231
|
+
if (compound.classes.some((className) => !classNames.has(className))) return false;
|
|
232
|
+
for (const attribute of compound.attributes) {
|
|
233
|
+
const actual = Object.entries(attributes).find(([name]) => name.toLowerCase() === attribute.name)?.[1];
|
|
234
|
+
if (actual === undefined || (attribute.value !== undefined && actual !== attribute.value)) return false;
|
|
235
|
+
}
|
|
236
|
+
if (compound.nthOfType !== undefined && generated?.nthOfType !== compound.nthOfType) return false;
|
|
237
|
+
return true;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function pathSegments(element: ElementRecord): readonly string[] | undefined {
|
|
241
|
+
const domPath = element.domPath;
|
|
242
|
+
return typeof domPath === 'string' && domPath ? domPath.split('/') : undefined;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function treeScope(element: ElementRecord): string | undefined {
|
|
246
|
+
const frame = element.frame;
|
|
247
|
+
if (typeof frame !== 'object' || frame === null || typeof (frame as Record<string, unknown>).frameId !== 'string') return undefined;
|
|
248
|
+
const shadow = element.shadow;
|
|
249
|
+
if (typeof shadow !== 'object' || shadow === null || (shadow as Record<string, unknown>).inShadowDom !== true) return `${(frame as Record<string, unknown>).frameId}:light`;
|
|
250
|
+
const values = shadow as Record<string, unknown>;
|
|
251
|
+
return `${(frame as Record<string, unknown>).frameId}:shadow:${String(values.chainDepth)}:${String(values.hostSelector)}`;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function isAncestorOf(ancestor: ElementRecord, descendant: ElementRecord, direct: boolean): boolean {
|
|
255
|
+
const ancestorPath = pathSegments(ancestor);
|
|
256
|
+
const descendantPath = pathSegments(descendant);
|
|
257
|
+
if (!ancestorPath || !descendantPath || ancestorPath.length >= descendantPath.length || treeScope(ancestor) !== treeScope(descendant)) return false;
|
|
258
|
+
if (direct && ancestorPath.length !== descendantPath.length - 1) return false;
|
|
259
|
+
return ancestorPath.every((segment, index) => descendantPath[index] === segment);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function matchesCssMatcher(elements: readonly ElementRecord[], target: ElementRecord, matcher: CssMatcher, index = matcher.compounds.length - 1): boolean {
|
|
263
|
+
if (!matchesCompound(target, matcher.compounds[index]!)) return false;
|
|
264
|
+
if (index === 0) return true;
|
|
265
|
+
const direct = matcher.combinators[index - 1] === 'child';
|
|
266
|
+
return elements.some((candidate) => isAncestorOf(candidate, target, direct) && matchesCssMatcher(elements, candidate, matcher, index - 1));
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Elements matching the supported CSS subset against their recorded DOM facts.
|
|
270
|
+
* A verbatim collector-generated selector remains an exact lookup. */
|
|
41
271
|
export function findBySelector(elements: readonly ElementRecord[], selector: string): ElementRecord[] {
|
|
42
|
-
|
|
272
|
+
const exact = elements.filter((element) => element.selector === selector);
|
|
273
|
+
if (exact.length) return exact;
|
|
274
|
+
const matcher = parseCssMatcher(selector);
|
|
275
|
+
return matcher ? elements.filter((element) => matchesCssMatcher(elements, element, matcher)) : [];
|
|
43
276
|
}
|
|
44
277
|
|
|
45
278
|
/** The element with this `backendNodeId`, if any. */
|
|
@@ -101,9 +334,9 @@ export interface ParsedSelectorInput {
|
|
|
101
334
|
* - `axid:<id>` — accessibility node id
|
|
102
335
|
* - `ax:<name>` — accessibility name (substring)
|
|
103
336
|
* - `text:<needle>`— text content (substring)
|
|
104
|
-
* - anything else — a CSS
|
|
105
|
-
*
|
|
106
|
-
*
|
|
337
|
+
* - anything else — a supported CSS selector: tag, `#id`, `.class`,
|
|
338
|
+
* `[attr]`, `[attr="value"]`, and descendant/child compound sequences,
|
|
339
|
+
* or a verbatim collector-generated selector path.
|
|
107
340
|
*/
|
|
108
341
|
export function parseSelectorInput(input: string): ParsedSelectorInput {
|
|
109
342
|
const prefixes: Array<[string, SelectorInputKind]> = [
|
package/src/session/commands.ts
CHANGED
|
@@ -330,40 +330,39 @@ export async function waitForPageLoad(
|
|
|
330
330
|
|
|
331
331
|
/** Root-help representation of this branch, assembled by `src/capture.ts`. */
|
|
332
332
|
export const COMMAND_BLOCK = `<command name="session">
|
|
333
|
-
the artifact container — a session records HAR and bundles every artifact; it may open
|
|
334
|
-
use when starting scoped work against a page: start
|
|
333
|
+
the artifact container — a session records HAR and bundles every artifact; it may open or adopt a tab; once it has one, every command auto-targets it
|
|
334
|
+
use when starting scoped work against a page: start with --url or --target, then every other capture command needs no --target/--port threading
|
|
335
335
|
start · stop · list · view · har · log — \`capture session -h\`
|
|
336
336
|
</command>`;
|
|
337
337
|
|
|
338
|
-
const START_USAGE = `capture session start [--url <url>] [--hold] — open or
|
|
338
|
+
const START_USAGE = `capture session start [--url <url> | --target <tab-id>] [--hold] [--port <n>] — open or adopt a tab, record HAR, and set the active capture context.
|
|
339
339
|
|
|
340
340
|
input:
|
|
341
|
-
--url <url>
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
--port <n> CDP port to attach to; default is auto-detected across localhost.
|
|
341
|
+
--url <url> Absolute URL to open in a fresh tab through CDP's
|
|
342
|
+
Target.createTarget. Some endpoints, including Electron,
|
|
343
|
+
expose existing tabs but reject Target.createTarget; on
|
|
344
|
+
those endpoints --url cannot be used. Conflicts with
|
|
345
|
+
--target.
|
|
346
|
+
--target <tab-id> Existing page tab to adopt. Resolves a full target id or
|
|
347
|
+
an unambiguous prefix of at least four characters at the
|
|
348
|
+
selected CDP endpoint; start fails if it does not resolve.
|
|
349
|
+
CDP_TARGET does not select a tab for session start. Conflicts
|
|
350
|
+
with --url.
|
|
351
|
+
--hold Hold one CDP browser connection open for the session's
|
|
352
|
+
lifetime, so browser-level state (permission grants,
|
|
353
|
+
ServiceWorker enablement) survives across separate commands.
|
|
354
|
+
--port <n> CDP port to attach to; default is auto-detected across localhost.
|
|
356
355
|
|
|
357
356
|
Output:
|
|
358
|
-
One <session id=… path=… [url=…]> block: the session id,
|
|
359
|
-
|
|
357
|
+
One <session id=… path=… [url=…] [target=… port=…]> block: the session id,
|
|
358
|
+
bundle dir, opened or adopted tab, HAR recording id, and held-bridge pid when
|
|
360
359
|
applicable. --json mirrors the same fields.
|
|
361
360
|
|
|
362
361
|
effects:
|
|
363
362
|
Creates a private session dir with shots/, starts a HAR recording, opens a
|
|
364
|
-
fresh tab only when --url is supplied
|
|
365
|
-
registers the session as the active capture
|
|
366
|
-
auto-target
|
|
363
|
+
fresh tab only when --url is supplied or adopts the resolved --target tab,
|
|
364
|
+
optionally holds a CDP bridge, and registers the session as the active capture
|
|
365
|
+
context so subsequent commands auto-target its tab.`;
|
|
367
366
|
|
|
368
367
|
const STOP_USAGE = `capture session stop <session-id> — finalize a session and write its bundle manifest.
|
|
369
368
|
|
|
@@ -475,13 +474,13 @@ effects:
|
|
|
475
474
|
the identity-bearing tailer record is registered in the session metadata.`;
|
|
476
475
|
|
|
477
476
|
function printSessionHelp(): void {
|
|
478
|
-
console.log(`capture session — the artifact container: records HAR and bundles artifacts; it may open
|
|
477
|
+
console.log(`capture session — the artifact container: records HAR and bundles artifacts; it may open or adopt a tab.
|
|
479
478
|
|
|
480
|
-
An active session auto-targets
|
|
479
|
+
An active session with a tab auto-targets it and auto-appends recorded traffic — no
|
|
481
480
|
--target/--har threading. \`stop\` finalizes the session and writes its bundle
|
|
482
481
|
manifest; \`view\` reads that manifest back.
|
|
483
482
|
|
|
484
|
-
<subcommand name="start" args="[--url <url>] [--hold]" whenToUse="open or
|
|
483
|
+
<subcommand name="start" args="[--url <url> | --target <tab-id>] [--hold] [--port <n>]" whenToUse="open or adopt a tab, start HAR, and set the active capture context"/>
|
|
485
484
|
<subcommand name="stop" args="<session-id>" whenToUse="finalize the session and write its bundle manifest"/>
|
|
486
485
|
<subcommand name="list" args="" whenToUse="show active and stopped sessions"/>
|
|
487
486
|
<subcommand name="view" args="<session-id> [--filter shots|har|logs|measure|motion|other]" whenToUse="read back a stopped session's bundle manifest"/>
|
|
@@ -506,6 +505,7 @@ export interface SessionStartWorld {
|
|
|
506
505
|
deleteHar(harId: string): Promise<void>;
|
|
507
506
|
detectCdpPort(): Promise<number>;
|
|
508
507
|
openTab(port: number, url: string): Promise<CDPTarget>;
|
|
508
|
+
findTabById(port: number, targetId: string): Promise<CDPTarget | null>;
|
|
509
509
|
closeTarget(port: number, targetId: string): Promise<void>;
|
|
510
510
|
/** Attach to the freshly-opened tab and wait for load; returns page-load-timed-out. */
|
|
511
511
|
awaitTabReady(target: CDPTarget, url: string): Promise<boolean>;
|
|
@@ -527,6 +527,10 @@ const productionStartWorld: SessionStartWorld = {
|
|
|
527
527
|
const { openTab } = await import('../cdp.js');
|
|
528
528
|
return openTab(port, url);
|
|
529
529
|
},
|
|
530
|
+
async findTabById(port, targetId) {
|
|
531
|
+
const { findTabById } = await import('../cdp/targets.js');
|
|
532
|
+
return findTabById(port, targetId);
|
|
533
|
+
},
|
|
530
534
|
async closeTarget(port, targetId) {
|
|
531
535
|
const { closeTarget } = await import('../cdp/targets.js');
|
|
532
536
|
return closeTarget(port, targetId);
|
|
@@ -600,7 +604,7 @@ async function start(parsed: ParsedArgs): Promise<void> {
|
|
|
600
604
|
return;
|
|
601
605
|
}
|
|
602
606
|
|
|
603
|
-
if (rejectSurplusPositionals(parsed, 'start', 0, 'capture session start [--url <url>] [--hold]')) return;
|
|
607
|
+
if (rejectSurplusPositionals(parsed, 'start', 0, 'capture session start [--url <url> | --target <tab-id>] [--hold] [--port <n>]')) return;
|
|
604
608
|
|
|
605
609
|
return withSessionScopeLifecycle(async () => {
|
|
606
610
|
const active = getActiveSession();
|
|
@@ -616,6 +620,7 @@ async function start(parsed: ParsedArgs): Promise<void> {
|
|
|
616
620
|
}
|
|
617
621
|
|
|
618
622
|
const url = parsed.url ?? null;
|
|
623
|
+
const requestedTarget = parsed.target ?? null;
|
|
619
624
|
const hold = parsed.hold === true;
|
|
620
625
|
const id = generateId();
|
|
621
626
|
const dir = sessionDir(id);
|
|
@@ -639,10 +644,20 @@ async function start(parsed: ParsedArgs): Promise<void> {
|
|
|
639
644
|
const acquiredHarId = harId;
|
|
640
645
|
acquired.push({ label: 'HAR recording', release: () => startWorld.deleteHar(acquiredHarId) });
|
|
641
646
|
|
|
642
|
-
if (url || hold) {
|
|
647
|
+
if (url || requestedTarget || hold) {
|
|
643
648
|
cdpPort = parsed.port ?? await startWorld.detectCdpPort();
|
|
644
649
|
}
|
|
645
650
|
|
|
651
|
+
if (requestedTarget) {
|
|
652
|
+
target = await startWorld.findTabById(cdpPort!, requestedTarget);
|
|
653
|
+
if (!target) {
|
|
654
|
+
throw worldFailure(`No tab found for target "${requestedTarget}" on port ${cdpPort}. Run \`capture tab list --port ${cdpPort}\` to see available tabs.`);
|
|
655
|
+
}
|
|
656
|
+
if (target.type !== 'page') {
|
|
657
|
+
throw worldFailure(`Target "${requestedTarget}" on port ${cdpPort} is ${target.type}, not a page tab. Run \`capture tab list --port ${cdpPort}\` to see available tabs.`);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
646
661
|
if (url) {
|
|
647
662
|
try {
|
|
648
663
|
target = await startWorld.openTab(cdpPort!, url);
|
|
@@ -650,7 +665,7 @@ async function start(parsed: ParsedArgs): Promise<void> {
|
|
|
650
665
|
const message = error instanceof Error ? error.message : String(error);
|
|
651
666
|
if (message.toLowerCase().includes('not supported')) {
|
|
652
667
|
throw worldFailure(
|
|
653
|
-
`Target.createTarget is unsupported on port ${cdpPort}. Use existing tab: \`capture
|
|
668
|
+
`Target.createTarget is unsupported on port ${cdpPort}. Use an existing tab: \`capture tab list --port ${cdpPort}\`, then \`capture session start --target <target-id> --port ${cdpPort}\`.`,
|
|
654
669
|
error,
|
|
655
670
|
);
|
|
656
671
|
}
|
|
@@ -680,7 +695,7 @@ async function start(parsed: ParsedArgs): Promise<void> {
|
|
|
680
695
|
startedAt: new Date().toISOString(),
|
|
681
696
|
stoppedAt: null,
|
|
682
697
|
stopping: false,
|
|
683
|
-
url,
|
|
698
|
+
url: target?.url ?? url,
|
|
684
699
|
targetId: target?.id ?? null,
|
|
685
700
|
port: cdpPort,
|
|
686
701
|
stepCount: 0,
|
|
@@ -699,14 +714,14 @@ async function start(parsed: ParsedArgs): Promise<void> {
|
|
|
699
714
|
}
|
|
700
715
|
|
|
701
716
|
const rows: FactLine[] = [fact`bundle dir: ${dir}`];
|
|
702
|
-
if (target) rows.push(fact`tab ${target.id} opened at ${url
|
|
717
|
+
if (target) rows.push(url ? fact`tab ${target.id} opened at ${url}` : fact`tab ${target.id} adopted at ${target.url}`);
|
|
703
718
|
if (pageLoadTimedOut) rows.push(fact`page load timed out after 10000ms; the session stays attached to target ${target?.id ?? ''}`);
|
|
704
719
|
if (harId) rows.push(fact`HAR recording ${harId} — traffic auto-appends while the session is active`);
|
|
705
720
|
if (hold) rows.push(fact`held CDP bridge: pid ${bridgePid ?? 0}`);
|
|
706
721
|
|
|
707
722
|
emitResult({
|
|
708
723
|
tag: 'session',
|
|
709
|
-
attrs: { id, path: dir, ...(url ? { url } : {}) },
|
|
724
|
+
attrs: { id, path: dir, ...(target?.url ? { url: target.url } : {}), ...(target ? { target: target.id } : {}), ...(cdpPort !== null ? { port: cdpPort } : {}) },
|
|
710
725
|
summary: fact`Session ${id} started; it is now the active capture context.`,
|
|
711
726
|
sections: [lineList(rows)],
|
|
712
727
|
followUp: fact`capture session stop ${id}`,
|
package/src/command-help.ts
DELETED
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
/** Pure specification-help assembly from Capture's descriptor tree. */
|
|
2
|
-
import { type RegistryNode } from './registry.js';
|
|
3
|
-
|
|
4
|
-
const usage = (node: RegistryNode): string => `capture${node.path ? ` ${node.path}` : ''}`;
|
|
5
|
-
const parameter = (name: string, grammar: string, required: boolean, variadic = false): string => ` ${required ? '<' : '['}${name}${required ? '>' : ']'} ${grammar}${variadic ? ' (variadic)' : ''}`;
|
|
6
|
-
|
|
7
|
-
/** Renders only descriptor-owned content; help never parses inputs or performs effects. */
|
|
8
|
-
export function assembleHelp(node: RegistryNode): string {
|
|
9
|
-
const { help } = node;
|
|
10
|
-
const lines = [usage(node), '', help.description, '', `When to use: ${help.whenToUse}`, `Model: ${help.model ?? help.rubric}`];
|
|
11
|
-
if (help.globals) lines.push('', 'Globals:', help.globals);
|
|
12
|
-
if (help.ioContract) lines.push('', `I/O contract: ${help.ioContract}`);
|
|
13
|
-
if (node.kind === 'branch') {
|
|
14
|
-
const children = node.children.filter((child) => child.visibility === 'public');
|
|
15
|
-
if (children.length) {
|
|
16
|
-
lines.push('', 'Commands:');
|
|
17
|
-
for (const child of children) lines.push(` ${child.path.split(' ').at(-1)} ${child.help.description} When to use: ${child.help.whenToUse}`);
|
|
18
|
-
}
|
|
19
|
-
} else {
|
|
20
|
-
if (node.positionals.length || node.flags.length) {
|
|
21
|
-
lines.push('', 'Parameters:');
|
|
22
|
-
for (const positional of node.positionals) lines.push(parameter(positional.name, positional.grammar, positional.required, positional.variadic));
|
|
23
|
-
for (const flag of node.flags) lines.push(` ${flag.name}${flag.grammar === 'boolean' ? '' : ` <${flag.grammar}>`}${flag.required ? ' required' : ''}${flag.repeatable ? ' repeatable' : ''}${flag.default === undefined ? '' : ` default=${flag.default}`}${flag.values ? ` values=${flag.values.join('|')}` : ''}${flag.units ? ` units=${flag.units}` : ''}`);
|
|
24
|
-
}
|
|
25
|
-
lines.push('', `Constraints: ${help.constraints}`, `Output: ${help.orderedOutput}`, `Artifact ownership: ${help.artifactOwnership}`, `Effects: browser=${node.effects.browser}, session=${node.effects.session}, artifact=${node.effects.artifact}, environment=${node.effects.environment}`, `Recovery: ${node.recovery}`);
|
|
26
|
-
}
|
|
27
|
-
lines.push('', `Next action: ${help.followUp}`);
|
|
28
|
-
return `${lines.join('\n')}\n`;
|
|
29
|
-
}
|
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
/** Pure pre-dispatch control precedence for U15's future launcher wiring. */
|
|
2
|
-
import { assembleHelp } from './command-help.js';
|
|
3
|
-
import { hasHelpFlag, hasRootVersionFlag, walkCommand } from './command-walker.js';
|
|
4
|
-
import { CAPTURE_REGISTRY, type RegistryBranch, type RegistryNode } from './registry.js';
|
|
5
|
-
|
|
6
|
-
export type CommandResolution =
|
|
7
|
-
| { readonly kind: 'version' }
|
|
8
|
-
| { readonly kind: 'help'; readonly node: RegistryNode; readonly text: string }
|
|
9
|
-
| { readonly kind: 'dispatch'; readonly node: RegistryNode; readonly argv: readonly string[] }
|
|
10
|
-
| { readonly kind: 'unknown-path'; readonly received: string; readonly expected: readonly string[]; readonly path: string };
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Applies only control precedence. It deliberately does not parse leaf argv,
|
|
14
|
-
* validate `--json`, invoke a handler, or allocate any effectful dependency.
|
|
15
|
-
*/
|
|
16
|
-
export function resolveCommand(argv: readonly string[], root: RegistryBranch = CAPTURE_REGISTRY): CommandResolution {
|
|
17
|
-
if (hasRootVersionFlag(argv)) return { kind: 'version' };
|
|
18
|
-
const walked = walkCommand(argv, root);
|
|
19
|
-
if (walked.kind === 'unknown-path') return { kind: 'unknown-path', received: walked.received, expected: walked.expected, path: walked.resolved.path };
|
|
20
|
-
if (hasHelpFlag(walked.remaining)) return { kind: 'help', node: walked.node, text: assembleHelp(walked.node) };
|
|
21
|
-
return { kind: 'dispatch', node: walked.node, argv: walked.remaining };
|
|
22
|
-
}
|
package/src/command-walker.ts
DELETED
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
/** Pure exact command-path resolution for the descriptor registry. */
|
|
2
|
-
import { CAPTURE_REGISTRY, type RegistryBranch, type RegistryNode } from './registry.js';
|
|
3
|
-
|
|
4
|
-
export type WalkResult =
|
|
5
|
-
| { readonly kind: 'resolved'; readonly node: RegistryNode; readonly remaining: readonly string[] }
|
|
6
|
-
| { readonly kind: 'unknown-path'; readonly received: string; readonly resolved: RegistryBranch; readonly expected: readonly string[]; readonly remaining: readonly string[] };
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Resolves only exact canonical path tokens. It stops before the first option,
|
|
10
|
-
* resolves the deepest known leaf/branch, and leaves all leaf argv untouched.
|
|
11
|
-
* Parsing, output compatibility, effects, and handler dispatch are intentionally
|
|
12
|
-
* outside this pure walker.
|
|
13
|
-
*/
|
|
14
|
-
export function walkCommand(argv: readonly string[], root: RegistryBranch = CAPTURE_REGISTRY): WalkResult {
|
|
15
|
-
let current: RegistryNode = root;
|
|
16
|
-
let index = 0;
|
|
17
|
-
while (current.kind === 'branch' && index < argv.length) {
|
|
18
|
-
const token = argv[index];
|
|
19
|
-
if (token.startsWith('-')) break;
|
|
20
|
-
const child: RegistryNode | undefined = current.children.find((candidate: RegistryNode) => candidate.path.split(' ').at(-1) === token);
|
|
21
|
-
if (!child) return { kind: 'unknown-path', received: token, resolved: current, expected: current.children.filter((candidate) => candidate.visibility === 'public').map((candidate) => candidate.path.split(' ').at(-1)!), remaining: argv.slice(index) };
|
|
22
|
-
current = child;
|
|
23
|
-
index += 1;
|
|
24
|
-
}
|
|
25
|
-
return { kind: 'resolved', node: current, remaining: argv.slice(index) };
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export function hasHelpFlag(argv: readonly string[]): boolean {
|
|
29
|
-
return argv.includes('-h') || argv.includes('--help');
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/** Root version is the sole accepted version spelling and wins before walking. */
|
|
33
|
-
export function hasRootVersionFlag(argv: readonly string[]): boolean {
|
|
34
|
-
return argv[0] === '--version';
|
|
35
|
-
}
|