cdp-skill 1.0.8 → 1.0.14

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 (47) hide show
  1. package/README.md +80 -35
  2. package/SKILL.md +151 -239
  3. package/install.js +1 -0
  4. package/package.json +1 -1
  5. package/src/aria/index.js +8 -0
  6. package/src/aria/output-processor.js +173 -0
  7. package/src/aria/role-query.js +1229 -0
  8. package/src/aria/snapshot.js +459 -0
  9. package/src/aria.js +237 -43
  10. package/src/cdp/browser.js +22 -4
  11. package/src/cdp-skill.js +245 -69
  12. package/src/dom/click-executor.js +240 -76
  13. package/src/dom/element-locator.js +34 -25
  14. package/src/dom/fill-executor.js +55 -27
  15. package/src/page/dialog-handler.js +119 -0
  16. package/src/page/page-controller.js +190 -3
  17. package/src/runner/context-helpers.js +33 -55
  18. package/src/runner/execute-dynamic.js +8 -7
  19. package/src/runner/execute-form.js +11 -11
  20. package/src/runner/execute-input.js +2 -2
  21. package/src/runner/execute-interaction.js +99 -120
  22. package/src/runner/execute-navigation.js +11 -26
  23. package/src/runner/execute-query.js +8 -5
  24. package/src/runner/step-executors.js +225 -84
  25. package/src/runner/step-registry.js +1064 -0
  26. package/src/runner/step-validator.js +16 -754
  27. package/src/tests/Aria.test.js +1025 -0
  28. package/src/tests/ContextHelpers.test.js +39 -28
  29. package/src/tests/ExecuteBrowser.test.js +572 -0
  30. package/src/tests/ExecuteDynamic.test.js +2 -457
  31. package/src/tests/ExecuteForm.test.js +700 -0
  32. package/src/tests/ExecuteInput.test.js +540 -0
  33. package/src/tests/ExecuteInteraction.test.js +319 -0
  34. package/src/tests/ExecuteQuery.test.js +820 -0
  35. package/src/tests/FillExecutor.test.js +2 -2
  36. package/src/tests/StepValidator.test.js +222 -76
  37. package/src/tests/TestRunner.test.js +36 -25
  38. package/src/tests/integration.test.js +2 -1
  39. package/src/types.js +9 -9
  40. package/src/utils/backoff.js +118 -0
  41. package/src/utils/cdp-helpers.js +130 -0
  42. package/src/utils/devices.js +140 -0
  43. package/src/utils/errors.js +242 -0
  44. package/src/utils/index.js +65 -0
  45. package/src/utils/temp.js +75 -0
  46. package/src/utils/validators.js +433 -0
  47. package/src/utils.js +14 -1142
package/src/aria.js CHANGED
@@ -184,7 +184,8 @@ export function createQueryOutputProcessor(session) {
184
184
  * @param {Object} elementLocator - Element locator instance
185
185
  * @returns {Object} Role query executor interface
186
186
  */
187
- export function createRoleQueryExecutor(session, elementLocator) {
187
+ export function createRoleQueryExecutor(session, elementLocator, options = {}) {
188
+ const getFrameContext = options.getFrameContext || null;
188
189
  const outputProcessor = createQueryOutputProcessor(session);
189
190
 
190
191
  async function releaseObject(objectId) {
@@ -327,10 +328,12 @@ export function createRoleQueryExecutor(session, elementLocator) {
327
328
 
328
329
  let result;
329
330
  try {
330
- result = await session.send('Runtime.evaluate', {
331
- expression,
332
- returnByValue: false
333
- });
331
+ const evalArgs = { expression, returnByValue: false };
332
+ if (getFrameContext) {
333
+ const contextId = getFrameContext();
334
+ if (contextId) evalArgs.contextId = contextId;
335
+ }
336
+ result = await session.send('Runtime.evaluate', evalArgs);
334
337
  } catch (error) {
335
338
  throw new Error(`Role query error: ${error.message}`);
336
339
  }
@@ -353,7 +356,7 @@ export function createRoleQueryExecutor(session, elementLocator) {
353
356
  throw new Error(`Role query error: ${error.message}`);
354
357
  }
355
358
 
356
- const { createElementHandle } = await import('./dom.js');
359
+ const { createElementHandle } = await import('./dom/element-handle.js');
357
360
  const elements = props.result
358
361
  .filter(p => /^\d+$/.test(p.name) && p.value && p.value.objectId)
359
362
  .map(p => createElementHandle(session, p.value.objectId, {
@@ -740,10 +743,18 @@ const SNAPSHOT_SCRIPT = `
740
743
 
741
744
  // Text content for buttons, links, etc.
742
745
  const role = getAriaRole(el);
743
- if (['button', 'link', 'menuitem', 'option', 'tab', 'treeitem', 'heading'].includes(role)) {
746
+ if (['button', 'link', 'menuitem', 'menuitemcheckbox', 'menuitemradio', 'option',
747
+ 'tab', 'treeitem', 'heading', 'gridcell', 'listitem', 'columnheader',
748
+ 'rowheader', 'cell', 'switch'].includes(role)) {
744
749
  return normalizeWhitespace(el.textContent);
745
750
  }
746
751
 
752
+ // Short-text fallback for any remaining role with empty name
753
+ if (role) {
754
+ const fallbackText = normalizeWhitespace(el.textContent);
755
+ if (fallbackText && fallbackText.length <= 80) return fallbackText;
756
+ }
757
+
747
758
  return '';
748
759
  }
749
760
 
@@ -918,6 +929,14 @@ const SNAPSHOT_SCRIPT = `
918
929
  // Check if element already has a ref in current snapshot
919
930
  if (elementRefs.has(el)) return elementRefs.get(el);
920
931
 
932
+ // Build metadata with shadow host path for shadow DOM elements
933
+ function buildMeta(element, r, n) {
934
+ const meta = { selector: generateSelector(element), role: r || '', name: n || '' };
935
+ const shadowPath = getShadowHostPath(element);
936
+ if (shadowPath.length > 0) meta.shadowHostPath = shadowPath;
937
+ return meta;
938
+ }
939
+
921
940
  // Check if element already has a ref from a previous snapshot
922
941
  // This ensures the same element always gets the same ref
923
942
  if (window.__ariaRefs) {
@@ -926,7 +945,7 @@ const SNAPSHOT_SCRIPT = `
926
945
  elementRefs.set(el, existingRef);
927
946
  refElements.set(existingRef, el);
928
947
  // Update metadata in case it changed
929
- refMeta.set(existingRef, { selector: generateSelector(el), role: role || '', name: name || '' });
948
+ refMeta.set(existingRef, buildMeta(el, role, name));
930
949
  return existingRef;
931
950
  }
932
951
  }
@@ -938,7 +957,7 @@ const SNAPSHOT_SCRIPT = `
938
957
  elementRefs.set(el, ref);
939
958
  refElements.set(ref, el);
940
959
  // Store metadata for re-resolution fallback
941
- refMeta.set(ref, { selector: generateSelector(el), role: role || '', name: name || '' });
960
+ refMeta.set(ref, buildMeta(el, role, name));
942
961
  return ref;
943
962
  }
944
963
 
@@ -1220,10 +1239,21 @@ const SNAPSHOT_SCRIPT = `
1220
1239
  return document.querySelector(selector);
1221
1240
  }
1222
1241
 
1223
- // Main execution
1224
- const root = resolveRoot(rootSelector);
1242
+ // Main execution - auto-scope to <main> when no root specified (reduces footer/boilerplate noise)
1243
+ let autoScoped = false;
1244
+ let root;
1245
+ if (!rootSelector) {
1246
+ const mainEl = document.querySelector('main, [role="main"]');
1247
+ if (mainEl) {
1248
+ root = mainEl;
1249
+ autoScoped = true;
1250
+ } else {
1251
+ root = document.body;
1252
+ }
1253
+ } else {
1254
+ root = resolveRoot(rootSelector);
1255
+ }
1225
1256
  if (!root) {
1226
- // Provide helpful error message based on selector type
1227
1257
  const roleMatch = rootSelector && rootSelector.match(/^role=(.+)$/i);
1228
1258
  if (roleMatch) {
1229
1259
  return { error: 'Root element not found for role: ' + roleMatch[1] + '. Use CSS selector (e.g., "main", "#container") or check that an element with this role exists.' };
@@ -1242,22 +1272,39 @@ const SNAPSHOT_SCRIPT = `
1242
1272
  refs[ref] = generateSelector(el);
1243
1273
  }
1244
1274
 
1245
- function generateSelector(el) {
1275
+ // Build the shadow host path for an element (empty array if not in shadow DOM)
1276
+ function getShadowHostPath(el) {
1277
+ const hosts = [];
1278
+ let node = el;
1279
+ while (node) {
1280
+ const root = node.getRootNode();
1281
+ if (root instanceof ShadowRoot) {
1282
+ hosts.unshift(generateSelectorForElement(root.host));
1283
+ node = root.host;
1284
+ } else {
1285
+ break;
1286
+ }
1287
+ }
1288
+ return hosts;
1289
+ }
1290
+
1291
+ // Generate a CSS selector for a single element (used by both generateSelector and shadow path)
1292
+ function generateSelectorForElement(el) {
1246
1293
  if (el.id) return '#' + CSS.escape(el.id);
1247
1294
 
1248
- // Try unique attributes
1249
1295
  for (const attr of ['data-testid', 'data-test-id', 'data-cy', 'name']) {
1250
1296
  if (el.hasAttribute(attr)) {
1251
1297
  const value = el.getAttribute(attr);
1252
1298
  const selector = '[' + attr + '=' + JSON.stringify(value) + ']';
1253
- if (document.querySelectorAll(selector).length === 1) return selector;
1299
+ try { if (document.querySelectorAll(selector).length === 1) return selector; } catch(e) {}
1254
1300
  }
1255
1301
  }
1256
1302
 
1257
- // Build path
1303
+ // Build path from element up to its root (document or shadow root)
1258
1304
  const path = [];
1259
1305
  let current = el;
1260
- while (current && current !== document.body) {
1306
+ const rootNode = el.getRootNode();
1307
+ while (current && current !== document.body && current !== rootNode) {
1261
1308
  let selector = current.tagName.toLowerCase();
1262
1309
  if (current.id) {
1263
1310
  selector = '#' + CSS.escape(current.id);
@@ -1279,7 +1326,38 @@ const SNAPSHOT_SCRIPT = `
1279
1326
  return path.join(' > ');
1280
1327
  }
1281
1328
 
1282
- const yaml = tree.children ? tree.children.map(c => renderYaml(c, '')).join('\\n') : renderYaml(tree, '');
1329
+ function generateSelector(el) {
1330
+ return generateSelectorForElement(el);
1331
+ }
1332
+
1333
+ // Build landmark header when auto-scoped to main (shows what else is on the page)
1334
+ let landmarkHeader = '';
1335
+ if (autoScoped) {
1336
+ const LM_QUERIES = [
1337
+ { sel: 'nav, [role="navigation"]', role: 'navigation' },
1338
+ { sel: 'header, [role="banner"]', role: 'banner' },
1339
+ { sel: 'footer, [role="contentinfo"]', role: 'contentinfo' },
1340
+ { sel: 'aside, [role="complementary"]', role: 'complementary' },
1341
+ { sel: '[role="search"]', role: 'search' }
1342
+ ];
1343
+ const found = [];
1344
+ for (const { sel, role } of LM_QUERIES) {
1345
+ try {
1346
+ const count = document.querySelectorAll(sel).length;
1347
+ if (count > 0) {
1348
+ const label = document.querySelector(sel).getAttribute('aria-label');
1349
+ found.push(label ? role + ' "' + label + '"' : role);
1350
+ }
1351
+ } catch (e) {}
1352
+ }
1353
+ if (found.length > 0) {
1354
+ landmarkHeader = '# Auto-scoped to main content. Other landmarks: ' + found.join(', ') + '\\n# Use {root: "body"} for full page\\n';
1355
+ } else {
1356
+ landmarkHeader = '# Auto-scoped to main content. Use {root: "body"} for full page\\n';
1357
+ }
1358
+ }
1359
+
1360
+ const yaml = landmarkHeader + (tree.children ? tree.children.map(c => renderYaml(c, '')).join('\\n') : renderYaml(tree, ''));
1283
1361
 
1284
1362
  // Store refs globally for later use (e.g., click by ref)
1285
1363
  // When preserveRefs is true, merge new refs into existing map instead of overwriting
@@ -1306,13 +1384,15 @@ const SNAPSHOT_SCRIPT = `
1306
1384
  // Store page hash for change detection
1307
1385
  window.__ariaSnapshotHash = computePageHash();
1308
1386
 
1309
- return {
1387
+ const snapshotResult = {
1310
1388
  tree,
1311
1389
  yaml,
1312
1390
  refs,
1313
1391
  truncated: limitReached,
1314
1392
  snapshotId: 's' + currentSnapshotId
1315
1393
  };
1394
+ if (autoScoped) snapshotResult.autoScoped = true;
1395
+ return snapshotResult;
1316
1396
  })
1317
1397
  `;
1318
1398
 
@@ -1321,7 +1401,8 @@ const SNAPSHOT_SCRIPT = `
1321
1401
  * @param {Object} session - CDP session
1322
1402
  * @returns {Object} ARIA snapshot interface
1323
1403
  */
1324
- export function createAriaSnapshot(session) {
1404
+ export function createAriaSnapshot(session, options = {}) {
1405
+ const getFrameContext = options.getFrameContext || null;
1325
1406
  /**
1326
1407
  * Generate accessibility snapshot of the page
1327
1408
  * @param {Object} options - Snapshot options
@@ -1341,11 +1422,16 @@ export function createAriaSnapshot(session) {
1341
1422
  async function generate(options = {}) {
1342
1423
  const { root = null, mode = 'ai', detail = 'full', maxDepth = 50, maxElements = 0, includeText = false, includeFrames = false, viewportOnly = false, pierceShadow = false, preserveRefs = false, since = null, internal = false } = options;
1343
1424
 
1344
- const result = await session.send('Runtime.evaluate', {
1425
+ const evalArgs = {
1345
1426
  expression: `(${SNAPSHOT_SCRIPT})(${JSON.stringify(root)}, ${JSON.stringify({ mode, detail, maxDepth, maxElements, includeText, includeFrames, viewportOnly, pierceShadow, preserveRefs, since, internal })})`,
1346
1427
  returnByValue: true,
1347
1428
  awaitPromise: false
1348
- });
1429
+ };
1430
+ if (getFrameContext) {
1431
+ const contextId = getFrameContext();
1432
+ if (contextId) evalArgs.contextId = contextId;
1433
+ }
1434
+ const result = await session.send('Runtime.evaluate', evalArgs);
1349
1435
 
1350
1436
  if (result.exceptionDetails) {
1351
1437
  throw new Error(`Snapshot generation failed: ${result.exceptionDetails.text}`);
@@ -1405,7 +1491,9 @@ export function createAriaSnapshot(session) {
1405
1491
  interactiveElements++;
1406
1492
  }
1407
1493
 
1408
- if (node.box) {
1494
+ // Count all semantic (non-generic, non-staticText) nodes as viewport elements
1495
+ // since they passed isVisible() checks during tree construction
1496
+ if (role && role !== 'generic' && role !== 'staticText') {
1409
1497
  viewportElements++;
1410
1498
  }
1411
1499
 
@@ -1554,7 +1642,7 @@ export function createAriaSnapshot(session) {
1554
1642
  * @returns {Promise<Object>} Element info with selector, box, and connection status
1555
1643
  */
1556
1644
  async function getElementByRef(ref) {
1557
- const result = await session.send('Runtime.evaluate', {
1645
+ const evalArgs = {
1558
1646
  expression: `(function() {
1559
1647
  const ref = ${JSON.stringify(ref)};
1560
1648
  const refsMap = window.__ariaRefs;
@@ -1619,32 +1707,133 @@ export function createAriaSnapshot(session) {
1619
1707
  return buildResult(el, false);
1620
1708
  }
1621
1709
 
1710
+ // Helper to check if candidate matches role+name
1711
+ function matchesRoleAndName(candidate, meta) {
1712
+ if (!candidate || !candidate.isConnected) return false;
1713
+ const candidateRole = getRole(candidate);
1714
+ const roleMatch = !meta.role || candidateRole === meta.role;
1715
+ if (!roleMatch) return false;
1716
+ if (!meta.name) return true;
1717
+ const candidateName = getAccessibleName(candidate);
1718
+ return candidateName.toLowerCase().includes(meta.name.toLowerCase().substring(0, 100));
1719
+ }
1720
+
1721
+ // Helper to resolve a CSS selector through a chain of shadow hosts
1722
+ function queryShadow(shadowHostPath, selector) {
1723
+ let root = document;
1724
+ for (const hostSel of shadowHostPath) {
1725
+ try {
1726
+ const host = root.querySelector(hostSel);
1727
+ if (!host || !host.shadowRoot) return null;
1728
+ root = host.shadowRoot;
1729
+ } catch (e) { return null; }
1730
+ }
1731
+ try { return root.querySelector(selector); } catch (e) { return null; }
1732
+ }
1733
+
1734
+ // Helper to querySelectorAll through shadow hosts
1735
+ function queryShadowAll(shadowHostPath, selector) {
1736
+ let root = document;
1737
+ for (const hostSel of shadowHostPath) {
1738
+ try {
1739
+ const host = root.querySelector(hostSel);
1740
+ if (!host || !host.shadowRoot) return [];
1741
+ root = host.shadowRoot;
1742
+ } catch (e) { return []; }
1743
+ }
1744
+ try { return Array.from(root.querySelectorAll(selector)); } catch (e) { return []; }
1745
+ }
1746
+
1747
+ // Helper to collect all shadow roots in the document for broad search
1748
+ function collectShadowRoots(node, roots) {
1749
+ if (node.shadowRoot) {
1750
+ roots.push(node.shadowRoot);
1751
+ collectShadowRoots(node.shadowRoot, roots);
1752
+ }
1753
+ const children = node.children || node.childNodes || [];
1754
+ for (const child of children) {
1755
+ if (child.nodeType === 1) collectShadowRoots(child, roots);
1756
+ }
1757
+ return roots;
1758
+ }
1759
+
1622
1760
  // 2. Element is null or stale - attempt re-resolution via metadata
1623
1761
  if (metaMap) {
1624
1762
  const meta = metaMap.get(ref);
1625
- if (meta && meta.selector) {
1626
- try {
1627
- const candidate = document.querySelector(meta.selector);
1628
- if (candidate && candidate.isConnected) {
1629
- // Verify role matches
1630
- const candidateRole = getRole(candidate);
1631
- const roleMatch = !meta.role || candidateRole === meta.role;
1632
-
1633
- // Verify name matches (loose: contains check, case-insensitive)
1634
- let nameMatch = true;
1635
- if (meta.name) {
1636
- const candidateName = getAccessibleName(candidate);
1637
- nameMatch = candidateName.toLowerCase().includes(meta.name.toLowerCase().substring(0, 100));
1638
- }
1763
+ if (meta) {
1764
+ const hasShadowPath = meta.shadowHostPath && meta.shadowHostPath.length > 0;
1639
1765
 
1640
- if (roleMatch && nameMatch) {
1641
- // Update the refs map so subsequent lookups are fast
1766
+ // 2a. Try stored CSS selector first (fastest)
1767
+ if (meta.selector) {
1768
+ try {
1769
+ const candidate = hasShadowPath
1770
+ ? queryShadow(meta.shadowHostPath, meta.selector)
1771
+ : document.querySelector(meta.selector);
1772
+ if (matchesRoleAndName(candidate, meta)) {
1642
1773
  if (refsMap) refsMap.set(ref, candidate);
1643
1774
  return buildResult(candidate, true);
1644
1775
  }
1776
+ } catch (e) {
1777
+ // querySelector can throw on invalid selectors - fall through
1778
+ }
1779
+ }
1780
+
1781
+ // 2b. Broader search: find by role + name
1782
+ if (meta.role) {
1783
+ const roleSelectors = {
1784
+ 'link': 'a[href]',
1785
+ 'button': 'button,[role="button"]',
1786
+ 'heading': 'h1,h2,h3,h4,h5,h6,[role="heading"]',
1787
+ 'textbox': 'input:not([type]),input[type="text"],input[type="email"],input[type="url"],input[type="search"],input[type="tel"],textarea,[role="textbox"]',
1788
+ 'checkbox': 'input[type="checkbox"],[role="checkbox"]',
1789
+ 'radio': 'input[type="radio"],[role="radio"]',
1790
+ 'combobox': 'select,[role="combobox"],[role="listbox"]',
1791
+ 'img': 'img,[role="img"]',
1792
+ 'listitem': 'li,[role="listitem"]',
1793
+ 'tab': '[role="tab"]',
1794
+ 'menuitem': '[role="menuitem"]'
1795
+ };
1796
+ const sel = roleSelectors[meta.role] || '[role="' + meta.role + '"]';
1797
+
1798
+ // Search in known shadow path first, then light DOM
1799
+ if (hasShadowPath) {
1800
+ try {
1801
+ const candidates = queryShadowAll(meta.shadowHostPath, sel);
1802
+ for (const candidate of candidates) {
1803
+ if (matchesRoleAndName(candidate, meta)) {
1804
+ if (refsMap) refsMap.set(ref, candidate);
1805
+ return buildResult(candidate, true);
1806
+ }
1807
+ }
1808
+ } catch (e) {}
1809
+ }
1810
+
1811
+ // Light DOM search
1812
+ try {
1813
+ const candidates = document.querySelectorAll(sel);
1814
+ for (const candidate of candidates) {
1815
+ if (matchesRoleAndName(candidate, meta)) {
1816
+ if (refsMap) refsMap.set(ref, candidate);
1817
+ return buildResult(candidate, true);
1818
+ }
1819
+ }
1820
+ } catch (e) {}
1821
+
1822
+ // 2c. Last resort: search ALL shadow roots in the document
1823
+ if (!hasShadowPath) {
1824
+ try {
1825
+ const shadowRoots = collectShadowRoots(document.body, []);
1826
+ for (const sr of shadowRoots) {
1827
+ const candidates = sr.querySelectorAll(sel);
1828
+ for (const candidate of candidates) {
1829
+ if (matchesRoleAndName(candidate, meta)) {
1830
+ if (refsMap) refsMap.set(ref, candidate);
1831
+ return buildResult(candidate, true);
1832
+ }
1833
+ }
1834
+ }
1835
+ } catch (e) {}
1645
1836
  }
1646
- } catch (e) {
1647
- // querySelector can throw on invalid selectors - fall through
1648
1837
  }
1649
1838
  }
1650
1839
  }
@@ -1656,7 +1845,12 @@ export function createAriaSnapshot(session) {
1656
1845
  return null;
1657
1846
  })()`,
1658
1847
  returnByValue: true
1659
- });
1848
+ };
1849
+ if (getFrameContext) {
1850
+ const contextId = getFrameContext();
1851
+ if (contextId) evalArgs.contextId = contextId;
1852
+ }
1853
+ const result = await session.send('Runtime.evaluate', evalArgs);
1660
1854
 
1661
1855
  return result.result.value;
1662
1856
  }
@@ -196,6 +196,8 @@ export async function launchChrome(options = {}) {
196
196
 
197
197
  if (headless) {
198
198
  args.push('--headless=new');
199
+ args.push('--disable-dev-shm-usage');
200
+ args.push('--disable-software-rasterizer');
199
201
  }
200
202
 
201
203
  // Chrome requires --user-data-dir for remote debugging (as of Chrome 129+)
@@ -218,10 +220,13 @@ export async function launchChrome(options = {}) {
218
220
  stdio: ['ignore', 'ignore', 'pipe'] // capture stderr
219
221
  });
220
222
 
221
- // Collect stderr output for error reporting
223
+ // Collect stderr output for error reporting (capped at 50KB)
224
+ const MAX_STDERR_BYTES = 50 * 1024;
222
225
  let stderrOutput = '';
223
226
  chromeProcess.stderr.on('data', (data) => {
224
- stderrOutput += data.toString();
227
+ if (stderrOutput.length < MAX_STDERR_BYTES) {
228
+ stderrOutput += data.toString().substring(0, MAX_STDERR_BYTES - stderrOutput.length);
229
+ }
225
230
  });
226
231
 
227
232
  // Don't let this process keep Node alive
@@ -418,6 +423,11 @@ export function createBrowser(options = {}) {
418
423
  while (targetLocks.has(targetId)) {
419
424
  await targetLocks.get(targetId);
420
425
  }
426
+ // Atomic check-and-set: verify lock is still free after await
427
+ if (targetLocks.has(targetId)) {
428
+ // Another acquirer won the race, recursively retry
429
+ return acquireLock(targetId);
430
+ }
421
431
  // Create a new lock
422
432
  let releaseFn;
423
433
  const lockPromise = new Promise(resolve => {
@@ -440,9 +450,15 @@ export function createBrowser(options = {}) {
440
450
  }
441
451
  }
442
452
 
443
- async function doConnect() {
453
+ async function doConnect(abortSignal) {
444
454
  const version = await discovery.getVersion();
445
455
  connection = createConnection(version.webSocketDebuggerUrl);
456
+
457
+ // Check if aborted before connecting
458
+ if (abortSignal?.aborted) {
459
+ throw new Error('Connection aborted');
460
+ }
461
+
446
462
  await connection.connect();
447
463
 
448
464
  targetManager = createTargetManager(connection);
@@ -459,9 +475,11 @@ export function createBrowser(options = {}) {
459
475
  async function connect() {
460
476
  if (connected) return;
461
477
 
462
- const connectPromise = doConnect();
478
+ const controller = new AbortController();
479
+ const connectPromise = doConnect(controller.signal);
463
480
  const timeoutPromise = new Promise((_, reject) => {
464
481
  setTimeout(() => {
482
+ controller.abort();
465
483
  reject(timeoutError(`Connection to Chrome timed out after ${connectTimeout}ms`));
466
484
  }, connectTimeout);
467
485
  });