@crouton-kit/capture 1.3.4 → 1.3.6

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.
@@ -1,8 +1,7 @@
1
1
  import * as fs from 'fs';
2
2
  import { CDPClient } from './client.js';
3
+ import { findTabByIdAcrossEndpoints, findTabByUrlAcrossEndpoints } from './targets.js';
3
4
  import { type CDPTarget, type ParsedArgs } from './types.js';
4
- import { findTabById } from './targets.js';
5
- import { detectCdpPortsAsync } from './detect.js';
6
5
  import { ConsoleRecorder, printConsoleSummary } from './console-recorder.js';
7
6
  import { HARRecorder } from './har-recorder.js';
8
7
  import {
@@ -11,33 +10,31 @@ import {
11
10
  } from '../har-manager.js';
12
11
  import { getActiveSession, updateActiveSession } from '../session-context.js';
13
12
 
13
+ function getPortFromWebSocketDebuggerUrl(url?: string): number | null {
14
+ if (!url) return null;
15
+ try {
16
+ return Number(new URL(url).port);
17
+ } catch {
18
+ return null;
19
+ }
20
+ }
21
+
14
22
  export async function connectForCommand(
15
23
  parsed: ParsedArgs,
16
24
  ): 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.');
25
+ if (!parsed.target && !parsed.url) {
26
+ throw new Error('Use --target <tabId> or --url <pattern> to target a tab. Run "capture list" to see available tabs.');
19
27
  }
20
28
 
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
- }
29
+ const resolved = parsed.target
30
+ ? await findTabByIdAcrossEndpoints(parsed.target, parsed.port)
31
+ : await findTabByUrlAcrossEndpoints(parsed.url!, parsed.port);
32
+ const tab = resolved?.tab ?? null;
36
33
 
37
34
  if (!tab) {
38
- const query = parsed.target;
35
+ const query = parsed.target ?? parsed.url;
39
36
  throw new Error(
40
- `No tab found for target "${query}". Run "capture list" to see available tabs.`,
37
+ `No tab found for ${parsed.target ? 'target' : 'URL pattern'} "${query}". Run "capture list" to see available tabs.`,
41
38
  );
42
39
  }
43
40
 
@@ -51,6 +48,11 @@ export async function connectForCommand(
51
48
  updateActiveSession({ targetId: tab.id });
52
49
  }
53
50
 
51
+ const port = resolved?.port ?? getPortFromWebSocketDebuggerUrl(tab.webSocketDebuggerUrl);
52
+ console.error(
53
+ `Using target ${tab.id.slice(0, 8)}${port ? ` on port ${port}` : ''}${tab.url ? ` (${tab.url})` : ''}`,
54
+ );
55
+
54
56
  const client = new CDPClient(tab.webSocketDebuggerUrl);
55
57
  await client.waitReady();
56
58
 
package/src/cdp/detect.ts CHANGED
@@ -27,8 +27,17 @@ interface CdpProbeResult {
27
27
  browser: string;
28
28
  app: string;
29
29
  isElectron: boolean;
30
+ hasPageTarget: boolean;
30
31
  }
31
32
 
33
+ // Node's inspector, workerd/Miniflare's inspector, and similar V8-Inspector-
34
+ // protocol processes all speak enough of the Chrome DevTools Protocol to
35
+ // answer /json/version with a `Browser` field, so a naive probe mistakes them
36
+ // for a real browser. They are not: they never return a top-level
37
+ // `webSocketDebuggerUrl` (there's no browser-level target to attach to, only
38
+ // the single runtime target) and they never host a `page`-type target. Both
39
+ // are checked so these endpoints are excluded from auto-selection instead of
40
+ // being confused for the intended browser.
32
41
  async function probeCdpPort(port: number): Promise<CdpProbeResult | null> {
33
42
  try {
34
43
  const controller = new AbortController();
@@ -41,10 +50,14 @@ async function probeCdpPort(port: number): Promise<CdpProbeResult | null> {
41
50
  const version = (await resp.json()) as {
42
51
  Browser?: string;
43
52
  'User-Agent'?: string;
53
+ webSocketDebuggerUrl?: string;
44
54
  };
45
55
  const browser = version.Browser;
46
56
  const userAgent = version['User-Agent'] ?? '';
47
57
  if (!browser) return null;
58
+ // No browser-level WebSocket debugger URL means there's nothing to
59
+ // open new tabs on — this isn't a controllable browser.
60
+ if (!version.webSocketDebuggerUrl) return null;
48
61
 
49
62
  const isElectron = userAgent.includes('Electron');
50
63
 
@@ -58,7 +71,9 @@ async function probeCdpPort(port: number): Promise<CdpProbeResult | null> {
58
71
  if (appMatch) app = appMatch[1];
59
72
  }
60
73
 
61
- return { browser, app, isElectron };
74
+ const hasPageTarget = await probeHasPageTarget(port);
75
+
76
+ return { browser, app, isElectron, hasPageTarget };
62
77
  }
63
78
  } catch {
64
79
  // Not a CDP port or not responding
@@ -66,6 +81,22 @@ async function probeCdpPort(port: number): Promise<CdpProbeResult | null> {
66
81
  return null;
67
82
  }
68
83
 
84
+ async function probeHasPageTarget(port: number): Promise<boolean> {
85
+ try {
86
+ const controller = new AbortController();
87
+ const timeout = setTimeout(() => controller.abort(), 500);
88
+ const resp = await fetch(`http://localhost:${port}/json/list`, {
89
+ signal: controller.signal,
90
+ });
91
+ clearTimeout(timeout);
92
+ if (!resp.ok) return false;
93
+ const list = (await resp.json()) as Array<{ type?: string }>;
94
+ return Array.isArray(list) && list.some((t) => t.type === 'page');
95
+ } catch {
96
+ return false;
97
+ }
98
+ }
99
+
69
100
  function getLocalhostListeningPorts(): number[] {
70
101
  try {
71
102
  // netstat is much faster than lsof on macOS
@@ -92,6 +123,7 @@ export interface CdpEndpoint {
92
123
  app: string;
93
124
  bundleId: string;
94
125
  isElectron: boolean;
126
+ hasPageTarget: boolean;
95
127
  }
96
128
 
97
129
  export async function detectCdpPortsAsync(): Promise<CdpEndpoint[]> {
@@ -116,6 +148,7 @@ export async function detectCdpPortsAsync(): Promise<CdpEndpoint[]> {
116
148
  app: probe.app,
117
149
  bundleId,
118
150
  isElectron: probe.isElectron,
151
+ hasPageTarget: probe.hasPageTarget,
119
152
  });
120
153
  }
121
154
  });
@@ -124,6 +157,33 @@ export async function detectCdpPortsAsync(): Promise<CdpEndpoint[]> {
124
157
  return results;
125
158
  }
126
159
 
160
+ // Selects the endpoint `detect`/`detectCdpPort` should treat as "the"
161
+ // browser out of everything discovered on localhost. Shared so `capture
162
+ // detect`'s printed default always matches what auto-discovery actually
163
+ // picks for session start / navigate / etc.
164
+ export function pickPreferredEndpoint(
165
+ endpoints: CdpEndpoint[],
166
+ defaultBrowser: string | null,
167
+ ): CdpEndpoint {
168
+ // Endpoints that already host a real page/tab are real browsers. CDP-
169
+ // speaking non-browser processes (Node/workerd inspectors, etc.) never do,
170
+ // so prefer real browsers whenever at least one is present — this is what
171
+ // keeps an unrelated CDP listener from being picked over the intended one.
172
+ const withPages = endpoints.filter((e) => e.hasPageTarget);
173
+ const candidates = withPages.length > 0 ? withPages : endpoints;
174
+
175
+ if (defaultBrowser) {
176
+ const match = candidates.find(
177
+ (p) => p.bundleId === defaultBrowser && !p.isElectron,
178
+ );
179
+ if (match) return match;
180
+ }
181
+
182
+ // Fall back to first non-Electron, then first found
183
+ const nonElectron = candidates.find((p) => !p.isElectron);
184
+ return nonElectron ?? candidates[0];
185
+ }
186
+
127
187
  export async function detectCdpPort(): Promise<number> {
128
188
  const endpoints = await detectCdpPortsAsync();
129
189
  if (endpoints.length === 0) {
@@ -135,16 +195,5 @@ export async function detectCdpPort(): Promise<number> {
135
195
  );
136
196
  }
137
197
 
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;
198
+ return pickPreferredEndpoint(endpoints, getDefaultBrowserId()).port;
150
199
  }
@@ -5,6 +5,7 @@ import { cmdExec } from './commands/exec.js';
5
5
  import { cmdScreenshot, cmdClick, cmdType, cmdA11y } from './commands/ui.js';
6
6
  import { cmdRecord, cmdNavigate, cmdNetwork } from './commands/traffic.js';
7
7
  import { cmdHar } from './commands/har.js';
8
+ import { cmdLib } from './commands/lib.js';
8
9
 
9
10
  export async function cdpMain(): Promise<void> {
10
11
  const args = process.argv.slice(2);
@@ -24,6 +25,7 @@ export async function cdpMain(): Promise<void> {
24
25
  case 'navigate': return cmdNavigate(parsed, args);
25
26
  case 'network': return cmdNetwork(parsed, args);
26
27
  case 'har': return cmdHar(parsed, args);
28
+ case 'lib': return cmdLib(parsed, args);
27
29
  default:
28
30
  console.log(CDP_USAGE);
29
31
  }
@@ -0,0 +1,49 @@
1
+ import * as ts from 'typescript';
2
+
3
+ function compiles(source: string): boolean {
4
+ try {
5
+ new Function(source);
6
+ return true;
7
+ } catch {
8
+ return false;
9
+ }
10
+ }
11
+
12
+ function wrapAsyncBody(code: string): string {
13
+ return `(async () => { ${code} })()`;
14
+ }
15
+
16
+ function wrapFinalExpression(code: string, source: ts.SourceFile): string {
17
+ const lastStatement = source.statements[source.statements.length - 1];
18
+ if (!ts.isExpressionStatement(lastStatement)) {
19
+ return wrapAsyncBody(code);
20
+ }
21
+
22
+ const expressionStart = lastStatement.expression.getStart(source);
23
+ const expressionEnd = lastStatement.expression.end;
24
+ return `(async () => { ${code.slice(0, lastStatement.getStart(source))}return (${code.slice(expressionStart, expressionEnd)}); })()`;
25
+ }
26
+
27
+ export function buildExecExpression(code: string, prebuilt?: string): string {
28
+ if (prebuilt) return prebuilt;
29
+
30
+ if (compiles(`return (${code});`)) {
31
+ return code;
32
+ }
33
+
34
+ const expression = `return (async () => (${code}))();`;
35
+ if (compiles(expression)) {
36
+ return `(async () => (${code}))()`;
37
+ }
38
+
39
+ const source = ts.createSourceFile('exec.js', code, ts.ScriptTarget.Latest, true, ts.ScriptKind.JS);
40
+ if (
41
+ source.statements.length > 0 &&
42
+ !source.statements.some(ts.isReturnStatement) &&
43
+ ts.isExpressionStatement(source.statements[source.statements.length - 1])
44
+ ) {
45
+ return wrapFinalExpression(code, source);
46
+ }
47
+
48
+ return wrapAsyncBody(code);
49
+ }
@@ -1,7 +1,6 @@
1
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';
2
+ import { findTabByIdAcrossEndpoints } from './targets.js';
3
+ import { buildExecExpression } from './exec-expression.js';
5
4
  import { ConsoleRecorder, type ConsoleEntry } from './console-recorder.js';
6
5
  import { HARRecorder } from './har-recorder.js';
7
6
  import { printConsoleSummary } from './console-recorder.js';
@@ -28,15 +27,12 @@ export async function executeInBrowser(
28
27
  code: string,
29
28
  options: ExecuteOptions = {},
30
29
  ): Promise<ExecuteResult> {
31
- const port = options.port ?? (await detectCdpPort());
32
-
33
30
  if (!options.targetId) {
34
31
  throw new Error('Use --target <tabId> to target a tab. Run "capture list" to see available tabs.');
35
32
  }
36
33
 
37
- // Find tab by exact/prefix ID
38
- let tab: CDPTarget | null = null;
39
- tab = await findTabById(port, options.targetId);
34
+ const resolved = await findTabByIdAcrossEndpoints(options.targetId, options.port);
35
+ const tab = resolved?.tab ?? null;
40
36
  if (!tab) {
41
37
  throw new Error(
42
38
  `No tab found for target "${options.targetId}". Run "capture list" to see available tabs.`,
@@ -68,7 +64,7 @@ export async function executeInBrowser(
68
64
  const result = (await client.send(
69
65
  'Runtime.evaluate',
70
66
  {
71
- expression: code,
67
+ expression: buildExecExpression(code),
72
68
  awaitPromise: true,
73
69
  returnByValue: true,
74
70
  },
package/src/cdp/record.ts CHANGED
@@ -1,7 +1,13 @@
1
1
  import { CDPClient } from './client.js';
2
- import { type CDPTarget } from './types.js';
3
2
  import { detectCdpPort } from './detect.js';
4
- import { findTab, findTabById, openTab, listTargets } from './targets.js';
3
+ import {
4
+ findTab,
5
+ findTabByIdAcrossEndpoints,
6
+ findTabByUrlAcrossEndpoints,
7
+ listTargets,
8
+ openTab,
9
+ type CDPTarget,
10
+ } from './targets.js';
5
11
  import { HARRecorder } from './har-recorder.js';
6
12
  import { writeHarAndPrintSummary } from './har-output.js';
7
13
  import { type HAREntry } from '../har-manager.js';
@@ -22,14 +28,18 @@ export interface RecordResult {
22
28
  export async function recordTraffic(
23
29
  options: RecordOptions,
24
30
  ): Promise<RecordResult> {
25
- const port = options.port ?? (await detectCdpPort());
31
+ let port = options.port ?? 0;
26
32
  const duration = options.duration ?? 10;
27
33
 
28
34
  if (!options.targetId) {
29
35
  throw new Error('Use --target <tabId> to target a tab. Run "capture list" to see available tabs.');
30
36
  }
31
37
 
32
- const tab = await findTabById(port, options.targetId);
38
+ const resolved = await findTabByIdAcrossEndpoints(options.targetId, options.port);
39
+ const tab = resolved?.tab ?? null;
40
+ if (resolved) {
41
+ port = resolved.port;
42
+ }
33
43
  if (!tab) {
34
44
  throw new Error(
35
45
  `No tab found for target "${options.targetId}". Run "capture list" to see available tabs.`,
@@ -43,11 +53,15 @@ export async function recordTraffic(
43
53
  const client = new CDPClient(tab.webSocketDebuggerUrl);
44
54
  await client.waitReady();
45
55
 
56
+ console.error(
57
+ `Using target ${tab.id.slice(0, 8)} on port ${port} (${tab.url})`,
58
+ );
59
+
46
60
  const recorder = new HARRecorder(client);
47
61
  await recorder.start();
48
62
 
49
63
  console.error(
50
- `Recording traffic on "${tab.url}" for ${duration}s... (click around in the browser)`,
64
+ `Recording traffic on target ${tab.id.slice(0, 8)} on port ${port} (${tab.url}) for ${duration}s... (click around in the browser)`,
51
65
  );
52
66
 
53
67
  await new Promise((r) => setTimeout(r, duration * 1000));
@@ -79,30 +93,41 @@ export interface NavigateAndRecordResult {
79
93
  export async function navigateAndRecord(
80
94
  options: NavigateAndRecordOptions,
81
95
  ): Promise<NavigateAndRecordResult> {
82
- const port = options.port ?? (await detectCdpPort());
96
+ let requestedUrl: URL;
97
+ try {
98
+ requestedUrl = new URL(options.url);
99
+ } catch {
100
+ throw new Error(`Invalid URL: ${options.url}`);
101
+ }
83
102
  const settle = options.settle ?? 2000;
84
103
 
85
- // Find tab — prefer targetId (exact) over domain match (fuzzy)
104
+ // Find tab — prefer exact target or an already-open matching URL across all endpoints.
105
+ let port = options.port ?? 0;
86
106
  let tab: CDPTarget | null = null;
87
107
  let isNewTab = false;
108
+
88
109
  if (options.targetId) {
89
- tab = await findTabById(port, options.targetId);
90
- if (!tab) {
110
+ const resolved = await findTabByIdAcrossEndpoints(options.targetId, options.port);
111
+ if (!resolved) {
91
112
  throw new Error(
92
113
  `No tab found with target ID "${options.targetId}". Tab may have been closed.`,
93
114
  );
94
115
  }
116
+ port = resolved.port;
117
+ tab = resolved.tab;
95
118
  } 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;
119
+ const matched = await findTabByUrlAcrossEndpoints(options.url, options.port);
120
+ if (matched) {
121
+ port = matched.port;
122
+ tab = matched.tab;
123
+ } else {
124
+ port = options.port ?? (await detectCdpPort());
125
+ const domain = requestedUrl.hostname;
126
+ tab = await findTab(port, domain);
127
+ if (!tab) {
128
+ tab = await openTab(port, options.url);
129
+ isNewTab = true;
130
+ }
106
131
  }
107
132
  }
108
133
  if (!tab.webSocketDebuggerUrl) {
@@ -113,6 +138,10 @@ export async function navigateAndRecord(
113
138
  await client.waitReady();
114
139
  await client.send('Page.enable');
115
140
 
141
+ console.error(
142
+ `Using target ${tab.id.slice(0, 8)} on port ${port} (${tab.url})`,
143
+ );
144
+
116
145
  // Start recording BEFORE navigation
117
146
  const recorder = new HARRecorder(client);
118
147
  await recorder.start();
@@ -120,7 +149,21 @@ export async function navigateAndRecord(
120
149
  if (!isNewTab) {
121
150
  // Navigate existing tab
122
151
  console.error(`Navigating to: ${options.url}`);
123
- await client.send('Page.navigate', { url: options.url });
152
+ const navResult = (await client.send('Page.navigate', {
153
+ url: options.url,
154
+ })) as { loaderId?: string; errorText?: string };
155
+ // A fragment-only change against the current document is a same-document
156
+ // navigation: Chrome updates location.hash but does NOT reload the page, so
157
+ // SPAs that read the fragment on mount (e.g. Excalidraw's #url= scene import)
158
+ // never see it. Same-document navigations return no loaderId. When that
159
+ // happens, force a genuine cross-document load by bouncing through
160
+ // about:blank and re-navigating, so the target URL (fragment included) is
161
+ // delivered to a freshly-mounted document. A plain Page.reload here races
162
+ // the still-committing fragment nav and can reload the pre-fragment URL.
163
+ if (!navResult.loaderId) {
164
+ await client.send('Page.navigate', { url: 'about:blank' });
165
+ await client.send('Page.navigate', { url: options.url });
166
+ }
124
167
  }
125
168
 
126
169
  const deadline = 60_000;
@@ -1,4 +1,5 @@
1
1
  import { CDPClient } from './client.js';
2
+ import { detectCdpPortsAsync } from './detect.js';
2
3
  import { type CDPTarget } from './types.js';
3
4
 
4
5
  async function getBrowserClient(
@@ -11,8 +12,15 @@ async function getBrowserClient(
11
12
  );
12
13
  }
13
14
  const version = (await versionResp.json()) as {
14
- webSocketDebuggerUrl: string;
15
+ webSocketDebuggerUrl?: string;
15
16
  };
17
+ if (!version.webSocketDebuggerUrl) {
18
+ throw new Error(
19
+ `Port ${port} answers CDP's /json/version but has no browser-level WebSocket debugger URL — ` +
20
+ `it's not a real browser (e.g. a Node/workerd inspector), so it can't host tabs. ` +
21
+ `Run "capture detect" to find a real browser endpoint, or pass --port explicitly.`,
22
+ );
23
+ }
16
24
  const client = new CDPClient(version.webSocketDebuggerUrl);
17
25
  await client.waitReady();
18
26
  return { client, browserWsUrl: version.webSocketDebuggerUrl };
@@ -86,6 +94,113 @@ export async function findTabById(
86
94
  return null;
87
95
  }
88
96
 
97
+ export async function findTabByIdInPorts(
98
+ targetId: string,
99
+ ports: number[],
100
+ finder: (port: number, targetId: string) => Promise<CDPTarget | null> = findTabById,
101
+ ): Promise<{ port: number; tab: CDPTarget } | null> {
102
+ for (const port of ports) {
103
+ try {
104
+ const tab = await finder(port, targetId);
105
+ if (tab) return { port, tab };
106
+ } catch {
107
+ // Skip endpoints that fail or are ambiguous on a different port.
108
+ }
109
+ }
110
+ return null;
111
+ }
112
+
113
+ function normalizeUrlForMatch(value: string): { full: string; host: string; path: string } | null {
114
+ try {
115
+ const url = new URL(value);
116
+ const path = url.pathname.replace(/\/+$/, '') || '/';
117
+ return {
118
+ full: `${url.origin}${path}${url.search}${url.hash}`.toLowerCase(),
119
+ host: url.hostname.toLowerCase(),
120
+ path: path.toLowerCase(),
121
+ };
122
+ } catch {
123
+ return null;
124
+ }
125
+ }
126
+
127
+ export function scoreTabUrlMatch(tabUrl: string, requestedUrl: string): number {
128
+ const tab = normalizeUrlForMatch(tabUrl);
129
+ const requested = normalizeUrlForMatch(requestedUrl);
130
+
131
+ if (tab && requested) {
132
+ if (tab.full === requested.full) return 100;
133
+ if (tab.host === requested.host && tab.path === requested.path) return 95;
134
+ if (tab.host === requested.host && tab.path.startsWith(requested.path)) return 90;
135
+ if (tab.host === requested.host && requested.path.startsWith(tab.path)) return 85;
136
+ if (tab.host === requested.host) return 70;
137
+ if (tab.full.includes(requested.full)) return 60;
138
+ if (tab.full.includes(requested.host)) return 50;
139
+ if (requested.full.includes(tab.full)) return 40;
140
+ return 0;
141
+ }
142
+
143
+ const tabLower = tabUrl.toLowerCase();
144
+ const requestedLower = requestedUrl.toLowerCase();
145
+ if (tabLower === requestedLower) return 100;
146
+ if (tabLower.includes(requestedLower)) return 60;
147
+ if (requestedLower.includes(tabLower)) return 40;
148
+ return 0;
149
+ }
150
+
151
+ function resolveSearchPorts(
152
+ endpoints: Array<{ port: number }>,
153
+ preferredPort?: number,
154
+ ): number[] {
155
+ if (preferredPort) return [preferredPort];
156
+ return endpoints.map((endpoint) => endpoint.port);
157
+ }
158
+
159
+ export async function findTabByIdAcrossEndpoints(
160
+ targetId: string,
161
+ preferredPort?: number,
162
+ ): Promise<{ port: number; tab: CDPTarget } | null> {
163
+ const ports = resolveSearchPorts(await detectCdpPortsAsync(), preferredPort);
164
+ return findTabByIdInPorts(targetId, ports);
165
+ }
166
+
167
+ export async function findTabByUrlAcrossEndpoints(
168
+ url: string,
169
+ preferredPort?: number,
170
+ ): Promise<{ port: number; tab: CDPTarget } | null> {
171
+ const ports = resolveSearchPorts(await detectCdpPortsAsync(), preferredPort);
172
+ let best: { port: number; tab: CDPTarget; score: number } | null = null;
173
+
174
+ for (const port of ports) {
175
+ try {
176
+ const targets = await listTargets(port);
177
+ for (const tab of targets.filter((t) => t.type === 'page')) {
178
+ const score = scoreTabUrlMatch(tab.url, url);
179
+ if (score <= 0) continue;
180
+ if (!best || score > best.score || (score === best.score && port < best.port)) {
181
+ best = { port, tab, score };
182
+ }
183
+ }
184
+ } catch {
185
+ // Skip endpoints that fail to respond.
186
+ }
187
+ }
188
+
189
+ return best ? { port: best.port, tab: best.tab } : null;
190
+ }
191
+
192
+ export function requireTargetId(
193
+ targetId: string | null | undefined,
194
+ url: string,
195
+ ): string {
196
+ if (!targetId) {
197
+ throw new Error(
198
+ `Target.createTarget returned no targetId for ${url}. Reuse an existing tab with --target.`,
199
+ );
200
+ }
201
+ return targetId;
202
+ }
203
+
89
204
  export async function openTab(port: number, url: string): Promise<CDPTarget> {
90
205
  const { client } = await getBrowserClient(port);
91
206
 
@@ -95,16 +210,17 @@ export async function openTab(port: number, url: string): Promise<CDPTarget> {
95
210
  url,
96
211
  background: true,
97
212
  })) as {
98
- targetId: string;
213
+ targetId: string | null;
99
214
  };
215
+ const targetId = requireTargetId(result.targetId, url);
100
216
 
101
217
  // Return target info
102
218
  return {
103
- id: result.targetId,
219
+ id: targetId,
104
220
  title: '',
105
221
  url,
106
222
  type: 'page',
107
- webSocketDebuggerUrl: `ws://localhost:${port}/devtools/page/${result.targetId}`,
223
+ webSocketDebuggerUrl: `ws://localhost:${port}/devtools/page/${targetId}`,
108
224
  };
109
225
  } finally {
110
226
  client.close();
package/src/cdp/types.ts CHANGED
@@ -22,6 +22,7 @@ export interface ParsedArgs {
22
22
  har?: string;
23
23
  new?: boolean;
24
24
  target?: string;
25
+ url?: string;
25
26
  role?: string;
26
27
  into?: string;
27
28
  noScreenshot?: boolean;
package/src/cdp/usage.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  export const CDP_USAGE = `CDP — browser automation via exec, a11y interactions, and screenshots.
2
2
 
3
3
  Commands:
4
- exec <code> Execute JavaScript in a browser tab
4
+ exec <code> Execute JavaScript in a browser tab (expressions, return, await)
5
5
  exec --file <path> Execute JS from file
6
6
  detect Detect CDP port (prioritizes default browser)
7
7
  list List all browser tabs
@@ -17,10 +17,15 @@ Commands:
17
17
  har create Create a HAR recording (returns id)
18
18
  har read [id] Read accumulated HAR entries (id optional in active session)
19
19
  har delete <id> Delete a HAR recording
20
+ lib list List available vault libs (dev checkout only)
21
+ lib search "<q>" Fuzzy-search lib functions [--limit N]
22
+ lib show <name> Lib + function summaries
23
+ lib read <name> [fn…] Full input/output schemas (+ .ts source path)
20
24
 
21
25
  Options:
22
26
  --port <port> Override CDP port
23
27
  --target <tabId> Target tab by exact ID (preferred, parallel-safe)
28
+ CDP_PORT / CDP_TARGET Default port + tab when set by orchestrators
24
29
  --new Force new tab (open)
25
30
  --record Enable HAR recording (exec)
26
31
  --har <id> Append traffic to a HAR recording
@@ -49,8 +54,8 @@ Examples:
49
54
  capture list
50
55
  capture open "https://app.example.com" --new
51
56
  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
57
+ capture exec "return document.title" --target <id>
58
+ capture exec "const r = await fetch('/api/data'); return await r.json()" --target <id> --record
54
59
  capture exec --file /tmp/scrape.js --target <id>
55
60
  capture screenshot --target <id> --out /tmp/shot.png
56
61
  capture a11y --target <id> --interactive
@@ -59,4 +64,7 @@ Examples:
59
64
  capture har create
60
65
  capture exec "document.querySelector('form').submit()" --target <id> --har <id>
61
66
  capture har read <id>
67
+ capture lib search "search emails"
68
+ capture lib read gmail searchEmails
69
+ capture exec "import {searchEmails} from 'libs/gmail'; const ctx = await getContext(); return await searchEmails({ ...ctx, query: 'invoice' })"
62
70
  `;