@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.
@@ -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,32 @@
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
+ import { cmdLib } from './commands/lib.js';
9
+
10
+ export async function cdpMain(): Promise<void> {
11
+ const args = process.argv.slice(2);
12
+ const parsed = parseCliArgs(args);
13
+
14
+ switch (parsed.command) {
15
+ case 'detect': return cmdDetect(parsed, args);
16
+ case 'list': return cmdList(parsed, args);
17
+ case 'open': return cmdOpen(parsed, args);
18
+ case 'reset-tab': return cmdResetTab(parsed, args);
19
+ case 'exec': return cmdExec(parsed, args);
20
+ case 'screenshot': return cmdScreenshot(parsed, args);
21
+ case 'click': return cmdClick(parsed, args);
22
+ case 'type': return cmdType(parsed, args);
23
+ case 'a11y': return cmdA11y(parsed, args);
24
+ case 'record': return cmdRecord(parsed, args);
25
+ case 'navigate': return cmdNavigate(parsed, args);
26
+ case 'network': return cmdNetwork(parsed, args);
27
+ case 'har': return cmdHar(parsed, args);
28
+ case 'lib': return cmdLib(parsed, args);
29
+ default:
30
+ console.log(CDP_USAGE);
31
+ }
32
+ }
@@ -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
+ }