@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.
- package/bin/capture +29841 -8539
- package/package.json +6 -3
- package/src/capture.ts +86 -173
- package/src/cdp/a11y.ts +113 -14
- package/src/cdp/args.ts +300 -205
- package/src/cdp/bridge/protocol.ts +93 -6
- package/src/cdp/bridge/server.ts +118 -53
- package/src/cdp/bridge/spawn.ts +53 -6
- package/src/cdp/client.ts +13 -3
- package/src/cdp/commands/bridge-serve.ts +20 -14
- package/src/cdp/commands/cdp.ts +259 -90
- package/src/cdp/commands/gate-guard.ts +43 -0
- package/src/cdp/commands/lib.ts +339 -213
- package/src/cdp/commands/measure/census.ts +76 -20
- package/src/cdp/commands/measure/check.ts +100 -26
- package/src/cdp/commands/measure/diff.ts +109 -21
- package/src/cdp/commands/measure/explain.ts +121 -19
- package/src/cdp/commands/measure/index.ts +31 -39
- package/src/cdp/commands/measure/map-ax.ts +52 -0
- package/src/cdp/commands/measure/map-focus.ts +41 -13
- package/src/cdp/commands/measure/map-layers.ts +49 -15
- package/src/cdp/commands/measure/map-scroll.ts +85 -15
- package/src/cdp/commands/measure/snap.ts +405 -25
- package/src/cdp/commands/measure/sweep.ts +217 -20
- package/src/cdp/commands/motion/index.ts +18 -17
- package/src/cdp/commands/motion/jank.ts +119 -9
- package/src/cdp/commands/motion/mask.ts +81 -12
- package/src/cdp/commands/motion/rec.ts +520 -19
- package/src/cdp/commands/motion/response.ts +65 -15
- package/src/cdp/commands/motion/timeline.ts +139 -14
- package/src/cdp/commands/page/click.ts +194 -0
- package/src/cdp/commands/page/elements.ts +154 -0
- package/src/cdp/commands/page/exec.ts +317 -0
- package/src/cdp/commands/page/index.ts +72 -0
- package/src/cdp/commands/page/navigate.ts +330 -0
- package/src/cdp/commands/page/scroll.ts +107 -0
- package/src/cdp/commands/page/shot.ts +208 -0
- package/src/cdp/commands/page/type.ts +104 -0
- package/src/cdp/commands/tab/index.ts +59 -0
- package/src/cdp/commands/tab/list.ts +167 -0
- package/src/cdp/commands/tab/network.ts +162 -0
- package/src/cdp/commands/tab/open.ts +90 -0
- package/src/cdp/commands/tab/reset.ts +160 -0
- package/src/cdp/connection.ts +368 -37
- package/src/cdp/detect.ts +42 -13
- package/src/cdp/dispatch.ts +74 -28
- package/src/cdp/har-recorder.ts +648 -115
- package/src/cdp/leaf-grammar.ts +99 -0
- package/src/cdp/measure/census.ts +205 -0
- package/src/cdp/measure/check.ts +318 -0
- package/src/cdp/measure/collectors/animation.ts +385 -0
- package/src/cdp/measure/collectors/ax.ts +278 -0
- package/src/cdp/measure/collectors/focus.ts +1041 -0
- package/src/cdp/measure/collectors/forms.ts +905 -0
- package/src/cdp/measure/collectors/geometry.ts +1038 -0
- package/src/cdp/measure/collectors/hittest.ts +835 -0
- package/src/cdp/measure/collectors/layers.ts +697 -0
- package/src/cdp/measure/collectors/media.ts +522 -0
- package/src/cdp/measure/collectors/pixels.ts +1584 -0
- package/src/cdp/measure/collectors/queries.ts +464 -0
- package/src/cdp/measure/collectors/scroll.ts +599 -0
- package/src/cdp/measure/collectors/states.ts +992 -0
- package/src/cdp/measure/collectors/style-provenance.ts +643 -0
- package/src/cdp/measure/collectors/styles.ts +355 -0
- package/src/cdp/measure/collectors/text.ts +713 -0
- package/src/cdp/measure/diff.ts +470 -0
- package/src/cdp/measure/explain.ts +498 -0
- package/src/cdp/measure/map-ax.ts +633 -0
- package/src/cdp/measure/map-focus.ts +188 -0
- package/src/cdp/measure/map-layers.ts +177 -0
- package/src/cdp/measure/map-scroll.ts +317 -0
- package/src/cdp/measure/redaction.ts +51 -0
- package/src/cdp/measure/settle.ts +1051 -0
- package/src/cdp/measure/snapshot.ts +390 -0
- package/src/cdp/measure/sweep.ts +330 -0
- package/src/cdp/measure/types.ts +220 -0
- package/src/cdp/measure/v2/collectors.ts +19 -0
- package/src/cdp/measure/v2/orchestrator.ts +46 -0
- package/src/cdp/measure/v2/snap-selection.ts +33 -0
- package/src/cdp/measure/v2/target-lock.ts +15 -0
- package/src/cdp/measure/v2/target.ts +16 -0
- package/src/cdp/motion/jank.ts +433 -0
- package/src/cdp/motion/mask.ts +421 -0
- package/src/cdp/motion/recorder.ts +764 -0
- package/src/cdp/motion/response.ts +356 -0
- package/src/cdp/motion/timeline.ts +215 -0
- package/src/cdp/record.ts +5 -212
- package/src/cdp/recorder-bridge.ts +1478 -105
- package/src/cdp/recorder-client.ts +326 -0
- package/src/cdp/scope-lock.ts +73 -0
- package/src/cdp/screenshot.ts +199 -66
- package/src/cdp/targets.ts +21 -1
- package/src/cdp/timing.ts +15 -10
- package/src/cdp/types.ts +22 -7
- package/src/cdp/viewport.ts +21 -0
- package/src/cdp.ts +12 -58
- package/src/command-help.ts +29 -0
- package/src/command-resolution.ts +22 -0
- package/src/command-walker.ts +35 -0
- package/src/contracts/a11y.ts +33 -0
- package/src/contracts/cursor.ts +35 -0
- package/src/contracts/index.ts +15 -0
- package/src/contracts/motion.ts +19 -0
- package/src/contracts/neutral.ts +56 -0
- package/src/contracts/primitives.ts +205 -0
- package/src/contracts/release.ts +17 -0
- package/src/contracts/results.ts +208 -0
- package/src/contracts/routes.ts +140 -0
- package/src/contracts/snapshot.ts +92 -0
- package/src/cursor/index.ts +13 -0
- package/src/cursor/service.ts +310 -0
- package/src/errors.ts +71 -0
- package/src/har-manager.ts +992 -41
- package/src/interact.ts +410 -98
- package/src/output/artifact-lifecycle.ts +371 -0
- package/src/output/artifact.ts +6 -0
- package/src/output/exact-raw.ts +114 -0
- package/src/output/render.ts +396 -2
- package/src/output/structured.ts +211 -0
- package/src/registry.ts +208 -0
- package/src/session/artifacts.ts +452 -200
- package/src/session/commands.ts +1162 -397
- package/src/session/coordinator.ts +251 -0
- package/src/session/har-filter.ts +57 -0
- package/src/session/log-tailer.ts +653 -0
- package/src/session-context.ts +279 -51
- package/src/vault/bundle.ts +32 -17
- package/src/cdp/commands/exec.ts +0 -105
- package/src/cdp/commands/har.ts +0 -128
- package/src/cdp/commands/tabs.ts +0 -183
- package/src/cdp/commands/traffic.ts +0 -121
- package/src/cdp/commands/ui.ts +0 -172
- package/src/cdp/execute.ts +0 -113
- package/src/cdp/har-output.ts +0 -47
- package/src/cdp/usage.ts +0 -79
- package/src/tab-session.ts +0 -91
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `page type <text> [--into <target>]` — type agent-supplied text into the
|
|
3
|
+
* focused element, or into exactly one live-resolved field (design D3).
|
|
4
|
+
*
|
|
5
|
+
* The block echoes the agent-supplied text (I-7); the screenshot label and
|
|
6
|
+
* any recorder landmark identify the FIELD (the action's target), matching
|
|
7
|
+
* `connection.ts`'s `deriveActionLabel` — the typed text is the action's
|
|
8
|
+
* payload and is echoed in the command's own result block.
|
|
9
|
+
*/
|
|
10
|
+
import { type ParsedArgs } from '../../types.js';
|
|
11
|
+
import {
|
|
12
|
+
resolveLiveTarget,
|
|
13
|
+
typeText,
|
|
14
|
+
focusAndType,
|
|
15
|
+
type LiveClient,
|
|
16
|
+
type ClickDispatch,
|
|
17
|
+
} from '../../../interact.js';
|
|
18
|
+
import { emitResult, fact, lineList, type FactLine } from '../../../output/render.js';
|
|
19
|
+
import {
|
|
20
|
+
pageInputDeps,
|
|
21
|
+
effectiveSettle,
|
|
22
|
+
emitInvalidInput,
|
|
23
|
+
emitResolutionError,
|
|
24
|
+
} from './click.js';
|
|
25
|
+
|
|
26
|
+
const USAGE = `capture page type <text> [--into <target>] — type text into the focused element or one resolved field
|
|
27
|
+
|
|
28
|
+
input:
|
|
29
|
+
<text> the text to insert (agent-supplied; echoed back in the result block)
|
|
30
|
+
--into <target> focus this field first — resolved against the LIVE page: bare CSS selector, ax:<name> (case-insensitive substring), axid:<id>, backend:<id>; text: is not accepted; exactly one match required
|
|
31
|
+
--settle <ms> network-settle window applied after typing (default: 500; 1500 with an active session; 0 disables)
|
|
32
|
+
--no-screenshot skip the auto-screenshot
|
|
33
|
+
output:
|
|
34
|
+
<typed> — the typed text, the resolved field identity (backend-node-id, role, name) and focus-click coordinates when --into was given, the measured settle (requested/waited), screenshot artifact path; --json mirrors the same fields
|
|
35
|
+
effects:
|
|
36
|
+
dispatches a real focus click (when --into) followed by real text insertion; writes one screenshot into the active session's shots/ sequence unless --no-screenshot`;
|
|
37
|
+
|
|
38
|
+
export async function cmdPageType(parsed: ParsedArgs, _args: string[]): Promise<void> {
|
|
39
|
+
if (parsed.help) {
|
|
40
|
+
console.log(USAGE);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const textArg = parsed.positional[0];
|
|
44
|
+
if (textArg === undefined || parsed.positional.length !== 1) {
|
|
45
|
+
return emitInvalidInput(
|
|
46
|
+
parsed,
|
|
47
|
+
'page type',
|
|
48
|
+
fact`received: ${parsed.positional.length} positional arguments; expected exactly one — the text to type (use --into <target> to focus a field first).`,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const deps = pageInputDeps();
|
|
53
|
+
const settle = effectiveSettle(parsed, { standalone: 500, session: 1500 });
|
|
54
|
+
// connection.ts derives the recorder landmark label from parsed.command —
|
|
55
|
+
// restoring the verb here is what engages deriveActionLabel's type-guard,
|
|
56
|
+
// so a routed type's landmark is `type:<field>` and NEVER the typed text
|
|
57
|
+
// (positional[0], which the generic label branch would otherwise use).
|
|
58
|
+
const { result: outcome, settle: settleFacts } = await deps.withPageAction(
|
|
59
|
+
{ ...parsed, command: 'type' },
|
|
60
|
+
{ settleMs: settle },
|
|
61
|
+
async (client) => {
|
|
62
|
+
const live = client as unknown as LiveClient;
|
|
63
|
+
let dispatch: ClickDispatch | null = null;
|
|
64
|
+
if (parsed.into) {
|
|
65
|
+
const resolved = await resolveLiveTarget(live, parsed.into);
|
|
66
|
+
if (!resolved.ok) return { failure: resolved } as const;
|
|
67
|
+
dispatch = await focusAndType(live, resolved, textArg);
|
|
68
|
+
} else {
|
|
69
|
+
await typeText(live, textArg);
|
|
70
|
+
}
|
|
71
|
+
// Screenshot label identifies the field, never the typed content.
|
|
72
|
+
const screenshot = await deps.autoScreenshot(client, 'type', parsed.into ?? 'focused element', parsed.noScreenshot);
|
|
73
|
+
return { dispatch, screenshot } as const;
|
|
74
|
+
},
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
if ('failure' in outcome) {
|
|
78
|
+
return emitResolutionError(parsed, 'page type', outcome.failure);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const { dispatch, screenshot } = outcome;
|
|
82
|
+
const rows: FactLine[] = [
|
|
83
|
+
dispatch
|
|
84
|
+
? fact`typed "${textArg}" into ${dispatch.role ?? 'unknown'} "${dispatch.name ?? ''}" (backend:${dispatch.backendNodeId}), focus click at x=${dispatch.x} y=${dispatch.y}`
|
|
85
|
+
: fact`typed "${textArg}" into the focused element`,
|
|
86
|
+
fact`settle: requested ${settleFacts.requestedMs}ms, waited ${settleFacts.waitedMs}ms`,
|
|
87
|
+
];
|
|
88
|
+
if (screenshot) rows.push(fact`screenshot: ${screenshot}`);
|
|
89
|
+
|
|
90
|
+
emitResult(
|
|
91
|
+
{
|
|
92
|
+
tag: 'typed',
|
|
93
|
+
attrs: dispatch
|
|
94
|
+
? {
|
|
95
|
+
'backend-node-id': dispatch.backendNodeId,
|
|
96
|
+
role: dispatch.role ?? undefined,
|
|
97
|
+
name: dispatch.name ?? undefined,
|
|
98
|
+
}
|
|
99
|
+
: {},
|
|
100
|
+
summary: lineList(rows),
|
|
101
|
+
},
|
|
102
|
+
{ json: parsed.json },
|
|
103
|
+
);
|
|
104
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `capture tab` branch router — tab and connection plumbing: endpoint/tab
|
|
3
|
+
* discovery, tab lifecycle, connection-level network emulation.
|
|
4
|
+
*
|
|
5
|
+
* Shifts the consumed leaf token out of `parsed.positional` before handing
|
|
6
|
+
* off, so every leaf command still finds its own primary target (a URL, an
|
|
7
|
+
* offline/online token, ...) at `positional[0]` exactly as every other
|
|
8
|
+
* capture command does — leaf commands never need to know they're nested
|
|
9
|
+
* under a branch.
|
|
10
|
+
*/
|
|
11
|
+
import { invalidInput } from '../../../errors.js';
|
|
12
|
+
import { type ParsedArgs } from '../../types.js';
|
|
13
|
+
import { cmdTabList } from './list.js';
|
|
14
|
+
import { cmdTabOpen } from './open.js';
|
|
15
|
+
import { cmdTabReset } from './reset.js';
|
|
16
|
+
import { cmdTabNetwork } from './network.js';
|
|
17
|
+
|
|
18
|
+
/** Root-help representation of this branch, assembled by `src/capture.ts`. */
|
|
19
|
+
export const COMMAND_BLOCK = `<command name="tab">
|
|
20
|
+
tab and connection plumbing — endpoint/tab discovery, open/reset tabs, connection-level network emulation
|
|
21
|
+
use when finding a CDP endpoint or tab, opening/replacing a tab, or toggling connectivity; \`tab list\` is the probe for a running browser
|
|
22
|
+
list · open · reset · network — \`capture tab -h\`
|
|
23
|
+
</command>`;
|
|
24
|
+
|
|
25
|
+
export const TAB_USAGE = `capture tab — tab and connection plumbing: discovery, lifecycle, network emulation.
|
|
26
|
+
|
|
27
|
+
\`tab list\` with no --port performs full endpoint discovery and is the probe
|
|
28
|
+
for whether a CDP-enabled browser is running at all. \`tab reset\` replaces a
|
|
29
|
+
stuck tab with a fresh one; under an active session it refuses while a
|
|
30
|
+
recording is active (stop it first), reaps dead recorder handles, and updates
|
|
31
|
+
the session's {target, port} pair together.
|
|
32
|
+
|
|
33
|
+
<subcommand name="list" args="[--port <port>]" whenToUse="discover CDP endpoints and the tabs open on them"/>
|
|
34
|
+
<subcommand name="open" args="<url> [--new] [--port <port>]" whenToUse="open a URL and get its tab id"/>
|
|
35
|
+
<subcommand name="reset" args="<url> [--port <port>]" whenToUse="abandon a stuck tab and open a fresh one (refuses while a recording is active; reaps dead recorder handles; updates the active session's {target, port} together)"/>
|
|
36
|
+
<subcommand name="network" args="<offline|online>" whenToUse="toggle connection-level network emulation for a tab"/>
|
|
37
|
+
|
|
38
|
+
capture tab <leaf> -h Per-leaf usage`;
|
|
39
|
+
|
|
40
|
+
export async function tabMain(parsed: ParsedArgs, args: string[]): Promise<void> {
|
|
41
|
+
const leaf = parsed.positional[0];
|
|
42
|
+
const rest: ParsedArgs = { ...parsed, positional: parsed.positional.slice(1) };
|
|
43
|
+
|
|
44
|
+
switch (leaf) {
|
|
45
|
+
case 'list':
|
|
46
|
+
return cmdTabList(rest, args);
|
|
47
|
+
case 'open':
|
|
48
|
+
return cmdTabOpen(rest, args);
|
|
49
|
+
case 'reset':
|
|
50
|
+
return cmdTabReset(rest, args);
|
|
51
|
+
case 'network':
|
|
52
|
+
return cmdTabNetwork(rest, args);
|
|
53
|
+
case undefined:
|
|
54
|
+
console.log(TAB_USAGE);
|
|
55
|
+
return;
|
|
56
|
+
default:
|
|
57
|
+
throw invalidInput(`Unknown tab leaf: ${leaf}.`, 'unknown_command');
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `capture tab list [--port <port>]` — CDP endpoint discovery and page-tab
|
|
3
|
+
* listing merged into one selection-shaped `<tabs>` block (D5: the old
|
|
4
|
+
* `detect` root command's discovery is the no-port default of listing tabs).
|
|
5
|
+
*
|
|
6
|
+
* A per-endpoint tab-fetch failure is reported as an `endpoints-unreachable`
|
|
7
|
+
* fact, never silently dropped (I-5). Every endpoint/page-derived string
|
|
8
|
+
* (titles, URLs, app names, target ids) flows through `data()`/`fact` (I-9).
|
|
9
|
+
*/
|
|
10
|
+
import {
|
|
11
|
+
detectCdpPortsAsync,
|
|
12
|
+
getDefaultBrowserId,
|
|
13
|
+
pickPreferredEndpoint,
|
|
14
|
+
} from '../../detect.js';
|
|
15
|
+
import { listTargets } from '../../targets.js';
|
|
16
|
+
import { type ParsedArgs } from '../../types.js';
|
|
17
|
+
import {
|
|
18
|
+
data,
|
|
19
|
+
emitResult,
|
|
20
|
+
fact,
|
|
21
|
+
line,
|
|
22
|
+
lineList,
|
|
23
|
+
text,
|
|
24
|
+
type FactLine,
|
|
25
|
+
type RenderableResult,
|
|
26
|
+
} from '../../../output/render.js';
|
|
27
|
+
|
|
28
|
+
const USAGE = `capture tab list — discover CDP endpoints and list the page tabs on each.
|
|
29
|
+
|
|
30
|
+
input:
|
|
31
|
+
--port <port> probe one endpoint only, instead of discovering every localhost CDP endpoint
|
|
32
|
+
|
|
33
|
+
output: <tabs endpoints=… tabs=…> — one section per reachable endpoint (port, app, page-tab count, preferred marker) listing each page tab's target id (first 8 chars, enough for --target), title, and url; an endpoint that failed the tab fetch is an endpoints-unreachable fact, never a silent omission.
|
|
34
|
+
effects: read-only — probes local CDP endpoints over HTTP/WebSocket; no page-observable writes.`;
|
|
35
|
+
|
|
36
|
+
export interface TabRow {
|
|
37
|
+
id: string;
|
|
38
|
+
title: string;
|
|
39
|
+
url: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface EndpointTabs {
|
|
43
|
+
port: number;
|
|
44
|
+
/** Endpoint-derived app name (from its /json/version User-Agent) — untrusted. */
|
|
45
|
+
app?: string;
|
|
46
|
+
/** Marked on the endpoint auto-discovery would pick for port-less commands. */
|
|
47
|
+
preferred: boolean;
|
|
48
|
+
pages: TabRow[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface UnreachableEndpoint {
|
|
52
|
+
port: number;
|
|
53
|
+
app?: string;
|
|
54
|
+
reason: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Pure `<tabs>` result builder — exported for tests. */
|
|
58
|
+
export function buildTabsResult(
|
|
59
|
+
endpoints: readonly EndpointTabs[],
|
|
60
|
+
unreachable: readonly UnreachableEndpoint[],
|
|
61
|
+
): RenderableResult {
|
|
62
|
+
const totalTabs = endpoints.reduce((n, ep) => n + ep.pages.length, 0);
|
|
63
|
+
|
|
64
|
+
let summary: FactLine =
|
|
65
|
+
endpoints.length === 0 && unreachable.length === 0
|
|
66
|
+
? text`0 CDP endpoints found listening on localhost.`
|
|
67
|
+
: fact`${totalTabs} page tab(s) on ${endpoints.length} reachable CDP endpoint(s).`;
|
|
68
|
+
if (unreachable.length > 0) {
|
|
69
|
+
summary = line(summary, fact` ${unreachable.length} endpoint(s) unreachable.`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const sections: FactLine[] = endpoints.map((ep) => {
|
|
73
|
+
const header = line(
|
|
74
|
+
text`port `,
|
|
75
|
+
data(ep.port),
|
|
76
|
+
...(ep.app !== undefined ? [line(text` — `, data(ep.app, 80))] : []),
|
|
77
|
+
fact` — ${ep.pages.length} page tab(s)`,
|
|
78
|
+
...(ep.preferred ? [text` [preferred]`] : []),
|
|
79
|
+
);
|
|
80
|
+
const rows = ep.pages.map((t) =>
|
|
81
|
+
line(text` `, data(t.id.slice(0, 8)), text` "`, data(t.title, 120), text`" `, data(t.url, 300)),
|
|
82
|
+
);
|
|
83
|
+
return lineList([header, ...rows]);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
if (unreachable.length > 0) {
|
|
87
|
+
sections.push(
|
|
88
|
+
lineList(
|
|
89
|
+
unreachable.map((u) =>
|
|
90
|
+
line(
|
|
91
|
+
text`endpoints-unreachable: port `,
|
|
92
|
+
data(u.port),
|
|
93
|
+
...(u.app !== undefined ? [line(text` (`, data(u.app, 80), text`)`)] : []),
|
|
94
|
+
text` — `,
|
|
95
|
+
data(u.reason, 300),
|
|
96
|
+
),
|
|
97
|
+
),
|
|
98
|
+
),
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
tag: 'tabs',
|
|
104
|
+
attrs: {
|
|
105
|
+
endpoints: endpoints.length,
|
|
106
|
+
tabs: totalTabs,
|
|
107
|
+
...(unreachable.length > 0 ? { unreachable: unreachable.length } : {}),
|
|
108
|
+
},
|
|
109
|
+
summary,
|
|
110
|
+
sections,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function errorMessage(err: unknown): string {
|
|
115
|
+
return err instanceof Error ? err.message : String(err);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export async function cmdTabList(parsed: ParsedArgs, _args: string[]): Promise<void> {
|
|
119
|
+
if (parsed.help) {
|
|
120
|
+
console.log(USAGE);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const reachable: EndpointTabs[] = [];
|
|
125
|
+
const unreachable: UnreachableEndpoint[] = [];
|
|
126
|
+
|
|
127
|
+
if (parsed.port) {
|
|
128
|
+
try {
|
|
129
|
+
const targets = await listTargets(parsed.port);
|
|
130
|
+
reachable.push({
|
|
131
|
+
port: parsed.port,
|
|
132
|
+
preferred: false,
|
|
133
|
+
pages: targets
|
|
134
|
+
.filter((t) => t.type === 'page')
|
|
135
|
+
.map((t) => ({ id: t.id, title: t.title, url: t.url })),
|
|
136
|
+
});
|
|
137
|
+
} catch (err) {
|
|
138
|
+
unreachable.push({ port: parsed.port, reason: errorMessage(err) });
|
|
139
|
+
}
|
|
140
|
+
} else {
|
|
141
|
+
const endpoints = await detectCdpPortsAsync();
|
|
142
|
+
const preferredPort =
|
|
143
|
+
endpoints.length > 0 ? pickPreferredEndpoint(endpoints, getDefaultBrowserId()).port : null;
|
|
144
|
+
await Promise.all(
|
|
145
|
+
endpoints.map(async (ep) => {
|
|
146
|
+
try {
|
|
147
|
+
const targets = await listTargets(ep.port);
|
|
148
|
+
reachable.push({
|
|
149
|
+
port: ep.port,
|
|
150
|
+
app: ep.app,
|
|
151
|
+
preferred: ep.port === preferredPort,
|
|
152
|
+
pages: targets
|
|
153
|
+
.filter((t) => t.type === 'page')
|
|
154
|
+
.map((t) => ({ id: t.id, title: t.title, url: t.url })),
|
|
155
|
+
});
|
|
156
|
+
} catch (err) {
|
|
157
|
+
unreachable.push({ port: ep.port, app: ep.app, reason: errorMessage(err) });
|
|
158
|
+
}
|
|
159
|
+
}),
|
|
160
|
+
);
|
|
161
|
+
// Parallel probes land in arrival order — sort for deterministic output.
|
|
162
|
+
reachable.sort((a, b) => a.port - b.port);
|
|
163
|
+
unreachable.sort((a, b) => a.port - b.port);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
emitResult(buildTabsResult(reachable, unreachable), { json: parsed.json });
|
|
167
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `capture tab network <offline|online>` — connection-level network
|
|
3
|
+
* emulation for the active session's held tab via
|
|
4
|
+
* `Network.emulateNetworkConditions`.
|
|
5
|
+
*
|
|
6
|
+
* Emulation is only meaningful while the CDP connection that applied it stays
|
|
7
|
+
* open, so this leaf refuses to run except against an active session that owns
|
|
8
|
+
* a live `session start --hold` bridge. It sends `Network.enable` +
|
|
9
|
+
* `Network.emulateNetworkConditions` through that exact bridge, scoped to the
|
|
10
|
+
* session's own target, and reports the truthful owner (`session-hold`) and the
|
|
11
|
+
* lifetime of the emulation — until a matching `online` or the session stops.
|
|
12
|
+
*/
|
|
13
|
+
import * as fs from 'node:fs';
|
|
14
|
+
import { captureError } from '../../../errors.js';
|
|
15
|
+
import { getActiveSession, setActiveNetworkOffline } from '../../../session-context.js';
|
|
16
|
+
import { admitSessionOperation } from '../../../session/coordinator.js';
|
|
17
|
+
import { sendBridgeRequest } from '../../bridge/client.js';
|
|
18
|
+
import { type ParsedArgs } from '../../types.js';
|
|
19
|
+
import {
|
|
20
|
+
emitResult,
|
|
21
|
+
fact,
|
|
22
|
+
text,
|
|
23
|
+
type RenderableResult,
|
|
24
|
+
} from '../../../output/render.js';
|
|
25
|
+
|
|
26
|
+
const USAGE = `capture tab network — toggle connection-level network emulation for the active session's held tab.
|
|
27
|
+
|
|
28
|
+
input:
|
|
29
|
+
<offline|online> required — offline blocks HTTP requests and drops WebSocket connections; online restores connectivity
|
|
30
|
+
--target <id> optional — must equal the active session's target; --url is not accepted
|
|
31
|
+
--port <port> optional — must equal the active session's port
|
|
32
|
+
|
|
33
|
+
output: <network mode=… owner="session-hold"> — the emulation applied to the session's held tab, and how long it lasts.
|
|
34
|
+
effects: page-observable — Network.emulateNetworkConditions is sent through the session's held CDP bridge and stays in effect on that tab until a matching \`capture tab network online\` or the session stops.
|
|
35
|
+
requires: an active session with a live \`session start --hold\` bridge and a target; emulation lives only as long as that held connection.`;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Pure `<network>` result builder — exported for tests. Reports the factual
|
|
39
|
+
* `session-hold` owner and the lifetime of the emulation; it never promises
|
|
40
|
+
* persistence beyond that owner.
|
|
41
|
+
*/
|
|
42
|
+
export function buildNetworkResult(mode: 'offline' | 'online', target?: string): RenderableResult {
|
|
43
|
+
const offline = mode === 'offline';
|
|
44
|
+
const attrs: Record<string, string> = { mode, owner: 'session-hold' };
|
|
45
|
+
if (target) attrs.target = target;
|
|
46
|
+
return {
|
|
47
|
+
tag: 'network',
|
|
48
|
+
attrs,
|
|
49
|
+
summary: offline
|
|
50
|
+
? fact`network emulation applied to the session-hold tab ${target ?? ''}: offline — HTTP requests blocked, WebSocket connections drop. It is held on the session's bridge and lasts until \`capture tab network online\` or the session stops.`
|
|
51
|
+
: fact`network emulation on the session-hold tab ${target ?? ''}: online — connectivity restored; the held offline emulation is cleared.`,
|
|
52
|
+
...(offline ? { followUp: text`capture tab network online` } : {}),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function cmdTabNetwork(parsed: ParsedArgs, _args: string[]): Promise<void> {
|
|
57
|
+
if (parsed.help) {
|
|
58
|
+
console.log(USAGE);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const mode = parsed.positional[0];
|
|
63
|
+
if (mode !== 'offline' && mode !== 'online') {
|
|
64
|
+
throw captureError(
|
|
65
|
+
'invocation',
|
|
66
|
+
'invalid_argument',
|
|
67
|
+
`received: \`${mode ?? ''}\`; expected: capture tab network <offline|online>.`,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// `tab network` emulates on the active session's held tab; a caller-supplied
|
|
72
|
+
// URL target has no place here. Reject before touching session state.
|
|
73
|
+
if (parsed.url !== undefined) {
|
|
74
|
+
throw captureError(
|
|
75
|
+
'invocation',
|
|
76
|
+
'unsupported_flag',
|
|
77
|
+
'capture tab network does not accept --url; it emulates on the active session\'s held tab.',
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const session = getActiveSession();
|
|
82
|
+
if (!session) {
|
|
83
|
+
throw captureError(
|
|
84
|
+
'precondition',
|
|
85
|
+
'no_active_session',
|
|
86
|
+
'capture tab network requires an active session; start one with `capture session start --hold`.',
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
if (!session.targetId) {
|
|
90
|
+
throw captureError(
|
|
91
|
+
'precondition',
|
|
92
|
+
'session_target_missing',
|
|
93
|
+
`session ${session.sessionId} has no target to emulate on; open one with \`capture session start --hold --url <url>\` or \`capture tab reset <url>\`.`,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
if (!session.bridgeSocket || !fs.existsSync(session.bridgeSocket)) {
|
|
97
|
+
throw captureError(
|
|
98
|
+
'precondition',
|
|
99
|
+
'no_held_bridge',
|
|
100
|
+
`session ${session.sessionId} has no live held CDP bridge; network emulation only outlives a command when the session was started with \`session start --hold\`.`,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
// The only target that can be emulated is the session's own. An explicit
|
|
104
|
+
// `--target` unequal to it is a mismatch; an equal one is accepted.
|
|
105
|
+
if (parsed.target !== undefined && parsed.target !== session.targetId) {
|
|
106
|
+
throw captureError(
|
|
107
|
+
'precondition',
|
|
108
|
+
'target_mismatch',
|
|
109
|
+
`received --target ${parsed.target}; capture tab network only emulates the active session's target ${session.targetId}.`,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
// Likewise, an explicitly flagged port must be the session's own port. An
|
|
113
|
+
// equal port is accepted; a mismatch rejects before any bridge send (A4).
|
|
114
|
+
if (parsed.port !== undefined && parsed.port !== session.port) {
|
|
115
|
+
throw captureError(
|
|
116
|
+
'precondition',
|
|
117
|
+
'port_mismatch',
|
|
118
|
+
`received --port ${parsed.port}; capture tab network only emulates on the active session's port ${session.port ?? 'unknown'}.`,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const offline = mode === 'offline';
|
|
123
|
+
const socket = session.bridgeSocket;
|
|
124
|
+
const targetId = session.targetId;
|
|
125
|
+
|
|
126
|
+
const operation = await admitSessionOperation(session.dir);
|
|
127
|
+
try {
|
|
128
|
+
const enable = await sendBridgeRequest(socket, { method: 'Network.enable', targetId });
|
|
129
|
+
if (!enable.ok) {
|
|
130
|
+
throw captureError(
|
|
131
|
+
'world',
|
|
132
|
+
'bridge_error',
|
|
133
|
+
`Network.enable failed over the session's held bridge: ${enable.error || 'unknown bridge error'}`,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
const emulate = await sendBridgeRequest(socket, {
|
|
137
|
+
method: 'Network.emulateNetworkConditions',
|
|
138
|
+
params: {
|
|
139
|
+
offline,
|
|
140
|
+
latency: offline ? -1 : 0,
|
|
141
|
+
downloadThroughput: offline ? 0 : -1,
|
|
142
|
+
uploadThroughput: offline ? 0 : -1,
|
|
143
|
+
},
|
|
144
|
+
targetId,
|
|
145
|
+
});
|
|
146
|
+
if (!emulate.ok) {
|
|
147
|
+
throw captureError(
|
|
148
|
+
'world',
|
|
149
|
+
'bridge_error',
|
|
150
|
+
`Network.emulateNetworkConditions failed over the session's held bridge: ${emulate.error || 'unknown bridge error'}`,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
// Local-main session network retention: record the emulation on the
|
|
154
|
+
// active session so later independent command connections re-apply it
|
|
155
|
+
// (applyActiveSessionNetworkConditions in cdp/connection.ts).
|
|
156
|
+
await setActiveNetworkOffline(offline);
|
|
157
|
+
} finally {
|
|
158
|
+
await operation.release();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
emitResult(buildNetworkResult(mode, targetId), { json: parsed.json });
|
|
162
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `capture tab open <url> [--new] [--port <port>]` — open a URL in the
|
|
3
|
+
* browser (reusing a matching tab unless `--new`) and report the tab's
|
|
4
|
+
* identity as a `<tab-opened>` block. Page-derived title/url strings flow
|
|
5
|
+
* through `data()` (I-9).
|
|
6
|
+
*/
|
|
7
|
+
import { detectCdpPort } from '../../detect.js';
|
|
8
|
+
import { captureError, invalidInput } from '../../../errors.js';
|
|
9
|
+
import { navigateAndWait } from '../../record.js';
|
|
10
|
+
import { type CDPTarget, type ParsedArgs } from '../../types.js';
|
|
11
|
+
import {
|
|
12
|
+
data,
|
|
13
|
+
emitResult,
|
|
14
|
+
fact,
|
|
15
|
+
line,
|
|
16
|
+
text,
|
|
17
|
+
type RenderableResult,
|
|
18
|
+
} from '../../../output/render.js';
|
|
19
|
+
|
|
20
|
+
const USAGE = `capture tab open — open a URL in the browser and report the tab's target id.
|
|
21
|
+
|
|
22
|
+
input:
|
|
23
|
+
<url> required — the URL to open; an existing tab already at it is reused unless --new
|
|
24
|
+
--new always create a fresh background tab instead of reusing a matching one
|
|
25
|
+
--port <port> CDP endpoint (default: the auto-discovered preferred endpoint)
|
|
26
|
+
|
|
27
|
+
output: <tab-opened port=… target=…> — the tab's target id (first 8 chars are enough for --target), title, and url.
|
|
28
|
+
effects: opens (or reuses) a background browser tab and waits up to 10s for its load event.`;
|
|
29
|
+
|
|
30
|
+
/** Pure `<tab-opened>` result builder — exported for tests. */
|
|
31
|
+
export function buildTabOpenedResult(tab: CDPTarget, port: number): RenderableResult {
|
|
32
|
+
return {
|
|
33
|
+
tag: 'tab-opened',
|
|
34
|
+
attrs: { port, target: tab.id },
|
|
35
|
+
summary: line(
|
|
36
|
+
text`tab `,
|
|
37
|
+
data(tab.id.slice(0, 8)),
|
|
38
|
+
text`: "`,
|
|
39
|
+
data(tab.title, 120),
|
|
40
|
+
text`" `,
|
|
41
|
+
data(tab.url, 300),
|
|
42
|
+
),
|
|
43
|
+
followUp: line(
|
|
44
|
+
text`capture page shot --port `,
|
|
45
|
+
data(port),
|
|
46
|
+
text` --target `,
|
|
47
|
+
data(tab.id.slice(0, 8)),
|
|
48
|
+
),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function errorMessage(err: unknown): string {
|
|
53
|
+
return err instanceof Error ? err.message : String(err);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function cmdTabOpen(parsed: ParsedArgs, _args: string[]): Promise<void> {
|
|
57
|
+
if (parsed.help) {
|
|
58
|
+
console.log(USAGE);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Positional cardinality (exactly 1) is enforced by `validateCliInvocation`
|
|
63
|
+
// before dispatch reaches this leaf; this guard covers direct programmatic
|
|
64
|
+
// callers only. Failures cross the boundary as typed CaptureErrors —
|
|
65
|
+
// capture.ts is the sole renderer/exit-status owner.
|
|
66
|
+
const url = parsed.positional[0];
|
|
67
|
+
if (!url) {
|
|
68
|
+
throw invalidInput('received: no URL; expected: capture tab open <url> [--new] [--port <port>].', 'missing_argument');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
let port: number;
|
|
72
|
+
try {
|
|
73
|
+
port = parsed.port ?? (await detectCdpPort());
|
|
74
|
+
} catch {
|
|
75
|
+
throw captureError(
|
|
76
|
+
'world',
|
|
77
|
+
'no_cdp_endpoint',
|
|
78
|
+
'received: no --port, and no CDP endpoint was discovered on localhost; expected: a running CDP-enabled browser (or an explicit --port <port>). capture tab list probes every localhost CDP endpoint.',
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
let tab: CDPTarget;
|
|
83
|
+
try {
|
|
84
|
+
tab = await navigateAndWait(port, url, { forceNew: parsed.new });
|
|
85
|
+
} catch (err) {
|
|
86
|
+
throw captureError('world', 'open_failed', `received: \`${url}\`; opening it on port ${port} failed: ${errorMessage(err)}`, err);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
emitResult(buildTabOpenedResult(tab, port), { json: parsed.json });
|
|
90
|
+
}
|