@crouton-kit/capture 1.3.2 → 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.
@@ -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
+ }
@@ -0,0 +1,221 @@
1
+ import { CDPClient } from './client.js';
2
+ import { type CDPTarget } from './types.js';
3
+ import { detectCdpPort } from './detect.js';
4
+ import { findTab, findTabById, openTab, listTargets } from './targets.js';
5
+ import { HARRecorder } from './har-recorder.js';
6
+ import { writeHarAndPrintSummary } from './har-output.js';
7
+ import { type HAREntry } from '../har-manager.js';
8
+
9
+ export interface RecordOptions {
10
+ port?: number;
11
+ targetId?: string;
12
+ duration?: number;
13
+ harOutPath?: string;
14
+ }
15
+
16
+ export interface RecordResult {
17
+ harPath: string | undefined;
18
+ entryCount: number;
19
+ har: { log: { entries: HAREntry[] } };
20
+ }
21
+
22
+ export async function recordTraffic(
23
+ options: RecordOptions,
24
+ ): Promise<RecordResult> {
25
+ const port = options.port ?? (await detectCdpPort());
26
+ const duration = options.duration ?? 10;
27
+
28
+ if (!options.targetId) {
29
+ throw new Error('Use --target <tabId> to target a tab. Run "capture list" to see available tabs.');
30
+ }
31
+
32
+ const tab = await findTabById(port, options.targetId);
33
+ if (!tab) {
34
+ throw new Error(
35
+ `No tab found for target "${options.targetId}". Run "capture list" to see available tabs.`,
36
+ );
37
+ }
38
+
39
+ if (!tab.webSocketDebuggerUrl) {
40
+ throw new Error('Tab has no WebSocket debugger URL');
41
+ }
42
+
43
+ const client = new CDPClient(tab.webSocketDebuggerUrl);
44
+ await client.waitReady();
45
+
46
+ const recorder = new HARRecorder(client);
47
+ await recorder.start();
48
+
49
+ console.error(
50
+ `Recording traffic on "${tab.url}" for ${duration}s... (click around in the browser)`,
51
+ );
52
+
53
+ await new Promise((r) => setTimeout(r, duration * 1000));
54
+
55
+ const har = await recorder.finish();
56
+ const harPath = writeHarAndPrintSummary(har, options.harOutPath);
57
+
58
+ client.close();
59
+
60
+ return { harPath, entryCount: har.log.entries.length, har };
61
+ }
62
+
63
+ export interface NavigateAndRecordOptions {
64
+ port?: number;
65
+ url: string;
66
+ targetId?: string;
67
+ harOutPath?: string;
68
+ settle?: number;
69
+ }
70
+
71
+ export interface NavigateAndRecordResult {
72
+ harPath: string | undefined;
73
+ entryCount: number;
74
+ har: { log: { entries: HAREntry[] } };
75
+ tab: CDPTarget;
76
+ timedOut: boolean;
77
+ }
78
+
79
+ export async function navigateAndRecord(
80
+ options: NavigateAndRecordOptions,
81
+ ): Promise<NavigateAndRecordResult> {
82
+ const port = options.port ?? (await detectCdpPort());
83
+ const settle = options.settle ?? 2000;
84
+
85
+ // Find tab — prefer targetId (exact) over domain match (fuzzy)
86
+ let tab: CDPTarget | null = null;
87
+ let isNewTab = false;
88
+ if (options.targetId) {
89
+ tab = await findTabById(port, options.targetId);
90
+ if (!tab) {
91
+ throw new Error(
92
+ `No tab found with target ID "${options.targetId}". Tab may have been closed.`,
93
+ );
94
+ }
95
+ } else {
96
+ let domain: string;
97
+ try {
98
+ domain = new URL(options.url).hostname;
99
+ } catch {
100
+ throw new Error(`Invalid URL: ${options.url}`);
101
+ }
102
+ tab = await findTab(port, domain);
103
+ if (!tab) {
104
+ tab = await openTab(port, options.url);
105
+ isNewTab = true;
106
+ }
107
+ }
108
+ if (!tab.webSocketDebuggerUrl) {
109
+ throw new Error('Tab has no WebSocket debugger URL');
110
+ }
111
+
112
+ const client = new CDPClient(tab.webSocketDebuggerUrl);
113
+ await client.waitReady();
114
+ await client.send('Page.enable');
115
+
116
+ // Start recording BEFORE navigation
117
+ const recorder = new HARRecorder(client);
118
+ await recorder.start();
119
+
120
+ if (!isNewTab) {
121
+ // Navigate existing tab
122
+ console.error(`Navigating to: ${options.url}`);
123
+ await client.send('Page.navigate', { url: options.url });
124
+ }
125
+
126
+ const deadline = 60_000;
127
+ const t0 = Date.now();
128
+ let timedOut = false;
129
+ let har!: { log: { entries: HAREntry[] } };
130
+
131
+ try {
132
+ await Promise.race([
133
+ (async () => {
134
+ // Wait for load event
135
+ await new Promise<void>((resolve) => {
136
+ const timer = setTimeout(() => {
137
+ console.error('Page load timeout (10s), continuing with settle...');
138
+ resolve();
139
+ }, 10000);
140
+ client.on('Page.loadEventFired', () => {
141
+ clearTimeout(timer);
142
+ resolve();
143
+ });
144
+ });
145
+
146
+ // Settle time for SPAs that load after DOMContentLoaded
147
+ console.error(`Settling for ${settle}ms...`);
148
+ await new Promise((r) => setTimeout(r, settle));
149
+
150
+ har = await recorder.finish();
151
+ })(),
152
+ new Promise<never>((_, reject) => {
153
+ const t = setTimeout(() => reject(new Error('__navigate_timeout__')), deadline - (Date.now() - t0));
154
+ // Don't keep process alive just for the deadline timer
155
+ if (typeof t === 'object' && 'unref' in t) t.unref();
156
+ }),
157
+ ]);
158
+ } catch (err) {
159
+ if (err instanceof Error && err.message === '__navigate_timeout__') {
160
+ timedOut = true;
161
+ console.error('Navigate timeout (60s) — returning partial HAR');
162
+ har = recorder.finishPartial();
163
+ } else {
164
+ throw err;
165
+ }
166
+ }
167
+
168
+ const harPath = writeHarAndPrintSummary(har, options.harOutPath);
169
+
170
+ client.close();
171
+
172
+ // Refresh target info
173
+ const updatedTab = (await findTab(port, options.url)) ?? tab;
174
+
175
+ return { harPath, entryCount: har.log.entries.length, har, tab: updatedTab, timedOut };
176
+ }
177
+
178
+ export async function navigateAndWait(
179
+ port: number,
180
+ url: string,
181
+ options: { timeout?: number; forceNew?: boolean } = {},
182
+ ): Promise<CDPTarget> {
183
+ const timeout = options.timeout ?? 10000;
184
+
185
+ // Check if tab already exists (unless forceNew requested)
186
+ if (!options.forceNew) {
187
+ const existing = await findTab(port, url);
188
+ if (existing) {
189
+ return existing;
190
+ }
191
+ }
192
+
193
+ // Open new tab
194
+ const tab = await openTab(port, url);
195
+ if (!tab.webSocketDebuggerUrl) {
196
+ throw new Error('New tab has no WebSocket debugger URL');
197
+ }
198
+
199
+ // Wait for page load
200
+ const client = new CDPClient(tab.webSocketDebuggerUrl);
201
+ await client.waitReady();
202
+ await client.send('Page.enable');
203
+
204
+ await new Promise<void>((resolve, reject) => {
205
+ const timer = setTimeout(
206
+ () => reject(new Error(`Page load timeout: ${url}`)),
207
+ timeout,
208
+ );
209
+ client.on('Page.loadEventFired', () => {
210
+ clearTimeout(timer);
211
+ resolve();
212
+ });
213
+ });
214
+
215
+ client.close();
216
+
217
+ // Refresh target info — find by ID to avoid returning a different tab with same URL
218
+ const targets = await listTargets(port);
219
+ const refreshed = targets.find((t) => t.id === tab.id);
220
+ return refreshed ?? tab;
221
+ }
@@ -0,0 +1,100 @@
1
+ import * as fs from 'fs';
2
+ import { CDPClient } from './client.js';
3
+ import { nextStepPath } from '../session-context.js';
4
+
5
+ export async function captureScreenshot(
6
+ client: CDPClient,
7
+ viewport?: { width: number; height: number },
8
+ options?: { fullPage?: boolean },
9
+ ): Promise<Buffer> {
10
+ const MAX_DIM = 1600; // headroom below Anthropic's 2000px many-image limit
11
+
12
+ // Apply viewport emulation if requested
13
+ if (viewport) {
14
+ await client.send('Emulation.setDeviceMetricsOverride', {
15
+ width: viewport.width,
16
+ height: viewport.height,
17
+ deviceScaleFactor: 1,
18
+ mobile: false,
19
+ });
20
+ // Let the page re-layout at the new size
21
+ await new Promise((r) => setTimeout(r, 150));
22
+ }
23
+
24
+ // For full-page capture, resize viewport to the full content height
25
+ if (options?.fullPage) {
26
+ const layoutMetrics = (await client.send('Page.getLayoutMetrics')) as {
27
+ contentSize?: { width: number; height: number };
28
+ cssVisualViewport?: { clientWidth: number };
29
+ };
30
+ const contentWidth = layoutMetrics.cssVisualViewport?.clientWidth ?? viewport?.width ?? 1280;
31
+ const contentHeight = layoutMetrics.contentSize?.height ?? viewport?.height ?? 800;
32
+ await client.send('Emulation.setDeviceMetricsOverride', {
33
+ width: contentWidth,
34
+ height: Math.ceil(contentHeight),
35
+ deviceScaleFactor: 1,
36
+ mobile: false,
37
+ });
38
+ await new Promise((r) => setTimeout(r, 150));
39
+ }
40
+
41
+ let screenshotOpts: Record<string, unknown> = {
42
+ format: 'png',
43
+ captureBeyondViewport: false,
44
+ };
45
+
46
+ try {
47
+ const metrics = (await client.send('Page.getLayoutMetrics')) as {
48
+ cssVisualViewport?: { clientWidth: number; clientHeight: number; pageX: number; pageY: number };
49
+ };
50
+ const vw = metrics.cssVisualViewport?.clientWidth ?? 0;
51
+ const vh = metrics.cssVisualViewport?.clientHeight ?? 0;
52
+ const sx = metrics.cssVisualViewport?.pageX ?? 0;
53
+ const sy = metrics.cssVisualViewport?.pageY ?? 0;
54
+
55
+ const dprResult = (await client.send('Runtime.evaluate', {
56
+ expression: 'window.devicePixelRatio',
57
+ returnByValue: true,
58
+ })) as { result: { value: number } };
59
+ const dpr = dprResult.result.value ?? 1;
60
+
61
+ const actualMaxSide = Math.max(vw, vh) * dpr;
62
+ const scale = actualMaxSide > MAX_DIM ? MAX_DIM / actualMaxSide : 1 / dpr;
63
+ screenshotOpts = {
64
+ ...screenshotOpts,
65
+ clip: { x: sx, y: sy, width: vw, height: vh, scale },
66
+ };
67
+ } catch {
68
+ // Fallback: capture without downscaling
69
+ }
70
+
71
+ const result = (await client.send(
72
+ 'Page.captureScreenshot',
73
+ screenshotOpts,
74
+ )) as { data: string };
75
+
76
+ // Reset emulation so the browser window isn't stuck at the overridden size
77
+ if (viewport || options?.fullPage) {
78
+ await client.send('Emulation.clearDeviceMetricsOverride');
79
+ }
80
+
81
+ return Buffer.from(result.data, 'base64');
82
+ }
83
+
84
+ export async function autoScreenshot(
85
+ client: CDPClient,
86
+ action: string,
87
+ label: string,
88
+ noScreenshot?: boolean,
89
+ ): Promise<string | null> {
90
+ if (noScreenshot) return null;
91
+ const shotPath = nextStepPath(action, label);
92
+ if (!shotPath) return null;
93
+
94
+ // Brief settle for UI to update
95
+ await new Promise((r) => setTimeout(r, 300));
96
+ const png = await captureScreenshot(client);
97
+ fs.writeFileSync(shotPath, png);
98
+ console.error(` [screenshot] ${shotPath}`);
99
+ return shotPath;
100
+ }