@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,132 @@
1
+ import * as fs from 'fs';
2
+ import * as os from 'os';
3
+ import * as path from 'path';
4
+
5
+ const LOCK_DIR = os.tmpdir();
6
+ const LOCK_STALE_MS = 120_000;
7
+ const LOCK_TIMEOUT_MS = 60_000;
8
+ const LOCK_POLL_BASE_MS = 200;
9
+
10
+ function lockPath(tabId: string): string {
11
+ return path.join(LOCK_DIR, `capture-cdp-tab-${tabId}.lock`);
12
+ }
13
+
14
+ function isPidAlive(pid: number): boolean {
15
+ try {
16
+ process.kill(pid, 0);
17
+ return true;
18
+ } catch {
19
+ return false;
20
+ }
21
+ }
22
+
23
+ function isLockStale(filePath: string): boolean {
24
+ try {
25
+ const content = fs.readFileSync(filePath, 'utf-8');
26
+ const { pid, ts } = JSON.parse(content) as { pid: number; ts: number };
27
+ if (!isPidAlive(pid)) return true;
28
+ return Date.now() - ts > LOCK_STALE_MS;
29
+ } catch {
30
+ return true;
31
+ }
32
+ }
33
+
34
+ // Track locks held by this process for cleanup on exit
35
+ const heldLocks = new Set<string>();
36
+ let cleanupRegistered = false;
37
+
38
+ function registerCleanup(): void {
39
+ if (cleanupRegistered) return;
40
+ cleanupRegistered = true;
41
+
42
+ const cleanup = () => {
43
+ for (const file of heldLocks) {
44
+ try {
45
+ fs.unlinkSync(file);
46
+ } catch {
47
+ // Best-effort
48
+ }
49
+ }
50
+ heldLocks.clear();
51
+ };
52
+
53
+ process.on('exit', cleanup);
54
+ process.on('SIGINT', () => {
55
+ cleanup();
56
+ process.exit(130);
57
+ });
58
+ process.on('SIGTERM', () => {
59
+ cleanup();
60
+ process.exit(143);
61
+ });
62
+ }
63
+
64
+ export async function acquireTabLock(tabId: string): Promise<void> {
65
+ registerCleanup();
66
+ const file = lockPath(tabId);
67
+ const deadline = Date.now() + LOCK_TIMEOUT_MS;
68
+ let delay = LOCK_POLL_BASE_MS;
69
+
70
+ while (true) {
71
+ try {
72
+ const fd = fs.openSync(file, 'wx');
73
+ fs.writeSync(fd, JSON.stringify({ pid: process.pid, ts: Date.now() }));
74
+ fs.closeSync(fd);
75
+ heldLocks.add(file);
76
+ return;
77
+ } catch (err: unknown) {
78
+ if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err;
79
+ }
80
+
81
+ if (isLockStale(file)) {
82
+ try {
83
+ fs.unlinkSync(file);
84
+ } catch {
85
+ // Another process may have already removed it
86
+ }
87
+ continue;
88
+ }
89
+
90
+ if (Date.now() >= deadline) {
91
+ throw new Error(
92
+ `Timed out waiting for CDP tab lock (${tabId}). ` +
93
+ `Another process may be using this tab. Lock file: ${file}`,
94
+ );
95
+ }
96
+
97
+ await new Promise((r) => setTimeout(r, delay));
98
+ delay = Math.min(delay * 2, 2000);
99
+ }
100
+ }
101
+
102
+ export function isTabLocked(tabId: string): boolean {
103
+ const file = lockPath(tabId);
104
+ try {
105
+ fs.accessSync(file);
106
+ return !isLockStale(file);
107
+ } catch {
108
+ return false;
109
+ }
110
+ }
111
+
112
+ export function releaseTabLock(tabId: string): void {
113
+ const file = lockPath(tabId);
114
+ heldLocks.delete(file);
115
+ try {
116
+ fs.unlinkSync(file);
117
+ } catch {
118
+ // Already removed — fine
119
+ }
120
+ }
121
+
122
+ export async function withTabLock<T>(
123
+ tabId: string,
124
+ fn: () => Promise<T>,
125
+ ): Promise<T> {
126
+ await acquireTabLock(tabId);
127
+ try {
128
+ return await fn();
129
+ } finally {
130
+ releaseTabLock(tabId);
131
+ }
132
+ }
@@ -0,0 +1,112 @@
1
+ import { CDPClient } from './client.js';
2
+ import { type CDPTarget } from './types.js';
3
+
4
+ async function getBrowserClient(
5
+ port: number,
6
+ ): Promise<{ client: CDPClient; browserWsUrl: string }> {
7
+ const versionResp = await fetch(`http://localhost:${port}/json/version`);
8
+ if (!versionResp.ok) {
9
+ throw new Error(
10
+ `Failed to connect to CDP on port ${port}: ${versionResp.status}`,
11
+ );
12
+ }
13
+ const version = (await versionResp.json()) as {
14
+ webSocketDebuggerUrl: string;
15
+ };
16
+ const client = new CDPClient(version.webSocketDebuggerUrl);
17
+ await client.waitReady();
18
+ return { client, browserWsUrl: version.webSocketDebuggerUrl };
19
+ }
20
+
21
+ export async function listTargets(port: number): Promise<CDPTarget[]> {
22
+ const { client } = await getBrowserClient(port);
23
+
24
+ try {
25
+ // Enable target discovery to get all targets including pages
26
+ await client.send('Target.setDiscoverTargets', { discover: true });
27
+ const result = (await client.send('Target.getTargets')) as {
28
+ targetInfos: Array<{
29
+ targetId: string;
30
+ type: string;
31
+ title: string;
32
+ url: string;
33
+ }>;
34
+ };
35
+
36
+ // Convert to CDPTarget format with webSocketDebuggerUrl
37
+ return result.targetInfos.map((t) => ({
38
+ id: t.targetId,
39
+ title: t.title,
40
+ url: t.url,
41
+ type: t.type,
42
+ webSocketDebuggerUrl: `ws://localhost:${port}/devtools/page/${t.targetId}`,
43
+ }));
44
+ } finally {
45
+ client.close();
46
+ }
47
+ }
48
+
49
+ export async function findTab(
50
+ port: number,
51
+ urlPattern?: string,
52
+ ): Promise<CDPTarget | null> {
53
+ const targets = await listTargets(port);
54
+ const pages = targets.filter((t) => t.type === 'page');
55
+
56
+ if (!urlPattern) {
57
+ // Return first page
58
+ return pages[0] ?? null;
59
+ }
60
+
61
+ // Find by URL pattern
62
+ const pattern = urlPattern.toLowerCase();
63
+ return pages.find((t) => t.url.toLowerCase().includes(pattern)) ?? null;
64
+ }
65
+
66
+ export async function findTabById(
67
+ port: number,
68
+ targetId: string,
69
+ ): Promise<CDPTarget | null> {
70
+ const targets = await listTargets(port);
71
+ // Exact match first
72
+ const exact = targets.find((t) => t.id === targetId);
73
+ if (exact) return exact;
74
+ // Prefix match (min 4 chars to avoid ambiguity)
75
+ if (targetId.length >= 4) {
76
+ const prefix = targetId.toUpperCase();
77
+ const matches = targets.filter((t) => t.id.toUpperCase().startsWith(prefix));
78
+ if (matches.length === 1) return matches[0];
79
+ if (matches.length > 1) {
80
+ throw new Error(
81
+ `Ambiguous target prefix "${targetId}" matches ${matches.length} tabs:\n` +
82
+ matches.map((t) => ` ${t.id.slice(0, 8)} ${t.url}`).join('\n'),
83
+ );
84
+ }
85
+ }
86
+ return null;
87
+ }
88
+
89
+ export async function openTab(port: number, url: string): Promise<CDPTarget> {
90
+ const { client } = await getBrowserClient(port);
91
+
92
+ try {
93
+ // Create new target in background to avoid stealing user's focus
94
+ const result = (await client.send('Target.createTarget', {
95
+ url,
96
+ background: true,
97
+ })) as {
98
+ targetId: string;
99
+ };
100
+
101
+ // Return target info
102
+ return {
103
+ id: result.targetId,
104
+ title: '',
105
+ url,
106
+ type: 'page',
107
+ webSocketDebuggerUrl: `ws://localhost:${port}/devtools/page/${result.targetId}`,
108
+ };
109
+ } finally {
110
+ client.close();
111
+ }
112
+ }
@@ -0,0 +1,36 @@
1
+ export interface CDPTarget {
2
+ id: string;
3
+ title: string;
4
+ url: string;
5
+ type: string;
6
+ webSocketDebuggerUrl?: string;
7
+ }
8
+
9
+ export interface ParsedArgs {
10
+ command: string;
11
+ positional: string[];
12
+ port?: number;
13
+ out?: string;
14
+ json?: boolean;
15
+ interactive?: boolean;
16
+ harOut?: string;
17
+ record?: boolean;
18
+ duration?: number;
19
+ settle?: number;
20
+ file?: string;
21
+ nested?: boolean;
22
+ har?: string;
23
+ new?: boolean;
24
+ target?: string;
25
+ role?: string;
26
+ into?: string;
27
+ noScreenshot?: boolean;
28
+ viewport?: string;
29
+ fullPage?: boolean;
30
+ height?: number;
31
+ help?: boolean;
32
+ filterUrl?: string;
33
+ filterStatus?: string;
34
+ filterMethod?: string;
35
+ limit?: number;
36
+ }
@@ -0,0 +1,62 @@
1
+ export const CDP_USAGE = `CDP — browser automation via exec, a11y interactions, and screenshots.
2
+
3
+ Commands:
4
+ exec <code> Execute JavaScript in a browser tab
5
+ exec --file <path> Execute JS from file
6
+ detect Detect CDP port (prioritizes default browser)
7
+ list List all browser tabs
8
+ open <url> Open URL in browser (returns tab ID)
9
+ reset-tab <url> Abandon stuck tab, open fresh one (updates session)
10
+ screenshot Capture screenshot
11
+ click "name" Click element by accessible name
12
+ type "text" Type text into focused element
13
+ a11y Get accessibility tree
14
+ record Passive HAR recording
15
+ navigate <url> Navigate to URL and record HAR
16
+ network <offline|online> Toggle network (simulate disconnect)
17
+ har create Create a HAR recording (returns id)
18
+ har read [id] Read accumulated HAR entries (id optional in active session)
19
+ har delete <id> Delete a HAR recording
20
+
21
+ Options:
22
+ --port <port> Override CDP port
23
+ --target <tabId> Target tab by exact ID (preferred, parallel-safe)
24
+ --new Force new tab (open)
25
+ --record Enable HAR recording (exec)
26
+ --har <id> Append traffic to a HAR recording
27
+ --har-out <path> HAR output path
28
+ --file <path> Read JS from file (exec)
29
+ --duration <secs> Recording duration (record, default: 10)
30
+ --settle <ms> Settle time override (navigate, click, type)
31
+ --filter-url <pat> Filter HAR entries by URL substring/regex (har read)
32
+ --filter-status <s> Filter HAR by status: code, prefix (4), or range (400-499)
33
+ --filter-method <m> Filter HAR by HTTP method (har read)
34
+ --limit <N> Return only first N matching HAR entries (har read)
35
+ --out <path> Output path (screenshot)
36
+ --viewport <preset> Viewport preset: desktop-wide|desktop|tablet|mobile (default: desktop)
37
+ --height <px> Override viewport height (screenshot)
38
+ --full-page Capture entire scrollable page (screenshot)
39
+ --json JSON output (a11y)
40
+ --interactive Interactive elements only (a11y)
41
+ --role <role> ARIA role filter (click)
42
+ --into "field" Target field by name (type)
43
+ --no-screenshot Skip auto-screenshot (click, type)
44
+
45
+ Console output (errors, warnings) is always captured and printed to stderr.
46
+
47
+ Examples:
48
+ capture detect
49
+ capture list
50
+ capture open "https://app.example.com" --new
51
+ capture exec "document.title" --target <id>
52
+ capture exec "document.querySelector('.btn').click()" --target <id>
53
+ capture exec "fetch('/api/data').then(r=>r.json())" --target <id> --record
54
+ capture exec --file /tmp/scrape.js --target <id>
55
+ capture screenshot --target <id> --out /tmp/shot.png
56
+ capture a11y --target <id> --interactive
57
+ capture record --target <id> --duration 15
58
+ capture navigate "https://app.example.com/dashboard"
59
+ capture har create
60
+ capture exec "document.querySelector('form').submit()" --target <id> --har <id>
61
+ capture har read <id>
62
+ `;