@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
package/src/cdp.ts
CHANGED
|
@@ -35,2349 +35,29 @@
|
|
|
35
35
|
* --interactive Interactive elements only (a11y)
|
|
36
36
|
*/
|
|
37
37
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
} from './
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
nextStepPath,
|
|
55
|
-
} from './session-context.js';
|
|
56
|
-
import { clickByName, typeText, focusAndType } from './interact.js';
|
|
57
|
-
|
|
58
|
-
// ============================================================================
|
|
59
|
-
// Types
|
|
60
|
-
// ============================================================================
|
|
61
|
-
|
|
62
|
-
export interface CDPTarget {
|
|
63
|
-
id: string;
|
|
64
|
-
title: string;
|
|
65
|
-
url: string;
|
|
66
|
-
type: string;
|
|
67
|
-
webSocketDebuggerUrl?: string;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
interface ParsedArgs {
|
|
71
|
-
command: string;
|
|
72
|
-
positional: string[];
|
|
73
|
-
port?: number;
|
|
74
|
-
out?: string;
|
|
75
|
-
json?: boolean;
|
|
76
|
-
interactive?: boolean;
|
|
77
|
-
harOut?: string;
|
|
78
|
-
record?: boolean;
|
|
79
|
-
duration?: number;
|
|
80
|
-
settle?: number;
|
|
81
|
-
file?: string;
|
|
82
|
-
nested?: boolean;
|
|
83
|
-
har?: string;
|
|
84
|
-
new?: boolean;
|
|
85
|
-
target?: string;
|
|
86
|
-
role?: string;
|
|
87
|
-
into?: string;
|
|
88
|
-
noScreenshot?: boolean;
|
|
89
|
-
viewport?: string;
|
|
90
|
-
fullPage?: boolean;
|
|
91
|
-
height?: number;
|
|
92
|
-
help?: boolean;
|
|
93
|
-
filterUrl?: string;
|
|
94
|
-
filterStatus?: string;
|
|
95
|
-
filterMethod?: string;
|
|
96
|
-
limit?: number;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
interface NetworkRequest {
|
|
100
|
-
requestId: string;
|
|
101
|
-
url: string;
|
|
102
|
-
method: string;
|
|
103
|
-
headers: Record<string, string>;
|
|
104
|
-
postData?: string;
|
|
105
|
-
timestamp: number;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
interface NetworkResponse {
|
|
109
|
-
requestId: string;
|
|
110
|
-
url: string;
|
|
111
|
-
status: number;
|
|
112
|
-
headers: Record<string, string>;
|
|
113
|
-
body?: string;
|
|
114
|
-
timestamp: number;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
// HAREntry imported from ./har-manager.ts
|
|
118
|
-
|
|
119
|
-
// ============================================================================
|
|
120
|
-
// HAR Recording
|
|
121
|
-
// ============================================================================
|
|
122
|
-
|
|
123
|
-
const SKIP_EXTENSIONS =
|
|
124
|
-
/\.(png|jpg|jpeg|gif|webp|svg|ico|woff2?|ttf|eot|css|mp4|webm|mp3)(\?|$)/i;
|
|
125
|
-
const SKIP_DOMAINS =
|
|
126
|
-
/(google-analytics|googletagmanager|doubleclick|facebook\.com\/tr|px\.ads|analytics|tracking|beacon|telemetry)/i;
|
|
127
|
-
const MAX_BODY_SIZE = 256 * 1024; // 256KB per response body in HAR
|
|
128
|
-
|
|
129
|
-
function shouldRecordRequest(url: string): boolean {
|
|
130
|
-
if (SKIP_EXTENSIONS.test(url)) return false;
|
|
131
|
-
if (SKIP_DOMAINS.test(url)) return false;
|
|
132
|
-
return true;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
export class HARRecorder {
|
|
136
|
-
private requests = new Map<string, NetworkRequest>();
|
|
137
|
-
private responses = new Map<string, NetworkResponse>();
|
|
138
|
-
|
|
139
|
-
constructor(private client: CDPClient) {}
|
|
140
|
-
|
|
141
|
-
get responseCount(): number {
|
|
142
|
-
return this.responses.size;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
async start(): Promise<void> {
|
|
146
|
-
await this.client.send('Network.enable');
|
|
147
|
-
|
|
148
|
-
this.client.on('Network.requestWillBeSent', (params: unknown) => {
|
|
149
|
-
const p = params as {
|
|
150
|
-
requestId: string;
|
|
151
|
-
request: {
|
|
152
|
-
url: string;
|
|
153
|
-
method: string;
|
|
154
|
-
headers: Record<string, string>;
|
|
155
|
-
postData?: string;
|
|
156
|
-
};
|
|
157
|
-
timestamp: number;
|
|
158
|
-
};
|
|
159
|
-
if (!shouldRecordRequest(p.request.url)) return;
|
|
160
|
-
this.requests.set(p.requestId, {
|
|
161
|
-
requestId: p.requestId,
|
|
162
|
-
url: p.request.url,
|
|
163
|
-
method: p.request.method,
|
|
164
|
-
headers: p.request.headers,
|
|
165
|
-
postData: p.request.postData,
|
|
166
|
-
timestamp: p.timestamp,
|
|
167
|
-
});
|
|
168
|
-
});
|
|
169
|
-
|
|
170
|
-
this.client.on('Network.responseReceived', (params: unknown) => {
|
|
171
|
-
const p = params as {
|
|
172
|
-
requestId: string;
|
|
173
|
-
response: {
|
|
174
|
-
url: string;
|
|
175
|
-
status: number;
|
|
176
|
-
headers: Record<string, string>;
|
|
177
|
-
};
|
|
178
|
-
timestamp: number;
|
|
179
|
-
};
|
|
180
|
-
if (!this.requests.has(p.requestId)) return;
|
|
181
|
-
this.responses.set(p.requestId, {
|
|
182
|
-
requestId: p.requestId,
|
|
183
|
-
url: p.response.url,
|
|
184
|
-
status: p.response.status,
|
|
185
|
-
headers: p.response.headers,
|
|
186
|
-
timestamp: p.timestamp,
|
|
187
|
-
});
|
|
188
|
-
});
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
private buildHar(): { log: { entries: HAREntry[] } } {
|
|
192
|
-
return {
|
|
193
|
-
log: {
|
|
194
|
-
entries: Array.from(this.responses.values()).map((resp) => {
|
|
195
|
-
const req = this.requests.get(resp.requestId);
|
|
196
|
-
return {
|
|
197
|
-
startedDateTime: new Date(resp.timestamp * 1000).toISOString(),
|
|
198
|
-
request: {
|
|
199
|
-
method: req?.method ?? 'GET',
|
|
200
|
-
url: resp.url,
|
|
201
|
-
headers: Object.entries(req?.headers ?? {}).map(
|
|
202
|
-
([name, value]) => ({ name, value }),
|
|
203
|
-
),
|
|
204
|
-
postData: req?.postData ? { text: req.postData } : undefined,
|
|
205
|
-
},
|
|
206
|
-
response: {
|
|
207
|
-
status: resp.status,
|
|
208
|
-
headers: Object.entries(resp.headers).map(([name, value]) => ({
|
|
209
|
-
name,
|
|
210
|
-
value,
|
|
211
|
-
})),
|
|
212
|
-
content: { text: resp.body },
|
|
213
|
-
},
|
|
214
|
-
};
|
|
215
|
-
}),
|
|
216
|
-
},
|
|
217
|
-
};
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
async finish(): Promise<{ log: { entries: HAREntry[] } }> {
|
|
221
|
-
// Fetch response bodies in parallel — sequential fetches with 5s timeouts
|
|
222
|
-
// were causing ~50s hangs when many responses had evicted bodies
|
|
223
|
-
await Promise.allSettled(
|
|
224
|
-
Array.from(this.responses.entries()).map(async ([reqId, resp]) => {
|
|
225
|
-
const bodyResult = (await this.client.send('Network.getResponseBody', {
|
|
226
|
-
requestId: reqId,
|
|
227
|
-
}, 5000)) as {
|
|
228
|
-
body: string;
|
|
229
|
-
base64Encoded: boolean;
|
|
230
|
-
};
|
|
231
|
-
const decoded = bodyResult.base64Encoded
|
|
232
|
-
? Buffer.from(bodyResult.body, 'base64').toString()
|
|
233
|
-
: bodyResult.body;
|
|
234
|
-
resp.body = decoded.length > MAX_BODY_SIZE
|
|
235
|
-
? decoded.slice(0, MAX_BODY_SIZE) + `\n[truncated: ${decoded.length} bytes]`
|
|
236
|
-
: decoded;
|
|
237
|
-
}),
|
|
238
|
-
);
|
|
239
|
-
|
|
240
|
-
return this.buildHar();
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
finishPartial(): { log: { entries: HAREntry[] } } {
|
|
244
|
-
return this.buildHar();
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
// ============================================================================
|
|
249
|
-
// Console Capture
|
|
250
|
-
// ============================================================================
|
|
251
|
-
|
|
252
|
-
export interface ConsoleEntry {
|
|
253
|
-
level: 'log' | 'warning' | 'error' | 'info' | 'debug';
|
|
254
|
-
text: string;
|
|
255
|
-
timestamp: number;
|
|
256
|
-
source?: string;
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
export class ConsoleRecorder {
|
|
260
|
-
private entries: ConsoleEntry[] = [];
|
|
261
|
-
|
|
262
|
-
constructor(private client: CDPClient) {}
|
|
263
|
-
|
|
264
|
-
async start(): Promise<void> {
|
|
265
|
-
await this.client.send('Runtime.enable');
|
|
266
|
-
|
|
267
|
-
this.client.on('Runtime.consoleAPICalled', (params: unknown) => {
|
|
268
|
-
const p = params as {
|
|
269
|
-
type: string;
|
|
270
|
-
args: Array<{ type: string; value?: unknown; description?: string }>;
|
|
271
|
-
timestamp: number;
|
|
272
|
-
};
|
|
273
|
-
const text = p.args
|
|
274
|
-
.map((a) =>
|
|
275
|
-
a.value !== undefined ? String(a.value) : (a.description ?? ''),
|
|
276
|
-
)
|
|
277
|
-
.join(' ');
|
|
278
|
-
this.entries.push({
|
|
279
|
-
level: p.type as ConsoleEntry['level'],
|
|
280
|
-
text,
|
|
281
|
-
timestamp: p.timestamp,
|
|
282
|
-
});
|
|
283
|
-
});
|
|
284
|
-
|
|
285
|
-
this.client.on('Runtime.exceptionThrown', (params: unknown) => {
|
|
286
|
-
const p = params as {
|
|
287
|
-
timestamp: number;
|
|
288
|
-
exceptionDetails: {
|
|
289
|
-
exception?: { description?: string };
|
|
290
|
-
text?: string;
|
|
291
|
-
url?: string;
|
|
292
|
-
lineNumber?: number;
|
|
293
|
-
};
|
|
294
|
-
};
|
|
295
|
-
this.entries.push({
|
|
296
|
-
level: 'error',
|
|
297
|
-
text:
|
|
298
|
-
p.exceptionDetails.exception?.description ??
|
|
299
|
-
p.exceptionDetails.text ??
|
|
300
|
-
'Unknown exception',
|
|
301
|
-
timestamp: p.timestamp,
|
|
302
|
-
source: p.exceptionDetails.url
|
|
303
|
-
? `${p.exceptionDetails.url}:${p.exceptionDetails.lineNumber}`
|
|
304
|
-
: undefined,
|
|
305
|
-
});
|
|
306
|
-
});
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
finish(): ConsoleEntry[] {
|
|
310
|
-
return [...this.entries];
|
|
311
|
-
}
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
function printConsoleSummary(entries: ConsoleEntry[]): void {
|
|
315
|
-
const errors = entries.filter((e) => e.level === 'error');
|
|
316
|
-
const warnings = entries.filter((e) => e.level === 'warning');
|
|
317
|
-
if (errors.length > 0 || warnings.length > 0) {
|
|
318
|
-
console.error(
|
|
319
|
-
`\nConsole: ${errors.length} errors, ${warnings.length} warnings`,
|
|
320
|
-
);
|
|
321
|
-
for (const entry of [...errors, ...warnings]) {
|
|
322
|
-
const prefix = entry.level === 'error' ? 'ERR' : 'WARN';
|
|
323
|
-
const src = entry.source ? ` (${entry.source})` : '';
|
|
324
|
-
const text =
|
|
325
|
-
entry.text.length > 200 ? entry.text.slice(0, 197) + '...' : entry.text;
|
|
326
|
-
console.error(` [${prefix}] ${text}${src}`);
|
|
327
|
-
}
|
|
328
|
-
}
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
function writeHarAndPrintSummary(
|
|
332
|
-
har: { log: { entries: HAREntry[] } },
|
|
333
|
-
harOutPath?: string,
|
|
334
|
-
): string | undefined {
|
|
335
|
-
if (har.log.entries.length === 0 && !harOutPath) return undefined;
|
|
336
|
-
|
|
337
|
-
const harPath = harOutPath ?? `/tmp/capture-har-${Date.now()}.json`;
|
|
338
|
-
try {
|
|
339
|
-
fs.writeFileSync(harPath, JSON.stringify(har, null, 2));
|
|
340
|
-
} catch (err) {
|
|
341
|
-
// Fallback: strip response bodies and retry
|
|
342
|
-
const stripped = {
|
|
343
|
-
log: {
|
|
344
|
-
...har.log,
|
|
345
|
-
entries: har.log.entries.map((e) => ({
|
|
346
|
-
...e,
|
|
347
|
-
response: { ...e.response, content: { text: '[body stripped — too large to serialize]' } },
|
|
348
|
-
})),
|
|
349
|
-
},
|
|
350
|
-
};
|
|
351
|
-
fs.writeFileSync(harPath, JSON.stringify(stripped, null, 2));
|
|
352
|
-
console.error(' WARNING: Response bodies stripped from HAR (too large to serialize)');
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
// Print summary to stderr
|
|
356
|
-
const errors = har.log.entries.filter((e) => e.response.status >= 400);
|
|
357
|
-
console.error(
|
|
358
|
-
`\nHAR: ${har.log.entries.length} requests captured → ${harPath}`,
|
|
359
|
-
);
|
|
360
|
-
if (errors.length > 0) {
|
|
361
|
-
console.error(` ${errors.length} failed:`);
|
|
362
|
-
for (const entry of errors) {
|
|
363
|
-
const method = entry.request.method.padEnd(6);
|
|
364
|
-
const status = entry.response.status;
|
|
365
|
-
const url =
|
|
366
|
-
entry.request.url.length > 100
|
|
367
|
-
? entry.request.url.slice(0, 97) + '...'
|
|
368
|
-
: entry.request.url;
|
|
369
|
-
console.error(` ${method} ${status} ${url}`);
|
|
370
|
-
}
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
return harPath;
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
// ============================================================================
|
|
377
|
-
// CLI Argument Parser
|
|
378
|
-
// ============================================================================
|
|
379
|
-
|
|
380
|
-
function parseCliArgs(argv: string[]): ParsedArgs {
|
|
381
|
-
const command = argv[0] ?? '';
|
|
382
|
-
const positional: string[] = [];
|
|
383
|
-
const parsed: Partial<ParsedArgs> = { command, positional };
|
|
384
|
-
|
|
385
|
-
for (let i = 1; i < argv.length; i++) {
|
|
386
|
-
const arg = argv[i];
|
|
387
|
-
const next = argv[i + 1];
|
|
388
|
-
|
|
389
|
-
if (arg === '--port' && next) {
|
|
390
|
-
parsed.port = parseInt(next, 10);
|
|
391
|
-
i++;
|
|
392
|
-
} else if (arg === '--out' && next) {
|
|
393
|
-
parsed.out = next;
|
|
394
|
-
i++;
|
|
395
|
-
} else if (arg === '--json') {
|
|
396
|
-
parsed.json = true;
|
|
397
|
-
} else if (arg === '--interactive') {
|
|
398
|
-
parsed.interactive = true;
|
|
399
|
-
} else if (arg === '--har-out' && next) {
|
|
400
|
-
parsed.harOut = next;
|
|
401
|
-
i++;
|
|
402
|
-
} else if (arg === '--record') {
|
|
403
|
-
parsed.record = true;
|
|
404
|
-
} else if (arg === '--duration' && next) {
|
|
405
|
-
parsed.duration = parseInt(next, 10);
|
|
406
|
-
i++;
|
|
407
|
-
} else if (arg === '--settle' && next) {
|
|
408
|
-
parsed.settle = parseInt(next, 10);
|
|
409
|
-
i++;
|
|
410
|
-
} else if (arg === '--file' && next) {
|
|
411
|
-
parsed.file = next;
|
|
412
|
-
i++;
|
|
413
|
-
} else if (arg === '--nested') {
|
|
414
|
-
parsed.nested = true;
|
|
415
|
-
} else if (arg === '--har' && next) {
|
|
416
|
-
parsed.har = next;
|
|
417
|
-
i++;
|
|
418
|
-
} else if (arg === '--new') {
|
|
419
|
-
parsed.new = true;
|
|
420
|
-
} else if (arg === '--target' && next) {
|
|
421
|
-
parsed.target = next;
|
|
422
|
-
i++;
|
|
423
|
-
} else if (arg === '--role' && next) {
|
|
424
|
-
parsed.role = next;
|
|
425
|
-
i++;
|
|
426
|
-
} else if (arg === '--into' && next) {
|
|
427
|
-
parsed.into = next;
|
|
428
|
-
i++;
|
|
429
|
-
} else if (arg === '--no-screenshot') {
|
|
430
|
-
parsed.noScreenshot = true;
|
|
431
|
-
} else if (arg === '--viewport' && next) {
|
|
432
|
-
parsed.viewport = next;
|
|
433
|
-
i++;
|
|
434
|
-
} else if (arg === '--full-page') {
|
|
435
|
-
parsed.fullPage = true;
|
|
436
|
-
} else if (arg === '--height' && next) {
|
|
437
|
-
parsed.height = parseInt(next, 10);
|
|
438
|
-
i++;
|
|
439
|
-
} else if (arg === '--filter-url' && next) {
|
|
440
|
-
parsed.filterUrl = next;
|
|
441
|
-
i++;
|
|
442
|
-
} else if (arg === '--filter-status' && next) {
|
|
443
|
-
parsed.filterStatus = next;
|
|
444
|
-
i++;
|
|
445
|
-
} else if (arg === '--filter-method' && next) {
|
|
446
|
-
parsed.filterMethod = next.toUpperCase();
|
|
447
|
-
i++;
|
|
448
|
-
} else if (arg === '--limit' && next) {
|
|
449
|
-
parsed.limit = parseInt(next, 10);
|
|
450
|
-
i++;
|
|
451
|
-
} else if (arg === '--help' || arg === '-h') {
|
|
452
|
-
parsed.help = true;
|
|
453
|
-
} else if (arg.startsWith('--')) {
|
|
454
|
-
console.error(`Unknown flag: ${arg}`);
|
|
455
|
-
process.exit(1);
|
|
456
|
-
} else {
|
|
457
|
-
positional.push(arg);
|
|
458
|
-
}
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
// Fill gaps from environment variables (set by pipeline orchestrators)
|
|
462
|
-
if (!parsed.target && process.env.CDP_TARGET) {
|
|
463
|
-
parsed.target = process.env.CDP_TARGET;
|
|
464
|
-
}
|
|
465
|
-
if (!parsed.har && process.env.CDP_HAR_ID) {
|
|
466
|
-
parsed.har = process.env.CDP_HAR_ID;
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
// Fill remaining gaps from active session context (explicit flags always win)
|
|
470
|
-
const session = getActiveSession();
|
|
471
|
-
if (session) {
|
|
472
|
-
if (!parsed.har && session.harId) parsed.har = session.harId;
|
|
473
|
-
if (!parsed.target && session.targetId) parsed.target = session.targetId;
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
return parsed as ParsedArgs;
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
// ============================================================================
|
|
480
|
-
// HAR Recording Management (delegated to har-manager.ts)
|
|
481
|
-
// ============================================================================
|
|
482
|
-
|
|
483
|
-
// ============================================================================
|
|
484
|
-
// Connection Helpers
|
|
485
|
-
// ============================================================================
|
|
486
|
-
|
|
487
|
-
async function connectForCommand(
|
|
488
|
-
parsed: ParsedArgs,
|
|
489
|
-
): Promise<{ client: CDPClient; tab: CDPTarget }> {
|
|
490
|
-
if (!parsed.target) {
|
|
491
|
-
throw new Error('Use --target <tabId> to target a tab. Run "capture list" to see available tabs.');
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
// If --port is explicit, search only that port. Otherwise search all endpoints.
|
|
495
|
-
let tab: CDPTarget | null = null;
|
|
496
|
-
if (parsed.port) {
|
|
497
|
-
tab = await findTabById(parsed.port, parsed.target);
|
|
498
|
-
} else {
|
|
499
|
-
const endpoints = await detectCdpPortsAsync();
|
|
500
|
-
for (const ep of endpoints) {
|
|
501
|
-
try {
|
|
502
|
-
tab = await findTabById(ep.port, parsed.target);
|
|
503
|
-
if (tab) break;
|
|
504
|
-
} catch {
|
|
505
|
-
// Skip endpoints that fail
|
|
506
|
-
}
|
|
507
|
-
}
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
if (!tab) {
|
|
511
|
-
const query = parsed.target;
|
|
512
|
-
throw new Error(
|
|
513
|
-
`No tab found for target "${query}". Run "capture list" to see available tabs.`,
|
|
514
|
-
);
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
if (!tab.webSocketDebuggerUrl) {
|
|
518
|
-
throw new Error('Tab has no WebSocket debugger URL');
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
// Lazy-populate targetId in active session if not yet set
|
|
522
|
-
const activeSession = getActiveSession();
|
|
523
|
-
if (activeSession && !activeSession.targetId) {
|
|
524
|
-
updateActiveSession({ targetId: tab.id });
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
const client = new CDPClient(tab.webSocketDebuggerUrl);
|
|
528
|
-
await client.waitReady();
|
|
529
|
-
|
|
530
|
-
return { client, tab };
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
async function withConnection<T>(
|
|
534
|
-
parsed: ParsedArgs,
|
|
535
|
-
fn: (client: CDPClient, tab: CDPTarget) => Promise<T>,
|
|
536
|
-
opts: { settle?: number } = {},
|
|
537
|
-
): Promise<T> {
|
|
538
|
-
const { client, tab } = await connectForCommand(parsed);
|
|
539
|
-
|
|
540
|
-
const consoleRecorder = new ConsoleRecorder(client);
|
|
541
|
-
await consoleRecorder.start();
|
|
542
|
-
|
|
543
|
-
let harRecorder: HARRecorder | undefined;
|
|
544
|
-
if (parsed.har) {
|
|
545
|
-
// Validate HAR ID exists before starting recording
|
|
546
|
-
const harPath = harFilePath(parsed.har);
|
|
547
|
-
if (!fs.existsSync(harPath)) {
|
|
548
|
-
console.error(
|
|
549
|
-
`ERROR: No HAR recording found for --har "${parsed.har}". Run 'har create' first.`,
|
|
550
|
-
);
|
|
551
|
-
process.exit(1);
|
|
552
|
-
}
|
|
553
|
-
harRecorder = new HARRecorder(client);
|
|
554
|
-
await harRecorder.start();
|
|
555
|
-
}
|
|
556
|
-
|
|
557
|
-
try {
|
|
558
|
-
const result = await fn(client, tab);
|
|
559
|
-
|
|
560
|
-
// Wait for network activity triggered by the action, then append to HAR
|
|
561
|
-
if (harRecorder && parsed.har) {
|
|
562
|
-
const settle = opts.settle !== undefined ? opts.settle : 3000;
|
|
563
|
-
if (settle > 0) {
|
|
564
|
-
await new Promise((r) => setTimeout(r, settle));
|
|
565
|
-
}
|
|
566
|
-
const har = await harRecorder.finish();
|
|
567
|
-
if (har.log.entries.length > 0) {
|
|
568
|
-
appendToHar(parsed.har, har.log.entries);
|
|
569
|
-
console.error(
|
|
570
|
-
` [har:${parsed.har}] +${har.log.entries.length} entries`,
|
|
571
|
-
);
|
|
572
|
-
}
|
|
573
|
-
}
|
|
574
|
-
|
|
575
|
-
printConsoleSummary(consoleRecorder.finish());
|
|
576
|
-
|
|
577
|
-
return result;
|
|
578
|
-
} finally {
|
|
579
|
-
client.close();
|
|
580
|
-
}
|
|
581
|
-
}
|
|
582
|
-
|
|
583
|
-
// ============================================================================
|
|
584
|
-
// A11y Tree Flattening
|
|
585
|
-
// ============================================================================
|
|
586
|
-
|
|
587
|
-
const INTERACTIVE_ROLES = new Set([
|
|
588
|
-
'button',
|
|
589
|
-
'link',
|
|
590
|
-
'textbox',
|
|
591
|
-
'searchbox',
|
|
592
|
-
'combobox',
|
|
593
|
-
'checkbox',
|
|
594
|
-
'radio',
|
|
595
|
-
'menuitem',
|
|
596
|
-
'tab',
|
|
597
|
-
'switch',
|
|
598
|
-
'slider',
|
|
599
|
-
'spinbutton',
|
|
600
|
-
'option',
|
|
601
|
-
'menuitemcheckbox',
|
|
602
|
-
'menuitemradio',
|
|
603
|
-
]);
|
|
604
|
-
|
|
605
|
-
function flattenA11yTree(
|
|
606
|
-
nodes: A11yNode[],
|
|
607
|
-
opts: { interactive?: boolean } = {},
|
|
608
|
-
depth = 0,
|
|
609
|
-
): string[] {
|
|
610
|
-
const lines: string[] = [];
|
|
611
|
-
|
|
612
|
-
for (const node of nodes) {
|
|
613
|
-
// Filter to interactive roles if requested
|
|
614
|
-
if (opts.interactive && !INTERACTIVE_ROLES.has(node.role)) {
|
|
615
|
-
lines.push(...flattenA11yTree(node.children, opts, depth));
|
|
616
|
-
continue;
|
|
617
|
-
}
|
|
618
|
-
|
|
619
|
-
// Skip nodes without meaningful role or name
|
|
620
|
-
if (!node.role || node.role === 'StaticText') {
|
|
621
|
-
lines.push(...flattenA11yTree(node.children, opts, depth));
|
|
622
|
-
continue;
|
|
623
|
-
}
|
|
624
|
-
|
|
625
|
-
const indent = ' '.repeat(depth);
|
|
626
|
-
const nameStr = node.name ? ` "${node.name}"` : '';
|
|
627
|
-
lines.push(`${indent}${node.role}${nameStr}`);
|
|
628
|
-
|
|
629
|
-
// Recurse
|
|
630
|
-
lines.push(...flattenA11yTree(node.children, opts, depth + 1));
|
|
631
|
-
}
|
|
632
|
-
|
|
633
|
-
return lines;
|
|
634
|
-
}
|
|
635
|
-
|
|
636
|
-
// ============================================================================
|
|
637
|
-
// Browser Detection
|
|
638
|
-
// ============================================================================
|
|
639
|
-
|
|
640
|
-
const BROWSER_PATTERNS: Record<string, string[]> = {
|
|
641
|
-
'company.thebrowser.browser': ['arc'],
|
|
642
|
-
'com.google.chrome': ['chrome', 'google chrome'],
|
|
643
|
-
'com.brave.browser': ['brave'],
|
|
644
|
-
'com.microsoft.edgemac': ['edge', 'microsoft edge'],
|
|
645
|
-
'org.chromium.chromium': ['chromium'],
|
|
646
|
-
};
|
|
647
|
-
|
|
648
|
-
function getDefaultBrowserId(): string | null {
|
|
649
|
-
try {
|
|
650
|
-
const output = execSync(
|
|
651
|
-
'defaults read com.apple.LaunchServices/com.apple.launchservices.secure LSHandlers 2>/dev/null',
|
|
652
|
-
{ encoding: 'utf-8' },
|
|
653
|
-
);
|
|
654
|
-
const match = output.match(
|
|
655
|
-
/LSHandlerRoleAll = "([^"]+)";\s*LSHandlerURLScheme = https;/,
|
|
656
|
-
);
|
|
657
|
-
return match?.[1] ?? null;
|
|
658
|
-
} catch {
|
|
659
|
-
return null;
|
|
660
|
-
}
|
|
661
|
-
}
|
|
662
|
-
|
|
663
|
-
interface CdpProbeResult {
|
|
664
|
-
browser: string;
|
|
665
|
-
app: string;
|
|
666
|
-
isElectron: boolean;
|
|
667
|
-
}
|
|
668
|
-
|
|
669
|
-
async function probeCdpPort(port: number): Promise<CdpProbeResult | null> {
|
|
670
|
-
try {
|
|
671
|
-
const controller = new AbortController();
|
|
672
|
-
const timeout = setTimeout(() => controller.abort(), 500);
|
|
673
|
-
const resp = await fetch(`http://localhost:${port}/json/version`, {
|
|
674
|
-
signal: controller.signal,
|
|
675
|
-
});
|
|
676
|
-
clearTimeout(timeout);
|
|
677
|
-
if (resp.ok) {
|
|
678
|
-
const version = (await resp.json()) as {
|
|
679
|
-
Browser?: string;
|
|
680
|
-
'User-Agent'?: string;
|
|
681
|
-
};
|
|
682
|
-
const browser = version.Browser;
|
|
683
|
-
const userAgent = version['User-Agent'] ?? '';
|
|
684
|
-
if (!browser) return null;
|
|
685
|
-
|
|
686
|
-
const isElectron = userAgent.includes('Electron');
|
|
687
|
-
|
|
688
|
-
// Extract app name from User-Agent for Electron apps
|
|
689
|
-
// Pattern: "AppName/1.2.3 Chrome/..." in the UA string
|
|
690
|
-
let app = browser;
|
|
691
|
-
if (isElectron) {
|
|
692
|
-
const appMatch = userAgent.match(
|
|
693
|
-
/(\S+?)\/[\d.]+ Chrome\/[\d.]+ Electron/,
|
|
694
|
-
);
|
|
695
|
-
if (appMatch) app = appMatch[1];
|
|
696
|
-
}
|
|
697
|
-
|
|
698
|
-
return { browser, app, isElectron };
|
|
699
|
-
}
|
|
700
|
-
} catch {
|
|
701
|
-
// Not a CDP port or not responding
|
|
702
|
-
}
|
|
703
|
-
return null;
|
|
704
|
-
}
|
|
705
|
-
|
|
706
|
-
function getLocalhostListeningPorts(): number[] {
|
|
707
|
-
try {
|
|
708
|
-
// netstat is much faster than lsof on macOS
|
|
709
|
-
const output = execSync(
|
|
710
|
-
'netstat -an 2>/dev/null | grep "127.0.0.1.*LISTEN"',
|
|
711
|
-
{ encoding: 'utf-8' },
|
|
712
|
-
);
|
|
713
|
-
const ports: number[] = [];
|
|
714
|
-
for (const line of output.split('\n')) {
|
|
715
|
-
// Format: tcp4 0 0 127.0.0.1.56192 *.* LISTEN
|
|
716
|
-
const match = line.match(/127\.0\.0\.1\.(\d+)/);
|
|
717
|
-
if (match) {
|
|
718
|
-
ports.push(parseInt(match[1], 10));
|
|
719
|
-
}
|
|
720
|
-
}
|
|
721
|
-
return ports;
|
|
722
|
-
} catch {
|
|
723
|
-
return [];
|
|
724
|
-
}
|
|
725
|
-
}
|
|
726
|
-
|
|
727
|
-
interface CdpEndpoint {
|
|
728
|
-
port: number;
|
|
729
|
-
app: string;
|
|
730
|
-
bundleId: string;
|
|
731
|
-
isElectron: boolean;
|
|
732
|
-
}
|
|
733
|
-
|
|
734
|
-
async function detectCdpPortsAsync(): Promise<CdpEndpoint[]> {
|
|
735
|
-
const ports = getLocalhostListeningPorts();
|
|
736
|
-
const results: CdpEndpoint[] = [];
|
|
737
|
-
|
|
738
|
-
// Probe ports in parallel for speed
|
|
739
|
-
const probes = ports.map(async (port) => {
|
|
740
|
-
const probe = await probeCdpPort(port);
|
|
741
|
-
if (probe) {
|
|
742
|
-
// Map browser string to bundle ID for default browser matching
|
|
743
|
-
const lower = probe.browser.toLowerCase();
|
|
744
|
-
let bundleId = 'unknown';
|
|
745
|
-
for (const [bid, patterns] of Object.entries(BROWSER_PATTERNS)) {
|
|
746
|
-
if (patterns.some((p) => lower.includes(p))) {
|
|
747
|
-
bundleId = bid;
|
|
748
|
-
break;
|
|
749
|
-
}
|
|
750
|
-
}
|
|
751
|
-
results.push({
|
|
752
|
-
port,
|
|
753
|
-
app: probe.app,
|
|
754
|
-
bundleId,
|
|
755
|
-
isElectron: probe.isElectron,
|
|
756
|
-
});
|
|
757
|
-
}
|
|
758
|
-
});
|
|
759
|
-
|
|
760
|
-
await Promise.all(probes);
|
|
761
|
-
return results;
|
|
762
|
-
}
|
|
763
|
-
|
|
764
|
-
export async function detectCdpPort(): Promise<number> {
|
|
765
|
-
const endpoints = await detectCdpPortsAsync();
|
|
766
|
-
if (endpoints.length === 0) {
|
|
767
|
-
throw new Error(
|
|
768
|
-
'No browser with CDP found. Start your browser with remote debugging enabled:\n' +
|
|
769
|
-
' Arc: Already enabled by default\n' +
|
|
770
|
-
' Chrome: --remote-debugging-port=9222\n' +
|
|
771
|
-
' Electron apps expose CDP automatically',
|
|
772
|
-
);
|
|
773
|
-
}
|
|
774
|
-
|
|
775
|
-
// Use default browser if available (prefer non-Electron)
|
|
776
|
-
const defaultBrowser = getDefaultBrowserId();
|
|
777
|
-
if (defaultBrowser) {
|
|
778
|
-
const match = endpoints.find(
|
|
779
|
-
(p) => p.bundleId === defaultBrowser && !p.isElectron,
|
|
780
|
-
);
|
|
781
|
-
if (match) return match.port;
|
|
782
|
-
}
|
|
783
|
-
|
|
784
|
-
// Fall back to first non-Electron, then first found
|
|
785
|
-
const nonElectron = endpoints.find((p) => !p.isElectron);
|
|
786
|
-
return (nonElectron ?? endpoints[0]).port;
|
|
787
|
-
}
|
|
788
|
-
|
|
789
|
-
// ============================================================================
|
|
790
|
-
// CDP Connection (Browser-level)
|
|
791
|
-
// ============================================================================
|
|
792
|
-
|
|
793
|
-
async function getBrowserClient(
|
|
794
|
-
port: number,
|
|
795
|
-
): Promise<{ client: CDPClient; browserWsUrl: string }> {
|
|
796
|
-
const versionResp = await fetch(`http://localhost:${port}/json/version`);
|
|
797
|
-
if (!versionResp.ok) {
|
|
798
|
-
throw new Error(
|
|
799
|
-
`Failed to connect to CDP on port ${port}: ${versionResp.status}`,
|
|
800
|
-
);
|
|
801
|
-
}
|
|
802
|
-
const version = (await versionResp.json()) as {
|
|
803
|
-
webSocketDebuggerUrl: string;
|
|
804
|
-
};
|
|
805
|
-
const client = new CDPClient(version.webSocketDebuggerUrl);
|
|
806
|
-
await client.waitReady();
|
|
807
|
-
return { client, browserWsUrl: version.webSocketDebuggerUrl };
|
|
808
|
-
}
|
|
809
|
-
|
|
810
|
-
export async function listTargets(port: number): Promise<CDPTarget[]> {
|
|
811
|
-
const { client } = await getBrowserClient(port);
|
|
812
|
-
|
|
813
|
-
try {
|
|
814
|
-
// Enable target discovery to get all targets including pages
|
|
815
|
-
await client.send('Target.setDiscoverTargets', { discover: true });
|
|
816
|
-
const result = (await client.send('Target.getTargets')) as {
|
|
817
|
-
targetInfos: Array<{
|
|
818
|
-
targetId: string;
|
|
819
|
-
type: string;
|
|
820
|
-
title: string;
|
|
821
|
-
url: string;
|
|
822
|
-
}>;
|
|
823
|
-
};
|
|
824
|
-
|
|
825
|
-
// Convert to CDPTarget format with webSocketDebuggerUrl
|
|
826
|
-
return result.targetInfos.map((t) => ({
|
|
827
|
-
id: t.targetId,
|
|
828
|
-
title: t.title,
|
|
829
|
-
url: t.url,
|
|
830
|
-
type: t.type,
|
|
831
|
-
webSocketDebuggerUrl: `ws://localhost:${port}/devtools/page/${t.targetId}`,
|
|
832
|
-
}));
|
|
833
|
-
} finally {
|
|
834
|
-
client.close();
|
|
835
|
-
}
|
|
836
|
-
}
|
|
837
|
-
|
|
838
|
-
export async function findTab(
|
|
839
|
-
port: number,
|
|
840
|
-
urlPattern?: string,
|
|
841
|
-
): Promise<CDPTarget | null> {
|
|
842
|
-
const targets = await listTargets(port);
|
|
843
|
-
const pages = targets.filter((t) => t.type === 'page');
|
|
844
|
-
|
|
845
|
-
if (!urlPattern) {
|
|
846
|
-
// Return first page
|
|
847
|
-
return pages[0] ?? null;
|
|
848
|
-
}
|
|
849
|
-
|
|
850
|
-
// Find by URL pattern
|
|
851
|
-
const pattern = urlPattern.toLowerCase();
|
|
852
|
-
return pages.find((t) => t.url.toLowerCase().includes(pattern)) ?? null;
|
|
853
|
-
}
|
|
854
|
-
|
|
855
|
-
export async function findTabById(
|
|
856
|
-
port: number,
|
|
857
|
-
targetId: string,
|
|
858
|
-
): Promise<CDPTarget | null> {
|
|
859
|
-
const targets = await listTargets(port);
|
|
860
|
-
// Exact match first
|
|
861
|
-
const exact = targets.find((t) => t.id === targetId);
|
|
862
|
-
if (exact) return exact;
|
|
863
|
-
// Prefix match (min 4 chars to avoid ambiguity)
|
|
864
|
-
if (targetId.length >= 4) {
|
|
865
|
-
const prefix = targetId.toUpperCase();
|
|
866
|
-
const matches = targets.filter((t) => t.id.toUpperCase().startsWith(prefix));
|
|
867
|
-
if (matches.length === 1) return matches[0];
|
|
868
|
-
if (matches.length > 1) {
|
|
869
|
-
throw new Error(
|
|
870
|
-
`Ambiguous target prefix "${targetId}" matches ${matches.length} tabs:\n` +
|
|
871
|
-
matches.map((t) => ` ${t.id.slice(0, 8)} ${t.url}`).join('\n'),
|
|
872
|
-
);
|
|
873
|
-
}
|
|
874
|
-
}
|
|
875
|
-
return null;
|
|
876
|
-
}
|
|
877
|
-
|
|
878
|
-
export async function openTab(port: number, url: string): Promise<CDPTarget> {
|
|
879
|
-
const { client } = await getBrowserClient(port);
|
|
880
|
-
|
|
881
|
-
try {
|
|
882
|
-
// Create new target in background to avoid stealing user's focus
|
|
883
|
-
const result = (await client.send('Target.createTarget', {
|
|
884
|
-
url,
|
|
885
|
-
background: true,
|
|
886
|
-
})) as {
|
|
887
|
-
targetId: string;
|
|
888
|
-
};
|
|
889
|
-
|
|
890
|
-
// Return target info
|
|
891
|
-
return {
|
|
892
|
-
id: result.targetId,
|
|
893
|
-
title: '',
|
|
894
|
-
url,
|
|
895
|
-
type: 'page',
|
|
896
|
-
webSocketDebuggerUrl: `ws://localhost:${port}/devtools/page/${result.targetId}`,
|
|
897
|
-
};
|
|
898
|
-
} finally {
|
|
899
|
-
client.close();
|
|
900
|
-
}
|
|
901
|
-
}
|
|
902
|
-
|
|
903
|
-
// ============================================================================
|
|
904
|
-
// CDP WebSocket Client
|
|
905
|
-
// ============================================================================
|
|
906
|
-
|
|
907
|
-
export class CDPClient {
|
|
908
|
-
private ws: WebSocket;
|
|
909
|
-
private messageId = 0;
|
|
910
|
-
private pendingRequests = new Map<
|
|
911
|
-
number,
|
|
912
|
-
{ resolve: (v: unknown) => void; reject: (e: Error) => void }
|
|
913
|
-
>();
|
|
914
|
-
private pendingTimeouts = new Map<number, NodeJS.Timeout>();
|
|
915
|
-
private eventHandlers = new Map<string, Array<(params: unknown) => void>>();
|
|
916
|
-
private ready: Promise<void>;
|
|
917
|
-
|
|
918
|
-
constructor(wsUrl: string, connectTimeout = 5000) {
|
|
919
|
-
this.ws = new WebSocket(wsUrl);
|
|
920
|
-
this.ready = new Promise((resolve, reject) => {
|
|
921
|
-
const timeout = setTimeout(() => {
|
|
922
|
-
reject(new Error(`WebSocket connection timeout (${connectTimeout}ms)`));
|
|
923
|
-
}, connectTimeout);
|
|
924
|
-
|
|
925
|
-
this.ws.on('open', () => {
|
|
926
|
-
clearTimeout(timeout);
|
|
927
|
-
resolve();
|
|
928
|
-
});
|
|
929
|
-
this.ws.on('error', (err) => {
|
|
930
|
-
clearTimeout(timeout);
|
|
931
|
-
reject(err);
|
|
932
|
-
});
|
|
933
|
-
});
|
|
934
|
-
|
|
935
|
-
this.ws.on('message', (data: Buffer) => {
|
|
936
|
-
const msg = JSON.parse(data.toString());
|
|
937
|
-
if (msg.id !== undefined) {
|
|
938
|
-
const pending = this.pendingRequests.get(msg.id);
|
|
939
|
-
if (pending) {
|
|
940
|
-
this.pendingRequests.delete(msg.id);
|
|
941
|
-
const timer = this.pendingTimeouts.get(msg.id);
|
|
942
|
-
if (timer) {
|
|
943
|
-
clearTimeout(timer);
|
|
944
|
-
this.pendingTimeouts.delete(msg.id);
|
|
945
|
-
}
|
|
946
|
-
if (msg.error) {
|
|
947
|
-
pending.reject(new Error(msg.error.message));
|
|
948
|
-
} else {
|
|
949
|
-
pending.resolve(msg.result);
|
|
950
|
-
}
|
|
951
|
-
}
|
|
952
|
-
} else if (msg.method) {
|
|
953
|
-
const handlers = this.eventHandlers.get(msg.method);
|
|
954
|
-
handlers?.forEach((h) => h(msg.params));
|
|
955
|
-
}
|
|
956
|
-
});
|
|
957
|
-
}
|
|
958
|
-
|
|
959
|
-
async waitReady(): Promise<void> {
|
|
960
|
-
await this.ready;
|
|
961
|
-
}
|
|
962
|
-
|
|
963
|
-
async send(
|
|
964
|
-
method: string,
|
|
965
|
-
params: Record<string, unknown> = {},
|
|
966
|
-
timeout = 60000,
|
|
967
|
-
): Promise<unknown> {
|
|
968
|
-
await this.ready;
|
|
969
|
-
const id = ++this.messageId;
|
|
970
|
-
|
|
971
|
-
return new Promise((resolve, reject) => {
|
|
972
|
-
this.pendingRequests.set(id, { resolve, reject });
|
|
973
|
-
this.ws.send(JSON.stringify({ id, method, params }));
|
|
974
|
-
|
|
975
|
-
const timer = setTimeout(() => {
|
|
976
|
-
if (this.pendingRequests.has(id)) {
|
|
977
|
-
this.pendingRequests.delete(id);
|
|
978
|
-
this.pendingTimeouts.delete(id);
|
|
979
|
-
reject(new Error(`CDP request timeout (${timeout}ms): ${method}`));
|
|
980
|
-
}
|
|
981
|
-
}, timeout);
|
|
982
|
-
this.pendingTimeouts.set(id, timer);
|
|
983
|
-
});
|
|
984
|
-
}
|
|
985
|
-
|
|
986
|
-
on(event: string, handler: (params: unknown) => void): void {
|
|
987
|
-
if (!this.eventHandlers.has(event)) {
|
|
988
|
-
this.eventHandlers.set(event, []);
|
|
989
|
-
}
|
|
990
|
-
this.eventHandlers.get(event)!.push(handler);
|
|
991
|
-
}
|
|
992
|
-
|
|
993
|
-
close(): void {
|
|
994
|
-
// Clear all pending timeouts to allow process to exit
|
|
995
|
-
for (const timer of this.pendingTimeouts.values()) {
|
|
996
|
-
clearTimeout(timer);
|
|
997
|
-
}
|
|
998
|
-
this.pendingTimeouts.clear();
|
|
999
|
-
this.ws.close();
|
|
1000
|
-
}
|
|
1001
|
-
}
|
|
1002
|
-
|
|
1003
|
-
// ============================================================================
|
|
1004
|
-
// Capture Utilities
|
|
1005
|
-
// ============================================================================
|
|
1006
|
-
|
|
1007
|
-
export async function captureScreenshot(
|
|
1008
|
-
client: CDPClient,
|
|
1009
|
-
viewport?: { width: number; height: number },
|
|
1010
|
-
options?: { fullPage?: boolean },
|
|
1011
|
-
): Promise<Buffer> {
|
|
1012
|
-
const MAX_DIM = 1600; // headroom below Anthropic's 2000px many-image limit
|
|
1013
|
-
|
|
1014
|
-
// Apply viewport emulation if requested
|
|
1015
|
-
if (viewport) {
|
|
1016
|
-
await client.send('Emulation.setDeviceMetricsOverride', {
|
|
1017
|
-
width: viewport.width,
|
|
1018
|
-
height: viewport.height,
|
|
1019
|
-
deviceScaleFactor: 1,
|
|
1020
|
-
mobile: false,
|
|
1021
|
-
});
|
|
1022
|
-
// Let the page re-layout at the new size
|
|
1023
|
-
await new Promise((r) => setTimeout(r, 150));
|
|
1024
|
-
}
|
|
1025
|
-
|
|
1026
|
-
// For full-page capture, resize viewport to the full content height
|
|
1027
|
-
if (options?.fullPage) {
|
|
1028
|
-
const layoutMetrics = (await client.send('Page.getLayoutMetrics')) as {
|
|
1029
|
-
contentSize?: { width: number; height: number };
|
|
1030
|
-
cssVisualViewport?: { clientWidth: number };
|
|
1031
|
-
};
|
|
1032
|
-
const contentWidth = layoutMetrics.cssVisualViewport?.clientWidth ?? viewport?.width ?? 1280;
|
|
1033
|
-
const contentHeight = layoutMetrics.contentSize?.height ?? viewport?.height ?? 800;
|
|
1034
|
-
await client.send('Emulation.setDeviceMetricsOverride', {
|
|
1035
|
-
width: contentWidth,
|
|
1036
|
-
height: Math.ceil(contentHeight),
|
|
1037
|
-
deviceScaleFactor: 1,
|
|
1038
|
-
mobile: false,
|
|
1039
|
-
});
|
|
1040
|
-
await new Promise((r) => setTimeout(r, 150));
|
|
1041
|
-
}
|
|
1042
|
-
|
|
1043
|
-
let screenshotOpts: Record<string, unknown> = {
|
|
1044
|
-
format: 'png',
|
|
1045
|
-
captureBeyondViewport: false,
|
|
1046
|
-
};
|
|
1047
|
-
|
|
1048
|
-
try {
|
|
1049
|
-
const metrics = (await client.send('Page.getLayoutMetrics')) as {
|
|
1050
|
-
cssVisualViewport?: { clientWidth: number; clientHeight: number; pageX: number; pageY: number };
|
|
1051
|
-
};
|
|
1052
|
-
const vw = metrics.cssVisualViewport?.clientWidth ?? 0;
|
|
1053
|
-
const vh = metrics.cssVisualViewport?.clientHeight ?? 0;
|
|
1054
|
-
const sx = metrics.cssVisualViewport?.pageX ?? 0;
|
|
1055
|
-
const sy = metrics.cssVisualViewport?.pageY ?? 0;
|
|
1056
|
-
|
|
1057
|
-
const dprResult = (await client.send('Runtime.evaluate', {
|
|
1058
|
-
expression: 'window.devicePixelRatio',
|
|
1059
|
-
returnByValue: true,
|
|
1060
|
-
})) as { result: { value: number } };
|
|
1061
|
-
const dpr = dprResult.result.value ?? 1;
|
|
1062
|
-
|
|
1063
|
-
const actualMaxSide = Math.max(vw, vh) * dpr;
|
|
1064
|
-
const scale = actualMaxSide > MAX_DIM ? MAX_DIM / actualMaxSide : 1 / dpr;
|
|
1065
|
-
screenshotOpts = {
|
|
1066
|
-
...screenshotOpts,
|
|
1067
|
-
clip: { x: sx, y: sy, width: vw, height: vh, scale },
|
|
1068
|
-
};
|
|
1069
|
-
} catch {
|
|
1070
|
-
// Fallback: capture without downscaling
|
|
1071
|
-
}
|
|
1072
|
-
|
|
1073
|
-
const result = (await client.send(
|
|
1074
|
-
'Page.captureScreenshot',
|
|
1075
|
-
screenshotOpts,
|
|
1076
|
-
)) as { data: string };
|
|
1077
|
-
|
|
1078
|
-
// Reset emulation so the browser window isn't stuck at the overridden size
|
|
1079
|
-
if (viewport || options?.fullPage) {
|
|
1080
|
-
await client.send('Emulation.clearDeviceMetricsOverride');
|
|
1081
|
-
}
|
|
1082
|
-
|
|
1083
|
-
return Buffer.from(result.data, 'base64');
|
|
1084
|
-
}
|
|
1085
|
-
|
|
1086
|
-
interface AXNode {
|
|
1087
|
-
nodeId: string;
|
|
1088
|
-
role?: { value: string };
|
|
1089
|
-
name?: { value: string };
|
|
1090
|
-
childIds?: string[];
|
|
1091
|
-
properties?: Array<{ name: string; value: { value: unknown } }>;
|
|
1092
|
-
}
|
|
1093
|
-
|
|
1094
|
-
export interface A11yNode {
|
|
1095
|
-
role: string;
|
|
1096
|
-
name: string;
|
|
1097
|
-
children: A11yNode[];
|
|
1098
|
-
}
|
|
1099
|
-
|
|
1100
|
-
export async function getAccessibilityTree(
|
|
1101
|
-
client: CDPClient,
|
|
1102
|
-
): Promise<A11yNode[]> {
|
|
1103
|
-
await client.send('Accessibility.enable');
|
|
1104
|
-
const result = (await client.send('Accessibility.getFullAXTree')) as {
|
|
1105
|
-
nodes: AXNode[];
|
|
1106
|
-
};
|
|
1107
|
-
await client.send('Accessibility.disable');
|
|
1108
|
-
|
|
1109
|
-
const nodeMap = new Map<string, AXNode>();
|
|
1110
|
-
for (const node of result.nodes) {
|
|
1111
|
-
nodeMap.set(node.nodeId, node);
|
|
1112
|
-
}
|
|
1113
|
-
|
|
1114
|
-
const SKIP_ROLES = new Set(['none', 'generic', 'InlineTextBox']);
|
|
1115
|
-
|
|
1116
|
-
function buildNode(node: AXNode): A11yNode[] {
|
|
1117
|
-
const role = node.role?.value ?? '';
|
|
1118
|
-
const childNodes = (node.childIds ?? []).flatMap((id) => {
|
|
1119
|
-
const child = nodeMap.get(id);
|
|
1120
|
-
return child ? buildNode(child) : [];
|
|
1121
|
-
});
|
|
1122
|
-
|
|
1123
|
-
if (SKIP_ROLES.has(role)) {
|
|
1124
|
-
return childNodes;
|
|
1125
|
-
}
|
|
1126
|
-
|
|
1127
|
-
return [
|
|
1128
|
-
{
|
|
1129
|
-
role,
|
|
1130
|
-
name: node.name?.value ?? '',
|
|
1131
|
-
children: childNodes,
|
|
1132
|
-
},
|
|
1133
|
-
];
|
|
1134
|
-
}
|
|
1135
|
-
|
|
1136
|
-
const root = result.nodes[0];
|
|
1137
|
-
return root ? buildNode(root) : [];
|
|
1138
|
-
}
|
|
1139
|
-
|
|
1140
|
-
// ============================================================================
|
|
1141
|
-
// Code Execution
|
|
1142
|
-
// ============================================================================
|
|
1143
|
-
|
|
1144
|
-
export interface ExecuteOptions {
|
|
1145
|
-
port?: number;
|
|
1146
|
-
targetId?: string;
|
|
1147
|
-
record?: boolean;
|
|
1148
|
-
harOutPath?: string;
|
|
1149
|
-
timeoutMs?: number;
|
|
1150
|
-
}
|
|
1151
|
-
|
|
1152
|
-
export interface ExecuteResult {
|
|
1153
|
-
success: boolean;
|
|
1154
|
-
value?: unknown;
|
|
1155
|
-
error?: string;
|
|
1156
|
-
har?: { log: { entries: HAREntry[] } };
|
|
1157
|
-
console: ConsoleEntry[];
|
|
1158
|
-
}
|
|
1159
|
-
|
|
1160
|
-
export async function executeInBrowser(
|
|
1161
|
-
code: string,
|
|
1162
|
-
options: ExecuteOptions = {},
|
|
1163
|
-
): Promise<ExecuteResult> {
|
|
1164
|
-
const port = options.port ?? (await detectCdpPort());
|
|
1165
|
-
|
|
1166
|
-
if (!options.targetId) {
|
|
1167
|
-
throw new Error('Use --target <tabId> to target a tab. Run "capture list" to see available tabs.');
|
|
1168
|
-
}
|
|
1169
|
-
|
|
1170
|
-
// Find tab by exact/prefix ID
|
|
1171
|
-
let tab: CDPTarget | null = null;
|
|
1172
|
-
tab = await findTabById(port, options.targetId);
|
|
1173
|
-
if (!tab) {
|
|
1174
|
-
throw new Error(
|
|
1175
|
-
`No tab found for target "${options.targetId}". Run "capture list" to see available tabs.`,
|
|
1176
|
-
);
|
|
1177
|
-
}
|
|
1178
|
-
|
|
1179
|
-
if (!tab.webSocketDebuggerUrl) {
|
|
1180
|
-
throw new Error('Tab has no WebSocket debugger URL');
|
|
1181
|
-
}
|
|
1182
|
-
|
|
1183
|
-
const client = new CDPClient(tab.webSocketDebuggerUrl);
|
|
1184
|
-
await client.waitReady();
|
|
1185
|
-
|
|
1186
|
-
// Emulate focus so network requests aren't deferred by the browser,
|
|
1187
|
-
// without actually bringing the tab to the foreground
|
|
1188
|
-
await client.send('Emulation.setFocusEmulationEnabled', { enabled: true });
|
|
1189
|
-
|
|
1190
|
-
const consoleRecorder = new ConsoleRecorder(client);
|
|
1191
|
-
await consoleRecorder.start();
|
|
1192
|
-
|
|
1193
|
-
let harRecorder: HARRecorder | undefined;
|
|
1194
|
-
if (options.record) {
|
|
1195
|
-
harRecorder = new HARRecorder(client);
|
|
1196
|
-
await harRecorder.start();
|
|
1197
|
-
}
|
|
1198
|
-
|
|
1199
|
-
try {
|
|
1200
|
-
// Execute code
|
|
1201
|
-
const result = (await client.send(
|
|
1202
|
-
'Runtime.evaluate',
|
|
1203
|
-
{
|
|
1204
|
-
expression: code,
|
|
1205
|
-
awaitPromise: true,
|
|
1206
|
-
returnByValue: true,
|
|
1207
|
-
},
|
|
1208
|
-
options.timeoutMs,
|
|
1209
|
-
)) as {
|
|
1210
|
-
result?: { value?: unknown };
|
|
1211
|
-
exceptionDetails?: { exception?: { description?: string } };
|
|
1212
|
-
};
|
|
1213
|
-
|
|
1214
|
-
// Build HAR if recording
|
|
1215
|
-
let har: { log: { entries: HAREntry[] } } | undefined;
|
|
1216
|
-
if (harRecorder) {
|
|
1217
|
-
har = await harRecorder.finish();
|
|
1218
|
-
writeHarAndPrintSummary(har, options.harOutPath);
|
|
1219
|
-
}
|
|
1220
|
-
|
|
1221
|
-
const consoleEntries = consoleRecorder.finish();
|
|
1222
|
-
printConsoleSummary(consoleEntries);
|
|
1223
|
-
client.close();
|
|
1224
|
-
|
|
1225
|
-
if (result.exceptionDetails) {
|
|
1226
|
-
return {
|
|
1227
|
-
success: false,
|
|
1228
|
-
error:
|
|
1229
|
-
result.exceptionDetails.exception?.description ?? 'Unknown error',
|
|
1230
|
-
har,
|
|
1231
|
-
console: consoleEntries,
|
|
1232
|
-
};
|
|
1233
|
-
}
|
|
1234
|
-
|
|
1235
|
-
return {
|
|
1236
|
-
success: true,
|
|
1237
|
-
value: result.result?.value,
|
|
1238
|
-
har,
|
|
1239
|
-
console: consoleEntries,
|
|
1240
|
-
};
|
|
1241
|
-
} catch (err) {
|
|
1242
|
-
const consoleEntries = consoleRecorder.finish();
|
|
1243
|
-
client.close();
|
|
1244
|
-
return {
|
|
1245
|
-
success: false,
|
|
1246
|
-
error: err instanceof Error ? err.message : String(err),
|
|
1247
|
-
console: consoleEntries,
|
|
1248
|
-
};
|
|
1249
|
-
}
|
|
1250
|
-
}
|
|
1251
|
-
|
|
1252
|
-
// ============================================================================
|
|
1253
|
-
// Passive HAR Recording
|
|
1254
|
-
// ============================================================================
|
|
1255
|
-
|
|
1256
|
-
export interface RecordOptions {
|
|
1257
|
-
port?: number;
|
|
1258
|
-
targetId?: string;
|
|
1259
|
-
duration?: number;
|
|
1260
|
-
harOutPath?: string;
|
|
1261
|
-
}
|
|
1262
|
-
|
|
1263
|
-
export interface RecordResult {
|
|
1264
|
-
harPath: string | undefined;
|
|
1265
|
-
entryCount: number;
|
|
1266
|
-
har: { log: { entries: HAREntry[] } };
|
|
1267
|
-
}
|
|
1268
|
-
|
|
1269
|
-
export async function recordTraffic(
|
|
1270
|
-
options: RecordOptions,
|
|
1271
|
-
): Promise<RecordResult> {
|
|
1272
|
-
const port = options.port ?? (await detectCdpPort());
|
|
1273
|
-
const duration = options.duration ?? 10;
|
|
1274
|
-
|
|
1275
|
-
if (!options.targetId) {
|
|
1276
|
-
throw new Error('Use --target <tabId> to target a tab. Run "capture list" to see available tabs.');
|
|
1277
|
-
}
|
|
1278
|
-
|
|
1279
|
-
const tab = await findTabById(port, options.targetId);
|
|
1280
|
-
if (!tab) {
|
|
1281
|
-
throw new Error(
|
|
1282
|
-
`No tab found for target "${options.targetId}". Run "capture list" to see available tabs.`,
|
|
1283
|
-
);
|
|
1284
|
-
}
|
|
1285
|
-
|
|
1286
|
-
if (!tab.webSocketDebuggerUrl) {
|
|
1287
|
-
throw new Error('Tab has no WebSocket debugger URL');
|
|
1288
|
-
}
|
|
1289
|
-
|
|
1290
|
-
const client = new CDPClient(tab.webSocketDebuggerUrl);
|
|
1291
|
-
await client.waitReady();
|
|
1292
|
-
|
|
1293
|
-
const recorder = new HARRecorder(client);
|
|
1294
|
-
await recorder.start();
|
|
1295
|
-
|
|
1296
|
-
console.error(
|
|
1297
|
-
`Recording traffic on "${tab.url}" for ${duration}s... (click around in the browser)`,
|
|
1298
|
-
);
|
|
1299
|
-
|
|
1300
|
-
await new Promise((r) => setTimeout(r, duration * 1000));
|
|
1301
|
-
|
|
1302
|
-
const har = await recorder.finish();
|
|
1303
|
-
const harPath = writeHarAndPrintSummary(har, options.harOutPath);
|
|
1304
|
-
|
|
1305
|
-
client.close();
|
|
1306
|
-
|
|
1307
|
-
return { harPath, entryCount: har.log.entries.length, har };
|
|
1308
|
-
}
|
|
1309
|
-
|
|
1310
|
-
// ============================================================================
|
|
1311
|
-
// Navigate + Record
|
|
1312
|
-
// ============================================================================
|
|
1313
|
-
|
|
1314
|
-
export interface NavigateAndRecordOptions {
|
|
1315
|
-
port?: number;
|
|
1316
|
-
url: string;
|
|
1317
|
-
targetId?: string;
|
|
1318
|
-
harOutPath?: string;
|
|
1319
|
-
settle?: number;
|
|
1320
|
-
}
|
|
1321
|
-
|
|
1322
|
-
export interface NavigateAndRecordResult {
|
|
1323
|
-
harPath: string | undefined;
|
|
1324
|
-
entryCount: number;
|
|
1325
|
-
har: { log: { entries: HAREntry[] } };
|
|
1326
|
-
tab: CDPTarget;
|
|
1327
|
-
timedOut: boolean;
|
|
1328
|
-
}
|
|
1329
|
-
|
|
1330
|
-
export async function navigateAndRecord(
|
|
1331
|
-
options: NavigateAndRecordOptions,
|
|
1332
|
-
): Promise<NavigateAndRecordResult> {
|
|
1333
|
-
const port = options.port ?? (await detectCdpPort());
|
|
1334
|
-
const settle = options.settle ?? 2000;
|
|
1335
|
-
|
|
1336
|
-
// Find tab — prefer targetId (exact) over domain match (fuzzy)
|
|
1337
|
-
let tab: CDPTarget | null = null;
|
|
1338
|
-
let isNewTab = false;
|
|
1339
|
-
if (options.targetId) {
|
|
1340
|
-
tab = await findTabById(port, options.targetId);
|
|
1341
|
-
if (!tab) {
|
|
1342
|
-
throw new Error(
|
|
1343
|
-
`No tab found with target ID "${options.targetId}". Tab may have been closed.`,
|
|
1344
|
-
);
|
|
1345
|
-
}
|
|
1346
|
-
} else {
|
|
1347
|
-
let domain: string;
|
|
1348
|
-
try {
|
|
1349
|
-
domain = new URL(options.url).hostname;
|
|
1350
|
-
} catch {
|
|
1351
|
-
throw new Error(`Invalid URL: ${options.url}`);
|
|
1352
|
-
}
|
|
1353
|
-
tab = await findTab(port, domain);
|
|
1354
|
-
if (!tab) {
|
|
1355
|
-
tab = await openTab(port, options.url);
|
|
1356
|
-
isNewTab = true;
|
|
1357
|
-
}
|
|
1358
|
-
}
|
|
1359
|
-
if (!tab.webSocketDebuggerUrl) {
|
|
1360
|
-
throw new Error('Tab has no WebSocket debugger URL');
|
|
1361
|
-
}
|
|
1362
|
-
|
|
1363
|
-
const client = new CDPClient(tab.webSocketDebuggerUrl);
|
|
1364
|
-
await client.waitReady();
|
|
1365
|
-
await client.send('Page.enable');
|
|
1366
|
-
|
|
1367
|
-
// Start recording BEFORE navigation
|
|
1368
|
-
const recorder = new HARRecorder(client);
|
|
1369
|
-
await recorder.start();
|
|
1370
|
-
|
|
1371
|
-
if (!isNewTab) {
|
|
1372
|
-
// Navigate existing tab
|
|
1373
|
-
console.error(`Navigating to: ${options.url}`);
|
|
1374
|
-
await client.send('Page.navigate', { url: options.url });
|
|
1375
|
-
}
|
|
1376
|
-
|
|
1377
|
-
const deadline = 60_000;
|
|
1378
|
-
const t0 = Date.now();
|
|
1379
|
-
let timedOut = false;
|
|
1380
|
-
let har!: { log: { entries: HAREntry[] } };
|
|
1381
|
-
|
|
1382
|
-
try {
|
|
1383
|
-
await Promise.race([
|
|
1384
|
-
(async () => {
|
|
1385
|
-
// Wait for load event
|
|
1386
|
-
await new Promise<void>((resolve) => {
|
|
1387
|
-
const timer = setTimeout(() => {
|
|
1388
|
-
console.error('Page load timeout (10s), continuing with settle...');
|
|
1389
|
-
resolve();
|
|
1390
|
-
}, 10000);
|
|
1391
|
-
client.on('Page.loadEventFired', () => {
|
|
1392
|
-
clearTimeout(timer);
|
|
1393
|
-
resolve();
|
|
1394
|
-
});
|
|
1395
|
-
});
|
|
1396
|
-
|
|
1397
|
-
// Settle time for SPAs that load after DOMContentLoaded
|
|
1398
|
-
console.error(`Settling for ${settle}ms...`);
|
|
1399
|
-
await new Promise((r) => setTimeout(r, settle));
|
|
1400
|
-
|
|
1401
|
-
har = await recorder.finish();
|
|
1402
|
-
})(),
|
|
1403
|
-
new Promise<never>((_, reject) => {
|
|
1404
|
-
const t = setTimeout(() => reject(new Error('__navigate_timeout__')), deadline - (Date.now() - t0));
|
|
1405
|
-
// Don't keep process alive just for the deadline timer
|
|
1406
|
-
if (typeof t === 'object' && 'unref' in t) t.unref();
|
|
1407
|
-
}),
|
|
1408
|
-
]);
|
|
1409
|
-
} catch (err) {
|
|
1410
|
-
if (err instanceof Error && err.message === '__navigate_timeout__') {
|
|
1411
|
-
timedOut = true;
|
|
1412
|
-
console.error('Navigate timeout (60s) — returning partial HAR');
|
|
1413
|
-
har = recorder.finishPartial();
|
|
1414
|
-
} else {
|
|
1415
|
-
throw err;
|
|
1416
|
-
}
|
|
1417
|
-
}
|
|
1418
|
-
|
|
1419
|
-
const harPath = writeHarAndPrintSummary(har, options.harOutPath);
|
|
1420
|
-
|
|
1421
|
-
client.close();
|
|
1422
|
-
|
|
1423
|
-
// Refresh target info
|
|
1424
|
-
const updatedTab = (await findTab(port, options.url)) ?? tab;
|
|
1425
|
-
|
|
1426
|
-
return { harPath, entryCount: har.log.entries.length, har, tab: updatedTab, timedOut };
|
|
1427
|
-
}
|
|
1428
|
-
|
|
1429
|
-
// ============================================================================
|
|
1430
|
-
// Navigate and Wait
|
|
1431
|
-
// ============================================================================
|
|
1432
|
-
|
|
1433
|
-
export async function navigateAndWait(
|
|
1434
|
-
port: number,
|
|
1435
|
-
url: string,
|
|
1436
|
-
options: { timeout?: number; forceNew?: boolean } = {},
|
|
1437
|
-
): Promise<CDPTarget> {
|
|
1438
|
-
const timeout = options.timeout ?? 10000;
|
|
1439
|
-
|
|
1440
|
-
// Check if tab already exists (unless forceNew requested)
|
|
1441
|
-
if (!options.forceNew) {
|
|
1442
|
-
const existing = await findTab(port, url);
|
|
1443
|
-
if (existing) {
|
|
1444
|
-
return existing;
|
|
1445
|
-
}
|
|
1446
|
-
}
|
|
1447
|
-
|
|
1448
|
-
// Open new tab
|
|
1449
|
-
const tab = await openTab(port, url);
|
|
1450
|
-
if (!tab.webSocketDebuggerUrl) {
|
|
1451
|
-
throw new Error('New tab has no WebSocket debugger URL');
|
|
1452
|
-
}
|
|
1453
|
-
|
|
1454
|
-
// Wait for page load
|
|
1455
|
-
const client = new CDPClient(tab.webSocketDebuggerUrl);
|
|
1456
|
-
await client.waitReady();
|
|
1457
|
-
await client.send('Page.enable');
|
|
1458
|
-
|
|
1459
|
-
await new Promise<void>((resolve, reject) => {
|
|
1460
|
-
const timer = setTimeout(
|
|
1461
|
-
() => reject(new Error(`Page load timeout: ${url}`)),
|
|
1462
|
-
timeout,
|
|
1463
|
-
);
|
|
1464
|
-
client.on('Page.loadEventFired', () => {
|
|
1465
|
-
clearTimeout(timer);
|
|
1466
|
-
resolve();
|
|
1467
|
-
});
|
|
1468
|
-
});
|
|
1469
|
-
|
|
1470
|
-
client.close();
|
|
1471
|
-
|
|
1472
|
-
// Refresh target info — find by ID to avoid returning a different tab with same URL
|
|
1473
|
-
const targets = await listTargets(port);
|
|
1474
|
-
const refreshed = targets.find((t) => t.id === tab.id);
|
|
1475
|
-
return refreshed ?? tab;
|
|
1476
|
-
}
|
|
1477
|
-
|
|
1478
|
-
// ============================================================================
|
|
1479
|
-
// Tab-Level File Lock
|
|
1480
|
-
// ============================================================================
|
|
1481
|
-
|
|
1482
|
-
const LOCK_DIR = os.tmpdir();
|
|
1483
|
-
const LOCK_STALE_MS = 120_000;
|
|
1484
|
-
const LOCK_TIMEOUT_MS = 60_000;
|
|
1485
|
-
const LOCK_POLL_BASE_MS = 200;
|
|
1486
|
-
|
|
1487
|
-
function lockPath(tabId: string): string {
|
|
1488
|
-
return path.join(LOCK_DIR, `capture-cdp-tab-${tabId}.lock`);
|
|
1489
|
-
}
|
|
1490
|
-
|
|
1491
|
-
function isPidAlive(pid: number): boolean {
|
|
1492
|
-
try {
|
|
1493
|
-
process.kill(pid, 0);
|
|
1494
|
-
return true;
|
|
1495
|
-
} catch {
|
|
1496
|
-
return false;
|
|
1497
|
-
}
|
|
1498
|
-
}
|
|
1499
|
-
|
|
1500
|
-
function isLockStale(filePath: string): boolean {
|
|
1501
|
-
try {
|
|
1502
|
-
const content = fs.readFileSync(filePath, 'utf-8');
|
|
1503
|
-
const { pid, ts } = JSON.parse(content) as { pid: number; ts: number };
|
|
1504
|
-
if (!isPidAlive(pid)) return true;
|
|
1505
|
-
return Date.now() - ts > LOCK_STALE_MS;
|
|
1506
|
-
} catch {
|
|
1507
|
-
return true;
|
|
1508
|
-
}
|
|
1509
|
-
}
|
|
1510
|
-
|
|
1511
|
-
// Track locks held by this process for cleanup on exit
|
|
1512
|
-
const heldLocks = new Set<string>();
|
|
1513
|
-
let cleanupRegistered = false;
|
|
1514
|
-
|
|
1515
|
-
function registerCleanup(): void {
|
|
1516
|
-
if (cleanupRegistered) return;
|
|
1517
|
-
cleanupRegistered = true;
|
|
1518
|
-
|
|
1519
|
-
const cleanup = () => {
|
|
1520
|
-
for (const file of heldLocks) {
|
|
1521
|
-
try {
|
|
1522
|
-
fs.unlinkSync(file);
|
|
1523
|
-
} catch {
|
|
1524
|
-
// Best-effort
|
|
1525
|
-
}
|
|
1526
|
-
}
|
|
1527
|
-
heldLocks.clear();
|
|
1528
|
-
};
|
|
1529
|
-
|
|
1530
|
-
process.on('exit', cleanup);
|
|
1531
|
-
process.on('SIGINT', () => {
|
|
1532
|
-
cleanup();
|
|
1533
|
-
process.exit(130);
|
|
1534
|
-
});
|
|
1535
|
-
process.on('SIGTERM', () => {
|
|
1536
|
-
cleanup();
|
|
1537
|
-
process.exit(143);
|
|
1538
|
-
});
|
|
1539
|
-
}
|
|
1540
|
-
|
|
1541
|
-
export async function acquireTabLock(tabId: string): Promise<void> {
|
|
1542
|
-
registerCleanup();
|
|
1543
|
-
const file = lockPath(tabId);
|
|
1544
|
-
const deadline = Date.now() + LOCK_TIMEOUT_MS;
|
|
1545
|
-
let delay = LOCK_POLL_BASE_MS;
|
|
1546
|
-
|
|
1547
|
-
while (true) {
|
|
1548
|
-
try {
|
|
1549
|
-
const fd = fs.openSync(file, 'wx');
|
|
1550
|
-
fs.writeSync(fd, JSON.stringify({ pid: process.pid, ts: Date.now() }));
|
|
1551
|
-
fs.closeSync(fd);
|
|
1552
|
-
heldLocks.add(file);
|
|
1553
|
-
return;
|
|
1554
|
-
} catch (err: unknown) {
|
|
1555
|
-
if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err;
|
|
1556
|
-
}
|
|
1557
|
-
|
|
1558
|
-
if (isLockStale(file)) {
|
|
1559
|
-
try {
|
|
1560
|
-
fs.unlinkSync(file);
|
|
1561
|
-
} catch {
|
|
1562
|
-
// Another process may have already removed it
|
|
1563
|
-
}
|
|
1564
|
-
continue;
|
|
1565
|
-
}
|
|
1566
|
-
|
|
1567
|
-
if (Date.now() >= deadline) {
|
|
1568
|
-
throw new Error(
|
|
1569
|
-
`Timed out waiting for CDP tab lock (${tabId}). ` +
|
|
1570
|
-
`Another process may be using this tab. Lock file: ${file}`,
|
|
1571
|
-
);
|
|
1572
|
-
}
|
|
1573
|
-
|
|
1574
|
-
await new Promise((r) => setTimeout(r, delay));
|
|
1575
|
-
delay = Math.min(delay * 2, 2000);
|
|
1576
|
-
}
|
|
1577
|
-
}
|
|
1578
|
-
|
|
1579
|
-
export function isTabLocked(tabId: string): boolean {
|
|
1580
|
-
const file = lockPath(tabId);
|
|
1581
|
-
try {
|
|
1582
|
-
fs.accessSync(file);
|
|
1583
|
-
return !isLockStale(file);
|
|
1584
|
-
} catch {
|
|
1585
|
-
return false;
|
|
1586
|
-
}
|
|
1587
|
-
}
|
|
1588
|
-
|
|
1589
|
-
export function releaseTabLock(tabId: string): void {
|
|
1590
|
-
const file = lockPath(tabId);
|
|
1591
|
-
heldLocks.delete(file);
|
|
1592
|
-
try {
|
|
1593
|
-
fs.unlinkSync(file);
|
|
1594
|
-
} catch {
|
|
1595
|
-
// Already removed — fine
|
|
1596
|
-
}
|
|
1597
|
-
}
|
|
1598
|
-
|
|
1599
|
-
export async function withTabLock<T>(
|
|
1600
|
-
tabId: string,
|
|
1601
|
-
fn: () => Promise<T>,
|
|
1602
|
-
): Promise<T> {
|
|
1603
|
-
await acquireTabLock(tabId);
|
|
1604
|
-
try {
|
|
1605
|
-
return await fn();
|
|
1606
|
-
} finally {
|
|
1607
|
-
releaseTabLock(tabId);
|
|
1608
|
-
}
|
|
1609
|
-
}
|
|
1610
|
-
|
|
1611
|
-
// ============================================================================
|
|
1612
|
-
// Auto-Screenshot Helper
|
|
1613
|
-
// ============================================================================
|
|
1614
|
-
|
|
1615
|
-
async function autoScreenshot(
|
|
1616
|
-
client: CDPClient,
|
|
1617
|
-
action: string,
|
|
1618
|
-
label: string,
|
|
1619
|
-
noScreenshot?: boolean,
|
|
1620
|
-
): Promise<string | null> {
|
|
1621
|
-
if (noScreenshot) return null;
|
|
1622
|
-
const shotPath = nextStepPath(action, label);
|
|
1623
|
-
if (!shotPath) return null;
|
|
1624
|
-
|
|
1625
|
-
// Brief settle for UI to update
|
|
1626
|
-
await new Promise((r) => setTimeout(r, 300));
|
|
1627
|
-
const png = await captureScreenshot(client);
|
|
1628
|
-
fs.writeFileSync(shotPath, png);
|
|
1629
|
-
console.error(` [screenshot] ${shotPath}`);
|
|
1630
|
-
return shotPath;
|
|
1631
|
-
}
|
|
1632
|
-
|
|
1633
|
-
// ============================================================================
|
|
1634
|
-
// CLI
|
|
1635
|
-
// ============================================================================
|
|
1636
|
-
|
|
1637
|
-
async function main() {
|
|
1638
|
-
const args = process.argv.slice(2);
|
|
1639
|
-
const parsed = parseCliArgs(args);
|
|
1640
|
-
|
|
1641
|
-
switch (parsed.command) {
|
|
1642
|
-
case 'detect': {
|
|
1643
|
-
if (parsed.help) {
|
|
1644
|
-
console.log(
|
|
1645
|
-
'Usage: capture detect\n\n' +
|
|
1646
|
-
'Detect CDP port (prioritizes default browser).\n' +
|
|
1647
|
-
'Returns JSON with port, app name, and all discovered endpoints.',
|
|
1648
|
-
);
|
|
1649
|
-
process.exit(0);
|
|
1650
|
-
}
|
|
1651
|
-
const endpoints = await detectCdpPortsAsync();
|
|
1652
|
-
if (endpoints.length === 0) {
|
|
1653
|
-
console.error(
|
|
1654
|
-
'ERROR: No browser with CDP found.\n\n' +
|
|
1655
|
-
'Fix: Start a browser with remote debugging enabled:\n' +
|
|
1656
|
-
' Arc: Already enabled by default\n' +
|
|
1657
|
-
' Chrome: --remote-debugging-port=9222\n' +
|
|
1658
|
-
' Electron apps expose CDP automatically',
|
|
1659
|
-
);
|
|
1660
|
-
process.exit(1);
|
|
1661
|
-
}
|
|
1662
|
-
const defaultBrowser = getDefaultBrowserId();
|
|
1663
|
-
const preferred =
|
|
1664
|
-
endpoints.find(
|
|
1665
|
-
(p) => p.bundleId === defaultBrowser && !p.isElectron,
|
|
1666
|
-
) ??
|
|
1667
|
-
endpoints.find((p) => !p.isElectron) ??
|
|
1668
|
-
endpoints[0];
|
|
1669
|
-
console.log(
|
|
1670
|
-
JSON.stringify(
|
|
1671
|
-
{
|
|
1672
|
-
port: preferred.port,
|
|
1673
|
-
app: preferred.app,
|
|
1674
|
-
isElectron: preferred.isElectron,
|
|
1675
|
-
all: endpoints.map((e) => ({
|
|
1676
|
-
port: e.port,
|
|
1677
|
-
app: e.app,
|
|
1678
|
-
isElectron: e.isElectron,
|
|
1679
|
-
})),
|
|
1680
|
-
},
|
|
1681
|
-
null,
|
|
1682
|
-
2,
|
|
1683
|
-
),
|
|
1684
|
-
);
|
|
1685
|
-
console.error(
|
|
1686
|
-
`\nFound ${endpoints.length} CDP endpoint${endpoints.length > 1 ? 's' : ''}. Default: port ${preferred.port} (${preferred.app}).` +
|
|
1687
|
-
`\n\nNext (most tasks): capture session start --url <your app url>` +
|
|
1688
|
-
`\n (one-off): capture list --port ${preferred.port}`,
|
|
1689
|
-
);
|
|
1690
|
-
break;
|
|
1691
|
-
}
|
|
1692
|
-
|
|
1693
|
-
case 'list': {
|
|
1694
|
-
if (parsed.help) {
|
|
1695
|
-
console.log(
|
|
1696
|
-
'Usage: capture list [--port <port>]\n\n' +
|
|
1697
|
-
'List all browser tabs across all CDP endpoints (or a specific port).\n' +
|
|
1698
|
-
'Returns JSON array of {id, title, url, port, app}.',
|
|
1699
|
-
);
|
|
1700
|
-
process.exit(0);
|
|
1701
|
-
}
|
|
1702
|
-
if (parsed.port) {
|
|
1703
|
-
// Single port mode
|
|
1704
|
-
const targets = await listTargets(parsed.port);
|
|
1705
|
-
const pages = targets.filter((t) => t.type === 'page');
|
|
1706
|
-
console.log(JSON.stringify(pages.map((t) => ({
|
|
1707
|
-
id: t.id,
|
|
1708
|
-
title: t.title,
|
|
1709
|
-
url: t.url,
|
|
1710
|
-
})), null, 2));
|
|
1711
|
-
console.error(
|
|
1712
|
-
`\n${pages.length} tab${pages.length !== 1 ? 's' : ''} on port ${parsed.port}.` +
|
|
1713
|
-
`\n\nTarget a tab with: --target <id prefix> (first 8 chars sufficient)`,
|
|
1714
|
-
);
|
|
1715
|
-
} else {
|
|
1716
|
-
// All endpoints mode
|
|
1717
|
-
const endpoints = await detectCdpPortsAsync();
|
|
1718
|
-
if (endpoints.length === 0) {
|
|
1719
|
-
console.error('No CDP endpoints found.');
|
|
1720
|
-
process.exit(1);
|
|
1721
|
-
}
|
|
1722
|
-
const allPages: { id: string; title: string; url: string; port: number; app: string }[] = [];
|
|
1723
|
-
await Promise.all(endpoints.map(async (ep) => {
|
|
1724
|
-
try {
|
|
1725
|
-
const targets = await listTargets(ep.port);
|
|
1726
|
-
const pages = targets.filter((t) => t.type === 'page');
|
|
1727
|
-
for (const t of pages) {
|
|
1728
|
-
allPages.push({ id: t.id, title: t.title, url: t.url, port: ep.port, app: ep.app });
|
|
1729
|
-
}
|
|
1730
|
-
} catch {
|
|
1731
|
-
// Skip endpoints that fail to respond
|
|
1732
|
-
}
|
|
1733
|
-
}));
|
|
1734
|
-
console.log(JSON.stringify(allPages, null, 2));
|
|
1735
|
-
console.error(
|
|
1736
|
-
`\n${allPages.length} tab${allPages.length !== 1 ? 's' : ''} across ${endpoints.length} CDP endpoint${endpoints.length !== 1 ? 's' : ''}.` +
|
|
1737
|
-
`\n\nTarget a tab with: --target <id prefix> (first 8 chars sufficient)`,
|
|
1738
|
-
);
|
|
1739
|
-
}
|
|
1740
|
-
break;
|
|
1741
|
-
}
|
|
1742
|
-
|
|
1743
|
-
case 'open': {
|
|
1744
|
-
if (parsed.help) {
|
|
1745
|
-
console.log(
|
|
1746
|
-
'Usage: capture open <url> [--new] [--port <port>]\n\n' +
|
|
1747
|
-
'Open URL in browser (returns tab ID).\n\n' +
|
|
1748
|
-
'Example: capture open "https://app.example.com" --new',
|
|
1749
|
-
);
|
|
1750
|
-
process.exit(0);
|
|
1751
|
-
}
|
|
1752
|
-
const url = parsed.positional[0];
|
|
1753
|
-
if (!url) {
|
|
1754
|
-
console.error(
|
|
1755
|
-
'ERROR: Missing URL.\n\n' +
|
|
1756
|
-
'Usage: capture open <url> [--new] [--port <port>]\n\n' +
|
|
1757
|
-
'Example: capture open "https://app.example.com" --new',
|
|
1758
|
-
);
|
|
1759
|
-
process.exit(1);
|
|
1760
|
-
}
|
|
1761
|
-
const p = parsed.port ?? (await detectCdpPort());
|
|
1762
|
-
const tab = await navigateAndWait(p, url, { forceNew: parsed.new });
|
|
1763
|
-
console.log(JSON.stringify({
|
|
1764
|
-
id: tab.id,
|
|
1765
|
-
title: tab.title,
|
|
1766
|
-
url: tab.url,
|
|
1767
|
-
port: p,
|
|
1768
|
-
}, null, 2));
|
|
1769
|
-
console.error(
|
|
1770
|
-
`\nOpened: ${tab.title || url}` +
|
|
1771
|
-
`\n\nFor a multi-step validation, prefer a session (auto-targets this kind of tab):` +
|
|
1772
|
-
`\n capture session start --url "${url}"` +
|
|
1773
|
-
`\n\nOr interact one-off against this tab:` +
|
|
1774
|
-
`\n capture a11y --port ${p} --target ${tab.id.slice(0, 8)} --interactive` +
|
|
1775
|
-
`\n capture screenshot --port ${p} --target ${tab.id.slice(0, 8)}` +
|
|
1776
|
-
`\n capture exec "<js>" --port ${p} --target ${tab.id.slice(0, 8)}`,
|
|
1777
|
-
);
|
|
1778
|
-
break;
|
|
1779
|
-
}
|
|
1780
|
-
|
|
1781
|
-
case 'exec': {
|
|
1782
|
-
if (parsed.help) {
|
|
1783
|
-
console.log(
|
|
1784
|
-
'Usage: capture exec <code> [--target <id>] [--record] [--file <path>] [--har <id>]\n\n' +
|
|
1785
|
-
'Execute JavaScript in a browser tab. Supports await (wrapped in async IIFE).\n\n' +
|
|
1786
|
-
'Examples:\n' +
|
|
1787
|
-
' capture exec "document.title" --target <id>\n' +
|
|
1788
|
-
' capture exec "await fetch(\'/api/data\').then(r=>r.json())" --target <id>\n' +
|
|
1789
|
-
' capture exec "document.querySelector(\'.btn\').click()" --target <id>\n' +
|
|
1790
|
-
' capture exec --file /tmp/scrape.js --target <id> --record',
|
|
1791
|
-
);
|
|
1792
|
-
process.exit(0);
|
|
1793
|
-
}
|
|
1794
|
-
let code: string;
|
|
1795
|
-
if (parsed.file) {
|
|
1796
|
-
code = fs.readFileSync(parsed.file, 'utf-8');
|
|
1797
|
-
} else {
|
|
1798
|
-
code = parsed.positional[0];
|
|
1799
|
-
if (!code) {
|
|
1800
|
-
console.error(
|
|
1801
|
-
'ERROR: Missing code to execute.\n\n' +
|
|
1802
|
-
'Usage: capture exec <code> [--target <id>] [--record] [--file <path>]\n\n' +
|
|
1803
|
-
'Examples:\n' +
|
|
1804
|
-
' capture exec "document.title" --target <id>\n' +
|
|
1805
|
-
' capture exec "document.querySelector(\'.btn\').click()" --target <id>\n' +
|
|
1806
|
-
' capture exec --file /tmp/scrape.js --target <id> --record',
|
|
1807
|
-
);
|
|
1808
|
-
process.exit(1);
|
|
1809
|
-
}
|
|
1810
|
-
}
|
|
1811
|
-
|
|
1812
|
-
await withConnection(
|
|
1813
|
-
parsed,
|
|
1814
|
-
async (client) => {
|
|
1815
|
-
await client.send('Emulation.setFocusEmulationEnabled', { enabled: true });
|
|
1816
|
-
|
|
1817
|
-
// Standalone HAR recording (--record flag writes its own file)
|
|
1818
|
-
let standaloneRecorder: HARRecorder | undefined;
|
|
1819
|
-
if (parsed.record) {
|
|
1820
|
-
standaloneRecorder = new HARRecorder(client);
|
|
1821
|
-
await standaloneRecorder.start();
|
|
1822
|
-
}
|
|
1823
|
-
|
|
1824
|
-
// Smart wrapping: only use async IIFE when code contains `await`.
|
|
1825
|
-
// Without wrapping, Runtime.evaluate returns the completion value
|
|
1826
|
-
// of the last expression, handling multi-statement code naturally.
|
|
1827
|
-
const needsAsyncWrap = /\bawait\b/.test(code);
|
|
1828
|
-
const expression = needsAsyncWrap
|
|
1829
|
-
? `(async () => { ${code} })()`
|
|
1830
|
-
: code;
|
|
1831
|
-
|
|
1832
|
-
const evalResult = (await client.send('Runtime.evaluate', {
|
|
1833
|
-
expression,
|
|
1834
|
-
awaitPromise: true,
|
|
1835
|
-
returnByValue: true,
|
|
1836
|
-
})) as {
|
|
1837
|
-
result?: { value?: unknown };
|
|
1838
|
-
exceptionDetails?: { exception?: { description?: string } };
|
|
1839
|
-
};
|
|
1840
|
-
|
|
1841
|
-
if (standaloneRecorder) {
|
|
1842
|
-
const har = await standaloneRecorder.finish();
|
|
1843
|
-
writeHarAndPrintSummary(har, parsed.harOut);
|
|
1844
|
-
}
|
|
1845
|
-
|
|
1846
|
-
if (evalResult.exceptionDetails) {
|
|
1847
|
-
console.error(
|
|
1848
|
-
`ERROR: ${evalResult.exceptionDetails.exception?.description ?? 'Unknown error'}`,
|
|
1849
|
-
);
|
|
1850
|
-
process.exit(1);
|
|
1851
|
-
}
|
|
1852
|
-
|
|
1853
|
-
console.log(JSON.stringify(evalResult.result?.value));
|
|
1854
|
-
},
|
|
1855
|
-
{ settle: 3000 },
|
|
1856
|
-
);
|
|
1857
|
-
break;
|
|
1858
|
-
}
|
|
1859
|
-
|
|
1860
|
-
case 'record': {
|
|
1861
|
-
if (parsed.help) {
|
|
1862
|
-
console.log(
|
|
1863
|
-
'Usage: capture record --target <id> [--duration <secs>] [--har-out <path>]\n\n' +
|
|
1864
|
-
'Passive HAR recording for the specified duration (default: 10s).',
|
|
1865
|
-
);
|
|
1866
|
-
process.exit(0);
|
|
1867
|
-
}
|
|
1868
|
-
if (!parsed.target) {
|
|
1869
|
-
console.error(
|
|
1870
|
-
'Usage: capture record --target <id> [--duration <secs>] [--har-out <path>]',
|
|
1871
|
-
);
|
|
1872
|
-
process.exit(1);
|
|
1873
|
-
}
|
|
1874
|
-
|
|
1875
|
-
const result = await recordTraffic({
|
|
1876
|
-
port: parsed.port,
|
|
1877
|
-
targetId: parsed.target,
|
|
1878
|
-
duration: parsed.duration,
|
|
1879
|
-
harOutPath: parsed.harOut,
|
|
1880
|
-
});
|
|
1881
|
-
console.log(
|
|
1882
|
-
JSON.stringify(
|
|
1883
|
-
{ entryCount: result.entryCount, harPath: result.harPath },
|
|
1884
|
-
null,
|
|
1885
|
-
2,
|
|
1886
|
-
),
|
|
1887
|
-
);
|
|
1888
|
-
break;
|
|
1889
|
-
}
|
|
1890
|
-
|
|
1891
|
-
case 'reset-tab': {
|
|
1892
|
-
if (parsed.help) {
|
|
1893
|
-
console.log(
|
|
1894
|
-
'Usage: capture reset-tab <url> [--port <port>]\n\n' +
|
|
1895
|
-
'Abandon a stuck/unresponsive tab and open a fresh one.\n' +
|
|
1896
|
-
'Updates session context so subsequent commands auto-target the new tab.\n\n' +
|
|
1897
|
-
'Example: capture reset-tab "https://www.instagram.com/"',
|
|
1898
|
-
);
|
|
1899
|
-
process.exit(0);
|
|
1900
|
-
}
|
|
1901
|
-
const url = parsed.positional[0];
|
|
1902
|
-
if (!url) {
|
|
1903
|
-
console.error(
|
|
1904
|
-
'Usage: capture reset-tab <url> [--port <port>]',
|
|
1905
|
-
);
|
|
1906
|
-
process.exit(1);
|
|
1907
|
-
}
|
|
1908
|
-
const p = parsed.port ?? (await detectCdpPort());
|
|
1909
|
-
const tab = await openTab(p, url);
|
|
1910
|
-
|
|
1911
|
-
// Wait for page load
|
|
1912
|
-
if (tab.webSocketDebuggerUrl) {
|
|
1913
|
-
const client = new CDPClient(tab.webSocketDebuggerUrl);
|
|
1914
|
-
await client.waitReady();
|
|
1915
|
-
await client.send('Page.enable');
|
|
1916
|
-
await new Promise<void>((resolve) => {
|
|
1917
|
-
const timer = setTimeout(() => resolve(), 10000);
|
|
1918
|
-
client.on('Page.loadEventFired', () => {
|
|
1919
|
-
clearTimeout(timer);
|
|
1920
|
-
resolve();
|
|
1921
|
-
});
|
|
1922
|
-
});
|
|
1923
|
-
client.close();
|
|
1924
|
-
}
|
|
1925
|
-
|
|
1926
|
-
// Update session context so subsequent commands auto-target the new tab
|
|
1927
|
-
updateActiveSession({ targetId: tab.id });
|
|
1928
|
-
|
|
1929
|
-
console.log(JSON.stringify({ id: tab.id, url: tab.url, port: p }, null, 2));
|
|
1930
|
-
console.error(`\nNew tab opened. Session context updated.`);
|
|
1931
|
-
console.error(`Use --target ${tab.id.slice(0, 8)} if passing target explicitly.`);
|
|
1932
|
-
break;
|
|
1933
|
-
}
|
|
1934
|
-
|
|
1935
|
-
case 'navigate': {
|
|
1936
|
-
if (parsed.help) {
|
|
1937
|
-
console.log(
|
|
1938
|
-
'Usage: capture navigate <url> [--har-out <path>] [--settle <ms>] [--har <id>]\n\n' +
|
|
1939
|
-
'Navigate to URL and record HAR. Appends to session HAR if --har is set.\n\n' +
|
|
1940
|
-
'Example: capture navigate "https://app.example.com/dashboard" --settle 3000',
|
|
1941
|
-
);
|
|
1942
|
-
process.exit(0);
|
|
1943
|
-
}
|
|
1944
|
-
const url = parsed.positional[0];
|
|
1945
|
-
if (!url) {
|
|
1946
|
-
console.error(
|
|
1947
|
-
'Usage: capture navigate <url> [--har-out <path>] [--settle <ms>]',
|
|
1948
|
-
);
|
|
1949
|
-
process.exit(1);
|
|
1950
|
-
}
|
|
1951
|
-
|
|
1952
|
-
const result = await navigateAndRecord({
|
|
1953
|
-
port: parsed.port,
|
|
1954
|
-
url,
|
|
1955
|
-
targetId: parsed.target,
|
|
1956
|
-
harOutPath: parsed.har ? undefined : parsed.harOut,
|
|
1957
|
-
settle: parsed.settle,
|
|
1958
|
-
});
|
|
1959
|
-
|
|
1960
|
-
// Append to session HAR if active
|
|
1961
|
-
if (parsed.har && result.har.log.entries.length > 0) {
|
|
1962
|
-
appendToHar(parsed.har, result.har.log.entries);
|
|
1963
|
-
console.error(` [har:${parsed.har}] +${result.har.log.entries.length} entries`);
|
|
1964
|
-
}
|
|
1965
|
-
|
|
1966
|
-
console.log(
|
|
1967
|
-
JSON.stringify(
|
|
1968
|
-
{
|
|
1969
|
-
entryCount: result.entryCount,
|
|
1970
|
-
harPath: result.harPath,
|
|
1971
|
-
tabUrl: result.tab.url,
|
|
1972
|
-
timedOut: result.timedOut,
|
|
1973
|
-
},
|
|
1974
|
-
null,
|
|
1975
|
-
2,
|
|
1976
|
-
),
|
|
1977
|
-
);
|
|
1978
|
-
break;
|
|
1979
|
-
}
|
|
1980
|
-
|
|
1981
|
-
case 'screenshot': {
|
|
1982
|
-
if (parsed.help) {
|
|
1983
|
-
console.log(
|
|
1984
|
-
'Usage: capture screenshot [--target <id>] [--out <path>] [--viewport <preset>] [--height <px>] [--full-page]\n\n' +
|
|
1985
|
-
'Capture a screenshot of the targeted tab. Saves to --out or auto-generates a path.\n\n' +
|
|
1986
|
-
'Options:\n' +
|
|
1987
|
-
' --viewport <preset> Viewport preset (default: desktop)\n' +
|
|
1988
|
-
' desktop-wide 1920x1080\n' +
|
|
1989
|
-
' desktop 1280x800\n' +
|
|
1990
|
-
' tablet 768x1024\n' +
|
|
1991
|
-
' mobile 390x844\n' +
|
|
1992
|
-
' --height <px> Override viewport height (e.g. 1600)\n' +
|
|
1993
|
-
' --full-page Capture the entire scrollable page',
|
|
1994
|
-
);
|
|
1995
|
-
process.exit(0);
|
|
1996
|
-
}
|
|
1997
|
-
const VIEWPORTS: Record<string, { width: number; height: number }> = {
|
|
1998
|
-
'desktop-wide': { width: 1920, height: 1080 },
|
|
1999
|
-
'desktop': { width: 1280, height: 800 },
|
|
2000
|
-
'tablet': { width: 768, height: 1024 },
|
|
2001
|
-
'mobile': { width: 390, height: 844 },
|
|
2002
|
-
};
|
|
2003
|
-
const viewportName = parsed.viewport ?? 'desktop';
|
|
2004
|
-
const viewport = VIEWPORTS[viewportName];
|
|
2005
|
-
if (!viewport) {
|
|
2006
|
-
console.error(`Unknown viewport "${viewportName}". Options: ${Object.keys(VIEWPORTS).join(', ')}`);
|
|
2007
|
-
process.exit(1);
|
|
2008
|
-
}
|
|
2009
|
-
// Allow --height to override the viewport preset height
|
|
2010
|
-
if (parsed.height) {
|
|
2011
|
-
viewport.height = parsed.height;
|
|
2012
|
-
}
|
|
2013
|
-
const result = await withConnection(
|
|
2014
|
-
parsed,
|
|
2015
|
-
async (client) => {
|
|
2016
|
-
const png = await captureScreenshot(client, viewport, { fullPage: parsed.fullPage });
|
|
2017
|
-
const outPath = parsed.out
|
|
2018
|
-
? parsed.out
|
|
2019
|
-
: (nextStepPath('screenshot', 'manual') ?? `/tmp/capture-screenshot-${Date.now()}.png`);
|
|
2020
|
-
fs.writeFileSync(outPath, png);
|
|
2021
|
-
return { path: outPath, bytes: png.length };
|
|
2022
|
-
},
|
|
2023
|
-
{ settle: 0 },
|
|
2024
|
-
);
|
|
2025
|
-
console.log(JSON.stringify(result, null, 2));
|
|
2026
|
-
break;
|
|
2027
|
-
}
|
|
2028
|
-
|
|
2029
|
-
case 'click': {
|
|
2030
|
-
if (parsed.help) {
|
|
2031
|
-
console.log(
|
|
2032
|
-
'Usage: capture click "name" [--role button|link|...] [--no-screenshot]\n\n' +
|
|
2033
|
-
'Click an element by its accessible name. Auto-captures a screenshot after clicking.',
|
|
2034
|
-
);
|
|
2035
|
-
process.exit(0);
|
|
2036
|
-
}
|
|
2037
|
-
const name = parsed.positional[0];
|
|
2038
|
-
if (!name) {
|
|
2039
|
-
console.error('Usage: capture click "name" [--role button|link|...] [--no-screenshot]');
|
|
2040
|
-
process.exit(1);
|
|
2041
|
-
}
|
|
2042
|
-
const clickSettle = parsed.settle !== undefined ? parsed.settle : (parsed.har ? 2500 : 1000);
|
|
2043
|
-
const result = await withConnection(
|
|
2044
|
-
parsed,
|
|
2045
|
-
async (client) => {
|
|
2046
|
-
const clickResult = await clickByName(client, name, parsed.role);
|
|
2047
|
-
const screenshot = await autoScreenshot(client, 'click', name, parsed.noScreenshot);
|
|
2048
|
-
return { ...clickResult, screenshot };
|
|
2049
|
-
},
|
|
2050
|
-
{ settle: clickSettle },
|
|
2051
|
-
);
|
|
2052
|
-
console.log(JSON.stringify(result, null, 2));
|
|
2053
|
-
break;
|
|
2054
|
-
}
|
|
2055
|
-
|
|
2056
|
-
case 'type': {
|
|
2057
|
-
if (parsed.help) {
|
|
2058
|
-
console.log(
|
|
2059
|
-
'Usage: capture type "text" [--into "field name"] [--no-screenshot]\n\n' +
|
|
2060
|
-
'Type text into the focused element, or into a named field with --into.',
|
|
2061
|
-
);
|
|
2062
|
-
process.exit(0);
|
|
2063
|
-
}
|
|
2064
|
-
const text = parsed.positional[0];
|
|
2065
|
-
if (!text) {
|
|
2066
|
-
console.error('Usage: capture type "text" [--into "field name"] [--no-screenshot]');
|
|
2067
|
-
process.exit(1);
|
|
2068
|
-
}
|
|
2069
|
-
const result = await withConnection(
|
|
2070
|
-
parsed,
|
|
2071
|
-
async (client) => {
|
|
2072
|
-
let field: string | null = null;
|
|
2073
|
-
if (parsed.into) {
|
|
2074
|
-
await focusAndType(client, parsed.into, text, parsed.role);
|
|
2075
|
-
field = parsed.into;
|
|
2076
|
-
} else {
|
|
2077
|
-
await typeText(client, text);
|
|
2078
|
-
}
|
|
2079
|
-
const label = field ?? text;
|
|
2080
|
-
const screenshot = await autoScreenshot(client, 'type', label, parsed.noScreenshot);
|
|
2081
|
-
return { typed: text, field, screenshot };
|
|
2082
|
-
},
|
|
2083
|
-
{ settle: parsed.settle !== undefined ? parsed.settle : (parsed.har ? 1500 : 500) },
|
|
2084
|
-
);
|
|
2085
|
-
console.log(JSON.stringify(result, null, 2));
|
|
2086
|
-
break;
|
|
2087
|
-
}
|
|
2088
|
-
|
|
2089
|
-
case 'a11y': {
|
|
2090
|
-
if (parsed.help) {
|
|
2091
|
-
console.log(
|
|
2092
|
-
'Usage: capture a11y [--target <id>] [--json] [--interactive]\n\n' +
|
|
2093
|
-
'Get the accessibility tree. Use --interactive for interactive elements only.\n' +
|
|
2094
|
-
'Use --json for structured JSON output.',
|
|
2095
|
-
);
|
|
2096
|
-
process.exit(0);
|
|
2097
|
-
}
|
|
2098
|
-
const result = await withConnection(
|
|
2099
|
-
parsed,
|
|
2100
|
-
async (client) => {
|
|
2101
|
-
const tree = await getAccessibilityTree(client);
|
|
2102
|
-
|
|
2103
|
-
if (parsed.json) {
|
|
2104
|
-
return tree;
|
|
2105
|
-
}
|
|
2106
|
-
|
|
2107
|
-
// Flat text output
|
|
2108
|
-
const lines = flattenA11yTree(tree, {
|
|
2109
|
-
interactive: parsed.interactive,
|
|
2110
|
-
});
|
|
2111
|
-
return lines.join('\n');
|
|
2112
|
-
},
|
|
2113
|
-
{ settle: 0 },
|
|
2114
|
-
);
|
|
2115
|
-
|
|
2116
|
-
if (parsed.json) {
|
|
2117
|
-
console.log(JSON.stringify(result, null, 2));
|
|
2118
|
-
} else {
|
|
2119
|
-
console.log(result);
|
|
2120
|
-
}
|
|
2121
|
-
|
|
2122
|
-
// Hint when a11y tree is sparse
|
|
2123
|
-
if (!parsed.json) {
|
|
2124
|
-
const lineCount = typeof result === 'string' ? result.split('\n').filter(Boolean).length : 0;
|
|
2125
|
-
if (lineCount === 0) {
|
|
2126
|
-
console.error(
|
|
2127
|
-
'\nNo elements found.' +
|
|
2128
|
-
(parsed.interactive ? ' Try without --interactive to see all nodes.' : '') +
|
|
2129
|
-
'\n\nIf this page is part of your project, consider adding ARIA attributes:\n' +
|
|
2130
|
-
' - role="button|link|textbox" on interactive elements\n' +
|
|
2131
|
-
' - aria-label="..." on elements without visible text\n' +
|
|
2132
|
-
' - Semantic HTML (<button>, <input>, <nav>) provides roles automatically',
|
|
2133
|
-
);
|
|
2134
|
-
} else if (parsed.interactive && lineCount < 5) {
|
|
2135
|
-
console.error(
|
|
2136
|
-
`\nOnly ${lineCount} interactive element${lineCount === 1 ? '' : 's'} found. ` +
|
|
2137
|
-
'If this seems low, the page may lack ARIA markup.\n' +
|
|
2138
|
-
'Consider adding role and aria-label attributes to interactive elements in your frontend code.',
|
|
2139
|
-
);
|
|
2140
|
-
}
|
|
2141
|
-
}
|
|
2142
|
-
|
|
2143
|
-
break;
|
|
2144
|
-
}
|
|
2145
|
-
|
|
2146
|
-
case 'har': {
|
|
2147
|
-
if (parsed.help) {
|
|
2148
|
-
console.log(
|
|
2149
|
-
'Usage: capture har <create|read|delete>\n\n' +
|
|
2150
|
-
' create Create a new HAR recording (returns id)\n' +
|
|
2151
|
-
' read [id] Read accumulated HAR entries (id optional in active session)\n' +
|
|
2152
|
-
' delete <id> Delete a HAR recording\n\n' +
|
|
2153
|
-
'Filters (read):\n' +
|
|
2154
|
-
' --filter-url <pattern> Substring or regex match on URL\n' +
|
|
2155
|
-
' --filter-status <code> Status code, prefix (e.g. 4), or range (e.g. 400-499)\n' +
|
|
2156
|
-
' --filter-method <method> HTTP method (GET, POST, ...)\n' +
|
|
2157
|
-
' --limit <N> Return only the first N matching entries\n\n' +
|
|
2158
|
-
'Pass --har <id> to exec/navigate commands to append traffic to a recording.',
|
|
2159
|
-
);
|
|
2160
|
-
process.exit(0);
|
|
2161
|
-
}
|
|
2162
|
-
const subcommand = args[1];
|
|
2163
|
-
|
|
2164
|
-
if (subcommand === 'create') {
|
|
2165
|
-
// Generate a short random ID
|
|
2166
|
-
const { id, path: harPath } = createHarRecording();
|
|
2167
|
-
console.log(JSON.stringify({ id, path: harPath }, null, 2));
|
|
2168
|
-
console.error(
|
|
2169
|
-
`\nPass --har ${id} to subsequent commands to record traffic.`,
|
|
2170
|
-
);
|
|
2171
|
-
break;
|
|
2172
|
-
}
|
|
2173
|
-
|
|
2174
|
-
if (subcommand === 'read') {
|
|
2175
|
-
// ID is optional — fall back to active session's harId
|
|
2176
|
-
let id = args[2];
|
|
2177
|
-
if (id && id.startsWith('--')) id = undefined as unknown as string;
|
|
2178
|
-
if (!id) {
|
|
2179
|
-
const session = getActiveSession();
|
|
2180
|
-
if (session?.harId) {
|
|
2181
|
-
id = session.harId;
|
|
2182
|
-
} else {
|
|
2183
|
-
console.error('Usage: capture har read [id]\n' +
|
|
2184
|
-
' ID is optional when a capture session is active.');
|
|
2185
|
-
process.exit(1);
|
|
2186
|
-
}
|
|
2187
|
-
}
|
|
2188
|
-
|
|
2189
|
-
const har = readHarFile(id);
|
|
2190
|
-
if (!har) {
|
|
2191
|
-
console.error(`No HAR recording found for id: ${id}`);
|
|
2192
|
-
process.exit(1);
|
|
2193
|
-
}
|
|
2194
|
-
|
|
2195
|
-
let entries = har.log.entries;
|
|
2196
|
-
const total = entries.length;
|
|
2197
|
-
const filters: string[] = [];
|
|
2198
|
-
|
|
2199
|
-
if (parsed.filterUrl) {
|
|
2200
|
-
const pattern = parsed.filterUrl;
|
|
2201
|
-
let re: RegExp | null = null;
|
|
2202
|
-
try { re = new RegExp(pattern, 'i'); } catch { re = null; }
|
|
2203
|
-
entries = entries.filter((e) =>
|
|
2204
|
-
re ? re.test(e.request.url) : e.request.url.toLowerCase().includes(pattern.toLowerCase()),
|
|
2205
|
-
);
|
|
2206
|
-
filters.push(`url~${pattern}`);
|
|
2207
|
-
}
|
|
2208
|
-
if (parsed.filterStatus) {
|
|
2209
|
-
const s = parsed.filterStatus;
|
|
2210
|
-
let matcher: (code: number) => boolean;
|
|
2211
|
-
if (/^\d+-\d+$/.test(s)) {
|
|
2212
|
-
const [lo, hi] = s.split('-').map((n) => parseInt(n, 10));
|
|
2213
|
-
matcher = (c) => c >= lo && c <= hi;
|
|
2214
|
-
} else if (/^\d+$/.test(s)) {
|
|
2215
|
-
if (s.length < 3) {
|
|
2216
|
-
matcher = (c) => String(c).startsWith(s);
|
|
2217
|
-
} else {
|
|
2218
|
-
const n = parseInt(s, 10);
|
|
2219
|
-
matcher = (c) => c === n;
|
|
2220
|
-
}
|
|
2221
|
-
} else {
|
|
2222
|
-
matcher = () => true;
|
|
2223
|
-
}
|
|
2224
|
-
entries = entries.filter((e) => matcher(e.response.status));
|
|
2225
|
-
filters.push(`status=${s}`);
|
|
2226
|
-
}
|
|
2227
|
-
if (parsed.filterMethod) {
|
|
2228
|
-
const m = parsed.filterMethod;
|
|
2229
|
-
entries = entries.filter((e) => e.request.method.toUpperCase() === m);
|
|
2230
|
-
filters.push(`method=${m}`);
|
|
2231
|
-
}
|
|
2232
|
-
if (typeof parsed.limit === 'number' && parsed.limit > 0) {
|
|
2233
|
-
entries = entries.slice(0, parsed.limit);
|
|
2234
|
-
filters.push(`limit=${parsed.limit}`);
|
|
2235
|
-
}
|
|
2236
|
-
|
|
2237
|
-
const filtersLabel = filters.length > 0 ? ` [${filters.join(', ')}]` : '';
|
|
2238
|
-
console.error(
|
|
2239
|
-
`HAR: ${harFilePath(id)} (${entries.length}/${total} entries)${filtersLabel}`,
|
|
2240
|
-
);
|
|
2241
|
-
console.log(JSON.stringify({ log: { entries } }, null, 2));
|
|
2242
|
-
break;
|
|
2243
|
-
}
|
|
2244
|
-
|
|
2245
|
-
if (subcommand === 'delete') {
|
|
2246
|
-
const id = args[2];
|
|
2247
|
-
if (!id) {
|
|
2248
|
-
console.error('Usage: capture har delete <id>');
|
|
2249
|
-
process.exit(1);
|
|
2250
|
-
}
|
|
2251
|
-
|
|
2252
|
-
deleteHarRecording(id);
|
|
2253
|
-
console.log(JSON.stringify({ deleted: true, id }, null, 2));
|
|
2254
|
-
break;
|
|
2255
|
-
}
|
|
2256
|
-
|
|
2257
|
-
console.error(
|
|
2258
|
-
'Usage: capture har <create|read|delete>\n' +
|
|
2259
|
-
' create Create a new HAR recording (returns id)\n' +
|
|
2260
|
-
' read <id> Read accumulated HAR entries\n' +
|
|
2261
|
-
' delete <id> Delete a HAR recording',
|
|
2262
|
-
);
|
|
2263
|
-
process.exit(1);
|
|
2264
|
-
break;
|
|
2265
|
-
}
|
|
2266
|
-
|
|
2267
|
-
case 'network': {
|
|
2268
|
-
if (parsed.help) {
|
|
2269
|
-
console.log(
|
|
2270
|
-
'Usage: capture network <offline|online> [--target <id>]\n\n' +
|
|
2271
|
-
'Toggle network connectivity for a tab. Use "offline" to simulate\n' +
|
|
2272
|
-
'network failure (kills WebSocket connections, blocks HTTP requests).\n' +
|
|
2273
|
-
'Use "online" to restore connectivity.',
|
|
2274
|
-
);
|
|
2275
|
-
process.exit(0);
|
|
2276
|
-
}
|
|
2277
|
-
const mode = parsed.positional[0];
|
|
2278
|
-
if (!mode || !['offline', 'online'].includes(mode)) {
|
|
2279
|
-
console.error('Usage: capture network <offline|online> [--target <id>]');
|
|
2280
|
-
process.exit(1);
|
|
2281
|
-
}
|
|
2282
|
-
const offline = mode === 'offline';
|
|
2283
|
-
const result = await withConnection(
|
|
2284
|
-
parsed,
|
|
2285
|
-
async (client) => {
|
|
2286
|
-
await client.send('Network.enable');
|
|
2287
|
-
await client.send('Network.emulateNetworkConditions', {
|
|
2288
|
-
offline,
|
|
2289
|
-
latency: offline ? -1 : 0,
|
|
2290
|
-
downloadThroughput: offline ? 0 : -1,
|
|
2291
|
-
uploadThroughput: offline ? 0 : -1,
|
|
2292
|
-
});
|
|
2293
|
-
return { network: mode, offline };
|
|
2294
|
-
},
|
|
2295
|
-
{ settle: 0 },
|
|
2296
|
-
);
|
|
2297
|
-
console.log(JSON.stringify(result, null, 2));
|
|
2298
|
-
if (offline) {
|
|
2299
|
-
console.error('\nNetwork disabled. WebSocket connections will drop.');
|
|
2300
|
-
console.error('Restore with: capture network online');
|
|
2301
|
-
} else {
|
|
2302
|
-
console.error('\nNetwork restored.');
|
|
2303
|
-
}
|
|
2304
|
-
break;
|
|
2305
|
-
}
|
|
2306
|
-
|
|
2307
|
-
default:
|
|
2308
|
-
console.log(`CDP — browser automation via exec, a11y interactions, and screenshots.
|
|
2309
|
-
|
|
2310
|
-
Commands:
|
|
2311
|
-
exec <code> Execute JavaScript in a browser tab
|
|
2312
|
-
exec --file <path> Execute JS from file
|
|
2313
|
-
detect Detect CDP port (prioritizes default browser)
|
|
2314
|
-
list List all browser tabs
|
|
2315
|
-
open <url> Open URL in browser (returns tab ID)
|
|
2316
|
-
reset-tab <url> Abandon stuck tab, open fresh one (updates session)
|
|
2317
|
-
screenshot Capture screenshot
|
|
2318
|
-
click "name" Click element by accessible name
|
|
2319
|
-
type "text" Type text into focused element
|
|
2320
|
-
a11y Get accessibility tree
|
|
2321
|
-
record Passive HAR recording
|
|
2322
|
-
navigate <url> Navigate to URL and record HAR
|
|
2323
|
-
network <offline|online> Toggle network (simulate disconnect)
|
|
2324
|
-
har create Create a HAR recording (returns id)
|
|
2325
|
-
har read [id] Read accumulated HAR entries (id optional in active session)
|
|
2326
|
-
har delete <id> Delete a HAR recording
|
|
2327
|
-
|
|
2328
|
-
Options:
|
|
2329
|
-
--port <port> Override CDP port
|
|
2330
|
-
--target <tabId> Target tab by exact ID (preferred, parallel-safe)
|
|
2331
|
-
--new Force new tab (open)
|
|
2332
|
-
--record Enable HAR recording (exec)
|
|
2333
|
-
--har <id> Append traffic to a HAR recording
|
|
2334
|
-
--har-out <path> HAR output path
|
|
2335
|
-
--file <path> Read JS from file (exec)
|
|
2336
|
-
--duration <secs> Recording duration (record, default: 10)
|
|
2337
|
-
--settle <ms> Settle time override (navigate, click, type)
|
|
2338
|
-
--filter-url <pat> Filter HAR entries by URL substring/regex (har read)
|
|
2339
|
-
--filter-status <s> Filter HAR by status: code, prefix (4), or range (400-499)
|
|
2340
|
-
--filter-method <m> Filter HAR by HTTP method (har read)
|
|
2341
|
-
--limit <N> Return only first N matching HAR entries (har read)
|
|
2342
|
-
--out <path> Output path (screenshot)
|
|
2343
|
-
--viewport <preset> Viewport preset: desktop-wide|desktop|tablet|mobile (default: desktop)
|
|
2344
|
-
--height <px> Override viewport height (screenshot)
|
|
2345
|
-
--full-page Capture entire scrollable page (screenshot)
|
|
2346
|
-
--json JSON output (a11y)
|
|
2347
|
-
--interactive Interactive elements only (a11y)
|
|
2348
|
-
--role <role> ARIA role filter (click)
|
|
2349
|
-
--into "field" Target field by name (type)
|
|
2350
|
-
--no-screenshot Skip auto-screenshot (click, type)
|
|
2351
|
-
|
|
2352
|
-
Console output (errors, warnings) is always captured and printed to stderr.
|
|
2353
|
-
|
|
2354
|
-
Examples:
|
|
2355
|
-
capture detect
|
|
2356
|
-
capture list
|
|
2357
|
-
capture open "https://app.example.com" --new
|
|
2358
|
-
capture exec "document.title" --target <id>
|
|
2359
|
-
capture exec "document.querySelector('.btn').click()" --target <id>
|
|
2360
|
-
capture exec "fetch('/api/data').then(r=>r.json())" --target <id> --record
|
|
2361
|
-
capture exec --file /tmp/scrape.js --target <id>
|
|
2362
|
-
capture screenshot --target <id> --out /tmp/shot.png
|
|
2363
|
-
capture a11y --target <id> --interactive
|
|
2364
|
-
capture record --target <id> --duration 15
|
|
2365
|
-
capture navigate "https://app.example.com/dashboard"
|
|
2366
|
-
capture har create
|
|
2367
|
-
capture exec "document.querySelector('form').submit()" --target <id> --har <id>
|
|
2368
|
-
capture har read <id>
|
|
2369
|
-
`);
|
|
2370
|
-
}
|
|
2371
|
-
}
|
|
2372
|
-
|
|
2373
|
-
export { main as cdpMain };
|
|
38
|
+
export { CDPClient } from './cdp/client.js';
|
|
39
|
+
export type { CDPTarget } from './cdp/types.js';
|
|
40
|
+
export { detectCdpPort } from './cdp/detect.js';
|
|
41
|
+
export { listTargets, findTab, findTabById, openTab } from './cdp/targets.js';
|
|
42
|
+
export { captureScreenshot } from './cdp/screenshot.js';
|
|
43
|
+
export type { A11yNode } from './cdp/a11y.js';
|
|
44
|
+
export { getAccessibilityTree } from './cdp/a11y.js';
|
|
45
|
+
export { executeInBrowser } from './cdp/execute.js';
|
|
46
|
+
export type { ExecuteOptions, ExecuteResult } from './cdp/execute.js';
|
|
47
|
+
export { recordTraffic, navigateAndRecord, navigateAndWait } from './cdp/record.js';
|
|
48
|
+
export type { RecordOptions, RecordResult, NavigateAndRecordOptions, NavigateAndRecordResult } from './cdp/record.js';
|
|
49
|
+
export { HARRecorder } from './cdp/har-recorder.js';
|
|
50
|
+
export { ConsoleRecorder } from './cdp/console-recorder.js';
|
|
51
|
+
export type { ConsoleEntry } from './cdp/console-recorder.js';
|
|
52
|
+
export { acquireTabLock, isTabLocked, releaseTabLock, withTabLock } from './cdp/tab-lock.js';
|
|
53
|
+
export { cdpMain } from './cdp/dispatch.js';
|
|
2374
54
|
|
|
2375
55
|
// Only run CLI when executed directly (not imported via capture.ts)
|
|
2376
56
|
const isMainModule =
|
|
2377
57
|
process.argv[1]?.endsWith('cdp.ts') ||
|
|
2378
58
|
process.argv[1]?.endsWith('cdp.mjs');
|
|
2379
59
|
if (isMainModule) {
|
|
2380
|
-
|
|
60
|
+
cdpMain()
|
|
2381
61
|
.then(() => {
|
|
2382
62
|
// Flush stdout before exiting — process.exit() doesn't wait for stream drain
|
|
2383
63
|
if (process.stdout.writableNeedDrain) {
|