@crouton-kit/capture 1.4.21 → 1.4.23
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/README.md +2 -0
- package/bin/capture +635 -577
- package/package.json +1 -1
- package/src/capture.ts +1 -1
- package/src/cdp/args.ts +57 -1
- package/src/cdp/commands/page/exec.ts +34 -2
- package/src/cdp/commands/tab/close.ts +3 -2
- package/src/interact.ts +17 -0
package/package.json
CHANGED
package/src/capture.ts
CHANGED
|
@@ -65,7 +65,7 @@ Selecting a noun: session, page, and tab address the live browser; measure, moti
|
|
|
65
65
|
|
|
66
66
|
I/O contract: flags and positionals on input; one rendered prose block on stdout. --json mirrors the same result as JSON, but the rendered block is the contract. stderr carries in-flight diagnostics only. --artifact-dir <path> selects the root for a session bundle (\`session start\`) or a sessionless artifact-producing leaf's one-shot bundle; leaf help states when it writes one. --out always names one caller-owned file, never a bundle directory.
|
|
67
67
|
|
|
68
|
-
A CDP-enabled browser must be running; \`capture tab list\` is the probe, and \`capture tab launch\` starts one capture owns and reaps when nothing is (never hand-roll a detached browser — nothing reaps that). Capture never signals a browser it did not start, so attaching to your own with --port is unchanged. CDP_PORT / CDP_TARGET pin the browser + tab for target-selecting commands — precedence: explicit flag > active session > env. \`session start\` adopts only an explicit --target.`;
|
|
68
|
+
A CDP-enabled browser must be running; \`capture tab list\` is the probe, and \`capture tab launch\` starts one capture owns and reaps when nothing is (never hand-roll a detached browser — nothing reaps that). Capture never signals a browser it did not start, so attaching to your own with --port is unchanged. CDP_PORT / CDP_TARGET pin the browser + tab for target-selecting commands — precedence: explicit flag > active session > env. \`session start\` adopts only an explicit --target. When CRTR_NODE_ID is set (a crtr agent node), a browser command with no --port, no --target and no active session of that node is refused instead of falling back to CDP_PORT, CDP_TARGET or localhost discovery, because the browser found could belong to another node: run \`capture session start --url <url>\`, or pass --port or --target. \`capture tab list\` and commands that only read a recorded artifact are not refused.`;
|
|
69
69
|
}
|
|
70
70
|
|
|
71
71
|
function printVersion(): void {
|
package/src/cdp/args.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { invalidInput } from '../errors.js';
|
|
1
|
+
import { captureError, invalidInput } from '../errors.js';
|
|
2
2
|
import { getActiveSession } from '../session-context.js';
|
|
3
|
+
import { isUrlRef } from '../output/artifact.js';
|
|
3
4
|
import { type ParsedArgs } from './types.js';
|
|
4
5
|
import { assertParseableUrl, assertScrollDestination, assertSweepBounds, parsePositiveNumber, parseDoAction, type SweepAxisName } from './leaf-grammar.js';
|
|
5
6
|
|
|
@@ -272,6 +273,16 @@ export function validateCliInvocation(parsed: ParsedArgs): void {
|
|
|
272
273
|
// `quit` takes an optional port; every other tab leaf names exactly one.
|
|
273
274
|
if (leaf === 'launch' || leaf === 'list') requireCount(values, 0, 0, `tab ${leaf}`);
|
|
274
275
|
else if (leaf === 'quit') requireCount(values, 0, 1, 'tab quit');
|
|
276
|
+
// `tab close` names its tab either positionally or with an explicit
|
|
277
|
+
// --target flag (the form every follow_up line teaches). Only the flag
|
|
278
|
+
// counts here: session/CDP_TARGET defaults are hydrated later and must
|
|
279
|
+
// never choose which tab a destructive close removes.
|
|
280
|
+
else if (leaf === 'close' && parsed.target !== undefined) {
|
|
281
|
+
requireCount(values, 0, 1, 'tab close');
|
|
282
|
+
if (values.length === 1 && values[0] !== parsed.target) {
|
|
283
|
+
throw schemaInput('tab close', `<target>=${values[0]}, --target=${parsed.target}`, 'one tab: the positional <target> or --target, not two different ids', '<target> or --target');
|
|
284
|
+
}
|
|
285
|
+
}
|
|
275
286
|
else requireCount(values, 1, 1, `tab ${leaf}`);
|
|
276
287
|
if (leaf === 'network' && values[0] !== 'offline' && values[0] !== 'online') {
|
|
277
288
|
throw schemaInput('tab network', values[0] ?? '(missing)', 'offline or online', '<mode>');
|
|
@@ -547,6 +558,50 @@ function needsEndpointResolution(parsed: ParsedArgs): boolean {
|
|
|
547
558
|
return true;
|
|
548
559
|
}
|
|
549
560
|
|
|
561
|
+
/**
|
|
562
|
+
* False only for leaves that never open a CDP connection: `tab list` (the probe
|
|
563
|
+
* that finds a port to pass), and the leaves that read a recorded artifact by
|
|
564
|
+
* id or path. A leaf that also takes a URL (`measure check`, `measure census`,
|
|
565
|
+
* `measure map ax|focus|layers|scroll`) reaches a browser only when it is given
|
|
566
|
+
* one. Everything not named here counts as reaching a browser, so a new leaf is
|
|
567
|
+
* guarded until it is listed.
|
|
568
|
+
*/
|
|
569
|
+
function mayReachBrowser(parsed: ParsedArgs): boolean {
|
|
570
|
+
const [leaf, sub] = parsed.positional;
|
|
571
|
+
switch (parsed.command) {
|
|
572
|
+
case 'tab': return leaf !== 'list';
|
|
573
|
+
case 'motion': return leaf === 'rec';
|
|
574
|
+
case 'perf': return leaf === 'trace';
|
|
575
|
+
case 'heap': return leaf === 'snapshot';
|
|
576
|
+
case 'measure': {
|
|
577
|
+
if (leaf === 'diff' || leaf === 'explain' || leaf === 'text' || (leaf === 'map' && sub === 'paint')) return false;
|
|
578
|
+
if (leaf === 'check' || leaf === 'census' || leaf === 'map') return parsed.url !== undefined || parsed.positional.some(isUrlRef);
|
|
579
|
+
return true;
|
|
580
|
+
}
|
|
581
|
+
default: return true;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* A node (`CRTR_NODE_ID` set) may only drive a browser it chose: an explicit
|
|
587
|
+
* `--port` or `--target`, or the port/tab recorded by its own active session.
|
|
588
|
+
* Otherwise `CDP_PORT`, `CDP_TARGET` and localhost auto-discovery would accept
|
|
589
|
+
* whatever browser is there, which can be another node's signed-in browser.
|
|
590
|
+
* `session start` is the way to choose one, and `tab list` only lists, so
|
|
591
|
+
* neither is refused. With no `CRTR_NODE_ID` (a person at a shell) nothing here
|
|
592
|
+
* applies.
|
|
593
|
+
*/
|
|
594
|
+
function refuseUnownedEndpoint(parsed: ParsedArgs, sessionStart: boolean): void {
|
|
595
|
+
if (!process.env.CRTR_NODE_ID?.trim() || sessionStart || !mayReachBrowser(parsed)) return;
|
|
596
|
+
if (parsed.port !== undefined || parsed.targetSource === 'flag' || parsed.targetSource === 'session') return;
|
|
597
|
+
const command = leafCommand(parsed.command, parsed.positional);
|
|
598
|
+
throw captureError(
|
|
599
|
+
'precondition',
|
|
600
|
+
'no_owned_browser',
|
|
601
|
+
`\`capture ${command}\` was not run: this node (CRTR_NODE_ID) has no browser of its own. With no --port, no --target and no active session holding a browser, capture will not use CDP_PORT, CDP_TARGET or auto-discovery, because the browser it found could belong to another node. Do one of: (1) \`capture session start --url <url>\` to open and hold your own browser tab, or \`capture tab launch\` then \`capture session start --port <port>\`; (2) pass \`--port <port>\` (see \`capture tab list\`); (3) pass \`--target <tabId>\`.`,
|
|
602
|
+
);
|
|
603
|
+
}
|
|
604
|
+
|
|
550
605
|
/**
|
|
551
606
|
* Hydrates endpoint/session defaults only after pure invocation validation
|
|
552
607
|
* succeeds. Endpoint precedence is `explicit flag > session endpoint > env`:
|
|
@@ -572,6 +627,7 @@ export function resolveCliContext(parsed: ParsedArgs): ParsedArgs {
|
|
|
572
627
|
// --port override for deliberate multi-browser work.
|
|
573
628
|
if (parsed.port === undefined && session.port !== undefined && session.port !== null) { parsed.port = session.port; parsed.portSource = 'session'; }
|
|
574
629
|
}
|
|
630
|
+
refuseUnownedEndpoint(parsed, sessionStart);
|
|
575
631
|
if (parsed.port === undefined && process.env.CDP_PORT !== undefined) {
|
|
576
632
|
parsed.port = schemaInteger(leafCommand(parsed.command, parsed.positional), process.env.CDP_PORT, 'CDP_PORT', 1, 65535);
|
|
577
633
|
parsed.portSource = 'env';
|
|
@@ -107,7 +107,39 @@ function spillWholeResult(payload: string, artifactDir?: string): string {
|
|
|
107
107
|
|
|
108
108
|
interface EvalResult {
|
|
109
109
|
result?: { value?: unknown };
|
|
110
|
-
exceptionDetails?:
|
|
110
|
+
exceptionDetails?: ExceptionDetails;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
interface ExceptionDetails {
|
|
114
|
+
text?: string;
|
|
115
|
+
exception?: {
|
|
116
|
+
type?: string;
|
|
117
|
+
subtype?: string;
|
|
118
|
+
className?: string;
|
|
119
|
+
value?: unknown;
|
|
120
|
+
description?: string;
|
|
121
|
+
preview?: { properties?: Array<{ name: string; value?: string }>; overflow?: boolean };
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Describes what the page threw. An Error carries its message and stack in
|
|
127
|
+
* `description`; a thrown string, number, or plain object does not, so its
|
|
128
|
+
* value (or its preview's properties) is named instead of falling back to a
|
|
129
|
+
* bare "Object" or "Unknown error".
|
|
130
|
+
*/
|
|
131
|
+
function describePageException(details: ExceptionDetails): string {
|
|
132
|
+
const exception = details.exception;
|
|
133
|
+
if (!exception) return details.text ?? 'Unknown error';
|
|
134
|
+
if (exception.subtype === 'error' && exception.description) return exception.description;
|
|
135
|
+
if (exception.type === 'undefined') return 'undefined';
|
|
136
|
+
if ('value' in exception) return JSON.stringify(exception.value);
|
|
137
|
+
const properties = exception.preview?.properties;
|
|
138
|
+
if (exception.type === 'object' && properties) {
|
|
139
|
+
const fields = properties.map((p) => `${p.name}: ${p.value ?? '?'}`).join(', ');
|
|
140
|
+
return `${exception.className ?? 'Object'} {${fields}${exception.preview?.overflow ? ', …' : ''}}`;
|
|
141
|
+
}
|
|
142
|
+
return exception.description ?? details.text ?? 'Unknown error';
|
|
111
143
|
}
|
|
112
144
|
|
|
113
145
|
/** The structural client surface the focus/evaluation scope drives — the
|
|
@@ -316,7 +348,7 @@ export async function cmdPageExec(parsed: ParsedArgs, _args: string[]): Promise<
|
|
|
316
348
|
}
|
|
317
349
|
|
|
318
350
|
if (evalResult.exceptionDetails) {
|
|
319
|
-
const description = evalResult.exceptionDetails
|
|
351
|
+
const description = describePageException(evalResult.exceptionDetails);
|
|
320
352
|
return emitExecError(
|
|
321
353
|
parsed,
|
|
322
354
|
'exec_exception',
|
|
@@ -17,6 +17,7 @@ const USAGE = `capture tab close — close one explicitly identified background
|
|
|
17
17
|
|
|
18
18
|
input:
|
|
19
19
|
<target> required — full tab target id or an unambiguous 8-character prefix
|
|
20
|
+
--target <id> same as <target>; only an explicit flag counts, never the session's tab or CDP_TARGET
|
|
20
21
|
--port <port> CDP endpoint (default: the auto-discovered preferred endpoint)
|
|
21
22
|
|
|
22
23
|
output: <tab-closed port=… target=…> — the closed tab's full target id and last observed URL.
|
|
@@ -36,9 +37,9 @@ export async function cmdTabClose(parsed: ParsedArgs, _args: string[]): Promise<
|
|
|
36
37
|
return;
|
|
37
38
|
}
|
|
38
39
|
|
|
39
|
-
const target = parsed.positional[0];
|
|
40
|
+
const target = parsed.positional[0] ?? (parsed.targetSource === 'flag' ? parsed.target : undefined);
|
|
40
41
|
if (!target) {
|
|
41
|
-
throw invalidInput('received: no target; expected: capture tab close <target> [--port <port>].', 'missing_argument');
|
|
42
|
+
throw invalidInput('received: no target; expected: capture tab close <target> (or --target <id>) [--port <port>].', 'missing_argument');
|
|
42
43
|
}
|
|
43
44
|
if (target.length < 8) {
|
|
44
45
|
throw invalidInput(`received: target ${target}; expected: a full target id or an unambiguous prefix of at least 8 characters.`, 'target_prefix_too_short');
|
package/src/interact.ts
CHANGED
|
@@ -434,6 +434,8 @@ async function dispatchResolvedClick(
|
|
|
434
434
|
if (hitTest.backendNodeId !== backendNodeId || opts.includeHitTestReceiver) hitTestReceiverBackendNodeId = hitTest.backendNodeId;
|
|
435
435
|
}
|
|
436
436
|
|
|
437
|
+
await presentFrameBeforeInput(client);
|
|
438
|
+
|
|
437
439
|
const pressParams = { type: 'mousePressed', x: dispatchX, y: dispatchY, button: 'left', clickCount: 1 };
|
|
438
440
|
if (opts.mark !== undefined && client.sendMarked) {
|
|
439
441
|
await client.sendMarked('Input.dispatchMouseEvent', pressParams, opts.mark);
|
|
@@ -452,6 +454,21 @@ async function dispatchResolvedClick(
|
|
|
452
454
|
};
|
|
453
455
|
}
|
|
454
456
|
|
|
457
|
+
/**
|
|
458
|
+
* Makes the renderer produce a frame before mouse input is dispatched. Arc does
|
|
459
|
+
* not paint a tab opened with `Target.createTarget({ background: true })` until
|
|
460
|
+
* something asks for a frame, and until it paints, `Input.dispatchMouseEvent`
|
|
461
|
+
* returns success while delivering no events to the page. A 1x1 clipped capture
|
|
462
|
+
* is the cheapest request that forces that frame. Chrome, which already paints
|
|
463
|
+
* background targets, only pays the capture.
|
|
464
|
+
*/
|
|
465
|
+
async function presentFrameBeforeInput(client: LiveClient): Promise<void> {
|
|
466
|
+
await client.send('Page.captureScreenshot', {
|
|
467
|
+
format: 'png',
|
|
468
|
+
clip: { x: 0, y: 0, width: 1, height: 1, scale: 1 },
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
|
|
455
472
|
/** Types text into the currently focused element via CDP `Input.insertText`. */
|
|
456
473
|
export async function typeText(client: LiveClient, text: string): Promise<void> {
|
|
457
474
|
await client.send('Input.insertText', { text });
|