@crouton-kit/capture 1.3.3 → 1.3.5
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 +2293 -1477
- package/package.json +7 -3
- package/src/capture.ts +10 -383
- package/src/cdp/a11y.ts +104 -0
- package/src/cdp/args.ts +101 -0
- package/src/cdp/client.ts +97 -0
- package/src/cdp/commands/exec.ts +102 -0
- package/src/cdp/commands/har.ts +128 -0
- package/src/cdp/commands/lib.ts +356 -0
- package/src/cdp/commands/tabs.ts +189 -0
- package/src/cdp/commands/traffic.ts +121 -0
- package/src/cdp/commands/ui.ts +172 -0
- package/src/cdp/connection.ts +108 -0
- package/src/cdp/console-recorder.ts +80 -0
- package/src/cdp/detect.ts +150 -0
- package/src/cdp/dispatch.ts +32 -0
- package/src/cdp/execute.ts +117 -0
- package/src/cdp/har-output.ts +47 -0
- package/src/cdp/har-recorder.ts +147 -0
- package/src/cdp/record.ts +221 -0
- package/src/cdp/screenshot.ts +100 -0
- package/src/cdp/tab-lock.ts +132 -0
- package/src/cdp/targets.ts +112 -0
- package/src/cdp/types.ts +36 -0
- package/src/cdp/usage.ts +69 -0
- package/src/cdp.ts +17 -2337
- package/src/session/commands.ts +385 -0
- package/src/vault/bundle.ts +205 -0
- package/src/vault/docs.ts +224 -0
- package/src/vault/paths.ts +87 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { CDPClient } from './client.js';
|
|
2
|
+
import { type CDPTarget } from './types.js';
|
|
3
|
+
import { detectCdpPort } from './detect.js';
|
|
4
|
+
import { findTab, findTabById, openTab, listTargets } from './targets.js';
|
|
5
|
+
import { HARRecorder } from './har-recorder.js';
|
|
6
|
+
import { writeHarAndPrintSummary } from './har-output.js';
|
|
7
|
+
import { type HAREntry } from '../har-manager.js';
|
|
8
|
+
|
|
9
|
+
export interface RecordOptions {
|
|
10
|
+
port?: number;
|
|
11
|
+
targetId?: string;
|
|
12
|
+
duration?: number;
|
|
13
|
+
harOutPath?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface RecordResult {
|
|
17
|
+
harPath: string | undefined;
|
|
18
|
+
entryCount: number;
|
|
19
|
+
har: { log: { entries: HAREntry[] } };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function recordTraffic(
|
|
23
|
+
options: RecordOptions,
|
|
24
|
+
): Promise<RecordResult> {
|
|
25
|
+
const port = options.port ?? (await detectCdpPort());
|
|
26
|
+
const duration = options.duration ?? 10;
|
|
27
|
+
|
|
28
|
+
if (!options.targetId) {
|
|
29
|
+
throw new Error('Use --target <tabId> to target a tab. Run "capture list" to see available tabs.');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const tab = await findTabById(port, options.targetId);
|
|
33
|
+
if (!tab) {
|
|
34
|
+
throw new Error(
|
|
35
|
+
`No tab found for target "${options.targetId}". Run "capture list" to see available tabs.`,
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (!tab.webSocketDebuggerUrl) {
|
|
40
|
+
throw new Error('Tab has no WebSocket debugger URL');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const client = new CDPClient(tab.webSocketDebuggerUrl);
|
|
44
|
+
await client.waitReady();
|
|
45
|
+
|
|
46
|
+
const recorder = new HARRecorder(client);
|
|
47
|
+
await recorder.start();
|
|
48
|
+
|
|
49
|
+
console.error(
|
|
50
|
+
`Recording traffic on "${tab.url}" for ${duration}s... (click around in the browser)`,
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
await new Promise((r) => setTimeout(r, duration * 1000));
|
|
54
|
+
|
|
55
|
+
const har = await recorder.finish();
|
|
56
|
+
const harPath = writeHarAndPrintSummary(har, options.harOutPath);
|
|
57
|
+
|
|
58
|
+
client.close();
|
|
59
|
+
|
|
60
|
+
return { harPath, entryCount: har.log.entries.length, har };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface NavigateAndRecordOptions {
|
|
64
|
+
port?: number;
|
|
65
|
+
url: string;
|
|
66
|
+
targetId?: string;
|
|
67
|
+
harOutPath?: string;
|
|
68
|
+
settle?: number;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface NavigateAndRecordResult {
|
|
72
|
+
harPath: string | undefined;
|
|
73
|
+
entryCount: number;
|
|
74
|
+
har: { log: { entries: HAREntry[] } };
|
|
75
|
+
tab: CDPTarget;
|
|
76
|
+
timedOut: boolean;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function navigateAndRecord(
|
|
80
|
+
options: NavigateAndRecordOptions,
|
|
81
|
+
): Promise<NavigateAndRecordResult> {
|
|
82
|
+
const port = options.port ?? (await detectCdpPort());
|
|
83
|
+
const settle = options.settle ?? 2000;
|
|
84
|
+
|
|
85
|
+
// Find tab — prefer targetId (exact) over domain match (fuzzy)
|
|
86
|
+
let tab: CDPTarget | null = null;
|
|
87
|
+
let isNewTab = false;
|
|
88
|
+
if (options.targetId) {
|
|
89
|
+
tab = await findTabById(port, options.targetId);
|
|
90
|
+
if (!tab) {
|
|
91
|
+
throw new Error(
|
|
92
|
+
`No tab found with target ID "${options.targetId}". Tab may have been closed.`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
} else {
|
|
96
|
+
let domain: string;
|
|
97
|
+
try {
|
|
98
|
+
domain = new URL(options.url).hostname;
|
|
99
|
+
} catch {
|
|
100
|
+
throw new Error(`Invalid URL: ${options.url}`);
|
|
101
|
+
}
|
|
102
|
+
tab = await findTab(port, domain);
|
|
103
|
+
if (!tab) {
|
|
104
|
+
tab = await openTab(port, options.url);
|
|
105
|
+
isNewTab = true;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (!tab.webSocketDebuggerUrl) {
|
|
109
|
+
throw new Error('Tab has no WebSocket debugger URL');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const client = new CDPClient(tab.webSocketDebuggerUrl);
|
|
113
|
+
await client.waitReady();
|
|
114
|
+
await client.send('Page.enable');
|
|
115
|
+
|
|
116
|
+
// Start recording BEFORE navigation
|
|
117
|
+
const recorder = new HARRecorder(client);
|
|
118
|
+
await recorder.start();
|
|
119
|
+
|
|
120
|
+
if (!isNewTab) {
|
|
121
|
+
// Navigate existing tab
|
|
122
|
+
console.error(`Navigating to: ${options.url}`);
|
|
123
|
+
await client.send('Page.navigate', { url: options.url });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const deadline = 60_000;
|
|
127
|
+
const t0 = Date.now();
|
|
128
|
+
let timedOut = false;
|
|
129
|
+
let har!: { log: { entries: HAREntry[] } };
|
|
130
|
+
|
|
131
|
+
try {
|
|
132
|
+
await Promise.race([
|
|
133
|
+
(async () => {
|
|
134
|
+
// Wait for load event
|
|
135
|
+
await new Promise<void>((resolve) => {
|
|
136
|
+
const timer = setTimeout(() => {
|
|
137
|
+
console.error('Page load timeout (10s), continuing with settle...');
|
|
138
|
+
resolve();
|
|
139
|
+
}, 10000);
|
|
140
|
+
client.on('Page.loadEventFired', () => {
|
|
141
|
+
clearTimeout(timer);
|
|
142
|
+
resolve();
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// Settle time for SPAs that load after DOMContentLoaded
|
|
147
|
+
console.error(`Settling for ${settle}ms...`);
|
|
148
|
+
await new Promise((r) => setTimeout(r, settle));
|
|
149
|
+
|
|
150
|
+
har = await recorder.finish();
|
|
151
|
+
})(),
|
|
152
|
+
new Promise<never>((_, reject) => {
|
|
153
|
+
const t = setTimeout(() => reject(new Error('__navigate_timeout__')), deadline - (Date.now() - t0));
|
|
154
|
+
// Don't keep process alive just for the deadline timer
|
|
155
|
+
if (typeof t === 'object' && 'unref' in t) t.unref();
|
|
156
|
+
}),
|
|
157
|
+
]);
|
|
158
|
+
} catch (err) {
|
|
159
|
+
if (err instanceof Error && err.message === '__navigate_timeout__') {
|
|
160
|
+
timedOut = true;
|
|
161
|
+
console.error('Navigate timeout (60s) — returning partial HAR');
|
|
162
|
+
har = recorder.finishPartial();
|
|
163
|
+
} else {
|
|
164
|
+
throw err;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const harPath = writeHarAndPrintSummary(har, options.harOutPath);
|
|
169
|
+
|
|
170
|
+
client.close();
|
|
171
|
+
|
|
172
|
+
// Refresh target info
|
|
173
|
+
const updatedTab = (await findTab(port, options.url)) ?? tab;
|
|
174
|
+
|
|
175
|
+
return { harPath, entryCount: har.log.entries.length, har, tab: updatedTab, timedOut };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export async function navigateAndWait(
|
|
179
|
+
port: number,
|
|
180
|
+
url: string,
|
|
181
|
+
options: { timeout?: number; forceNew?: boolean } = {},
|
|
182
|
+
): Promise<CDPTarget> {
|
|
183
|
+
const timeout = options.timeout ?? 10000;
|
|
184
|
+
|
|
185
|
+
// Check if tab already exists (unless forceNew requested)
|
|
186
|
+
if (!options.forceNew) {
|
|
187
|
+
const existing = await findTab(port, url);
|
|
188
|
+
if (existing) {
|
|
189
|
+
return existing;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Open new tab
|
|
194
|
+
const tab = await openTab(port, url);
|
|
195
|
+
if (!tab.webSocketDebuggerUrl) {
|
|
196
|
+
throw new Error('New tab has no WebSocket debugger URL');
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Wait for page load
|
|
200
|
+
const client = new CDPClient(tab.webSocketDebuggerUrl);
|
|
201
|
+
await client.waitReady();
|
|
202
|
+
await client.send('Page.enable');
|
|
203
|
+
|
|
204
|
+
await new Promise<void>((resolve, reject) => {
|
|
205
|
+
const timer = setTimeout(
|
|
206
|
+
() => reject(new Error(`Page load timeout: ${url}`)),
|
|
207
|
+
timeout,
|
|
208
|
+
);
|
|
209
|
+
client.on('Page.loadEventFired', () => {
|
|
210
|
+
clearTimeout(timer);
|
|
211
|
+
resolve();
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
client.close();
|
|
216
|
+
|
|
217
|
+
// Refresh target info — find by ID to avoid returning a different tab with same URL
|
|
218
|
+
const targets = await listTargets(port);
|
|
219
|
+
const refreshed = targets.find((t) => t.id === tab.id);
|
|
220
|
+
return refreshed ?? tab;
|
|
221
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import { CDPClient } from './client.js';
|
|
3
|
+
import { nextStepPath } from '../session-context.js';
|
|
4
|
+
|
|
5
|
+
export async function captureScreenshot(
|
|
6
|
+
client: CDPClient,
|
|
7
|
+
viewport?: { width: number; height: number },
|
|
8
|
+
options?: { fullPage?: boolean },
|
|
9
|
+
): Promise<Buffer> {
|
|
10
|
+
const MAX_DIM = 1600; // headroom below Anthropic's 2000px many-image limit
|
|
11
|
+
|
|
12
|
+
// Apply viewport emulation if requested
|
|
13
|
+
if (viewport) {
|
|
14
|
+
await client.send('Emulation.setDeviceMetricsOverride', {
|
|
15
|
+
width: viewport.width,
|
|
16
|
+
height: viewport.height,
|
|
17
|
+
deviceScaleFactor: 1,
|
|
18
|
+
mobile: false,
|
|
19
|
+
});
|
|
20
|
+
// Let the page re-layout at the new size
|
|
21
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// For full-page capture, resize viewport to the full content height
|
|
25
|
+
if (options?.fullPage) {
|
|
26
|
+
const layoutMetrics = (await client.send('Page.getLayoutMetrics')) as {
|
|
27
|
+
contentSize?: { width: number; height: number };
|
|
28
|
+
cssVisualViewport?: { clientWidth: number };
|
|
29
|
+
};
|
|
30
|
+
const contentWidth = layoutMetrics.cssVisualViewport?.clientWidth ?? viewport?.width ?? 1280;
|
|
31
|
+
const contentHeight = layoutMetrics.contentSize?.height ?? viewport?.height ?? 800;
|
|
32
|
+
await client.send('Emulation.setDeviceMetricsOverride', {
|
|
33
|
+
width: contentWidth,
|
|
34
|
+
height: Math.ceil(contentHeight),
|
|
35
|
+
deviceScaleFactor: 1,
|
|
36
|
+
mobile: false,
|
|
37
|
+
});
|
|
38
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let screenshotOpts: Record<string, unknown> = {
|
|
42
|
+
format: 'png',
|
|
43
|
+
captureBeyondViewport: false,
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
const metrics = (await client.send('Page.getLayoutMetrics')) as {
|
|
48
|
+
cssVisualViewport?: { clientWidth: number; clientHeight: number; pageX: number; pageY: number };
|
|
49
|
+
};
|
|
50
|
+
const vw = metrics.cssVisualViewport?.clientWidth ?? 0;
|
|
51
|
+
const vh = metrics.cssVisualViewport?.clientHeight ?? 0;
|
|
52
|
+
const sx = metrics.cssVisualViewport?.pageX ?? 0;
|
|
53
|
+
const sy = metrics.cssVisualViewport?.pageY ?? 0;
|
|
54
|
+
|
|
55
|
+
const dprResult = (await client.send('Runtime.evaluate', {
|
|
56
|
+
expression: 'window.devicePixelRatio',
|
|
57
|
+
returnByValue: true,
|
|
58
|
+
})) as { result: { value: number } };
|
|
59
|
+
const dpr = dprResult.result.value ?? 1;
|
|
60
|
+
|
|
61
|
+
const actualMaxSide = Math.max(vw, vh) * dpr;
|
|
62
|
+
const scale = actualMaxSide > MAX_DIM ? MAX_DIM / actualMaxSide : 1 / dpr;
|
|
63
|
+
screenshotOpts = {
|
|
64
|
+
...screenshotOpts,
|
|
65
|
+
clip: { x: sx, y: sy, width: vw, height: vh, scale },
|
|
66
|
+
};
|
|
67
|
+
} catch {
|
|
68
|
+
// Fallback: capture without downscaling
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const result = (await client.send(
|
|
72
|
+
'Page.captureScreenshot',
|
|
73
|
+
screenshotOpts,
|
|
74
|
+
)) as { data: string };
|
|
75
|
+
|
|
76
|
+
// Reset emulation so the browser window isn't stuck at the overridden size
|
|
77
|
+
if (viewport || options?.fullPage) {
|
|
78
|
+
await client.send('Emulation.clearDeviceMetricsOverride');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return Buffer.from(result.data, 'base64');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function autoScreenshot(
|
|
85
|
+
client: CDPClient,
|
|
86
|
+
action: string,
|
|
87
|
+
label: string,
|
|
88
|
+
noScreenshot?: boolean,
|
|
89
|
+
): Promise<string | null> {
|
|
90
|
+
if (noScreenshot) return null;
|
|
91
|
+
const shotPath = nextStepPath(action, label);
|
|
92
|
+
if (!shotPath) return null;
|
|
93
|
+
|
|
94
|
+
// Brief settle for UI to update
|
|
95
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
96
|
+
const png = await captureScreenshot(client);
|
|
97
|
+
fs.writeFileSync(shotPath, png);
|
|
98
|
+
console.error(` [screenshot] ${shotPath}`);
|
|
99
|
+
return shotPath;
|
|
100
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as os from 'os';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
|
|
5
|
+
const LOCK_DIR = os.tmpdir();
|
|
6
|
+
const LOCK_STALE_MS = 120_000;
|
|
7
|
+
const LOCK_TIMEOUT_MS = 60_000;
|
|
8
|
+
const LOCK_POLL_BASE_MS = 200;
|
|
9
|
+
|
|
10
|
+
function lockPath(tabId: string): string {
|
|
11
|
+
return path.join(LOCK_DIR, `capture-cdp-tab-${tabId}.lock`);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function isPidAlive(pid: number): boolean {
|
|
15
|
+
try {
|
|
16
|
+
process.kill(pid, 0);
|
|
17
|
+
return true;
|
|
18
|
+
} catch {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function isLockStale(filePath: string): boolean {
|
|
24
|
+
try {
|
|
25
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
26
|
+
const { pid, ts } = JSON.parse(content) as { pid: number; ts: number };
|
|
27
|
+
if (!isPidAlive(pid)) return true;
|
|
28
|
+
return Date.now() - ts > LOCK_STALE_MS;
|
|
29
|
+
} catch {
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Track locks held by this process for cleanup on exit
|
|
35
|
+
const heldLocks = new Set<string>();
|
|
36
|
+
let cleanupRegistered = false;
|
|
37
|
+
|
|
38
|
+
function registerCleanup(): void {
|
|
39
|
+
if (cleanupRegistered) return;
|
|
40
|
+
cleanupRegistered = true;
|
|
41
|
+
|
|
42
|
+
const cleanup = () => {
|
|
43
|
+
for (const file of heldLocks) {
|
|
44
|
+
try {
|
|
45
|
+
fs.unlinkSync(file);
|
|
46
|
+
} catch {
|
|
47
|
+
// Best-effort
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
heldLocks.clear();
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
process.on('exit', cleanup);
|
|
54
|
+
process.on('SIGINT', () => {
|
|
55
|
+
cleanup();
|
|
56
|
+
process.exit(130);
|
|
57
|
+
});
|
|
58
|
+
process.on('SIGTERM', () => {
|
|
59
|
+
cleanup();
|
|
60
|
+
process.exit(143);
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function acquireTabLock(tabId: string): Promise<void> {
|
|
65
|
+
registerCleanup();
|
|
66
|
+
const file = lockPath(tabId);
|
|
67
|
+
const deadline = Date.now() + LOCK_TIMEOUT_MS;
|
|
68
|
+
let delay = LOCK_POLL_BASE_MS;
|
|
69
|
+
|
|
70
|
+
while (true) {
|
|
71
|
+
try {
|
|
72
|
+
const fd = fs.openSync(file, 'wx');
|
|
73
|
+
fs.writeSync(fd, JSON.stringify({ pid: process.pid, ts: Date.now() }));
|
|
74
|
+
fs.closeSync(fd);
|
|
75
|
+
heldLocks.add(file);
|
|
76
|
+
return;
|
|
77
|
+
} catch (err: unknown) {
|
|
78
|
+
if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (isLockStale(file)) {
|
|
82
|
+
try {
|
|
83
|
+
fs.unlinkSync(file);
|
|
84
|
+
} catch {
|
|
85
|
+
// Another process may have already removed it
|
|
86
|
+
}
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (Date.now() >= deadline) {
|
|
91
|
+
throw new Error(
|
|
92
|
+
`Timed out waiting for CDP tab lock (${tabId}). ` +
|
|
93
|
+
`Another process may be using this tab. Lock file: ${file}`,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
98
|
+
delay = Math.min(delay * 2, 2000);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function isTabLocked(tabId: string): boolean {
|
|
103
|
+
const file = lockPath(tabId);
|
|
104
|
+
try {
|
|
105
|
+
fs.accessSync(file);
|
|
106
|
+
return !isLockStale(file);
|
|
107
|
+
} catch {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function releaseTabLock(tabId: string): void {
|
|
113
|
+
const file = lockPath(tabId);
|
|
114
|
+
heldLocks.delete(file);
|
|
115
|
+
try {
|
|
116
|
+
fs.unlinkSync(file);
|
|
117
|
+
} catch {
|
|
118
|
+
// Already removed — fine
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function withTabLock<T>(
|
|
123
|
+
tabId: string,
|
|
124
|
+
fn: () => Promise<T>,
|
|
125
|
+
): Promise<T> {
|
|
126
|
+
await acquireTabLock(tabId);
|
|
127
|
+
try {
|
|
128
|
+
return await fn();
|
|
129
|
+
} finally {
|
|
130
|
+
releaseTabLock(tabId);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { CDPClient } from './client.js';
|
|
2
|
+
import { type CDPTarget } from './types.js';
|
|
3
|
+
|
|
4
|
+
async function getBrowserClient(
|
|
5
|
+
port: number,
|
|
6
|
+
): Promise<{ client: CDPClient; browserWsUrl: string }> {
|
|
7
|
+
const versionResp = await fetch(`http://localhost:${port}/json/version`);
|
|
8
|
+
if (!versionResp.ok) {
|
|
9
|
+
throw new Error(
|
|
10
|
+
`Failed to connect to CDP on port ${port}: ${versionResp.status}`,
|
|
11
|
+
);
|
|
12
|
+
}
|
|
13
|
+
const version = (await versionResp.json()) as {
|
|
14
|
+
webSocketDebuggerUrl: string;
|
|
15
|
+
};
|
|
16
|
+
const client = new CDPClient(version.webSocketDebuggerUrl);
|
|
17
|
+
await client.waitReady();
|
|
18
|
+
return { client, browserWsUrl: version.webSocketDebuggerUrl };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function listTargets(port: number): Promise<CDPTarget[]> {
|
|
22
|
+
const { client } = await getBrowserClient(port);
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
// Enable target discovery to get all targets including pages
|
|
26
|
+
await client.send('Target.setDiscoverTargets', { discover: true });
|
|
27
|
+
const result = (await client.send('Target.getTargets')) as {
|
|
28
|
+
targetInfos: Array<{
|
|
29
|
+
targetId: string;
|
|
30
|
+
type: string;
|
|
31
|
+
title: string;
|
|
32
|
+
url: string;
|
|
33
|
+
}>;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// Convert to CDPTarget format with webSocketDebuggerUrl
|
|
37
|
+
return result.targetInfos.map((t) => ({
|
|
38
|
+
id: t.targetId,
|
|
39
|
+
title: t.title,
|
|
40
|
+
url: t.url,
|
|
41
|
+
type: t.type,
|
|
42
|
+
webSocketDebuggerUrl: `ws://localhost:${port}/devtools/page/${t.targetId}`,
|
|
43
|
+
}));
|
|
44
|
+
} finally {
|
|
45
|
+
client.close();
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function findTab(
|
|
50
|
+
port: number,
|
|
51
|
+
urlPattern?: string,
|
|
52
|
+
): Promise<CDPTarget | null> {
|
|
53
|
+
const targets = await listTargets(port);
|
|
54
|
+
const pages = targets.filter((t) => t.type === 'page');
|
|
55
|
+
|
|
56
|
+
if (!urlPattern) {
|
|
57
|
+
// Return first page
|
|
58
|
+
return pages[0] ?? null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Find by URL pattern
|
|
62
|
+
const pattern = urlPattern.toLowerCase();
|
|
63
|
+
return pages.find((t) => t.url.toLowerCase().includes(pattern)) ?? null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function findTabById(
|
|
67
|
+
port: number,
|
|
68
|
+
targetId: string,
|
|
69
|
+
): Promise<CDPTarget | null> {
|
|
70
|
+
const targets = await listTargets(port);
|
|
71
|
+
// Exact match first
|
|
72
|
+
const exact = targets.find((t) => t.id === targetId);
|
|
73
|
+
if (exact) return exact;
|
|
74
|
+
// Prefix match (min 4 chars to avoid ambiguity)
|
|
75
|
+
if (targetId.length >= 4) {
|
|
76
|
+
const prefix = targetId.toUpperCase();
|
|
77
|
+
const matches = targets.filter((t) => t.id.toUpperCase().startsWith(prefix));
|
|
78
|
+
if (matches.length === 1) return matches[0];
|
|
79
|
+
if (matches.length > 1) {
|
|
80
|
+
throw new Error(
|
|
81
|
+
`Ambiguous target prefix "${targetId}" matches ${matches.length} tabs:\n` +
|
|
82
|
+
matches.map((t) => ` ${t.id.slice(0, 8)} ${t.url}`).join('\n'),
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function openTab(port: number, url: string): Promise<CDPTarget> {
|
|
90
|
+
const { client } = await getBrowserClient(port);
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
// Create new target in background to avoid stealing user's focus
|
|
94
|
+
const result = (await client.send('Target.createTarget', {
|
|
95
|
+
url,
|
|
96
|
+
background: true,
|
|
97
|
+
})) as {
|
|
98
|
+
targetId: string;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
// Return target info
|
|
102
|
+
return {
|
|
103
|
+
id: result.targetId,
|
|
104
|
+
title: '',
|
|
105
|
+
url,
|
|
106
|
+
type: 'page',
|
|
107
|
+
webSocketDebuggerUrl: `ws://localhost:${port}/devtools/page/${result.targetId}`,
|
|
108
|
+
};
|
|
109
|
+
} finally {
|
|
110
|
+
client.close();
|
|
111
|
+
}
|
|
112
|
+
}
|
package/src/cdp/types.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export interface CDPTarget {
|
|
2
|
+
id: string;
|
|
3
|
+
title: string;
|
|
4
|
+
url: string;
|
|
5
|
+
type: string;
|
|
6
|
+
webSocketDebuggerUrl?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface ParsedArgs {
|
|
10
|
+
command: string;
|
|
11
|
+
positional: string[];
|
|
12
|
+
port?: number;
|
|
13
|
+
out?: string;
|
|
14
|
+
json?: boolean;
|
|
15
|
+
interactive?: boolean;
|
|
16
|
+
harOut?: string;
|
|
17
|
+
record?: boolean;
|
|
18
|
+
duration?: number;
|
|
19
|
+
settle?: number;
|
|
20
|
+
file?: string;
|
|
21
|
+
nested?: boolean;
|
|
22
|
+
har?: string;
|
|
23
|
+
new?: boolean;
|
|
24
|
+
target?: string;
|
|
25
|
+
role?: string;
|
|
26
|
+
into?: string;
|
|
27
|
+
noScreenshot?: boolean;
|
|
28
|
+
viewport?: string;
|
|
29
|
+
fullPage?: boolean;
|
|
30
|
+
height?: number;
|
|
31
|
+
help?: boolean;
|
|
32
|
+
filterUrl?: string;
|
|
33
|
+
filterStatus?: string;
|
|
34
|
+
filterMethod?: string;
|
|
35
|
+
limit?: number;
|
|
36
|
+
}
|
package/src/cdp/usage.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
export const CDP_USAGE = `CDP — browser automation via exec, a11y interactions, and screenshots.
|
|
2
|
+
|
|
3
|
+
Commands:
|
|
4
|
+
exec <code> Execute JavaScript in a browser tab
|
|
5
|
+
exec --file <path> Execute JS from file
|
|
6
|
+
detect Detect CDP port (prioritizes default browser)
|
|
7
|
+
list List all browser tabs
|
|
8
|
+
open <url> Open URL in browser (returns tab ID)
|
|
9
|
+
reset-tab <url> Abandon stuck tab, open fresh one (updates session)
|
|
10
|
+
screenshot Capture screenshot
|
|
11
|
+
click "name" Click element by accessible name
|
|
12
|
+
type "text" Type text into focused element
|
|
13
|
+
a11y Get accessibility tree
|
|
14
|
+
record Passive HAR recording
|
|
15
|
+
navigate <url> Navigate to URL and record HAR
|
|
16
|
+
network <offline|online> Toggle network (simulate disconnect)
|
|
17
|
+
har create Create a HAR recording (returns id)
|
|
18
|
+
har read [id] Read accumulated HAR entries (id optional in active session)
|
|
19
|
+
har delete <id> Delete a HAR recording
|
|
20
|
+
lib list List available vault libs (dev checkout only)
|
|
21
|
+
lib search "<q>" Fuzzy-search lib functions [--limit N]
|
|
22
|
+
lib show <name> Lib + function summaries
|
|
23
|
+
lib read <name> [fn…] Full input/output schemas (+ .ts source path)
|
|
24
|
+
|
|
25
|
+
Options:
|
|
26
|
+
--port <port> Override CDP port
|
|
27
|
+
--target <tabId> Target tab by exact ID (preferred, parallel-safe)
|
|
28
|
+
--new Force new tab (open)
|
|
29
|
+
--record Enable HAR recording (exec)
|
|
30
|
+
--har <id> Append traffic to a HAR recording
|
|
31
|
+
--har-out <path> HAR output path
|
|
32
|
+
--file <path> Read JS from file (exec)
|
|
33
|
+
--duration <secs> Recording duration (record, default: 10)
|
|
34
|
+
--settle <ms> Settle time override (navigate, click, type)
|
|
35
|
+
--filter-url <pat> Filter HAR entries by URL substring/regex (har read)
|
|
36
|
+
--filter-status <s> Filter HAR by status: code, prefix (4), or range (400-499)
|
|
37
|
+
--filter-method <m> Filter HAR by HTTP method (har read)
|
|
38
|
+
--limit <N> Return only first N matching HAR entries (har read)
|
|
39
|
+
--out <path> Output path (screenshot)
|
|
40
|
+
--viewport <preset> Viewport preset: desktop-wide|desktop|tablet|mobile (default: desktop)
|
|
41
|
+
--height <px> Override viewport height (screenshot)
|
|
42
|
+
--full-page Capture entire scrollable page (screenshot)
|
|
43
|
+
--json JSON output (a11y)
|
|
44
|
+
--interactive Interactive elements only (a11y)
|
|
45
|
+
--role <role> ARIA role filter (click)
|
|
46
|
+
--into "field" Target field by name (type)
|
|
47
|
+
--no-screenshot Skip auto-screenshot (click, type)
|
|
48
|
+
|
|
49
|
+
Console output (errors, warnings) is always captured and printed to stderr.
|
|
50
|
+
|
|
51
|
+
Examples:
|
|
52
|
+
capture detect
|
|
53
|
+
capture list
|
|
54
|
+
capture open "https://app.example.com" --new
|
|
55
|
+
capture exec "document.title" --target <id>
|
|
56
|
+
capture exec "document.querySelector('.btn').click()" --target <id>
|
|
57
|
+
capture exec "fetch('/api/data').then(r=>r.json())" --target <id> --record
|
|
58
|
+
capture exec --file /tmp/scrape.js --target <id>
|
|
59
|
+
capture screenshot --target <id> --out /tmp/shot.png
|
|
60
|
+
capture a11y --target <id> --interactive
|
|
61
|
+
capture record --target <id> --duration 15
|
|
62
|
+
capture navigate "https://app.example.com/dashboard"
|
|
63
|
+
capture har create
|
|
64
|
+
capture exec "document.querySelector('form').submit()" --target <id> --har <id>
|
|
65
|
+
capture har read <id>
|
|
66
|
+
capture lib search "search emails"
|
|
67
|
+
capture lib read gmail searchEmails
|
|
68
|
+
capture exec "import {searchEmails} from 'libs/gmail'; const ctx = await getContext(); return await searchEmails({ ...ctx, query: 'invoice' })"
|
|
69
|
+
`;
|