cdp-skill 1.0.8 → 1.0.15

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 (51) hide show
  1. package/README.md +80 -35
  2. package/SKILL.md +157 -241
  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 +251 -50
  10. package/src/cdp/browser.js +22 -4
  11. package/src/cdp-skill.js +246 -69
  12. package/src/dom/LazyResolver.js +634 -0
  13. package/src/dom/click-executor.js +366 -94
  14. package/src/dom/element-locator.js +34 -25
  15. package/src/dom/fill-executor.js +83 -50
  16. package/src/dom/index.js +3 -0
  17. package/src/page/dialog-handler.js +119 -0
  18. package/src/page/page-controller.js +236 -3
  19. package/src/runner/context-helpers.js +33 -55
  20. package/src/runner/execute-dynamic.js +8 -7
  21. package/src/runner/execute-form.js +11 -11
  22. package/src/runner/execute-input.js +2 -2
  23. package/src/runner/execute-interaction.js +105 -126
  24. package/src/runner/execute-navigation.js +14 -29
  25. package/src/runner/execute-query.js +17 -11
  26. package/src/runner/step-executors.js +225 -84
  27. package/src/runner/step-registry.js +1064 -0
  28. package/src/runner/step-validator.js +16 -754
  29. package/src/tests/Aria.test.js +1025 -0
  30. package/src/tests/ClickExecutor.test.js +170 -50
  31. package/src/tests/ContextHelpers.test.js +41 -30
  32. package/src/tests/ExecuteBrowser.test.js +572 -0
  33. package/src/tests/ExecuteDynamic.test.js +2 -457
  34. package/src/tests/ExecuteForm.test.js +700 -0
  35. package/src/tests/ExecuteInput.test.js +540 -0
  36. package/src/tests/ExecuteInteraction.test.js +319 -0
  37. package/src/tests/ExecuteQuery.test.js +820 -0
  38. package/src/tests/FillExecutor.test.js +89 -37
  39. package/src/tests/LazyResolver.test.js +383 -0
  40. package/src/tests/StepValidator.test.js +224 -78
  41. package/src/tests/TestRunner.test.js +38 -27
  42. package/src/tests/integration.test.js +2 -1
  43. package/src/types.js +9 -9
  44. package/src/utils/backoff.js +118 -0
  45. package/src/utils/cdp-helpers.js +130 -0
  46. package/src/utils/devices.js +140 -0
  47. package/src/utils/errors.js +242 -0
  48. package/src/utils/index.js +65 -0
  49. package/src/utils/temp.js +75 -0
  50. package/src/utils/validators.js +433 -0
  51. 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, {
@@ -472,7 +475,7 @@ export function createRoleQueryExecutor(session, elementLocator) {
472
475
  // The snapshot script runs entirely in the browser context
473
476
  const SNAPSHOT_SCRIPT = `
474
477
  (function generateAriaSnapshot(rootSelector, options) {
475
- const { mode = 'ai', maxDepth = 50, maxElements = 0, includeText = false, includeFrames = false, viewportOnly = false, pierceShadow = false, preserveRefs = false, since = null, internal = false } = options || {};
478
+ const { mode = 'ai', maxDepth = 50, maxElements = 0, includeText = false, includeFrames = false, viewportOnly = false, pierceShadow = false, preserveRefs = false, since = null, internal = false, frameIdentifier = 'f0' } = options || {};
476
479
 
477
480
  // Viewport dimensions for viewport-only mode
478
481
  const viewportWidth = window.innerWidth;
@@ -488,6 +491,9 @@ const SNAPSHOT_SCRIPT = `
488
491
  window.__ariaSnapshotId = 0;
489
492
  }
490
493
 
494
+ // Store frame identifier for ref generation (used by all ref-generating operations)
495
+ window.__ariaFrameIdentifier = frameIdentifier;
496
+
491
497
  // Compute page hash for change detection
492
498
  // Hash combines: URL + scroll position + DOM size + interactive element count
493
499
  function computePageHash() {
@@ -511,7 +517,7 @@ const SNAPSHOT_SCRIPT = `
511
517
  if (currentHash === window.__ariaSnapshotHash) {
512
518
  return {
513
519
  unchanged: true,
514
- snapshotId: 's' + window.__ariaSnapshotId,
520
+ snapshotId: frameIdentifier + 's' + window.__ariaSnapshotId,
515
521
  hash: currentHash
516
522
  };
517
523
  }
@@ -740,10 +746,18 @@ const SNAPSHOT_SCRIPT = `
740
746
 
741
747
  // Text content for buttons, links, etc.
742
748
  const role = getAriaRole(el);
743
- if (['button', 'link', 'menuitem', 'option', 'tab', 'treeitem', 'heading'].includes(role)) {
749
+ if (['button', 'link', 'menuitem', 'menuitemcheckbox', 'menuitemradio', 'option',
750
+ 'tab', 'treeitem', 'heading', 'gridcell', 'listitem', 'columnheader',
751
+ 'rowheader', 'cell', 'switch'].includes(role)) {
744
752
  return normalizeWhitespace(el.textContent);
745
753
  }
746
754
 
755
+ // Short-text fallback for any remaining role with empty name
756
+ if (role) {
757
+ const fallbackText = normalizeWhitespace(el.textContent);
758
+ if (fallbackText && fallbackText.length <= 80) return fallbackText;
759
+ }
760
+
747
761
  return '';
748
762
  }
749
763
 
@@ -918,6 +932,14 @@ const SNAPSHOT_SCRIPT = `
918
932
  // Check if element already has a ref in current snapshot
919
933
  if (elementRefs.has(el)) return elementRefs.get(el);
920
934
 
935
+ // Build metadata with shadow host path for shadow DOM elements
936
+ function buildMeta(element, r, n) {
937
+ const meta = { selector: generateSelector(element), role: r || '', name: n || '' };
938
+ const shadowPath = getShadowHostPath(element);
939
+ if (shadowPath.length > 0) meta.shadowHostPath = shadowPath;
940
+ return meta;
941
+ }
942
+
921
943
  // Check if element already has a ref from a previous snapshot
922
944
  // This ensures the same element always gets the same ref
923
945
  if (window.__ariaRefs) {
@@ -926,19 +948,19 @@ const SNAPSHOT_SCRIPT = `
926
948
  elementRefs.set(el, existingRef);
927
949
  refElements.set(existingRef, el);
928
950
  // Update metadata in case it changed
929
- refMeta.set(existingRef, { selector: generateSelector(el), role: role || '', name: name || '' });
951
+ refMeta.set(existingRef, buildMeta(el, role, name));
930
952
  return existingRef;
931
953
  }
932
954
  }
933
955
  }
934
956
 
935
- // New element - assign new ref with versioned format: s{snapshotId}e{refCounter}
957
+ // New element - assign new ref with versioned format: f{frameId}s{snapshotId}e{refCounter}
936
958
  refCounter++;
937
- const ref = 's' + currentSnapshotId + 'e' + refCounter;
959
+ const ref = frameIdentifier + 's' + currentSnapshotId + 'e' + refCounter;
938
960
  elementRefs.set(el, ref);
939
961
  refElements.set(ref, el);
940
962
  // Store metadata for re-resolution fallback
941
- refMeta.set(ref, { selector: generateSelector(el), role: role || '', name: name || '' });
963
+ refMeta.set(ref, buildMeta(el, role, name));
942
964
  return ref;
943
965
  }
944
966
 
@@ -1220,10 +1242,21 @@ const SNAPSHOT_SCRIPT = `
1220
1242
  return document.querySelector(selector);
1221
1243
  }
1222
1244
 
1223
- // Main execution
1224
- const root = resolveRoot(rootSelector);
1245
+ // Main execution - auto-scope to <main> when no root specified (reduces footer/boilerplate noise)
1246
+ let autoScoped = false;
1247
+ let root;
1248
+ if (!rootSelector) {
1249
+ const mainEl = document.querySelector('main, [role="main"]');
1250
+ if (mainEl) {
1251
+ root = mainEl;
1252
+ autoScoped = true;
1253
+ } else {
1254
+ root = document.body;
1255
+ }
1256
+ } else {
1257
+ root = resolveRoot(rootSelector);
1258
+ }
1225
1259
  if (!root) {
1226
- // Provide helpful error message based on selector type
1227
1260
  const roleMatch = rootSelector && rootSelector.match(/^role=(.+)$/i);
1228
1261
  if (roleMatch) {
1229
1262
  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 +1275,39 @@ const SNAPSHOT_SCRIPT = `
1242
1275
  refs[ref] = generateSelector(el);
1243
1276
  }
1244
1277
 
1245
- function generateSelector(el) {
1278
+ // Build the shadow host path for an element (empty array if not in shadow DOM)
1279
+ function getShadowHostPath(el) {
1280
+ const hosts = [];
1281
+ let node = el;
1282
+ while (node) {
1283
+ const root = node.getRootNode();
1284
+ if (root instanceof ShadowRoot) {
1285
+ hosts.unshift(generateSelectorForElement(root.host));
1286
+ node = root.host;
1287
+ } else {
1288
+ break;
1289
+ }
1290
+ }
1291
+ return hosts;
1292
+ }
1293
+
1294
+ // Generate a CSS selector for a single element (used by both generateSelector and shadow path)
1295
+ function generateSelectorForElement(el) {
1246
1296
  if (el.id) return '#' + CSS.escape(el.id);
1247
1297
 
1248
- // Try unique attributes
1249
1298
  for (const attr of ['data-testid', 'data-test-id', 'data-cy', 'name']) {
1250
1299
  if (el.hasAttribute(attr)) {
1251
1300
  const value = el.getAttribute(attr);
1252
1301
  const selector = '[' + attr + '=' + JSON.stringify(value) + ']';
1253
- if (document.querySelectorAll(selector).length === 1) return selector;
1302
+ try { if (document.querySelectorAll(selector).length === 1) return selector; } catch(e) {}
1254
1303
  }
1255
1304
  }
1256
1305
 
1257
- // Build path
1306
+ // Build path from element up to its root (document or shadow root)
1258
1307
  const path = [];
1259
1308
  let current = el;
1260
- while (current && current !== document.body) {
1309
+ const rootNode = el.getRootNode();
1310
+ while (current && current !== document.body && current !== rootNode) {
1261
1311
  let selector = current.tagName.toLowerCase();
1262
1312
  if (current.id) {
1263
1313
  selector = '#' + CSS.escape(current.id);
@@ -1279,7 +1329,38 @@ const SNAPSHOT_SCRIPT = `
1279
1329
  return path.join(' > ');
1280
1330
  }
1281
1331
 
1282
- const yaml = tree.children ? tree.children.map(c => renderYaml(c, '')).join('\\n') : renderYaml(tree, '');
1332
+ function generateSelector(el) {
1333
+ return generateSelectorForElement(el);
1334
+ }
1335
+
1336
+ // Build landmark header when auto-scoped to main (shows what else is on the page)
1337
+ let landmarkHeader = '';
1338
+ if (autoScoped) {
1339
+ const LM_QUERIES = [
1340
+ { sel: 'nav, [role="navigation"]', role: 'navigation' },
1341
+ { sel: 'header, [role="banner"]', role: 'banner' },
1342
+ { sel: 'footer, [role="contentinfo"]', role: 'contentinfo' },
1343
+ { sel: 'aside, [role="complementary"]', role: 'complementary' },
1344
+ { sel: '[role="search"]', role: 'search' }
1345
+ ];
1346
+ const found = [];
1347
+ for (const { sel, role } of LM_QUERIES) {
1348
+ try {
1349
+ const count = document.querySelectorAll(sel).length;
1350
+ if (count > 0) {
1351
+ const label = document.querySelector(sel).getAttribute('aria-label');
1352
+ found.push(label ? role + ' "' + label + '"' : role);
1353
+ }
1354
+ } catch (e) {}
1355
+ }
1356
+ if (found.length > 0) {
1357
+ landmarkHeader = '# Auto-scoped to main content. Other landmarks: ' + found.join(', ') + '\\n# Use {root: "body"} for full page\\n';
1358
+ } else {
1359
+ landmarkHeader = '# Auto-scoped to main content. Use {root: "body"} for full page\\n';
1360
+ }
1361
+ }
1362
+
1363
+ const yaml = landmarkHeader + (tree.children ? tree.children.map(c => renderYaml(c, '')).join('\\n') : renderYaml(tree, ''));
1283
1364
 
1284
1365
  // Store refs globally for later use (e.g., click by ref)
1285
1366
  // When preserveRefs is true, merge new refs into existing map instead of overwriting
@@ -1306,13 +1387,15 @@ const SNAPSHOT_SCRIPT = `
1306
1387
  // Store page hash for change detection
1307
1388
  window.__ariaSnapshotHash = computePageHash();
1308
1389
 
1309
- return {
1390
+ const snapshotResult = {
1310
1391
  tree,
1311
1392
  yaml,
1312
1393
  refs,
1313
1394
  truncated: limitReached,
1314
- snapshotId: 's' + currentSnapshotId
1395
+ snapshotId: frameIdentifier + 's' + currentSnapshotId
1315
1396
  };
1397
+ if (autoScoped) snapshotResult.autoScoped = true;
1398
+ return snapshotResult;
1316
1399
  })
1317
1400
  `;
1318
1401
 
@@ -1321,7 +1404,9 @@ const SNAPSHOT_SCRIPT = `
1321
1404
  * @param {Object} session - CDP session
1322
1405
  * @returns {Object} ARIA snapshot interface
1323
1406
  */
1324
- export function createAriaSnapshot(session) {
1407
+ export function createAriaSnapshot(session, options = {}) {
1408
+ const getFrameContext = options.getFrameContext || null;
1409
+ const getFrameIdentifier = options.getFrameIdentifier || null;
1325
1410
  /**
1326
1411
  * Generate accessibility snapshot of the page
1327
1412
  * @param {Object} options - Snapshot options
@@ -1335,17 +1420,25 @@ export function createAriaSnapshot(session) {
1335
1420
  * @param {boolean} options.viewportOnly - Only include elements visible in viewport (default: false)
1336
1421
  * @param {boolean} options.pierceShadow - Traverse into open shadow DOM trees (default: false)
1337
1422
  * @param {boolean} options.preserveRefs - Merge new refs into existing instead of overwriting (default: false)
1338
- * @param {string} options.since - Snapshot ID to check against (e.g., "s1") - returns {unchanged: true} if page hasn't changed
1423
+ * @param {string} options.since - Snapshot ID to check against (e.g., "f0s1") - returns {unchanged: true} if page hasn't changed
1339
1424
  * @returns {Promise<Object>} Snapshot result with tree, yaml, refs, and snapshotId
1340
1425
  */
1341
1426
  async function generate(options = {}) {
1342
1427
  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
1428
 
1344
- const result = await session.send('Runtime.evaluate', {
1345
- expression: `(${SNAPSHOT_SCRIPT})(${JSON.stringify(root)}, ${JSON.stringify({ mode, detail, maxDepth, maxElements, includeText, includeFrames, viewportOnly, pierceShadow, preserveRefs, since, internal })})`,
1429
+ // Get frame identifier for ref generation (f0 for main frame, f1, f2, etc. for iframes)
1430
+ const frameIdentifier = getFrameIdentifier ? await getFrameIdentifier() : 'f0';
1431
+
1432
+ const evalArgs = {
1433
+ expression: `(${SNAPSHOT_SCRIPT})(${JSON.stringify(root)}, ${JSON.stringify({ mode, detail, maxDepth, maxElements, includeText, includeFrames, viewportOnly, pierceShadow, preserveRefs, since, internal, frameIdentifier })})`,
1346
1434
  returnByValue: true,
1347
1435
  awaitPromise: false
1348
- });
1436
+ };
1437
+ if (getFrameContext) {
1438
+ const contextId = getFrameContext();
1439
+ if (contextId) evalArgs.contextId = contextId;
1440
+ }
1441
+ const result = await session.send('Runtime.evaluate', evalArgs);
1349
1442
 
1350
1443
  if (result.exceptionDetails) {
1351
1444
  throw new Error(`Snapshot generation failed: ${result.exceptionDetails.text}`);
@@ -1405,7 +1498,9 @@ export function createAriaSnapshot(session) {
1405
1498
  interactiveElements++;
1406
1499
  }
1407
1500
 
1408
- if (node.box) {
1501
+ // Count all semantic (non-generic, non-staticText) nodes as viewport elements
1502
+ // since they passed isVisible() checks during tree construction
1503
+ if (role && role !== 'generic' && role !== 'staticText') {
1409
1504
  viewportElements++;
1410
1505
  }
1411
1506
 
@@ -1554,7 +1649,7 @@ export function createAriaSnapshot(session) {
1554
1649
  * @returns {Promise<Object>} Element info with selector, box, and connection status
1555
1650
  */
1556
1651
  async function getElementByRef(ref) {
1557
- const result = await session.send('Runtime.evaluate', {
1652
+ const evalArgs = {
1558
1653
  expression: `(function() {
1559
1654
  const ref = ${JSON.stringify(ref)};
1560
1655
  const refsMap = window.__ariaRefs;
@@ -1619,32 +1714,133 @@ export function createAriaSnapshot(session) {
1619
1714
  return buildResult(el, false);
1620
1715
  }
1621
1716
 
1717
+ // Helper to check if candidate matches role+name
1718
+ function matchesRoleAndName(candidate, meta) {
1719
+ if (!candidate || !candidate.isConnected) return false;
1720
+ const candidateRole = getRole(candidate);
1721
+ const roleMatch = !meta.role || candidateRole === meta.role;
1722
+ if (!roleMatch) return false;
1723
+ if (!meta.name) return true;
1724
+ const candidateName = getAccessibleName(candidate);
1725
+ return candidateName.toLowerCase().includes(meta.name.toLowerCase().substring(0, 100));
1726
+ }
1727
+
1728
+ // Helper to resolve a CSS selector through a chain of shadow hosts
1729
+ function queryShadow(shadowHostPath, selector) {
1730
+ let root = document;
1731
+ for (const hostSel of shadowHostPath) {
1732
+ try {
1733
+ const host = root.querySelector(hostSel);
1734
+ if (!host || !host.shadowRoot) return null;
1735
+ root = host.shadowRoot;
1736
+ } catch (e) { return null; }
1737
+ }
1738
+ try { return root.querySelector(selector); } catch (e) { return null; }
1739
+ }
1740
+
1741
+ // Helper to querySelectorAll through shadow hosts
1742
+ function queryShadowAll(shadowHostPath, selector) {
1743
+ let root = document;
1744
+ for (const hostSel of shadowHostPath) {
1745
+ try {
1746
+ const host = root.querySelector(hostSel);
1747
+ if (!host || !host.shadowRoot) return [];
1748
+ root = host.shadowRoot;
1749
+ } catch (e) { return []; }
1750
+ }
1751
+ try { return Array.from(root.querySelectorAll(selector)); } catch (e) { return []; }
1752
+ }
1753
+
1754
+ // Helper to collect all shadow roots in the document for broad search
1755
+ function collectShadowRoots(node, roots) {
1756
+ if (node.shadowRoot) {
1757
+ roots.push(node.shadowRoot);
1758
+ collectShadowRoots(node.shadowRoot, roots);
1759
+ }
1760
+ const children = node.children || node.childNodes || [];
1761
+ for (const child of children) {
1762
+ if (child.nodeType === 1) collectShadowRoots(child, roots);
1763
+ }
1764
+ return roots;
1765
+ }
1766
+
1622
1767
  // 2. Element is null or stale - attempt re-resolution via metadata
1623
1768
  if (metaMap) {
1624
1769
  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
- }
1770
+ if (meta) {
1771
+ const hasShadowPath = meta.shadowHostPath && meta.shadowHostPath.length > 0;
1639
1772
 
1640
- if (roleMatch && nameMatch) {
1641
- // Update the refs map so subsequent lookups are fast
1773
+ // 2a. Try stored CSS selector first (fastest)
1774
+ if (meta.selector) {
1775
+ try {
1776
+ const candidate = hasShadowPath
1777
+ ? queryShadow(meta.shadowHostPath, meta.selector)
1778
+ : document.querySelector(meta.selector);
1779
+ if (matchesRoleAndName(candidate, meta)) {
1642
1780
  if (refsMap) refsMap.set(ref, candidate);
1643
1781
  return buildResult(candidate, true);
1644
1782
  }
1783
+ } catch (e) {
1784
+ // querySelector can throw on invalid selectors - fall through
1785
+ }
1786
+ }
1787
+
1788
+ // 2b. Broader search: find by role + name
1789
+ if (meta.role) {
1790
+ const roleSelectors = {
1791
+ 'link': 'a[href]',
1792
+ 'button': 'button,[role="button"]',
1793
+ 'heading': 'h1,h2,h3,h4,h5,h6,[role="heading"]',
1794
+ 'textbox': 'input:not([type]),input[type="text"],input[type="email"],input[type="url"],input[type="search"],input[type="tel"],textarea,[role="textbox"]',
1795
+ 'checkbox': 'input[type="checkbox"],[role="checkbox"]',
1796
+ 'radio': 'input[type="radio"],[role="radio"]',
1797
+ 'combobox': 'select,[role="combobox"],[role="listbox"]',
1798
+ 'img': 'img,[role="img"]',
1799
+ 'listitem': 'li,[role="listitem"]',
1800
+ 'tab': '[role="tab"]',
1801
+ 'menuitem': '[role="menuitem"]'
1802
+ };
1803
+ const sel = roleSelectors[meta.role] || '[role="' + meta.role + '"]';
1804
+
1805
+ // Search in known shadow path first, then light DOM
1806
+ if (hasShadowPath) {
1807
+ try {
1808
+ const candidates = queryShadowAll(meta.shadowHostPath, sel);
1809
+ for (const candidate of candidates) {
1810
+ if (matchesRoleAndName(candidate, meta)) {
1811
+ if (refsMap) refsMap.set(ref, candidate);
1812
+ return buildResult(candidate, true);
1813
+ }
1814
+ }
1815
+ } catch (e) {}
1816
+ }
1817
+
1818
+ // Light DOM search
1819
+ try {
1820
+ const candidates = document.querySelectorAll(sel);
1821
+ for (const candidate of candidates) {
1822
+ if (matchesRoleAndName(candidate, meta)) {
1823
+ if (refsMap) refsMap.set(ref, candidate);
1824
+ return buildResult(candidate, true);
1825
+ }
1826
+ }
1827
+ } catch (e) {}
1828
+
1829
+ // 2c. Last resort: search ALL shadow roots in the document
1830
+ if (!hasShadowPath) {
1831
+ try {
1832
+ const shadowRoots = collectShadowRoots(document.body, []);
1833
+ for (const sr of shadowRoots) {
1834
+ const candidates = sr.querySelectorAll(sel);
1835
+ for (const candidate of candidates) {
1836
+ if (matchesRoleAndName(candidate, meta)) {
1837
+ if (refsMap) refsMap.set(ref, candidate);
1838
+ return buildResult(candidate, true);
1839
+ }
1840
+ }
1841
+ }
1842
+ } catch (e) {}
1645
1843
  }
1646
- } catch (e) {
1647
- // querySelector can throw on invalid selectors - fall through
1648
1844
  }
1649
1845
  }
1650
1846
  }
@@ -1656,7 +1852,12 @@ export function createAriaSnapshot(session) {
1656
1852
  return null;
1657
1853
  })()`,
1658
1854
  returnByValue: true
1659
- });
1855
+ };
1856
+ if (getFrameContext) {
1857
+ const contextId = getFrameContext();
1858
+ if (contextId) evalArgs.contextId = contextId;
1859
+ }
1860
+ const result = await session.send('Runtime.evaluate', evalArgs);
1660
1861
 
1661
1862
  return result.result.value;
1662
1863
  }
@@ -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
  });