@crouton-kit/capture 1.3.3 → 1.3.4
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 +1683 -1459
- package/package.json +1 -1
- package/src/capture.ts +1 -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 +84 -0
- package/src/cdp/commands/har.ts +128 -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 +30 -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 +62 -0
- package/src/cdp.ts +17 -2337
- package/src/session/commands.ts +385 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import { CDPClient } from './client.js';
|
|
3
|
+
import { type CDPTarget, type ParsedArgs } from './types.js';
|
|
4
|
+
import { findTabById } from './targets.js';
|
|
5
|
+
import { detectCdpPortsAsync } from './detect.js';
|
|
6
|
+
import { ConsoleRecorder, printConsoleSummary } from './console-recorder.js';
|
|
7
|
+
import { HARRecorder } from './har-recorder.js';
|
|
8
|
+
import {
|
|
9
|
+
harFilePath,
|
|
10
|
+
appendToHarRecording as appendToHar,
|
|
11
|
+
} from '../har-manager.js';
|
|
12
|
+
import { getActiveSession, updateActiveSession } from '../session-context.js';
|
|
13
|
+
|
|
14
|
+
export async function connectForCommand(
|
|
15
|
+
parsed: ParsedArgs,
|
|
16
|
+
): Promise<{ client: CDPClient; tab: CDPTarget }> {
|
|
17
|
+
if (!parsed.target) {
|
|
18
|
+
throw new Error('Use --target <tabId> to target a tab. Run "capture list" to see available tabs.');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// If --port is explicit, search only that port. Otherwise search all endpoints.
|
|
22
|
+
let tab: CDPTarget | null = null;
|
|
23
|
+
if (parsed.port) {
|
|
24
|
+
tab = await findTabById(parsed.port, parsed.target);
|
|
25
|
+
} else {
|
|
26
|
+
const endpoints = await detectCdpPortsAsync();
|
|
27
|
+
for (const ep of endpoints) {
|
|
28
|
+
try {
|
|
29
|
+
tab = await findTabById(ep.port, parsed.target);
|
|
30
|
+
if (tab) break;
|
|
31
|
+
} catch {
|
|
32
|
+
// Skip endpoints that fail
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (!tab) {
|
|
38
|
+
const query = parsed.target;
|
|
39
|
+
throw new Error(
|
|
40
|
+
`No tab found for target "${query}". Run "capture list" to see available tabs.`,
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (!tab.webSocketDebuggerUrl) {
|
|
45
|
+
throw new Error('Tab has no WebSocket debugger URL');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Lazy-populate targetId in active session if not yet set
|
|
49
|
+
const activeSession = getActiveSession();
|
|
50
|
+
if (activeSession && !activeSession.targetId) {
|
|
51
|
+
updateActiveSession({ targetId: tab.id });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const client = new CDPClient(tab.webSocketDebuggerUrl);
|
|
55
|
+
await client.waitReady();
|
|
56
|
+
|
|
57
|
+
return { client, tab };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function withConnection<T>(
|
|
61
|
+
parsed: ParsedArgs,
|
|
62
|
+
fn: (client: CDPClient, tab: CDPTarget) => Promise<T>,
|
|
63
|
+
opts: { settle?: number } = {},
|
|
64
|
+
): Promise<T> {
|
|
65
|
+
const { client, tab } = await connectForCommand(parsed);
|
|
66
|
+
|
|
67
|
+
const consoleRecorder = new ConsoleRecorder(client);
|
|
68
|
+
await consoleRecorder.start();
|
|
69
|
+
|
|
70
|
+
let harRecorder: HARRecorder | undefined;
|
|
71
|
+
if (parsed.har) {
|
|
72
|
+
// Validate HAR ID exists before starting recording
|
|
73
|
+
const harPath = harFilePath(parsed.har);
|
|
74
|
+
if (!fs.existsSync(harPath)) {
|
|
75
|
+
console.error(
|
|
76
|
+
`ERROR: No HAR recording found for --har "${parsed.har}". Run 'har create' first.`,
|
|
77
|
+
);
|
|
78
|
+
process.exit(1);
|
|
79
|
+
}
|
|
80
|
+
harRecorder = new HARRecorder(client);
|
|
81
|
+
await harRecorder.start();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
const result = await fn(client, tab);
|
|
86
|
+
|
|
87
|
+
// Wait for network activity triggered by the action, then append to HAR
|
|
88
|
+
if (harRecorder && parsed.har) {
|
|
89
|
+
const settle = opts.settle !== undefined ? opts.settle : 3000;
|
|
90
|
+
if (settle > 0) {
|
|
91
|
+
await new Promise((r) => setTimeout(r, settle));
|
|
92
|
+
}
|
|
93
|
+
const har = await harRecorder.finish();
|
|
94
|
+
if (har.log.entries.length > 0) {
|
|
95
|
+
appendToHar(parsed.har, har.log.entries);
|
|
96
|
+
console.error(
|
|
97
|
+
` [har:${parsed.har}] +${har.log.entries.length} entries`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
printConsoleSummary(consoleRecorder.finish());
|
|
103
|
+
|
|
104
|
+
return result;
|
|
105
|
+
} finally {
|
|
106
|
+
client.close();
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { type CDPClient } from './client.js';
|
|
2
|
+
|
|
3
|
+
export interface ConsoleEntry {
|
|
4
|
+
level: 'log' | 'warning' | 'error' | 'info' | 'debug';
|
|
5
|
+
text: string;
|
|
6
|
+
timestamp: number;
|
|
7
|
+
source?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export class ConsoleRecorder {
|
|
11
|
+
private entries: ConsoleEntry[] = [];
|
|
12
|
+
|
|
13
|
+
constructor(private client: CDPClient) {}
|
|
14
|
+
|
|
15
|
+
async start(): Promise<void> {
|
|
16
|
+
await this.client.send('Runtime.enable');
|
|
17
|
+
|
|
18
|
+
this.client.on('Runtime.consoleAPICalled', (params: unknown) => {
|
|
19
|
+
const p = params as {
|
|
20
|
+
type: string;
|
|
21
|
+
args: Array<{ type: string; value?: unknown; description?: string }>;
|
|
22
|
+
timestamp: number;
|
|
23
|
+
};
|
|
24
|
+
const text = p.args
|
|
25
|
+
.map((a) =>
|
|
26
|
+
a.value !== undefined ? String(a.value) : (a.description ?? ''),
|
|
27
|
+
)
|
|
28
|
+
.join(' ');
|
|
29
|
+
this.entries.push({
|
|
30
|
+
level: p.type as ConsoleEntry['level'],
|
|
31
|
+
text,
|
|
32
|
+
timestamp: p.timestamp,
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
this.client.on('Runtime.exceptionThrown', (params: unknown) => {
|
|
37
|
+
const p = params as {
|
|
38
|
+
timestamp: number;
|
|
39
|
+
exceptionDetails: {
|
|
40
|
+
exception?: { description?: string };
|
|
41
|
+
text?: string;
|
|
42
|
+
url?: string;
|
|
43
|
+
lineNumber?: number;
|
|
44
|
+
};
|
|
45
|
+
};
|
|
46
|
+
this.entries.push({
|
|
47
|
+
level: 'error',
|
|
48
|
+
text:
|
|
49
|
+
p.exceptionDetails.exception?.description ??
|
|
50
|
+
p.exceptionDetails.text ??
|
|
51
|
+
'Unknown exception',
|
|
52
|
+
timestamp: p.timestamp,
|
|
53
|
+
source: p.exceptionDetails.url
|
|
54
|
+
? `${p.exceptionDetails.url}:${p.exceptionDetails.lineNumber}`
|
|
55
|
+
: undefined,
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
finish(): ConsoleEntry[] {
|
|
61
|
+
return [...this.entries];
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function printConsoleSummary(entries: ConsoleEntry[]): void {
|
|
66
|
+
const errors = entries.filter((e) => e.level === 'error');
|
|
67
|
+
const warnings = entries.filter((e) => e.level === 'warning');
|
|
68
|
+
if (errors.length > 0 || warnings.length > 0) {
|
|
69
|
+
console.error(
|
|
70
|
+
`\nConsole: ${errors.length} errors, ${warnings.length} warnings`,
|
|
71
|
+
);
|
|
72
|
+
for (const entry of [...errors, ...warnings]) {
|
|
73
|
+
const prefix = entry.level === 'error' ? 'ERR' : 'WARN';
|
|
74
|
+
const src = entry.source ? ` (${entry.source})` : '';
|
|
75
|
+
const text =
|
|
76
|
+
entry.text.length > 200 ? entry.text.slice(0, 197) + '...' : entry.text;
|
|
77
|
+
console.error(` [${prefix}] ${text}${src}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { execSync } from 'child_process';
|
|
2
|
+
|
|
3
|
+
export const BROWSER_PATTERNS: Record<string, string[]> = {
|
|
4
|
+
'company.thebrowser.browser': ['arc'],
|
|
5
|
+
'com.google.chrome': ['chrome', 'google chrome'],
|
|
6
|
+
'com.brave.browser': ['brave'],
|
|
7
|
+
'com.microsoft.edgemac': ['edge', 'microsoft edge'],
|
|
8
|
+
'org.chromium.chromium': ['chromium'],
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export function getDefaultBrowserId(): string | null {
|
|
12
|
+
try {
|
|
13
|
+
const output = execSync(
|
|
14
|
+
'defaults read com.apple.LaunchServices/com.apple.launchservices.secure LSHandlers 2>/dev/null',
|
|
15
|
+
{ encoding: 'utf-8' },
|
|
16
|
+
);
|
|
17
|
+
const match = output.match(
|
|
18
|
+
/LSHandlerRoleAll = "([^"]+)";\s*LSHandlerURLScheme = https;/,
|
|
19
|
+
);
|
|
20
|
+
return match?.[1] ?? null;
|
|
21
|
+
} catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface CdpProbeResult {
|
|
27
|
+
browser: string;
|
|
28
|
+
app: string;
|
|
29
|
+
isElectron: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function probeCdpPort(port: number): Promise<CdpProbeResult | null> {
|
|
33
|
+
try {
|
|
34
|
+
const controller = new AbortController();
|
|
35
|
+
const timeout = setTimeout(() => controller.abort(), 500);
|
|
36
|
+
const resp = await fetch(`http://localhost:${port}/json/version`, {
|
|
37
|
+
signal: controller.signal,
|
|
38
|
+
});
|
|
39
|
+
clearTimeout(timeout);
|
|
40
|
+
if (resp.ok) {
|
|
41
|
+
const version = (await resp.json()) as {
|
|
42
|
+
Browser?: string;
|
|
43
|
+
'User-Agent'?: string;
|
|
44
|
+
};
|
|
45
|
+
const browser = version.Browser;
|
|
46
|
+
const userAgent = version['User-Agent'] ?? '';
|
|
47
|
+
if (!browser) return null;
|
|
48
|
+
|
|
49
|
+
const isElectron = userAgent.includes('Electron');
|
|
50
|
+
|
|
51
|
+
// Extract app name from User-Agent for Electron apps
|
|
52
|
+
// Pattern: "AppName/1.2.3 Chrome/..." in the UA string
|
|
53
|
+
let app = browser;
|
|
54
|
+
if (isElectron) {
|
|
55
|
+
const appMatch = userAgent.match(
|
|
56
|
+
/(\S+?)\/[\d.]+ Chrome\/[\d.]+ Electron/,
|
|
57
|
+
);
|
|
58
|
+
if (appMatch) app = appMatch[1];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return { browser, app, isElectron };
|
|
62
|
+
}
|
|
63
|
+
} catch {
|
|
64
|
+
// Not a CDP port or not responding
|
|
65
|
+
}
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function getLocalhostListeningPorts(): number[] {
|
|
70
|
+
try {
|
|
71
|
+
// netstat is much faster than lsof on macOS
|
|
72
|
+
const output = execSync(
|
|
73
|
+
'netstat -an 2>/dev/null | grep "127.0.0.1.*LISTEN"',
|
|
74
|
+
{ encoding: 'utf-8' },
|
|
75
|
+
);
|
|
76
|
+
const ports: number[] = [];
|
|
77
|
+
for (const line of output.split('\n')) {
|
|
78
|
+
// Format: tcp4 0 0 127.0.0.1.56192 *.* LISTEN
|
|
79
|
+
const match = line.match(/127\.0\.0\.1\.(\d+)/);
|
|
80
|
+
if (match) {
|
|
81
|
+
ports.push(parseInt(match[1], 10));
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return ports;
|
|
85
|
+
} catch {
|
|
86
|
+
return [];
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface CdpEndpoint {
|
|
91
|
+
port: number;
|
|
92
|
+
app: string;
|
|
93
|
+
bundleId: string;
|
|
94
|
+
isElectron: boolean;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function detectCdpPortsAsync(): Promise<CdpEndpoint[]> {
|
|
98
|
+
const ports = getLocalhostListeningPorts();
|
|
99
|
+
const results: CdpEndpoint[] = [];
|
|
100
|
+
|
|
101
|
+
// Probe ports in parallel for speed
|
|
102
|
+
const probes = ports.map(async (port) => {
|
|
103
|
+
const probe = await probeCdpPort(port);
|
|
104
|
+
if (probe) {
|
|
105
|
+
// Map browser string to bundle ID for default browser matching
|
|
106
|
+
const lower = probe.browser.toLowerCase();
|
|
107
|
+
let bundleId = 'unknown';
|
|
108
|
+
for (const [bid, patterns] of Object.entries(BROWSER_PATTERNS)) {
|
|
109
|
+
if (patterns.some((p) => lower.includes(p))) {
|
|
110
|
+
bundleId = bid;
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
results.push({
|
|
115
|
+
port,
|
|
116
|
+
app: probe.app,
|
|
117
|
+
bundleId,
|
|
118
|
+
isElectron: probe.isElectron,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
await Promise.all(probes);
|
|
124
|
+
return results;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export async function detectCdpPort(): Promise<number> {
|
|
128
|
+
const endpoints = await detectCdpPortsAsync();
|
|
129
|
+
if (endpoints.length === 0) {
|
|
130
|
+
throw new Error(
|
|
131
|
+
'No browser with CDP found. Start your browser with remote debugging enabled:\n' +
|
|
132
|
+
' Arc: Already enabled by default\n' +
|
|
133
|
+
' Chrome: --remote-debugging-port=9222\n' +
|
|
134
|
+
' Electron apps expose CDP automatically',
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Use default browser if available (prefer non-Electron)
|
|
139
|
+
const defaultBrowser = getDefaultBrowserId();
|
|
140
|
+
if (defaultBrowser) {
|
|
141
|
+
const match = endpoints.find(
|
|
142
|
+
(p) => p.bundleId === defaultBrowser && !p.isElectron,
|
|
143
|
+
);
|
|
144
|
+
if (match) return match.port;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Fall back to first non-Electron, then first found
|
|
148
|
+
const nonElectron = endpoints.find((p) => !p.isElectron);
|
|
149
|
+
return (nonElectron ?? endpoints[0]).port;
|
|
150
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { parseCliArgs } from './args.js';
|
|
2
|
+
import { CDP_USAGE } from './usage.js';
|
|
3
|
+
import { cmdDetect, cmdList, cmdOpen, cmdResetTab } from './commands/tabs.js';
|
|
4
|
+
import { cmdExec } from './commands/exec.js';
|
|
5
|
+
import { cmdScreenshot, cmdClick, cmdType, cmdA11y } from './commands/ui.js';
|
|
6
|
+
import { cmdRecord, cmdNavigate, cmdNetwork } from './commands/traffic.js';
|
|
7
|
+
import { cmdHar } from './commands/har.js';
|
|
8
|
+
|
|
9
|
+
export async function cdpMain(): Promise<void> {
|
|
10
|
+
const args = process.argv.slice(2);
|
|
11
|
+
const parsed = parseCliArgs(args);
|
|
12
|
+
|
|
13
|
+
switch (parsed.command) {
|
|
14
|
+
case 'detect': return cmdDetect(parsed, args);
|
|
15
|
+
case 'list': return cmdList(parsed, args);
|
|
16
|
+
case 'open': return cmdOpen(parsed, args);
|
|
17
|
+
case 'reset-tab': return cmdResetTab(parsed, args);
|
|
18
|
+
case 'exec': return cmdExec(parsed, args);
|
|
19
|
+
case 'screenshot': return cmdScreenshot(parsed, args);
|
|
20
|
+
case 'click': return cmdClick(parsed, args);
|
|
21
|
+
case 'type': return cmdType(parsed, args);
|
|
22
|
+
case 'a11y': return cmdA11y(parsed, args);
|
|
23
|
+
case 'record': return cmdRecord(parsed, args);
|
|
24
|
+
case 'navigate': return cmdNavigate(parsed, args);
|
|
25
|
+
case 'network': return cmdNetwork(parsed, args);
|
|
26
|
+
case 'har': return cmdHar(parsed, args);
|
|
27
|
+
default:
|
|
28
|
+
console.log(CDP_USAGE);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { CDPClient } from './client.js';
|
|
2
|
+
import { type CDPTarget } from './types.js';
|
|
3
|
+
import { detectCdpPort } from './detect.js';
|
|
4
|
+
import { findTabById } from './targets.js';
|
|
5
|
+
import { ConsoleRecorder, type ConsoleEntry } from './console-recorder.js';
|
|
6
|
+
import { HARRecorder } from './har-recorder.js';
|
|
7
|
+
import { printConsoleSummary } from './console-recorder.js';
|
|
8
|
+
import { writeHarAndPrintSummary } from './har-output.js';
|
|
9
|
+
import { type HAREntry } from '../har-manager.js';
|
|
10
|
+
|
|
11
|
+
export interface ExecuteOptions {
|
|
12
|
+
port?: number;
|
|
13
|
+
targetId?: string;
|
|
14
|
+
record?: boolean;
|
|
15
|
+
harOutPath?: string;
|
|
16
|
+
timeoutMs?: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ExecuteResult {
|
|
20
|
+
success: boolean;
|
|
21
|
+
value?: unknown;
|
|
22
|
+
error?: string;
|
|
23
|
+
har?: { log: { entries: HAREntry[] } };
|
|
24
|
+
console: ConsoleEntry[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function executeInBrowser(
|
|
28
|
+
code: string,
|
|
29
|
+
options: ExecuteOptions = {},
|
|
30
|
+
): Promise<ExecuteResult> {
|
|
31
|
+
const port = options.port ?? (await detectCdpPort());
|
|
32
|
+
|
|
33
|
+
if (!options.targetId) {
|
|
34
|
+
throw new Error('Use --target <tabId> to target a tab. Run "capture list" to see available tabs.');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Find tab by exact/prefix ID
|
|
38
|
+
let tab: CDPTarget | null = null;
|
|
39
|
+
tab = await findTabById(port, options.targetId);
|
|
40
|
+
if (!tab) {
|
|
41
|
+
throw new Error(
|
|
42
|
+
`No tab found for target "${options.targetId}". Run "capture list" to see available tabs.`,
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (!tab.webSocketDebuggerUrl) {
|
|
47
|
+
throw new Error('Tab has no WebSocket debugger URL');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const client = new CDPClient(tab.webSocketDebuggerUrl);
|
|
51
|
+
await client.waitReady();
|
|
52
|
+
|
|
53
|
+
// Emulate focus so network requests aren't deferred by the browser,
|
|
54
|
+
// without actually bringing the tab to the foreground
|
|
55
|
+
await client.send('Emulation.setFocusEmulationEnabled', { enabled: true });
|
|
56
|
+
|
|
57
|
+
const consoleRecorder = new ConsoleRecorder(client);
|
|
58
|
+
await consoleRecorder.start();
|
|
59
|
+
|
|
60
|
+
let harRecorder: HARRecorder | undefined;
|
|
61
|
+
if (options.record) {
|
|
62
|
+
harRecorder = new HARRecorder(client);
|
|
63
|
+
await harRecorder.start();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
// Execute code
|
|
68
|
+
const result = (await client.send(
|
|
69
|
+
'Runtime.evaluate',
|
|
70
|
+
{
|
|
71
|
+
expression: code,
|
|
72
|
+
awaitPromise: true,
|
|
73
|
+
returnByValue: true,
|
|
74
|
+
},
|
|
75
|
+
options.timeoutMs,
|
|
76
|
+
)) as {
|
|
77
|
+
result?: { value?: unknown };
|
|
78
|
+
exceptionDetails?: { exception?: { description?: string } };
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
// Build HAR if recording
|
|
82
|
+
let har: { log: { entries: HAREntry[] } } | undefined;
|
|
83
|
+
if (harRecorder) {
|
|
84
|
+
har = await harRecorder.finish();
|
|
85
|
+
writeHarAndPrintSummary(har, options.harOutPath);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const consoleEntries = consoleRecorder.finish();
|
|
89
|
+
printConsoleSummary(consoleEntries);
|
|
90
|
+
client.close();
|
|
91
|
+
|
|
92
|
+
if (result.exceptionDetails) {
|
|
93
|
+
return {
|
|
94
|
+
success: false,
|
|
95
|
+
error:
|
|
96
|
+
result.exceptionDetails.exception?.description ?? 'Unknown error',
|
|
97
|
+
har,
|
|
98
|
+
console: consoleEntries,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
success: true,
|
|
104
|
+
value: result.result?.value,
|
|
105
|
+
har,
|
|
106
|
+
console: consoleEntries,
|
|
107
|
+
};
|
|
108
|
+
} catch (err) {
|
|
109
|
+
const consoleEntries = consoleRecorder.finish();
|
|
110
|
+
client.close();
|
|
111
|
+
return {
|
|
112
|
+
success: false,
|
|
113
|
+
error: err instanceof Error ? err.message : String(err),
|
|
114
|
+
console: consoleEntries,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import { type HAREntry } from '../har-manager.js';
|
|
3
|
+
|
|
4
|
+
export function writeHarAndPrintSummary(
|
|
5
|
+
har: { log: { entries: HAREntry[] } },
|
|
6
|
+
harOutPath?: string,
|
|
7
|
+
): string | undefined {
|
|
8
|
+
if (har.log.entries.length === 0 && !harOutPath) return undefined;
|
|
9
|
+
|
|
10
|
+
const harPath = harOutPath ?? `/tmp/capture-har-${Date.now()}.json`;
|
|
11
|
+
try {
|
|
12
|
+
fs.writeFileSync(harPath, JSON.stringify(har, null, 2));
|
|
13
|
+
} catch (err) {
|
|
14
|
+
// Fallback: strip response bodies and retry
|
|
15
|
+
const stripped = {
|
|
16
|
+
log: {
|
|
17
|
+
...har.log,
|
|
18
|
+
entries: har.log.entries.map((e) => ({
|
|
19
|
+
...e,
|
|
20
|
+
response: { ...e.response, content: { text: '[body stripped — too large to serialize]' } },
|
|
21
|
+
})),
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
fs.writeFileSync(harPath, JSON.stringify(stripped, null, 2));
|
|
25
|
+
console.error(' WARNING: Response bodies stripped from HAR (too large to serialize)');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Print summary to stderr
|
|
29
|
+
const errors = har.log.entries.filter((e) => e.response.status >= 400);
|
|
30
|
+
console.error(
|
|
31
|
+
`\nHAR: ${har.log.entries.length} requests captured → ${harPath}`,
|
|
32
|
+
);
|
|
33
|
+
if (errors.length > 0) {
|
|
34
|
+
console.error(` ${errors.length} failed:`);
|
|
35
|
+
for (const entry of errors) {
|
|
36
|
+
const method = entry.request.method.padEnd(6);
|
|
37
|
+
const status = entry.response.status;
|
|
38
|
+
const url =
|
|
39
|
+
entry.request.url.length > 100
|
|
40
|
+
? entry.request.url.slice(0, 97) + '...'
|
|
41
|
+
: entry.request.url;
|
|
42
|
+
console.error(` ${method} ${status} ${url}`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return harPath;
|
|
47
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { type HAREntry } from '../har-manager.js';
|
|
2
|
+
import { type CDPClient } from './client.js';
|
|
3
|
+
|
|
4
|
+
interface NetworkRequest {
|
|
5
|
+
requestId: string;
|
|
6
|
+
url: string;
|
|
7
|
+
method: string;
|
|
8
|
+
headers: Record<string, string>;
|
|
9
|
+
postData?: string;
|
|
10
|
+
timestamp: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface NetworkResponse {
|
|
14
|
+
requestId: string;
|
|
15
|
+
url: string;
|
|
16
|
+
status: number;
|
|
17
|
+
headers: Record<string, string>;
|
|
18
|
+
body?: string;
|
|
19
|
+
timestamp: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// HAREntry imported from ../har-manager.ts
|
|
23
|
+
|
|
24
|
+
const SKIP_EXTENSIONS =
|
|
25
|
+
/\.(png|jpg|jpeg|gif|webp|svg|ico|woff2?|ttf|eot|css|mp4|webm|mp3)(\?|$)/i;
|
|
26
|
+
const SKIP_DOMAINS =
|
|
27
|
+
/(google-analytics|googletagmanager|doubleclick|facebook\.com\/tr|px\.ads|analytics|tracking|beacon|telemetry)/i;
|
|
28
|
+
const MAX_BODY_SIZE = 256 * 1024; // 256KB per response body in HAR
|
|
29
|
+
|
|
30
|
+
function shouldRecordRequest(url: string): boolean {
|
|
31
|
+
if (SKIP_EXTENSIONS.test(url)) return false;
|
|
32
|
+
if (SKIP_DOMAINS.test(url)) return false;
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class HARRecorder {
|
|
37
|
+
private requests = new Map<string, NetworkRequest>();
|
|
38
|
+
private responses = new Map<string, NetworkResponse>();
|
|
39
|
+
|
|
40
|
+
constructor(private client: CDPClient) {}
|
|
41
|
+
|
|
42
|
+
get responseCount(): number {
|
|
43
|
+
return this.responses.size;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async start(): Promise<void> {
|
|
47
|
+
await this.client.send('Network.enable');
|
|
48
|
+
|
|
49
|
+
this.client.on('Network.requestWillBeSent', (params: unknown) => {
|
|
50
|
+
const p = params as {
|
|
51
|
+
requestId: string;
|
|
52
|
+
request: {
|
|
53
|
+
url: string;
|
|
54
|
+
method: string;
|
|
55
|
+
headers: Record<string, string>;
|
|
56
|
+
postData?: string;
|
|
57
|
+
};
|
|
58
|
+
timestamp: number;
|
|
59
|
+
};
|
|
60
|
+
if (!shouldRecordRequest(p.request.url)) return;
|
|
61
|
+
this.requests.set(p.requestId, {
|
|
62
|
+
requestId: p.requestId,
|
|
63
|
+
url: p.request.url,
|
|
64
|
+
method: p.request.method,
|
|
65
|
+
headers: p.request.headers,
|
|
66
|
+
postData: p.request.postData,
|
|
67
|
+
timestamp: p.timestamp,
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
this.client.on('Network.responseReceived', (params: unknown) => {
|
|
72
|
+
const p = params as {
|
|
73
|
+
requestId: string;
|
|
74
|
+
response: {
|
|
75
|
+
url: string;
|
|
76
|
+
status: number;
|
|
77
|
+
headers: Record<string, string>;
|
|
78
|
+
};
|
|
79
|
+
timestamp: number;
|
|
80
|
+
};
|
|
81
|
+
if (!this.requests.has(p.requestId)) return;
|
|
82
|
+
this.responses.set(p.requestId, {
|
|
83
|
+
requestId: p.requestId,
|
|
84
|
+
url: p.response.url,
|
|
85
|
+
status: p.response.status,
|
|
86
|
+
headers: p.response.headers,
|
|
87
|
+
timestamp: p.timestamp,
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
private buildHar(): { log: { entries: HAREntry[] } } {
|
|
93
|
+
return {
|
|
94
|
+
log: {
|
|
95
|
+
entries: Array.from(this.responses.values()).map((resp) => {
|
|
96
|
+
const req = this.requests.get(resp.requestId);
|
|
97
|
+
return {
|
|
98
|
+
startedDateTime: new Date(resp.timestamp * 1000).toISOString(),
|
|
99
|
+
request: {
|
|
100
|
+
method: req?.method ?? 'GET',
|
|
101
|
+
url: resp.url,
|
|
102
|
+
headers: Object.entries(req?.headers ?? {}).map(
|
|
103
|
+
([name, value]) => ({ name, value }),
|
|
104
|
+
),
|
|
105
|
+
postData: req?.postData ? { text: req.postData } : undefined,
|
|
106
|
+
},
|
|
107
|
+
response: {
|
|
108
|
+
status: resp.status,
|
|
109
|
+
headers: Object.entries(resp.headers).map(([name, value]) => ({
|
|
110
|
+
name,
|
|
111
|
+
value,
|
|
112
|
+
})),
|
|
113
|
+
content: { text: resp.body },
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
}),
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async finish(): Promise<{ log: { entries: HAREntry[] } }> {
|
|
122
|
+
// Fetch response bodies in parallel — sequential fetches with 5s timeouts
|
|
123
|
+
// were causing ~50s hangs when many responses had evicted bodies
|
|
124
|
+
await Promise.allSettled(
|
|
125
|
+
Array.from(this.responses.entries()).map(async ([reqId, resp]) => {
|
|
126
|
+
const bodyResult = (await this.client.send('Network.getResponseBody', {
|
|
127
|
+
requestId: reqId,
|
|
128
|
+
}, 5000)) as {
|
|
129
|
+
body: string;
|
|
130
|
+
base64Encoded: boolean;
|
|
131
|
+
};
|
|
132
|
+
const decoded = bodyResult.base64Encoded
|
|
133
|
+
? Buffer.from(bodyResult.body, 'base64').toString()
|
|
134
|
+
: bodyResult.body;
|
|
135
|
+
resp.body = decoded.length > MAX_BODY_SIZE
|
|
136
|
+
? decoded.slice(0, MAX_BODY_SIZE) + `\n[truncated: ${decoded.length} bytes]`
|
|
137
|
+
: decoded;
|
|
138
|
+
}),
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
return this.buildHar();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
finishPartial(): { log: { entries: HAREntry[] } } {
|
|
145
|
+
return this.buildHar();
|
|
146
|
+
}
|
|
147
|
+
}
|