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.
- package/README.md +80 -35
- package/SKILL.md +157 -241
- package/install.js +1 -0
- package/package.json +1 -1
- package/src/aria/index.js +8 -0
- package/src/aria/output-processor.js +173 -0
- package/src/aria/role-query.js +1229 -0
- package/src/aria/snapshot.js +459 -0
- package/src/aria.js +251 -50
- package/src/cdp/browser.js +22 -4
- package/src/cdp-skill.js +246 -69
- package/src/dom/LazyResolver.js +634 -0
- package/src/dom/click-executor.js +366 -94
- package/src/dom/element-locator.js +34 -25
- package/src/dom/fill-executor.js +83 -50
- package/src/dom/index.js +3 -0
- package/src/page/dialog-handler.js +119 -0
- package/src/page/page-controller.js +236 -3
- package/src/runner/context-helpers.js +33 -55
- package/src/runner/execute-dynamic.js +8 -7
- package/src/runner/execute-form.js +11 -11
- package/src/runner/execute-input.js +2 -2
- package/src/runner/execute-interaction.js +105 -126
- package/src/runner/execute-navigation.js +14 -29
- package/src/runner/execute-query.js +17 -11
- package/src/runner/step-executors.js +225 -84
- package/src/runner/step-registry.js +1064 -0
- package/src/runner/step-validator.js +16 -754
- package/src/tests/Aria.test.js +1025 -0
- package/src/tests/ClickExecutor.test.js +170 -50
- package/src/tests/ContextHelpers.test.js +41 -30
- package/src/tests/ExecuteBrowser.test.js +572 -0
- package/src/tests/ExecuteDynamic.test.js +2 -457
- package/src/tests/ExecuteForm.test.js +700 -0
- package/src/tests/ExecuteInput.test.js +540 -0
- package/src/tests/ExecuteInteraction.test.js +319 -0
- package/src/tests/ExecuteQuery.test.js +820 -0
- package/src/tests/FillExecutor.test.js +89 -37
- package/src/tests/LazyResolver.test.js +383 -0
- package/src/tests/StepValidator.test.js +224 -78
- package/src/tests/TestRunner.test.js +38 -27
- package/src/tests/integration.test.js +2 -1
- package/src/types.js +9 -9
- package/src/utils/backoff.js +118 -0
- package/src/utils/cdp-helpers.js +130 -0
- package/src/utils/devices.js +140 -0
- package/src/utils/errors.js +242 -0
- package/src/utils/index.js +65 -0
- package/src/utils/temp.js +75 -0
- package/src/utils/validators.js +433 -0
- 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
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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', '
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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., "
|
|
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
|
-
|
|
1345
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
1626
|
-
|
|
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
|
-
|
|
1641
|
-
|
|
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
|
}
|
package/src/cdp/browser.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
});
|