cdp-skill 1.0.2 → 1.0.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.
Files changed (78) hide show
  1. package/README.md +3 -0
  2. package/SKILL.md +34 -5
  3. package/package.json +2 -1
  4. package/src/capture/console-capture.js +241 -0
  5. package/src/capture/debug-capture.js +144 -0
  6. package/src/capture/error-aggregator.js +151 -0
  7. package/src/capture/eval-serializer.js +320 -0
  8. package/src/capture/index.js +40 -0
  9. package/src/capture/network-capture.js +211 -0
  10. package/src/capture/pdf-capture.js +256 -0
  11. package/src/capture/screenshot-capture.js +325 -0
  12. package/src/cdp/browser.js +569 -0
  13. package/src/cdp/connection.js +369 -0
  14. package/src/cdp/discovery.js +138 -0
  15. package/src/cdp/index.js +29 -0
  16. package/src/cdp/target-and-session.js +439 -0
  17. package/src/cdp-skill.js +25 -11
  18. package/src/constants.js +79 -0
  19. package/src/dom/actionability.js +638 -0
  20. package/src/dom/click-executor.js +923 -0
  21. package/src/dom/element-handle.js +496 -0
  22. package/src/dom/element-locator.js +475 -0
  23. package/src/dom/element-validator.js +120 -0
  24. package/src/dom/fill-executor.js +489 -0
  25. package/src/dom/index.js +248 -0
  26. package/src/dom/input-emulator.js +406 -0
  27. package/src/dom/keyboard-executor.js +202 -0
  28. package/src/dom/quad-helpers.js +89 -0
  29. package/src/dom/react-filler.js +94 -0
  30. package/src/dom/wait-executor.js +423 -0
  31. package/src/index.js +6 -6
  32. package/src/page/cookie-manager.js +202 -0
  33. package/src/page/dom-stability.js +181 -0
  34. package/src/page/index.js +36 -0
  35. package/src/{page.js → page/page-controller.js} +109 -839
  36. package/src/page/wait-utilities.js +302 -0
  37. package/src/page/web-storage-manager.js +108 -0
  38. package/src/runner/context-helpers.js +224 -0
  39. package/src/runner/execute-browser.js +518 -0
  40. package/src/runner/execute-form.js +315 -0
  41. package/src/runner/execute-input.js +308 -0
  42. package/src/runner/execute-interaction.js +672 -0
  43. package/src/runner/execute-navigation.js +180 -0
  44. package/src/runner/execute-query.js +771 -0
  45. package/src/runner/index.js +51 -0
  46. package/src/runner/step-executors.js +421 -0
  47. package/src/runner/step-validator.js +641 -0
  48. package/src/tests/Actionability.test.js +613 -0
  49. package/src/tests/BrowserClient.test.js +1 -1
  50. package/src/tests/ChromeDiscovery.test.js +1 -1
  51. package/src/tests/ClickExecutor.test.js +554 -0
  52. package/src/tests/ConsoleCapture.test.js +1 -1
  53. package/src/tests/ContextHelpers.test.js +453 -0
  54. package/src/tests/CookieManager.test.js +450 -0
  55. package/src/tests/DebugCapture.test.js +307 -0
  56. package/src/tests/ElementHandle.test.js +1 -1
  57. package/src/tests/ElementLocator.test.js +1 -1
  58. package/src/tests/ErrorAggregator.test.js +1 -1
  59. package/src/tests/EvalSerializer.test.js +391 -0
  60. package/src/tests/FillExecutor.test.js +611 -0
  61. package/src/tests/InputEmulator.test.js +1 -1
  62. package/src/tests/KeyboardExecutor.test.js +430 -0
  63. package/src/tests/NetworkErrorCapture.test.js +1 -1
  64. package/src/tests/PageController.test.js +1 -1
  65. package/src/tests/PdfCapture.test.js +333 -0
  66. package/src/tests/ScreenshotCapture.test.js +1 -1
  67. package/src/tests/SessionRegistry.test.js +1 -1
  68. package/src/tests/StepValidator.test.js +527 -0
  69. package/src/tests/TargetManager.test.js +1 -1
  70. package/src/tests/TestRunner.test.js +1 -1
  71. package/src/tests/WaitStrategy.test.js +1 -1
  72. package/src/tests/WaitUtilities.test.js +508 -0
  73. package/src/tests/WebStorageManager.test.js +333 -0
  74. package/src/types.js +309 -0
  75. package/src/capture.js +0 -1400
  76. package/src/cdp.js +0 -1286
  77. package/src/dom.js +0 -4379
  78. package/src/runner.js +0 -3676
@@ -0,0 +1,569 @@
1
+ /**
2
+ * Browser Module
3
+ * High-level browser client and Chrome launcher
4
+ *
5
+ * PUBLIC EXPORTS:
6
+ * - createBrowser(options?) - Factory for browser client
7
+ * - findChromePath() - Find Chrome executable
8
+ * - launchChrome(options?) - Launch Chrome with CDP
9
+ * - getChromeStatus(options?) - Check/launch Chrome
10
+ * - isChromeProcessRunning() - Check if Chrome process exists
11
+ * - createNewTab(host?, port?, url?) - Create new tab via HTTP
12
+ *
13
+ * @module cdp-skill/cdp/browser
14
+ */
15
+
16
+ import { spawn, execSync } from 'child_process';
17
+ import os from 'os';
18
+ import path from 'path';
19
+ import fs from 'fs';
20
+ import { timeoutError, sleep } from '../utils.js';
21
+ import { createConnection } from './connection.js';
22
+ import { createDiscovery } from './discovery.js';
23
+ import { createTargetManager, createSessionRegistry, createPageSession } from './target-and-session.js';
24
+
25
+ // Chrome executable paths by platform
26
+ const CHROME_PATHS = {
27
+ darwin: [
28
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
29
+ '/Applications/Chromium.app/Contents/MacOS/Chromium',
30
+ '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary'
31
+ ],
32
+ linux: [
33
+ 'google-chrome',
34
+ 'google-chrome-stable',
35
+ 'chromium-browser',
36
+ 'chromium',
37
+ '/usr/bin/google-chrome',
38
+ '/usr/bin/chromium-browser',
39
+ '/usr/bin/chromium',
40
+ '/snap/bin/chromium'
41
+ ],
42
+ win32: [
43
+ process.env.LOCALAPPDATA + '\\Google\\Chrome\\Application\\chrome.exe',
44
+ process.env.PROGRAMFILES + '\\Google\\Chrome\\Application\\chrome.exe',
45
+ process.env['PROGRAMFILES(X86)'] + '\\Google\\Chrome\\Application\\chrome.exe',
46
+ process.env.LOCALAPPDATA + '\\Chromium\\Application\\chrome.exe'
47
+ ]
48
+ };
49
+
50
+ /**
51
+ * Find Chrome executable on the system
52
+ * @returns {string|null} Path to Chrome executable or null if not found
53
+ */
54
+ export function findChromePath() {
55
+ // Check environment variable first
56
+ if (process.env.CHROME_PATH) {
57
+ if (fs.existsSync(process.env.CHROME_PATH)) {
58
+ return process.env.CHROME_PATH;
59
+ }
60
+ }
61
+
62
+ const platform = os.platform();
63
+ const paths = CHROME_PATHS[platform] || [];
64
+
65
+ for (const chromePath of paths) {
66
+ try {
67
+ // For Linux, check if command exists in PATH
68
+ if (platform === 'linux' && !chromePath.startsWith('/')) {
69
+ try {
70
+ const result = execSync(`which ${chromePath}`, { encoding: 'utf8' }).trim();
71
+ if (result) return result;
72
+ } catch {
73
+ continue;
74
+ }
75
+ } else if (fs.existsSync(chromePath)) {
76
+ return chromePath;
77
+ }
78
+ } catch {
79
+ continue;
80
+ }
81
+ }
82
+
83
+ return null;
84
+ }
85
+
86
+ /**
87
+ * Check if Chrome process is running (without necessarily having CDP enabled)
88
+ * On macOS, Chrome can run without windows and without CDP port
89
+ * @returns {{running: boolean, hasCdpPort: boolean, pid: number|null}}
90
+ */
91
+ export function isChromeProcessRunning() {
92
+ const platform = os.platform();
93
+
94
+ try {
95
+ if (platform === 'darwin') {
96
+ // macOS: Check for Chrome process
97
+ const result = execSync('pgrep -x "Google Chrome" 2>/dev/null || pgrep -f "Google Chrome.app" 2>/dev/null', {
98
+ encoding: 'utf8',
99
+ timeout: 5000
100
+ }).trim();
101
+ if (result) {
102
+ const pids = result.split('\n').filter(p => p);
103
+ // Check if any Chrome process has --remote-debugging-port
104
+ try {
105
+ const psResult = execSync(`ps aux | grep -E "Google Chrome.*--remote-debugging-port" | grep -v grep`, {
106
+ encoding: 'utf8',
107
+ timeout: 5000
108
+ }).trim();
109
+ return { running: true, hasCdpPort: psResult.length > 0, pid: parseInt(pids[0]) };
110
+ } catch {
111
+ return { running: true, hasCdpPort: false, pid: parseInt(pids[0]) };
112
+ }
113
+ }
114
+ } else if (platform === 'linux') {
115
+ const result = execSync('pgrep -f "(chrome|chromium)" 2>/dev/null', { encoding: 'utf8', timeout: 5000 }).trim();
116
+ if (result) {
117
+ const pids = result.split('\n').filter(p => p);
118
+ try {
119
+ const psResult = execSync(`ps aux | grep -E "(chrome|chromium).*--remote-debugging-port" | grep -v grep`, {
120
+ encoding: 'utf8',
121
+ timeout: 5000
122
+ }).trim();
123
+ return { running: true, hasCdpPort: psResult.length > 0, pid: parseInt(pids[0]) };
124
+ } catch {
125
+ return { running: true, hasCdpPort: false, pid: parseInt(pids[0]) };
126
+ }
127
+ }
128
+ } else if (platform === 'win32') {
129
+ const result = execSync('tasklist /FI "IMAGENAME eq chrome.exe" /NH', { encoding: 'utf8', timeout: 5000 });
130
+ if (result.includes('chrome.exe')) {
131
+ // Windows: harder to check command line args, assume CDP might be available
132
+ return { running: true, hasCdpPort: true, pid: null };
133
+ }
134
+ }
135
+ } catch {
136
+ // Process check failed, assume not running
137
+ }
138
+
139
+ return { running: false, hasCdpPort: false, pid: null };
140
+ }
141
+
142
+ /**
143
+ * Create a new tab in Chrome via CDP HTTP endpoint
144
+ * @param {string} [host='localhost'] - Chrome debugging host
145
+ * @param {number} [port=9222] - Chrome debugging port
146
+ * @param {string} [url='about:blank'] - URL to open
147
+ * @returns {Promise<{targetId: string, url: string}>}
148
+ */
149
+ export async function createNewTab(host = 'localhost', port = 9222, url = 'about:blank') {
150
+ const response = await fetch(`http://${host}:${port}/json/new?${encodeURIComponent(url)}`);
151
+ if (!response.ok) {
152
+ throw new Error(`Failed to create new tab: ${response.statusText}`);
153
+ }
154
+ const target = await response.json();
155
+ return {
156
+ targetId: target.id,
157
+ url: target.url
158
+ };
159
+ }
160
+
161
+ /**
162
+ * Launch Chrome with remote debugging enabled
163
+ *
164
+ * IMPORTANT: On macOS/Linux, if Chrome is already running without CDP,
165
+ * starting Chrome with --remote-debugging-port will just open a new window
166
+ * in the existing Chrome (which ignores the flag). To solve this, we
167
+ * automatically use a separate user data directory when Chrome is already running.
168
+ *
169
+ * @param {Object} [options] - Launch options
170
+ * @param {number} [options.port=9222] - Debugging port
171
+ * @param {string} [options.chromePath] - Custom Chrome path
172
+ * @param {boolean} [options.headless=false] - Run in headless mode
173
+ * @param {string} [options.userDataDir] - Custom user data directory
174
+ * @returns {Promise<{process: ChildProcess, port: number, usedSeparateProfile: boolean}>}
175
+ */
176
+ export async function launchChrome(options = {}) {
177
+ const {
178
+ port = 9222,
179
+ chromePath = findChromePath(),
180
+ headless = false,
181
+ userDataDir = null
182
+ } = options;
183
+
184
+ if (!chromePath) {
185
+ throw new Error(
186
+ 'Chrome not found. Install Google Chrome or set CHROME_PATH environment variable.\n' +
187
+ 'Download: https://www.google.com/chrome/'
188
+ );
189
+ }
190
+
191
+ const args = [
192
+ `--remote-debugging-port=${port}`,
193
+ '--no-first-run',
194
+ '--no-default-browser-check'
195
+ ];
196
+
197
+ if (headless) {
198
+ args.push('--headless=new');
199
+ }
200
+
201
+ // Chrome requires --user-data-dir for remote debugging (as of Chrome 129+)
202
+ // Always use a dedicated profile for CDP to avoid conflicts with user's normal browsing
203
+ let usedSeparateProfile = false;
204
+ if (userDataDir) {
205
+ args.push(`--user-data-dir=${userDataDir}`);
206
+ } else {
207
+ // Use a separate profile per port to allow multiple instances
208
+ // Include headless flag to separate headless from headful profiles
209
+ const profileSuffix = headless ? `-headless-${port}` : `-${port}`;
210
+ const tempDir = path.join(os.tmpdir(), `cdp-skill-chrome-profile${profileSuffix}`);
211
+ args.push(`--user-data-dir=${tempDir}`);
212
+ usedSeparateProfile = true;
213
+ }
214
+
215
+ // Capture stderr to report Chrome errors to the agent
216
+ const chromeProcess = spawn(chromePath, args, {
217
+ detached: true,
218
+ stdio: ['ignore', 'ignore', 'pipe'] // capture stderr
219
+ });
220
+
221
+ // Collect stderr output for error reporting
222
+ let stderrOutput = '';
223
+ chromeProcess.stderr.on('data', (data) => {
224
+ stderrOutput += data.toString();
225
+ });
226
+
227
+ // Don't let this process keep Node alive
228
+ chromeProcess.unref();
229
+
230
+ // Wait for Chrome to be ready
231
+ const discovery = createDiscovery('localhost', port, 1000);
232
+ const maxWait = 10000;
233
+ const startTime = Date.now();
234
+
235
+ while (Date.now() - startTime < maxWait) {
236
+ if (await discovery.isAvailable()) {
237
+ return { process: chromeProcess, port, usedSeparateProfile };
238
+ }
239
+ await sleep(100);
240
+ }
241
+
242
+ // Kill process if it didn't start properly
243
+ try {
244
+ chromeProcess.kill();
245
+ } catch { /* ignore */ }
246
+
247
+ // Include Chrome's error output in the error message
248
+ const errorDetails = stderrOutput.trim();
249
+ const errorMsg = errorDetails
250
+ ? `Chrome failed to start within ${maxWait}ms. Chrome error: ${errorDetails}`
251
+ : `Chrome failed to start within ${maxWait}ms`;
252
+ throw new Error(errorMsg);
253
+ }
254
+
255
+ /**
256
+ * Get Chrome status - check if running, optionally launch if not
257
+ *
258
+ * Handles several scenarios:
259
+ * 1. Chrome not running -> launch new Chrome with CDP port
260
+ * 2. Chrome running but without CDP port (common on macOS when all windows closed)
261
+ * -> launch NEW Chrome instance with CDP (never kills existing Chrome)
262
+ * 3. Chrome running with CDP but no tabs -> create new tab
263
+ * 4. Chrome running with CDP and tabs -> return tabs
264
+ *
265
+ * @param {Object} [options] - Options
266
+ * @param {string} [options.host='localhost'] - Chrome host
267
+ * @param {number} [options.port=9222] - Chrome debugging port
268
+ * @param {boolean} [options.autoLaunch=true] - Auto-launch if not running
269
+ * @param {boolean} [options.headless=false] - Launch in headless mode
270
+ * @returns {Promise<{running: boolean, launched?: boolean, version?: string, tabs?: Array, error?: string, note?: string}>}
271
+ */
272
+ export async function getChromeStatus(options = {}) {
273
+ const {
274
+ host = 'localhost',
275
+ port = 9222,
276
+ autoLaunch = true,
277
+ headless = false
278
+ } = options;
279
+
280
+ const discovery = createDiscovery(host, port, 2000);
281
+
282
+ // Check if CDP is available on the port
283
+ let cdpAvailable = await discovery.isAvailable();
284
+ let launched = false;
285
+ let createdTab = false;
286
+ let note = null;
287
+
288
+ // If CDP not available, check if Chrome process is running
289
+ if (!cdpAvailable && autoLaunch && host === 'localhost') {
290
+ const processCheck = isChromeProcessRunning();
291
+
292
+ if (processCheck.running) {
293
+ // Chrome is running but CDP isn't available
294
+ const reason = processCheck.hasCdpPort
295
+ ? 'Chrome has --remote-debugging-port flag but CDP is not responding (stale instance)'
296
+ : 'Chrome is running without CDP port';
297
+ note = `${reason}. Launched new instance with debugging enabled.`;
298
+ try {
299
+ await launchChrome({ port, headless });
300
+ launched = true;
301
+ cdpAvailable = true;
302
+ } catch (err) {
303
+ return {
304
+ running: false,
305
+ launched: false,
306
+ error: `${reason}. Failed to launch new instance: ${err.message}`,
307
+ note: 'On macOS, Chrome keeps running after closing all windows. A new Chrome instance with CDP was attempted but failed.'
308
+ };
309
+ }
310
+ } else {
311
+ // Chrome not running at all - launch it
312
+ try {
313
+ await launchChrome({ port, headless });
314
+ launched = true;
315
+ cdpAvailable = true;
316
+ } catch (err) {
317
+ return {
318
+ running: false,
319
+ launched: false,
320
+ error: err.message
321
+ };
322
+ }
323
+ }
324
+ }
325
+
326
+ if (!cdpAvailable) {
327
+ const processCheck = isChromeProcessRunning();
328
+ if (processCheck.running) {
329
+ const reason = processCheck.hasCdpPort
330
+ ? `Chrome has --remote-debugging-port flag but CDP is not responding on port ${port} (stale instance).`
331
+ : `Chrome is running but not with CDP debugging enabled on port ${port}.`;
332
+ return {
333
+ running: false,
334
+ launched: false,
335
+ error: `${reason} On macOS, Chrome stays running after closing all windows. ` +
336
+ `Set autoLaunch:true to start a new Chrome instance with CDP.`
337
+ };
338
+ }
339
+ return {
340
+ running: false,
341
+ launched: false,
342
+ error: `Chrome not running on ${host}:${port}`
343
+ };
344
+ }
345
+
346
+ // Get version and tabs
347
+ try {
348
+ const version = await discovery.getVersion();
349
+ let pages = await discovery.getPages();
350
+
351
+ // If no tabs and autoLaunch is enabled, create a new tab
352
+ if (pages.length === 0 && autoLaunch && host === 'localhost') {
353
+ try {
354
+ const newTab = await createNewTab(host, port, 'about:blank');
355
+ pages = [{ id: newTab.targetId, url: newTab.url, title: '' }];
356
+ createdTab = true;
357
+ note = note ? note + ' Created new tab.' : 'Chrome had no tabs open. Created new tab.';
358
+ } catch (err) {
359
+ return {
360
+ running: true,
361
+ launched,
362
+ version: version.browser,
363
+ port,
364
+ tabs: [],
365
+ error: `Chrome running but has no tabs and failed to create one: ${err.message}`,
366
+ note: 'On macOS, Chrome can run without any windows. Try opening a new Chrome window manually.'
367
+ };
368
+ }
369
+ }
370
+
371
+ const result = {
372
+ running: true,
373
+ launched,
374
+ version: version.browser,
375
+ port,
376
+ tabs: pages.map(p => ({
377
+ targetId: p.id,
378
+ url: p.url,
379
+ title: p.title
380
+ }))
381
+ };
382
+
383
+ if (createdTab) result.createdTab = true;
384
+ if (note) result.note = note;
385
+
386
+ return result;
387
+ } catch (err) {
388
+ return {
389
+ running: false,
390
+ launched,
391
+ error: err.message
392
+ };
393
+ }
394
+ }
395
+
396
+ /**
397
+ * Create a high-level browser client
398
+ * @param {Object} [options] - Configuration options
399
+ * @param {string} [options.host='localhost'] - Chrome host
400
+ * @param {number} [options.port=9222] - Chrome debugging port
401
+ * @param {number} [options.connectTimeout=30000] - Connection timeout in ms
402
+ * @returns {Object} Browser client interface
403
+ */
404
+ export function createBrowser(options = {}) {
405
+ const host = options.host ?? 'localhost';
406
+ const port = options.port ?? 9222;
407
+ const connectTimeout = options.connectTimeout ?? 30000;
408
+
409
+ let discovery = createDiscovery(host, port, connectTimeout);
410
+ let connection = null;
411
+ let targetManager = null;
412
+ let sessionRegistry = null;
413
+ let connected = false;
414
+ const targetLocks = new Map();
415
+
416
+ async function acquireLock(targetId) {
417
+ // Wait for any existing lock to be released
418
+ while (targetLocks.has(targetId)) {
419
+ await targetLocks.get(targetId);
420
+ }
421
+ // Create a new lock
422
+ let releaseFn;
423
+ const lockPromise = new Promise(resolve => {
424
+ releaseFn = resolve;
425
+ });
426
+ targetLocks.set(targetId, lockPromise);
427
+ return { promise: lockPromise, release: releaseFn };
428
+ }
429
+
430
+ function releaseLock(targetId, lock) {
431
+ if (targetLocks.get(targetId) === lock.promise) {
432
+ targetLocks.delete(targetId);
433
+ }
434
+ lock.release();
435
+ }
436
+
437
+ function ensureConnected() {
438
+ if (!connected) {
439
+ throw new Error('BrowserClient not connected. Call connect() first.');
440
+ }
441
+ }
442
+
443
+ async function doConnect() {
444
+ const version = await discovery.getVersion();
445
+ connection = createConnection(version.webSocketDebuggerUrl);
446
+ await connection.connect();
447
+
448
+ targetManager = createTargetManager(connection);
449
+ sessionRegistry = createSessionRegistry(connection);
450
+
451
+ await targetManager.enableDiscovery();
452
+ connected = true;
453
+ }
454
+
455
+ /**
456
+ * Connect to Chrome
457
+ * @returns {Promise<void>}
458
+ */
459
+ async function connect() {
460
+ if (connected) return;
461
+
462
+ const connectPromise = doConnect();
463
+ const timeoutPromise = new Promise((_, reject) => {
464
+ setTimeout(() => {
465
+ reject(timeoutError(`Connection to Chrome timed out after ${connectTimeout}ms`));
466
+ }, connectTimeout);
467
+ });
468
+
469
+ await Promise.race([connectPromise, timeoutPromise]);
470
+ }
471
+
472
+ /**
473
+ * Disconnect from Chrome
474
+ * @returns {Promise<void>}
475
+ */
476
+ async function disconnect() {
477
+ if (!connected) return;
478
+
479
+ await sessionRegistry.cleanup();
480
+ await targetManager.cleanup();
481
+ await connection.close();
482
+ connected = false;
483
+ }
484
+
485
+ /**
486
+ * Get all page targets
487
+ * @returns {Promise<Array>} Array of page info objects
488
+ */
489
+ async function getPages() {
490
+ ensureConnected();
491
+ return targetManager.getPages();
492
+ }
493
+
494
+ /**
495
+ * Create a new page (tab)
496
+ * @param {string} [url='about:blank'] - Initial URL
497
+ * @returns {Promise<import('../types.js').CDPSession>} Page session
498
+ */
499
+ async function newPage(url = 'about:blank') {
500
+ ensureConnected();
501
+
502
+ const targetId = await targetManager.createTarget(url);
503
+ const sessionId = await sessionRegistry.attach(targetId);
504
+
505
+ return createPageSession(connection, sessionId, targetId);
506
+ }
507
+
508
+ /**
509
+ * Attach to existing page
510
+ * @param {string} targetId - Target ID to attach to
511
+ * @returns {Promise<import('../types.js').CDPSession>} Page session
512
+ */
513
+ async function attachToPage(targetId) {
514
+ ensureConnected();
515
+ const lock = await acquireLock(targetId);
516
+ try {
517
+ const sessionId = await sessionRegistry.attach(targetId);
518
+ return createPageSession(connection, sessionId, targetId);
519
+ } finally {
520
+ releaseLock(targetId, lock);
521
+ }
522
+ }
523
+
524
+ /**
525
+ * Find and attach to page by URL pattern
526
+ * @param {string|RegExp} urlPattern - URL pattern to match
527
+ * @returns {Promise<import('../types.js').CDPSession|null>} Page session or null
528
+ */
529
+ async function findPage(urlPattern) {
530
+ ensureConnected();
531
+
532
+ const pages = await getPages();
533
+ const regex = urlPattern instanceof RegExp ? urlPattern : new RegExp(urlPattern);
534
+ const target = pages.find(p => regex.test(p.url));
535
+
536
+ if (!target) return null;
537
+ return attachToPage(target.targetId);
538
+ }
539
+
540
+ /**
541
+ * Close a page
542
+ * @param {string} targetId - Target ID to close
543
+ * @returns {Promise<void>}
544
+ */
545
+ async function closePage(targetId) {
546
+ ensureConnected();
547
+ const lock = await acquireLock(targetId);
548
+ try {
549
+ await sessionRegistry.detachByTarget(targetId);
550
+ await targetManager.closeTarget(targetId);
551
+ } finally {
552
+ releaseLock(targetId, lock);
553
+ }
554
+ }
555
+
556
+ return {
557
+ connect,
558
+ disconnect,
559
+ getPages,
560
+ newPage,
561
+ attachToPage,
562
+ findPage,
563
+ closePage,
564
+ isConnected: () => connected,
565
+ get connection() { return connection; },
566
+ get targets() { return targetManager; },
567
+ get sessions() { return sessionRegistry; }
568
+ };
569
+ }