diff --git a/.claude/skills/bdg/SKILL.md b/.claude/skills/bdg/SKILL.md index 0870c5df..a040a3b9 100644 --- a/.claude/skills/bdg/SKILL.md +++ b/.claude/skills/bdg/SKILL.md @@ -47,33 +47,45 @@ bdg dom screenshot /tmp/el.png --selector "#main" # Element only bdg dom screenshot /tmp/scroll.png --scroll "#target" # Scroll to element first ``` -## Form Interaction +## Playwright Selectors + +Use Playwright-style selectors for precise element targeting: + +```bash +bdg dom click 'button:has-text("Submit")' # Contains text +bdg dom click ':text("Login")' # Smallest element with text +bdg dom fill 'input:has-text("Email")' "me@example.com" +bdg dom click 'button:visible' # Only visible elements +bdg dom click 'button.primary:has-text("Save")' # CSS + text +``` +When multiple elements match, use `--index`: ```bash -# Discover forms -bdg dom form --brief # Quick scan: field names, types, required +bdg dom query "button" # Shows [0], [1], [2]... +bdg dom click "button" --index 0 # Click first match +``` -# Fill and interact -bdg dom fill "input[name='user']" "myuser" # Fill by selector -bdg dom fill 0 "value" # Fill by index (from query) -bdg dom click "button.submit" # Click element -bdg dom submit "form" --wait-navigation # Submit and wait for page load -bdg dom pressKey "input" Enter # Press Enter key +## Form Interaction -# Options ---no-wait # Skip network stability wait ---wait-navigation # Wait for page navigation (traditional forms) ---wait-network # Wait for network idle (SPA forms) ---index # Select nth element when multiple match +```bash +bdg dom form --brief # Quick scan: names, types, required +bdg dom fill "input[name='user']" "myuser" # Fill by attribute +bdg dom fill 'input:has-text("Username")' "me" # Fill by label text +bdg dom click 'button:text("Submit")' # Click by text +bdg dom submit "form" --wait-navigation # Submit and wait +bdg dom pressKey "input" Enter # Press key +bdg dom pressKey "input" Tab --times 3 # Press key multiple times ``` +Options: `--no-wait`, `--wait-navigation`, `--wait-network `, `--index ` + ## DOM Inspection ```bash -bdg dom query "selector" # Find elements, returns [0], [1], [2]... +bdg dom query "selector" # Find elements matching selector bdg dom get "selector" # Get semantic a11y info (token-efficient) bdg dom get "selector" --raw # Get full HTML -bdg dom eval "js expression" # Run JavaScript +bdg dom eval "js expression" # Run JavaScript (handles DOM elements) ``` ## CDP Access @@ -103,7 +115,7 @@ bdg https://example.com/login bdg dom form --brief bdg dom fill "input[name='username']" "$USER" bdg dom fill "input[name='password']" "$PASS" -bdg dom submit "button[type='submit']" --wait-navigation +bdg dom click 'button:text("Log in")' --wait-navigation bdg dom screenshot /tmp/result.png bdg stop ``` @@ -120,6 +132,12 @@ for i in {1..20}; do done ``` +### Click Button by Text +```bash +bdg dom click 'button:text("Submit")' +bdg dom click 'button:has-text("Save")' --wait-navigation +``` + ### Extract Data ```bash bdg cdp Runtime.evaluate --params '{ @@ -177,37 +195,23 @@ bdg https://example.com --chrome-flags="--ignore-certificate-errors --disable-we **Prefer DOM queries over screenshots** for verification: ```bash -# GOOD: Fast, precise, scriptable +# Check element with text exists +bdg dom query 'div:has-text("Success")' + +# Check specific text content bdg cdp Runtime.evaluate --params '{ "expression": "document.querySelector(\".error-message\")?.textContent", "returnByValue": true }' -# GOOD: Check element exists -bdg dom query ".submit-btn" - -# GOOD: Check text content +# Check text anywhere on page bdg cdp Runtime.evaluate --params '{ "expression": "document.body.innerText.includes(\"Success\")", "returnByValue": true }' - -# AVOID: Screenshots for simple verification (slow, requires visual inspection) -bdg dom screenshot /tmp/check.png # Only use when you need visual proof ``` -**When to use screenshots:** -- Visual regression testing -- Capturing proof for user review -- Debugging layout issues -- When DOM structure is unknown - -**When to use DOM queries:** -- Verifying text content appeared -- Checking element exists/visible -- Validating form state -- Counting elements -- Any programmatic assertion +Use screenshots only for visual proof or when DOM structure is unknown. ## When NOT to Use bdg diff --git a/docs/CLI_REFERENCE.md b/docs/CLI_REFERENCE.md index 42aef5dd..b5df9280 100644 --- a/docs/CLI_REFERENCE.md +++ b/docs/CLI_REFERENCE.md @@ -34,7 +34,7 @@ bdg peek # Last 10 items (compact format) bdg peek --last 50 # Show last 50 items bdg peek --network # Show only network requests bdg peek --console # Show only console messages -bdg peek --dom # Show DOM/A11y tree (available after stop) +bdg peek --dom # Show DOM/A11y tree bdg peek --type Document # Filter by resource type (Document requests only) bdg peek --type XHR,Fetch # Multiple types (XHR or Fetch requests) bdg peek --json # JSON output diff --git a/package-lock.json b/package-lock.json index 5dac341c..dc91b36d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "chrome-launcher": "^1.2.1", "commander": "^14.0.2", + "css-selector-parser": "^3.3.0", "devtools-protocol": "^0.0.1558402", "ws": "^8.18.0" }, @@ -697,9 +698,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.39.2", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", - "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", + "version": "9.39.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.1.tgz", + "integrity": "sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==", "dev": true, "license": "MIT", "engines": { @@ -1346,11 +1347,10 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "25.0.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.2.tgz", - "integrity": "sha512-gWEkeiyYE4vqjON/+Obqcoeffmk0NF15WSBwSs7zwVA2bAbTaE0SJ7P0WNGoJn8uE7fiaV5a7dKYIJriEqOrmA==", + "version": "25.0.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.3.tgz", + "integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -1366,18 +1366,18 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.50.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.50.0.tgz", - "integrity": "sha512-O7QnmOXYKVtPrfYzMolrCTfkezCJS9+ljLdKW/+DCvRsc3UAz+sbH6Xcsv7p30+0OwUbeWfUDAQE0vpabZ3QLg==", + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.48.1.tgz", + "integrity": "sha512-X63hI1bxl5ohelzr0LY5coufyl0LJNthld+abwxpCoo6Gq+hSqhKwci7MUWkXo67mzgUK6YFByhmaHmUcuBJmA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.50.0", - "@typescript-eslint/type-utils": "8.50.0", - "@typescript-eslint/utils": "8.50.0", - "@typescript-eslint/visitor-keys": "8.50.0", + "@typescript-eslint/scope-manager": "8.48.1", + "@typescript-eslint/type-utils": "8.48.1", + "@typescript-eslint/utils": "8.48.1", + "@typescript-eslint/visitor-keys": "8.48.1", + "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" @@ -1390,23 +1390,22 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.50.0", + "@typescript-eslint/parser": "^8.48.1", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/parser": { - "version": "8.50.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.50.0.tgz", - "integrity": "sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==", + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.48.1.tgz", + "integrity": "sha512-PC0PDZfJg8sP7cmKe6L3QIL8GZwU5aRvUFedqSIpw3B+QjRSUZeeITC2M5XKeMXEzL6wccN196iy3JLwKNvDVA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@typescript-eslint/scope-manager": "8.50.0", - "@typescript-eslint/types": "8.50.0", - "@typescript-eslint/typescript-estree": "8.50.0", - "@typescript-eslint/visitor-keys": "8.50.0", + "@typescript-eslint/scope-manager": "8.48.1", + "@typescript-eslint/types": "8.48.1", + "@typescript-eslint/typescript-estree": "8.48.1", + "@typescript-eslint/visitor-keys": "8.48.1", "debug": "^4.3.4" }, "engines": { @@ -1421,15 +1420,145 @@ "typescript": ">=4.8.4 <6.0.0" } }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/project-service": { + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.48.1.tgz", + "integrity": "sha512-HQWSicah4s9z2/HifRPQ6b6R7G+SBx64JlFQpgSSHWPKdvCZX57XCbszg/bapbRsOEv42q5tayTYcEFpACcX1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.48.1", + "@typescript-eslint/types": "^8.48.1", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/scope-manager": { + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.48.1.tgz", + "integrity": "sha512-rj4vWQsytQbLxC5Bf4XwZ0/CKd362DkWMUkviT7DCS057SK64D5lH74sSGzhI6PDD2HCEq02xAP9cX68dYyg1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.48.1", + "@typescript-eslint/visitor-keys": "8.48.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.48.1.tgz", + "integrity": "sha512-k0Jhs4CpEffIBm6wPaCXBAD7jxBtrHjrSgtfCjUvPp9AZ78lXKdTR8fxyZO5y4vWNlOvYXRtngSZNSn+H53Jkw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/types": { + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.48.1.tgz", + "integrity": "sha512-+fZ3LZNeiELGmimrujsDCT4CRIbq5oXdHe7chLiW8qzqyPMnn1puNstCrMNVAqwcl2FdIxkuJ4tOs/RFDBVc/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/typescript-estree": { + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.48.1.tgz", + "integrity": "sha512-/9wQ4PqaefTK6POVTjJaYS0bynCgzh6ClJHGSBj06XEHjkfylzB+A3qvyaXnErEZSaxhIo4YdyBgq6j4RysxDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.48.1", + "@typescript-eslint/tsconfig-utils": "8.48.1", + "@typescript-eslint/types": "8.48.1", + "@typescript-eslint/visitor-keys": "8.48.1", + "debug": "^4.3.4", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/visitor-keys": { + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.48.1.tgz", + "integrity": "sha512-BmxxndzEWhE4TIEEMBs8lP3MBWN3jFPs/p6gPm/wkv02o41hI6cq9AuSmGAaTTHPtA1FTi2jBre4A9rm5ZmX+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.48.1", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/@typescript-eslint/project-service": { - "version": "8.50.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.50.0.tgz", - "integrity": "sha512-Cg/nQcL1BcoTijEWyx4mkVC56r8dj44bFDvBdygifuS20f3OZCHmFbjF34DPSi07kwlFvqfv/xOLnJ5DquxSGQ==", + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.48.1.tgz", + "integrity": "sha512-HQWSicah4s9z2/HifRPQ6b6R7G+SBx64JlFQpgSSHWPKdvCZX57XCbszg/bapbRsOEv42q5tayTYcEFpACcX1w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.50.0", - "@typescript-eslint/types": "^8.50.0", + "@typescript-eslint/tsconfig-utils": "^8.48.1", + "@typescript-eslint/types": "^8.48.1", "debug": "^4.3.4" }, "engines": { @@ -1444,14 +1573,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.50.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.50.0.tgz", - "integrity": "sha512-xCwfuCZjhIqy7+HKxBLrDVT5q/iq7XBVBXLn57RTIIpelLtEIZHXAF/Upa3+gaCpeV1NNS5Z9A+ID6jn50VD4A==", + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.48.1.tgz", + "integrity": "sha512-rj4vWQsytQbLxC5Bf4XwZ0/CKd362DkWMUkviT7DCS057SK64D5lH74sSGzhI6PDD2HCEq02xAP9cX68dYyg1w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.50.0", - "@typescript-eslint/visitor-keys": "8.50.0" + "@typescript-eslint/types": "8.48.1", + "@typescript-eslint/visitor-keys": "8.48.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1462,9 +1591,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.50.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.50.0.tgz", - "integrity": "sha512-vxd3G/ybKTSlm31MOA96gqvrRGv9RJ7LGtZCn2Vrc5htA0zCDvcMqUkifcjrWNNKXHUU3WCkYOzzVSFBd0wa2w==", + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.48.1.tgz", + "integrity": "sha512-k0Jhs4CpEffIBm6wPaCXBAD7jxBtrHjrSgtfCjUvPp9AZ78lXKdTR8fxyZO5y4vWNlOvYXRtngSZNSn+H53Jkw==", "dev": true, "license": "MIT", "engines": { @@ -1479,15 +1608,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.50.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.50.0.tgz", - "integrity": "sha512-7OciHT2lKCewR0mFoBrvZJ4AXTMe/sYOe87289WAViOocEmDjjv8MvIOT2XESuKj9jp8u3SZYUSh89QA4S1kQw==", + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.48.1.tgz", + "integrity": "sha512-1jEop81a3LrJQLTf/1VfPQdhIY4PlGDBc/i67EVWObrtvcziysbLN3oReexHOM6N3jyXgCrkBsZpqwH0hiDOQg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.50.0", - "@typescript-eslint/typescript-estree": "8.50.0", - "@typescript-eslint/utils": "8.50.0", + "@typescript-eslint/types": "8.48.1", + "@typescript-eslint/typescript-estree": "8.48.1", + "@typescript-eslint/utils": "8.48.1", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, @@ -1504,9 +1633,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.50.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.50.0.tgz", - "integrity": "sha512-iX1mgmGrXdANhhITbpp2QQM2fGehBse9LbTf0sidWK6yg/NE+uhV5dfU1g6EYPlcReYmkE9QLPq/2irKAmtS9w==", + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.48.1.tgz", + "integrity": "sha512-+fZ3LZNeiELGmimrujsDCT4CRIbq5oXdHe7chLiW8qzqyPMnn1puNstCrMNVAqwcl2FdIxkuJ4tOs/RFDBVc/Q==", "dev": true, "license": "MIT", "engines": { @@ -1518,16 +1647,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.50.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.50.0.tgz", - "integrity": "sha512-W7SVAGBR/IX7zm1t70Yujpbk+zdPq/u4soeFSknWFdXIFuWsBGBOUu/Tn/I6KHSKvSh91OiMuaSnYp3mtPt5IQ==", + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.48.1.tgz", + "integrity": "sha512-/9wQ4PqaefTK6POVTjJaYS0bynCgzh6ClJHGSBj06XEHjkfylzB+A3qvyaXnErEZSaxhIo4YdyBgq6j4RysxDg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.50.0", - "@typescript-eslint/tsconfig-utils": "8.50.0", - "@typescript-eslint/types": "8.50.0", - "@typescript-eslint/visitor-keys": "8.50.0", + "@typescript-eslint/project-service": "8.48.1", + "@typescript-eslint/tsconfig-utils": "8.48.1", + "@typescript-eslint/types": "8.48.1", + "@typescript-eslint/visitor-keys": "8.48.1", "debug": "^4.3.4", "minimatch": "^9.0.4", "semver": "^7.6.0", @@ -1546,16 +1675,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.50.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.50.0.tgz", - "integrity": "sha512-87KgUXET09CRjGCi2Ejxy3PULXna63/bMYv72tCAlDJC3Yqwln0HiFJ3VJMst2+mEtNtZu5oFvX4qJGjKsnAgg==", + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.48.1.tgz", + "integrity": "sha512-fAnhLrDjiVfey5wwFRwrweyRlCmdz5ZxXz2G/4cLn0YDLjTapmN4gcCsTBR1N2rWnZSDeWpYtgLDsJt+FpmcwA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.50.0", - "@typescript-eslint/types": "8.50.0", - "@typescript-eslint/typescript-estree": "8.50.0" + "@typescript-eslint/scope-manager": "8.48.1", + "@typescript-eslint/types": "8.48.1", + "@typescript-eslint/typescript-estree": "8.48.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1570,13 +1699,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.50.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.50.0.tgz", - "integrity": "sha512-Xzmnb58+Db78gT/CCj/PVCvK+zxbnsw6F+O1oheYszJbBSdEjVhQi3C/Xttzxgi/GLmpvOggRs1RFpiJ8+c34Q==", + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.48.1.tgz", + "integrity": "sha512-BmxxndzEWhE4TIEEMBs8lP3MBWN3jFPs/p6gPm/wkv02o41hI6cq9AuSmGAaTTHPtA1FTi2jBre4A9rm5ZmX+Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.50.0", + "@typescript-eslint/types": "8.48.1", "eslint-visitor-keys": "^4.2.1" }, "engines": { @@ -1875,7 +2004,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2561,6 +2689,22 @@ "node": ">= 8" } }, + "node_modules/css-selector-parser": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/css-selector-parser/-/css-selector-parser-3.3.0.tgz", + "integrity": "sha512-Y2asgMGFqJKF4fq4xHDSlFYIkeVfRsm69lQC1q9kbEsH5XtnINTMrweLkjYMeaUgiXBy/uvKeO/a1JHTNnmB2g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/mdevils" + }, + { + "type": "patreon", + "url": "https://patreon.com/mdevils" + } + ], + "license": "MIT" + }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -2963,12 +3107,11 @@ } }, "node_modules/eslint": { - "version": "9.39.2", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", - "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", + "version": "9.39.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.1.tgz", + "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -2976,7 +3119,7 @@ "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.39.2", + "@eslint/js": "9.39.1", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", @@ -3155,7 +3298,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -4030,6 +4172,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, "node_modules/has-bigints": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", @@ -4745,7 +4894,6 @@ "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "jiti": "lib/jiti-cli.mjs" } @@ -4815,9 +4963,9 @@ } }, "node_modules/knip": { - "version": "5.73.4", - "resolved": "https://registry.npmjs.org/knip/-/knip-5.73.4.tgz", - "integrity": "sha512-q0DDgqsRMa4z2IMEPEblns0igitG8Fu7exkvEgQx1QMLKEqSvcvKP9fMk+C1Ehy+Ux6oayl6zfAEGt6DvFtidw==", + "version": "5.72.0", + "resolved": "https://registry.npmjs.org/knip/-/knip-5.72.0.tgz", + "integrity": "sha512-rlyoXI8FcggNtM/QXd/GW0sbsYvNuA/zPXt7bsuVi6kVQogY2PDCr81bPpzNnl0CP8AkFm2Z2plVeL5QQSis2w==", "dev": true, "funding": [ { @@ -6382,7 +6530,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -6595,7 +6742,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6636,7 +6782,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "napi-postinstall": "^0.3.0" }, diff --git a/package.json b/package.json index a31ac69a..e93422f3 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "dependencies": { "chrome-launcher": "^1.2.1", "commander": "^14.0.2", + "css-selector-parser": "^3.3.0", "devtools-protocol": "^0.0.1558402", "ws": "^8.18.0" }, diff --git a/src/commands/dom/DomElementResolver.ts b/src/commands/dom/DomElementResolver.ts deleted file mode 100644 index 9e183c4d..00000000 --- a/src/commands/dom/DomElementResolver.ts +++ /dev/null @@ -1,321 +0,0 @@ -/** - * DOM element resolver for index and selector-based access. - * - * Provides centralized resolution of DOM elements from: - * - Numeric indices (referencing cached query results) - * - CSS selectors (used directly) - * - * Handles cache validation, staleness detection, and automatic refresh. - * When cache is stale due to navigation, automatically re-runs the original - * query to provide seamless "just works" experience. - * - * @example - * ```typescript - * const resolver = DomElementResolver.getInstance(); - * - * // Resolve index from cached query - * const target = await resolver.resolve('0'); - * // { success: true, selector: '.cached-selector', index: 1 } - * - * // Resolve CSS selector directly - * const target = await resolver.resolve('button.submit'); - * // { success: true, selector: 'button.submit' } - * - * // Get nodeId for cached index (throws if invalid) - * const nodeId = await resolver.getNodeIdForIndex(0); - * ``` - */ - -import { QueryCacheManager } from '@/session/QueryCacheManager.js'; -import { CommandError } from '@/ui/errors/index.js'; -import { createLogger } from '@/ui/logging/index.js'; -import { elementAtIndexNotFoundError, indexOutOfRangeError } from '@/ui/messages/errors.js'; -import { EXIT_CODES } from '@/utils/exitCodes.js'; - -const log = createLogger('dom'); - -/** - * Successful result of resolving a selector or index argument. - */ -export interface ElementTargetSuccess { - /** Resolution succeeded */ - success: true; - /** CSS selector to use */ - selector: string; - /** 0-based index for selector (if resolved from cached query) */ - index?: number | undefined; -} - -/** - * Failed result of resolving a selector or index argument. - */ -export interface ElementTargetFailure { - /** Resolution failed */ - success: false; - /** Error message */ - error: string; - /** Exit code for the error */ - exitCode: number; - /** Suggestion for fixing the error */ - suggestion?: string | undefined; -} - -/** - * Result of resolving a selector or index argument to an element target. - * Discriminated union that guarantees selector exists when success is true. - */ -export type ElementTargetResult = ElementTargetSuccess | ElementTargetFailure; - -/** - * Singleton resolver for DOM element access patterns. - * - * Centralizes element resolution with cache validation, automatic refresh, - * and consistent error handling. - */ -export class DomElementResolver { - private static instance: DomElementResolver | null = null; - private cacheManager: QueryCacheManager; - - /** - * Create a new resolver instance. - * - * @param cacheManager - Query cache manager (defaults to singleton) - */ - constructor(cacheManager?: QueryCacheManager) { - this.cacheManager = cacheManager ?? QueryCacheManager.getInstance(); - } - - /** - * Refresh stale cache by re-running the original query. - * - * Called automatically when cache validation fails due to navigation. - * Re-queries using the stored selector and updates the cache with fresh results. - * - * @param selector - Original CSS selector from stale cache - * @returns Fresh query result - */ - private async refreshCache(selector: string): Promise { - log.debug(`Cache stale, auto-refreshing query "${selector}"`); - - const { queryDOMElements } = await import('@/commands/dom/helpers.js'); - const result = await queryDOMElements(selector); - - const navigationId = await this.cacheManager.getCurrentNavigationId(); - const resultWithNavId = { - ...result, - ...(navigationId !== null && { navigationId }), - }; - - await this.cacheManager.set(resultWithNavId); - this.cacheManager.invalidateNavigationCache(); - - log.debug(`Cache refreshed: found ${result.count} elements`); - } - - /** - * Get the singleton instance. - * - * @returns DomElementResolver instance - */ - static getInstance(): DomElementResolver { - DomElementResolver.instance ??= new DomElementResolver(); - return DomElementResolver.instance; - } - - /** - * Reset the singleton instance (for testing). - */ - static resetInstance(): void { - DomElementResolver.instance = null; - } - - /** - * Resolve a selectorOrIndex argument to an element target. - * - * Handles the common pattern of accepting either: - * - A CSS selector string (used directly) - * - A numeric index (resolved from cached query results) - * - * Automatically refreshes stale cache by re-running the original query. - * This provides a "just works" experience where navigation doesn't break - * index-based access. - * - * @param selectorOrIndex - CSS selector or numeric index from query results - * @param explicitIndex - Optional explicit --index flag value (0-based) - * @returns Resolution result with selector and optional index - * - * @example - * ```typescript - * const target = await resolver.resolve('button'); - * // { success: true, selector: 'button' } - * - * const target = await resolver.resolve('0'); - * // { success: true, selector: '.cached-selector', index: 1 } - * ``` - */ - async resolve(selectorOrIndex: string, explicitIndex?: number): Promise { - const isNumericIndex = /^\d+$/.test(selectorOrIndex); - - if (isNumericIndex) { - let validation = await this.cacheManager.validate(); - - // Auto-refresh: if cache is stale but has selector, re-run query - if (!validation.valid && validation.cache?.selector) { - await this.refreshCache(validation.cache.selector); - validation = await this.cacheManager.validate(); - } - - if (!validation.valid || !validation.cache) { - return { - success: false, - error: validation.error ?? 'No cached query results found', - exitCode: EXIT_CODES.INVALID_ARGUMENTS, - suggestion: validation.suggestion, - }; - } - - const cachedQuery = validation.cache; - const index = parseInt(selectorOrIndex, 10); - - // Find node by index property (supports non-sequential indices from form discovery) - const targetNode = cachedQuery.nodes.find((n) => n.index === index); - - if (!targetNode) { - // Fall back to array-position lookup for sequential indices (standard queries) - if (index >= 0 && index < cachedQuery.nodes.length) { - const nodeByPosition = cachedQuery.nodes[index]; - if (nodeByPosition) { - const fallbackSelector = nodeByPosition.preview ?? cachedQuery.selector; - const fallbackIndex = nodeByPosition.preview ? undefined : index + 1; - return { - success: true, - selector: fallbackSelector, - index: fallbackIndex, - }; - } - } - - const validIndices = cachedQuery.nodes.map((n) => n.index).sort((a, b) => a - b); - return { - success: false, - error: `Index ${index} not found in cached results (query "${cachedQuery.selector}")`, - exitCode: EXIT_CODES.STALE_CACHE, - suggestion: - validIndices.length === 0 - ? `No elements found. The selector "${cachedQuery.selector}" may no longer match any elements.` - : `Valid indices: ${validIndices.join(', ')}`, - }; - } - - // Use node's preview selector if available (from form discovery), otherwise original selector - const resolvedSelector = targetNode.preview ?? cachedQuery.selector; - - // If using preview selector (unique element), don't pass index - // Index is only needed when original selector matches multiple elements - const resolvedIndex = targetNode.preview ? undefined : index + 1; - - return { - success: true, - selector: resolvedSelector, - index: resolvedIndex, - }; - } - - return { - success: true, - selector: selectorOrIndex, - index: explicitIndex, - }; - } - - /** - * Get nodeId for a cached index. - * - * Automatically refreshes stale cache by re-running the original query. - * Throws CommandError only if refresh fails or index is out of range after refresh. - * - * @param index - Zero-based index from query results - * @returns Node with nodeId from cache - * @throws CommandError if cache missing, index out of range after refresh, or node not found - * - * @example - * ```typescript - * const node = await resolver.getNodeIdForIndex(0); - * console.log(node.nodeId); // CDP node ID - * ``` - */ - async getNodeIdForIndex(index: number): Promise<{ nodeId: number }> { - let validation = await this.cacheManager.validate(); - - // Auto-refresh: if cache is stale but has selector, re-run query - if (!validation.valid && validation.cache?.selector) { - await this.refreshCache(validation.cache.selector); - validation = await this.cacheManager.validate(); - } - - if (!validation.valid || !validation.cache) { - throw new CommandError( - validation.error ?? 'No cached query results found', - validation.suggestion ? { suggestion: validation.suggestion } : {}, - EXIT_CODES.INVALID_ARGUMENTS - ); - } - - const cachedQuery = validation.cache; - - if (index < 0 || index >= cachedQuery.nodes.length) { - const err = indexOutOfRangeError(index, cachedQuery.nodes.length - 1); - throw new CommandError(err.message, { suggestion: err.suggestion }, EXIT_CODES.STALE_CACHE); - } - - const targetNode = cachedQuery.nodes[index]; - if (!targetNode) { - const err = elementAtIndexNotFoundError(index, cachedQuery.selector); - throw new CommandError( - err.message, - { suggestion: err.suggestion }, - EXIT_CODES.RESOURCE_NOT_FOUND - ); - } - - return targetNode; - } - - /** - * Get the count of cached elements. - * - * Automatically refreshes stale cache by re-running the original query. - * - * @returns Number of cached elements - * @throws CommandError if cache is missing or refresh fails - */ - async getElementCount(): Promise { - let validation = await this.cacheManager.validate(); - - // Auto-refresh: if cache is stale but has selector, re-run query - if (!validation.valid && validation.cache?.selector) { - await this.refreshCache(validation.cache.selector); - validation = await this.cacheManager.validate(); - } - - if (!validation.valid || !validation.cache) { - throw new CommandError( - validation.error ?? 'No cached query results found', - validation.suggestion ? { suggestion: validation.suggestion } : {}, - EXIT_CODES.INVALID_ARGUMENTS - ); - } - - return validation.cache.nodes.length; - } - - /** - * Check if the argument is a numeric index. - * - * @param selectorOrIndex - String to check - * @returns True if the string is a numeric index - */ - isNumericIndex(selectorOrIndex: string): boolean { - return /^\d+$/.test(selectorOrIndex); - } -} diff --git a/src/commands/dom/a11y.ts b/src/commands/dom/a11y.ts index 2abf6852..65639d77 100644 --- a/src/commands/dom/a11y.ts +++ b/src/commands/dom/a11y.ts @@ -11,7 +11,6 @@ import type { Command } from 'commander'; -import { DomElementResolver } from '@/commands/dom/DomElementResolver.js'; import { getDomContext } from '@/commands/dom/helpers.js'; import type { DomContext } from '@/commands/dom/helpers.js'; import { runCommand, runJsonCommand } from '@/commands/shared/CommandRunner.js'; @@ -38,7 +37,6 @@ import { elementNotFoundError, invalidQueryPatternError, noA11yNodesFoundError, - elementNotAccessibleError, } from '@/ui/messages/errors.js'; import { EXIT_CODES } from '@/utils/exitCodes.js'; @@ -128,14 +126,13 @@ interface A11yNodeWithContext { } /** - * Handle bdg dom a11y describe command + * Handle bdg dom a11y describe command * - * Gets accessibility properties for a DOM element by CSS selector or numeric index. - * Supports index-based access from query results (e.g., "bdg dom a11y describe 0"). + * Gets accessibility properties for a DOM element by CSS/Playwright selector. * Useful for understanding how an element is exposed to assistive technologies. * Includes DOM context (tag, classes, text preview) when a11y data is sparse. * - * @param selectorOrIndex - CSS selector (e.g., "button.submit") or numeric index from query results + * @param selector - CSS/Playwright selector (e.g., "button.submit", ":text('Login')") * @param options - Command options * * @example @@ -143,49 +140,24 @@ interface A11yNodeWithContext { * bdg dom a11y describe "button.submit" * bdg dom a11y describe "#email" * bdg dom a11y describe "form input[type=password]" - * bdg dom a11y describe 0 # Uses cached query results + * bdg dom a11y describe ":has-text('Submit')" * ``` */ async function handleA11yDescribe( - selectorOrIndex: string, + selector: string, options: A11yDescribeCommandOptions ): Promise { - const resolver = DomElementResolver.getInstance(); - const isNumericIndex = resolver.isNumericIndex(selectorOrIndex); - /** - * Fetch a11y node data for a given selector or index. + * Fetch a11y node data for a given selector. */ async function fetchA11yNodeData(): Promise { - let node: A11yNode | null; - let nodeId: number | undefined; - - if (isNumericIndex) { - const index = parseInt(selectorOrIndex, 10); - const targetNode = await resolver.getNodeIdForIndex(index); - nodeId = targetNode.nodeId; - node = await resolveA11yNode('', nodeId); - } else { - node = await resolveA11yNode(selectorOrIndex); - } + const node = await resolveA11yNode(selector); if (!node) { - if (isNumericIndex) { - const err = elementNotAccessibleError(parseInt(selectorOrIndex, 10)); - throw new CommandError(err.message, { suggestion: err.suggestion }, EXIT_CODES.STALE_CACHE); - } - throw new CommandError( - elementNotFoundError(selectorOrIndex), - {}, - EXIT_CODES.RESOURCE_NOT_FOUND - ); + throw new CommandError(elementNotFoundError(selector), {}, EXIT_CODES.RESOURCE_NOT_FOUND); } - let domContext: DomContext | null = null; - const domNodeId = node.backendDOMNodeId ?? nodeId; - if (domNodeId) { - domContext = await getDomContext(domNodeId); - } + const domContext = await getDomContext(selector); return { node, domContext }; } @@ -215,7 +187,7 @@ export function registerA11yCommands(domCmd: Command): void { .description('Accessibility tree inspection and semantic queries') .argument( '[search]', - 'Quick search: index (describe), CSS selector (#id, .class), pattern with ":" (query), or name search' + 'Quick search: CSS selector (#id, .class), pattern with "role:" or "name:" (query), or name search' ) .enablePositionalOptions() .action(async (search: string | undefined, options: A11yDescribeCommandOptions) => { @@ -224,15 +196,15 @@ export function registerA11yCommands(domCmd: Command): void { return; } - const isNumericIndex = /^\d+$/.test(search); const isCssSelector = /^[#.[]/u.test(search) || search.includes(' '); - const isPatternQuery = search.includes(':') || search.includes('='); + const isPatternQuery = /(?:role|name|description):/i.test(search); - if (isNumericIndex || isCssSelector) { + if (isCssSelector) { await handleA11yDescribe(search, options); } else if (isPatternQuery) { await handleA11yQuery(search, options); } else { + // Default: treat as name search await handleA11yQuery(`name:*${search}*`, options); } }); @@ -259,13 +231,13 @@ export function registerA11yCommands(domCmd: Command): void { a11y .command('describe') - .description('Get accessibility properties for CSS selector or index') + .description('Get accessibility properties for CSS/Playwright selector') .argument( - '', - 'CSS selector (e.g., "button.submit") or numeric index from query results' + '', + 'CSS/Playwright selector (e.g., "button.submit", ":has-text(\'Login\')")' ) .addOption(jsonOption()) - .action(async (selectorOrIndex: string, options: A11yDescribeCommandOptions) => { - await handleA11yDescribe(selectorOrIndex, options); + .action(async (selector: string, options: A11yDescribeCommandOptions) => { + await handleA11yDescribe(selector, options); }); } diff --git a/src/commands/dom/evalHelpers.ts b/src/commands/dom/evalHelpers.ts index bc0edda6..0d1955a1 100644 --- a/src/commands/dom/evalHelpers.ts +++ b/src/commands/dom/evalHelpers.ts @@ -134,18 +134,22 @@ export async function verifyTargetExists(metadata: SessionMetadata, port: number /** * Execute JavaScript in browser context via CDP * + * Handles non-serializable results (like DOM nodes) gracefully by returning + * a description instead of failing with "Object reference chain is too long". + * * @param cdp - CDP connection instance * @param script - JavaScript expression to execute - * @returns Execution result + * @returns Execution result with value or description for non-serializable objects * @throws Error When script execution throws exception or returns invalid response */ export async function executeScript( cdp: CDPConnection, script: string ): Promise { + // First try without returnByValue to safely get result type const response = await cdp.send('Runtime.evaluate', { expression: script, - returnByValue: true, + returnByValue: false, awaitPromise: true, }); @@ -167,5 +171,56 @@ export async function executeScript( throw new CommandError(err.message, { suggestion: err.suggestion }, EXIT_CODES.SOFTWARE_ERROR); } - return response; + const result = response.result as Protocol.Runtime.RemoteObject | undefined; + if (!result) { + return response; + } + + // For DOM nodes, return description since they can't be serialized + if (result.subtype === 'node') { + return { + ...response, + result: { + ...result, + value: result.description ?? `[${result.className ?? 'Node'}]`, + }, + }; + } + + // For primitives and simple types, CDP already provides the value + const primitiveTypes = ['string', 'number', 'boolean', 'undefined']; + if (primitiveTypes.includes(result.type) || result.subtype === 'null') { + return { + ...response, + result: { + ...result, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + value: result.value, + }, + }; + } + + // For objects/arrays, try to serialize with returnByValue + try { + const serializedResponse = await cdp.send('Runtime.evaluate', { + expression: script, + returnByValue: true, + awaitPromise: true, + }); + + if (isRuntimeEvaluateResult(serializedResponse) && !serializedResponse.exceptionDetails) { + return serializedResponse; + } + } catch { + // Serialization failed, fall through to description + } + + // Return description for complex objects that can't be serialized + return { + ...response, + result: { + ...result, + value: result.description ?? `[${result.className ?? result.type}]`, + }, + }; } diff --git a/src/commands/dom/form.ts b/src/commands/dom/form.ts index f9d6ab3a..c5b11558 100644 --- a/src/commands/dom/form.ts +++ b/src/commands/dom/form.ts @@ -28,15 +28,11 @@ import { runCommand } from '@/commands/shared/CommandRunner.js'; import { jsonOption } from '@/commands/shared/commonOptions.js'; import type { CDPConnection } from '@/connection/cdp.js'; import type { Protocol } from '@/connection/typed-cdp.js'; -import { QueryCacheManager } from '@/session/QueryCacheManager.js'; import { CommandError } from '@/ui/errors/index.js'; import { formatFormDiscovery } from '@/ui/formatters/form.js'; -import { createLogger } from '@/ui/logging/index.js'; import { noFormsFoundError, formInIframeError } from '@/ui/messages/errors.js'; import { EXIT_CODES } from '@/utils/exitCodes.js'; -const log = createLogger('dom'); - /** * Execute form discovery in page context. * @@ -194,36 +190,15 @@ function buildInteractionWarning(raw: RawField): string | undefined { } /** - * Build fill command for a field. - * - * @param index - Global element index - * @param type - Field type - * @returns Command string - */ -function buildFieldCommand(index: number, type: string): string { - const lowerType = type.toLowerCase(); - - if (lowerType === 'checkbox' || lowerType === 'radio' || lowerType === 'switch') { - return `bdg dom click ${index}`; - } - - if (lowerType === 'file') { - return `bdg dom click ${index}`; - } - - return `bdg dom fill ${index} ""`; -} - -/** - * Build selector-based command for a field. + * Build fill command for a field using selector. * * @param selector - CSS selector * @param type - Field type * @returns Command string */ -function buildSelectorCommand(selector: string, type: string): string { +function buildFieldCommand(selector: string, type: string): string { const lowerType = type.toLowerCase(); - // Escape backslashes first, then double quotes (CodeQL js/incomplete-string-escaping) + // Escape backslashes first, then double quotes const escaped = selector.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); if (lowerType === 'checkbox' || lowerType === 'radio' || lowerType === 'switch') { @@ -240,19 +215,20 @@ function buildSelectorCommand(selector: string, type: string): string { /** * Build alternative command for non-native fields. * - * @param index - Global element index + * @param selector - CSS selector * @param raw - Raw field data * @returns Alternative command or undefined */ -function buildAlternativeCommand(index: number, raw: RawField): string | undefined { +function buildAlternativeCommand(selector: string, raw: RawField): string | undefined { if (raw.native) { return undefined; } const type = raw.type.toLowerCase(); + const escaped = selector.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); if (type === 'contenteditable' || type === 'textbox') { - return `bdg dom click ${index} && bdg dom pressKey ${index} ""`; + return `bdg dom click "${escaped}" && bdg dom pressKey "${escaped}" ""`; } return undefined; @@ -265,6 +241,7 @@ function buildAlternativeCommand(index: number, raw: RawField): string | undefin * @returns Structured FormField */ function transformField(raw: RawField): FormField { + const command = buildFieldCommand(raw.selector, raw.type); return { index: raw.index, formIndex: raw.formIndex, @@ -285,9 +262,9 @@ function transformField(raw: RawField): FormField { maskedValue: buildMaskedValue(raw), validation: buildValidation(raw), options: raw.options, - command: buildFieldCommand(raw.index, raw.type), - selectorCommand: buildSelectorCommand(raw.selector, raw.type), - alternativeCommand: buildAlternativeCommand(raw.index, raw), + command, + selectorCommand: command, + alternativeCommand: buildAlternativeCommand(raw.selector, raw), }; } @@ -298,6 +275,7 @@ function transformField(raw: RawField): FormField { * @returns Structured FormButton */ function transformButton(raw: RawButton): FormButton { + const escaped = raw.selector.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); return { index: raw.index, selector: raw.selector, @@ -306,7 +284,7 @@ function transformButton(raw: RawButton): FormButton { primary: raw.isPrimary, enabled: !raw.disabled, disabledReason: raw.disabled ? 'Button is disabled' : undefined, - command: `bdg dom click ${raw.index}`, + command: `bdg dom click "${escaped}"`, }; } @@ -396,49 +374,6 @@ function transformForm(raw: RawForm): DiscoveredForm { }; } -/** - * Cache form elements for index-based access. - * - * @param forms - Discovered forms - */ -async function cacheFormElements(forms: DiscoveredForm[]): Promise { - const cacheManager = QueryCacheManager.getInstance(); - const allElements: Array<{ index: number; nodeId: number; selector: string }> = []; - - for (const form of forms) { - for (const field of form.fields) { - allElements.push({ - index: field.index, - nodeId: 0, - selector: field.selector, - }); - } - for (const button of form.buttons) { - allElements.push({ - index: button.index, - nodeId: 0, - selector: button.selector, - }); - } - } - - const navigationId = await cacheManager.getCurrentNavigationId(); - - await cacheManager.set({ - selector: 'form:auto-discovered', - count: allElements.length, - nodes: allElements.map((el) => ({ - index: el.index, - nodeId: el.nodeId, - tag: 'input', - preview: el.selector, - })), - ...(navigationId !== null && { navigationId }), - }); - - log.debug(`Cached ${allElements.length} form elements`); -} - /** * Execute CDP connection lifecycle for form discovery. * @@ -516,9 +451,6 @@ async function handleFormCommand(options: FormCommandOptions): Promise { const allForms = rawData.forms.map(transformForm); const forms = options.all ? allForms : [allForms[0] as DiscoveredForm]; - // Cache ALL forms so global indices work with bdg dom fill/click - await cacheFormElements(allForms); - const result: FormDiscoveryResult = { formCount: rawData.forms.length, selectedForm: 0, diff --git a/src/commands/dom/formFillHelpers.ts b/src/commands/dom/formFillHelpers.ts index 1c15094f..3842a524 100644 --- a/src/commands/dom/formFillHelpers.ts +++ b/src/commands/dom/formFillHelpers.ts @@ -21,6 +21,7 @@ import { getKeyDefinition, parseModifiers, type KeyDefinition } from './keyMappi import { REACT_FILL_SCRIPT, CLICK_ELEMENT_SCRIPT, + QUERY_ELEMENTS_HELPER, isFillResult, isClickResult, type FillOptions, @@ -364,32 +365,21 @@ export interface PressKeyResult { } /** - * Script to focus an element by selector and optional index. + * Script to focus an element by selector. * * @returns Object with success status and element info */ const FOCUS_ELEMENT_SCRIPT = ` -(function(selector, index) { - const allMatches = document.querySelectorAll(selector); - if (allMatches.length === 0) { +(function(selector) { +${QUERY_ELEMENTS_HELPER} + const elements = __bdgQueryElements(selector); + if (elements.length === 0) { return { success: false, error: 'No nodes found matching selector: ' + selector }; } - let el; - if (typeof index === 'number' && index >= 0) { - if (index >= allMatches.length) { - return { - success: false, - error: 'Index ' + index + ' out of range (found ' + allMatches.length + ' nodes, use 0-' + (allMatches.length - 1) + ')' - }; - } - el = allMatches[index]; - } else { - el = allMatches[0]; - } - + const el = elements[0]; el.focus(); - + return { success: true, selector: selector, @@ -439,8 +429,7 @@ export async function pressKeyElement( const times = options.times ?? 1; const modifierFlags = parseModifiers(options.modifiers); - const indexArg = options.index ?? 'null'; - const focusExpression = `(${FOCUS_ELEMENT_SCRIPT})('${escapeSelectorForJS(selector)}', ${indexArg})`; + const focusExpression = `(${FOCUS_ELEMENT_SCRIPT})('${escapeSelectorForJS(selector)}')`; try { const focusResponse = await cdp.send('Runtime.evaluate', { @@ -642,31 +631,16 @@ export interface ScrollResult { * Script to scroll an element into view. */ const SCROLL_TO_ELEMENT_SCRIPT = ` -(function(selector, index) { - const allMatches = document.querySelectorAll(selector); - if (allMatches.length === 0) { +(function(selector) { +${QUERY_ELEMENTS_HELPER} + const elements = __bdgQueryElements(selector); + if (elements.length === 0) { return { success: false, error: 'No nodes found matching selector: ' + selector }; } - let el; - if (typeof index === 'number' && index >= 0) { - if (index >= allMatches.length) { - return { - success: false, - error: 'Index ' + index + ' out of range (found ' + allMatches.length + ' nodes, use 0-' + (allMatches.length - 1) + ')' - }; - } - el = allMatches[index]; - } else { - el = allMatches[0]; - } - + const el = elements[0]; el.scrollIntoView({ behavior: 'instant', block: 'center' }); - const rect = el.getBoundingClientRect(); - const scrollX = window.scrollX + rect.left + rect.width / 2 - window.innerWidth / 2; - const scrollY = window.scrollY + rect.top + rect.height / 2 - window.innerHeight / 2; - return { success: true, scrollType: 'element', @@ -773,8 +747,7 @@ export async function scrollPage( ): Promise { try { if (selector) { - const indexArg = options.index ?? 'null'; - const expression = `(${SCROLL_TO_ELEMENT_SCRIPT})('${escapeSelectorForJS(selector)}', ${indexArg})`; + const expression = `(${SCROLL_TO_ELEMENT_SCRIPT})('${escapeSelectorForJS(selector)}')`; const response = await cdp.send('Runtime.evaluate', { expression, diff --git a/src/commands/dom/formInteraction.ts b/src/commands/dom/formInteraction.ts index 10307568..5c0ee8cc 100644 --- a/src/commands/dom/formInteraction.ts +++ b/src/commands/dom/formInteraction.ts @@ -4,7 +4,6 @@ import type { Command } from 'commander'; -import { DomElementResolver } from '@/commands/dom/DomElementResolver.js'; import { fillElement, clickElement, @@ -109,36 +108,22 @@ export function registerFormInteractionCommands(program: Command): void { domCommand .command('fill') .description('Fill a form field with a value (React-compatible, waits for stability)') - .argument('', 'CSS selector or numeric index from query results (0-based)') + .argument('', 'CSS/Playwright selector (e.g., "#email", ":has-text(\'Email\')")') .argument('', 'Value to fill') .option('--index ', 'Element index if selector matches multiple (0-based)', parseInt) .option('--no-blur', 'Do not blur after filling (keeps focus on element)') .option('--no-wait', 'Skip waiting for network stability after fill') .addOption(jsonOption()) - .action(async (selectorOrIndex: string, value: string, options: FillCommandOptions) => { + .action(async (selector: string, value: string, options: FillCommandOptions) => { await runCommand( async () => { - const target = await DomElementResolver.getInstance().resolve( - selectorOrIndex, - options.index - ); - - if (!target.success) { - return { - success: false, - error: target.error ?? 'Failed to resolve element target', - exitCode: target.exitCode ?? EXIT_CODES.INVALID_ARGUMENTS, - ...(target.suggestion && { errorContext: { suggestion: target.suggestion } }), - }; - } - return await withCDPConnection(async (cdp) => { const fillOptions = filterDefined({ - index: target.index, + index: options.index, blur: options.blur, }) as { index?: number; blur?: boolean }; - const result = await fillElement(cdp, target.selector, value, fillOptions); + const result = await fillElement(cdp, selector, value, fillOptions); if (!result.success) { return { @@ -169,34 +154,20 @@ export function registerFormInteractionCommands(program: Command): void { domCommand .command('click') - .description('Click an element and wait for stability (accepts selector or index)') - .argument('', 'CSS selector or numeric index from query results (0-based)') + .description('Click an element and wait for stability') + .argument('', 'CSS/Playwright selector (e.g., "button", ":text(\'Submit\')")') .option('--index ', 'Element index if selector matches multiple (0-based)', parseInt) .option('--no-wait', 'Skip waiting for network stability after click') .addOption(jsonOption()) - .action(async (selectorOrIndex: string, options: ClickCommandOptions) => { + .action(async (selector: string, options: ClickCommandOptions) => { await runCommand( async () => { - const target = await DomElementResolver.getInstance().resolve( - selectorOrIndex, - options.index - ); - - if (!target.success) { - return { - success: false, - error: target.error ?? 'Failed to resolve element target', - exitCode: target.exitCode ?? EXIT_CODES.INVALID_ARGUMENTS, - ...(target.suggestion && { errorContext: { suggestion: target.suggestion } }), - }; - } - return await withCDPConnection(async (cdp) => { const clickOptions = filterDefined({ - index: target.index, + index: options.index, }) as { index?: number }; - const result = await clickElement(cdp, target.selector, clickOptions); + const result = await clickElement(cdp, selector, clickOptions); if (!result.success) { return { @@ -227,32 +198,18 @@ export function registerFormInteractionCommands(program: Command): void { domCommand .command('submit') .description('Submit a form by clicking submit button and waiting for completion') - .argument('', 'CSS selector or numeric index from query results (0-based)') + .argument('', 'CSS/Playwright selector for form or submit button') .option('--index ', 'Element index if selector matches multiple (0-based)', parseInt) .option('--wait-navigation', 'Wait for page navigation after submit') .option('--wait-network ', 'Wait for network idle after submit (milliseconds)', '1000') .option('--timeout ', 'Maximum time to wait (milliseconds)', '10000') .addOption(jsonOption()) - .action(async (selectorOrIndex: string, options: SubmitCommandOptions) => { + .action(async (selector: string, options: SubmitCommandOptions) => { await runCommand( async () => { - const target = await DomElementResolver.getInstance().resolve( - selectorOrIndex, - options.index - ); - - if (!target.success) { - return { - success: false, - error: target.error ?? 'Failed to resolve element target', - exitCode: target.exitCode ?? EXIT_CODES.INVALID_ARGUMENTS, - ...(target.suggestion && { errorContext: { suggestion: target.suggestion } }), - }; - } - return await withCDPConnection(async (cdp) => { const submitOptions = filterDefined({ - index: target.index, + index: options.index, waitNavigation: options.waitNavigation, waitNetwork: parseInt(options.waitNetwork, 10), timeout: parseInt(options.timeout, 10), @@ -263,7 +220,7 @@ export function registerFormInteractionCommands(program: Command): void { timeout?: number; }; - const result = await submitForm(cdp, target.selector, submitOptions); + const result = await submitForm(cdp, selector, submitOptions); if (!result.success) { return { @@ -292,38 +249,24 @@ export function registerFormInteractionCommands(program: Command): void { domCommand .command('pressKey') .description('Press a key on an element (for Enter-to-submit, keyboard navigation)') - .argument('', 'CSS selector or numeric index from query results (0-based)') + .argument('', 'CSS/Playwright selector for the element') .argument('', 'Key to press (Enter, Tab, Escape, Space, ArrowUp, etc.)') .option('--index ', 'Element index if selector matches multiple (0-based)', parseInt) .option('--times ', 'Press key multiple times (default: 1)', parseInt) .option('--modifiers ', 'Modifier keys: shift,ctrl,alt,meta (comma-separated)') .option('--no-wait', 'Skip waiting for network stability after key press') .addOption(jsonOption()) - .action(async (selectorOrIndex: string, key: string, options: PressKeyCommandOptions) => { + .action(async (selector: string, key: string, options: PressKeyCommandOptions) => { await runCommand( async () => { - const target = await DomElementResolver.getInstance().resolve( - selectorOrIndex, - options.index - ); - - if (!target.success) { - return { - success: false, - error: target.error ?? 'Failed to resolve element target', - exitCode: target.exitCode ?? EXIT_CODES.INVALID_ARGUMENTS, - ...(target.suggestion && { errorContext: { suggestion: target.suggestion } }), - }; - } - return await withCDPConnection(async (cdp) => { const pressKeyOptions = filterDefined({ - index: target.index, + index: options.index, times: options.times, modifiers: options.modifiers, }) as { index?: number; times?: number; modifiers?: string }; - const result = await pressKeyElement(cdp, target.selector, key, pressKeyOptions); + const result = await pressKeyElement(cdp, selector, key, pressKeyOptions); if (!result.success) { return { @@ -354,7 +297,7 @@ export function registerFormInteractionCommands(program: Command): void { domCommand .command('scroll') .description('Scroll page to element, by pixels, or to page boundaries') - .argument('[selector]', 'CSS selector to scroll into view (optional)') + .argument('[selector]', 'CSS/Playwright selector to scroll into view (optional)') .option('--index ', 'Element index if selector matches multiple (0-based)', parseInt) .option('--down ', 'Scroll down by pixels', parseInt) .option('--up ', 'Scroll up by pixels', parseInt) diff --git a/src/commands/dom/helpers.ts b/src/commands/dom/helpers.ts index 7cf1d467..c9f50eec 100644 --- a/src/commands/dom/helpers.ts +++ b/src/commands/dom/helpers.ts @@ -1,8 +1,12 @@ /** * DOM helpers using CDP relay pattern. * - * Provides query, get, and screenshot functionality using the worker's persistent CDP connection. - * All operations go through IPC callCDP() for optimal performance. + * All queries use Playwright-style selector support via JS evaluation. + * Supports standard CSS selectors and Playwright pseudo-classes: + * - `:has-text("text")` - Element contains text (case-insensitive) + * - `:text("text")` - Smallest element with text + * - `:text-is("text")` - Exact text match + * - `:visible` - Element is visible */ import { @@ -27,13 +31,9 @@ import { CommandError } from '@/ui/errors/index.js'; import { createLogger } from '@/ui/logging/index.js'; import { noNodesFoundError, - indexOutOfRangeError, elementNotVisibleError, elementZeroDimensionsError, - eitherArgumentRequiredError, } from '@/ui/messages/errors.js'; -import { ConcurrencyLimiter } from '@/utils/concurrency.js'; -import { getErrorMessage } from '@/utils/errors.js'; import { EXIT_CODES } from '@/utils/exitCodes.js'; const log = createLogger('dom'); @@ -48,12 +48,6 @@ export type { ElementBounds, }; -/** - * Maximum concurrent CDP calls for DOM operations. - * Prevents overwhelming CDP connection with too many simultaneous requests. - */ -const CDP_CONCURRENCY_LIMIT = 10; - /** * Scroll position before a scroll operation. */ @@ -71,6 +65,98 @@ const POST_SCROLL_MAX_WAIT_MS = 2000; /** Check interval for stability polling */ const STABILITY_CHECK_INTERVAL_MS = 50; +/** + * JavaScript helper for Playwright-style selector support. + * Injected into browser context for all DOM queries. + */ +const QUERY_ELEMENTS_JS = ` +function __bdgQueryElements(selector) { + // Quick check for Playwright pseudo-classes + if (!/:(?:has-text|text-is|text|visible)\\s*(?:\\(|$)/.test(selector)) { + return [...document.querySelectorAll(selector)]; + } + + // Parse out Playwright pseudo-classes + const pseudoMatches = []; + let cssSelector = selector; + + // Extract :has-text("...") + cssSelector = cssSelector.replace(/:has-text\\((['"])(.*?)\\1\\)/g, (_, q, text) => { + pseudoMatches.push({ type: 'has-text', arg: text }); + return ''; + }); + + // Extract :text-is("...") - must come before :text + cssSelector = cssSelector.replace(/:text-is\\((['"])(.*?)\\1\\)/g, (_, q, text) => { + pseudoMatches.push({ type: 'text-is', arg: text }); + return ''; + }); + + // Extract :text("...") + cssSelector = cssSelector.replace(/:text\\((['"])(.*?)\\1\\)/g, (_, q, text) => { + pseudoMatches.push({ type: 'text', arg: text }); + return ''; + }); + + // Extract :visible + cssSelector = cssSelector.replace(/:visible/g, () => { + pseudoMatches.push({ type: 'visible' }); + return ''; + }); + + // Clean up any trailing/multiple spaces + cssSelector = cssSelector.replace(/\\s+/g, ' ').trim() || '*'; + + // Query with CSS selector + let elements = [...document.querySelectorAll(cssSelector)]; + + // Apply filters for each pseudo-class + for (const pseudo of pseudoMatches) { + elements = elements.filter(el => { + switch (pseudo.type) { + case 'has-text': { + const text = pseudo.arg.toLowerCase(); + return el.textContent?.toLowerCase().includes(text); + } + case 'text': { + const text = pseudo.arg.toLowerCase(); + const elText = el.textContent?.replace(/\\s+/g, ' ').trim().toLowerCase() || ''; + if (!elText.includes(text)) return false; + // Check no child has the complete match (we want the smallest container) + for (const child of el.children) { + const childText = child.textContent?.replace(/\\s+/g, ' ').trim().toLowerCase() || ''; + if (childText.includes(text)) return false; + } + return true; + } + case 'text-is': { + return el.textContent?.replace(/\\s+/g, ' ').trim() === pseudo.arg; + } + case 'visible': { + const style = window.getComputedStyle(el); + if (style.display === 'none') return false; + if (style.visibility === 'hidden') return false; + if (style.opacity === '0') return false; + const rect = el.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + } + default: + return true; + } + }); + } + + return elements; +} +`; + +/** + * Escape selector for safe inclusion in JavaScript string. + */ +function escapeSelector(selector: string): string { + return selector.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/\n/g, '\\n'); +} + /** * Wait for page to stabilize after scrolling. * @@ -180,16 +266,19 @@ async function waitForPostScrollStability(): Promise { * Returns the original scroll position so it can be restored after capture. * Waits for lazy-loaded content and DOM mutations to stabilize before returning. * - * @param selector - CSS selector of element to scroll to + * @param selector - CSS/Playwright selector of element to scroll to * @returns Original scroll position before scrolling * @throws CommandError if element not found */ async function scrollToElement(selector: string): Promise { + const escapedSelector = escapeSelector(selector); const result = await callCDP('Runtime.evaluate', { expression: ` + ${QUERY_ELEMENTS_JS} (() => { - const el = document.querySelector(${JSON.stringify(selector)}); - if (!el) return { found: false }; + const elements = __bdgQueryElements('${escapedSelector}'); + if (elements.length === 0) return { found: false }; + const el = elements[0]; const originalX = window.scrollX; const originalY = window.scrollY; el.scrollIntoView({ block: 'center', behavior: 'instant' }); @@ -231,90 +320,45 @@ async function restoreScrollPosition(position: ScrollPosition): Promise { } /** - * Query DOM elements by CSS selector using CDP relay. + * Query DOM elements by selector. + * + * Supports standard CSS selectors and Playwright-style pseudo-classes. * - * @param selector - CSS selector to query + * @param selector - CSS/Playwright selector * @returns Query result with matched nodes - * @throws CDPConnectionError if CDP operation fails */ export async function queryDOMElements(selector: string): Promise { - await callCDP('DOM.enable', {}); + const escapedSelector = escapeSelector(selector); - const docResponse = await callCDP('DOM.getDocument', {}); - const doc = docResponse.data?.result as Protocol.DOM.GetDocumentResponse | undefined; - if (!doc?.root?.nodeId) { - throw new CDPConnectionError('Failed to get document root', new Error('No root node')); - } - - const queryResponse = await callCDP('DOM.querySelectorAll', { - nodeId: doc.root.nodeId, - selector, + const result = await callCDP('Runtime.evaluate', { + expression: ` + ${QUERY_ELEMENTS_JS} + (() => { + const elements = __bdgQueryElements('${escapedSelector}'); + return elements.map((el, index) => { + const tag = el.tagName?.toLowerCase() || ''; + const classes = el.className?.split?.(/\\s+/).filter(c => c.length > 0) || []; + const textContent = el.textContent?.replace(/<[^>]*>/g, '').replace(/\\s+/g, ' ').trim() || ''; + const preview = textContent.slice(0, 80) + (textContent.length > 80 ? '...' : ''); + return { index, tag, classes, preview }; + }); + })() + `, + returnByValue: true, }); - const queryResult = queryResponse.data?.result as - | Protocol.DOM.QuerySelectorAllResponse - | undefined; - const nodeIds = queryResult?.nodeIds ?? []; - - if (nodeIds.length > 20) { - log.debug(`Querying ${nodeIds.length} elements with selector: ${selector}`); - } - - const limiter = new ConcurrencyLimiter(CDP_CONCURRENCY_LIMIT); - const nodes = await Promise.all( - nodeIds.map((nodeId, index) => - limiter.run(async () => { - const descResponse = await callCDP('DOM.describeNode', { nodeId }); - const descResult = descResponse.data?.result as - | Protocol.DOM.DescribeNodeResponse - | undefined; - const nodeDesc = descResult?.node; - - if (!nodeDesc) { - return { index, nodeId }; - } - const attributes: Record = {}; - if (nodeDesc.attributes) { - for (let i = 0; i < nodeDesc.attributes.length; i += 2) { - const key = nodeDesc.attributes[i]; - const value = nodeDesc.attributes[i + 1]; - if (key !== undefined && value !== undefined) { - attributes[key] = value; - } - } - } + const nodes = + ( + result.data?.result as { + result?: { + value?: Array<{ index: number; tag: string; classes: string[]; preview: string }>; + }; + } + )?.result?.value ?? []; - const classes = attributes['class']?.split(/\s+/).filter((c) => c.length > 0); - const tag = nodeDesc.nodeName.toLowerCase(); - - const htmlResponse = await callCDP('DOM.getOuterHTML', { nodeId }); - const htmlResult = htmlResponse.data?.result as - | Protocol.DOM.GetOuterHTMLResponse - | undefined; - const outerHTML = htmlResult?.outerHTML ?? ''; - - const textContent = outerHTML - .replace(/<[^>]*>/g, '') - .replace(/\s+/g, ' ') - .trim(); - const preview = textContent.slice(0, 80) + (textContent.length > 80 ? '...' : ''); - - const node: { - index: number; - nodeId: number; - tag?: string; - classes?: string[]; - preview?: string; - } = { index, nodeId }; - - if (tag) node.tag = tag; - if (classes) node.classes = classes; - if (preview) node.preview = preview; - - return node; - }) - ) - ); + if (nodes.length > 20) { + log.debug(`Found ${nodes.length} elements for selector: ${selector}`); + } return { selector, @@ -324,200 +368,175 @@ export async function queryDOMElements(selector: string): Promise { - try { - await callCDP('DOM.enable', {}); +export async function getDOMElements(options: DomGetOptions): Promise { + if (!options.selector) { + throw new CommandError( + 'Selector is required', + { suggestion: 'Use: bdg dom get ' }, + EXIT_CODES.INVALID_ARGUMENTS + ); + } - const descResponse = await callCDP('DOM.describeNode', { nodeId }); - const descResult = descResponse.data?.result as Protocol.DOM.DescribeNodeResponse | undefined; - const nodeDesc = descResult?.node; + const escapedSelector = escapeSelector(options.selector); - if (!nodeDesc) { - return null; - } + const result = await callCDP('Runtime.evaluate', { + expression: ` + ${QUERY_ELEMENTS_JS} + (() => { + const elements = __bdgQueryElements('${escapedSelector}'); + ${options.all ? '' : 'elements.splice(1);'} // Keep only first unless --all + return elements.map(el => { + const tag = el.tagName?.toLowerCase() || ''; + const attributes = {}; + for (const attr of el.attributes || []) { + attributes[attr.name] = attr.value; + } + const classes = el.className?.split?.(/\\s+/).filter(c => c.length > 0) || []; + return { tag, attributes, classes, outerHTML: el.outerHTML }; + }); + })() + `, + returnByValue: true, + }); - const attributes: Record = {}; - if (nodeDesc.attributes) { - for (let i = 0; i < nodeDesc.attributes.length; i += 2) { - const key = nodeDesc.attributes[i]; - const value = nodeDesc.attributes[i + 1]; - if (key !== undefined && value !== undefined) { - attributes[key] = value; - } + const nodes = + ( + result.data?.result as { + result?: { + value?: Array<{ + tag: string; + attributes: Record; + classes: string[]; + outerHTML: string; + }>; + }; } - } + )?.result?.value ?? []; + + if (nodes.length === 0) { + const err = noNodesFoundError(options.selector); + throw new CommandError( + err.message, + { suggestion: err.suggestion }, + EXIT_CODES.RESOURCE_NOT_FOUND + ); + } + + return { nodes }; +} - const classes = attributes['class']?.split(/\s+/).filter((c) => c.length > 0); - const tag = nodeDesc.nodeName.toLowerCase(); +/** + * Fetch DOM context (tag, classes, text preview) for first matching element. + * + * @param selector - CSS/Playwright selector + * @returns DOM context with tag, classes, and text preview + */ +export async function getDomContext(selector: string): Promise { + try { + const escapedSelector = escapeSelector(selector); + + const result = await callCDP('Runtime.evaluate', { + expression: ` + ${QUERY_ELEMENTS_JS} + (() => { + const elements = __bdgQueryElements('${escapedSelector}'); + if (elements.length === 0) return null; + const el = elements[0]; + const tag = el.tagName?.toLowerCase() || ''; + const classes = el.className?.split?.(/\\s+/).filter(c => c.length > 0) || []; + const textContent = el.textContent?.replace(/\\s+/g, ' ').trim() || ''; + const preview = textContent.slice(0, 80) + (textContent.length > 80 ? '...' : ''); + return { tag, classes, preview }; + })() + `, + returnByValue: true, + }); - const htmlResponse = await callCDP('DOM.getOuterHTML', { nodeId }); - const htmlResult = htmlResponse.data?.result as Protocol.DOM.GetOuterHTMLResponse | undefined; - const outerHTML = htmlResult?.outerHTML ?? ''; + const context = ( + result.data?.result as { + result?: { value?: { tag: string; classes: string[]; preview: string } | null }; + } + )?.result?.value; - const textContent = outerHTML - .replace(/<[^>]*>/g, '') - .replace(/\s+/g, ' ') - .trim(); - const preview = textContent.slice(0, 80) + (textContent.length > 80 ? '...' : ''); + if (!context) return null; - const context: DomContext = { tag }; - if (classes && classes.length > 0) context.classes = classes; - if (preview) context.preview = preview; + const domContext: DomContext = { tag: context.tag }; + if (context.classes.length > 0) domContext.classes = context.classes; + if (context.preview) domContext.preview = context.preview; - return context; - } catch (error) { - log.debug(`Failed to get DOM context for nodeId ${nodeId}: ${getErrorMessage(error)}`); + return domContext; + } catch { return null; } } /** - * Get full HTML and attributes for DOM elements using CDP relay. + * Get element bounding box via JS getBoundingClientRect. * - * @param options - Get options (selector or nodeId, plus optional --all or --nth flags) - * @returns Get result with node details - * @throws CDPConnectionError if CDP operation fails + * @param selector - CSS/Playwright selector + * @returns Element bounds (x, y, width, height) in document coordinates + * @throws CommandError if element not found or has zero dimensions */ -export async function getDOMElements(options: DomGetOptions): Promise { - await callCDP('DOM.enable', {}); - - let nodeIds: number[] = []; +export async function getElementBounds(selector: string): Promise { + const escapedSelector = escapeSelector(selector); - if (options.nodeId !== undefined) { - nodeIds = [options.nodeId]; - } else if (options.selector) { - const docResponse = await callCDP('DOM.getDocument', {}); - const doc = docResponse.data?.result as Protocol.DOM.GetDocumentResponse | undefined; - if (!doc?.root?.nodeId) { - throw new CDPConnectionError('Failed to get document root', new Error('No root node')); - } + const result = await callCDP('Runtime.evaluate', { + expression: ` + ${QUERY_ELEMENTS_JS} + (() => { + const elements = __bdgQueryElements('${escapedSelector}'); + if (elements.length === 0) return { found: false }; + const el = elements[0]; + const rect = el.getBoundingClientRect(); + return { + found: true, + x: rect.left + window.scrollX, + y: rect.top + window.scrollY, + width: rect.width, + height: rect.height + }; + })() + `, + returnByValue: true, + }); - const queryResponse = await callCDP('DOM.querySelectorAll', { - nodeId: doc.root.nodeId, - selector: options.selector, - }); - const queryResult = queryResponse.data?.result as - | Protocol.DOM.QuerySelectorAllResponse - | undefined; - nodeIds = queryResult?.nodeIds ?? []; - - if (nodeIds.length === 0) { - const err = noNodesFoundError(options.selector); - throw new CommandError( - err.message, - { suggestion: err.suggestion }, - EXIT_CODES.RESOURCE_NOT_FOUND - ); + const value = ( + result.data?.result as { + result?: { + value?: { found: boolean; x?: number; y?: number; width?: number; height?: number }; + }; } + )?.result?.value; - if (options.nth !== undefined) { - if (options.nth < 0 || options.nth >= nodeIds.length) { - const err = indexOutOfRangeError(options.nth, nodeIds.length - 1); - throw new CommandError( - err.message, - { suggestion: err.suggestion }, - EXIT_CODES.INVALID_ARGUMENTS - ); - } - const nthNode = nodeIds[options.nth]; - if (nthNode === undefined) { - const err = indexOutOfRangeError(options.nth, nodeIds.length - 1); - throw new CommandError( - err.message, - { suggestion: err.suggestion }, - EXIT_CODES.RESOURCE_NOT_FOUND - ); - } - nodeIds = [nthNode]; - } else if (!options.all) { - const firstNode = nodeIds[0]; - if (firstNode === undefined) { - const err = noNodesFoundError(options.selector ?? ''); - throw new CommandError( - err.message, - { suggestion: err.suggestion }, - EXIT_CODES.RESOURCE_NOT_FOUND - ); - } - nodeIds = [firstNode]; - } - } else { - const err = eitherArgumentRequiredError( - 'selector', - 'nodeId', - 'bdg dom get or bdg dom get --node-id ' - ); + if (!value?.found) { + const err = elementNotVisibleError(); throw new CommandError( err.message, { suggestion: err.suggestion }, - EXIT_CODES.INVALID_ARGUMENTS + EXIT_CODES.RESOURCE_NOT_FOUND ); } - if (nodeIds.length > 20) { - log.debug(`Fetching details for ${nodeIds.length} DOM elements`); - } - - const limiter = new ConcurrencyLimiter(CDP_CONCURRENCY_LIMIT); - const nodes = await Promise.all( - nodeIds.map((nodeId) => - limiter.run(async () => { - const descResponse = await callCDP('DOM.describeNode', { nodeId }); - const descResult = descResponse.data?.result as - | Protocol.DOM.DescribeNodeResponse - | undefined; - const nodeDesc = descResult?.node; - - if (!nodeDesc) { - return { nodeId }; - } + const x = value.x ?? 0; + const y = value.y ?? 0; + const width = value.width ?? 0; + const height = value.height ?? 0; - const attributes: Record = {}; - if (nodeDesc.attributes) { - for (let i = 0; i < nodeDesc.attributes.length; i += 2) { - const key = nodeDesc.attributes[i]; - const value = nodeDesc.attributes[i + 1]; - if (key !== undefined && value !== undefined) { - attributes[key] = value; - } - } - } - - const classes = attributes['class']?.split(/\s+/).filter((c) => c.length > 0); - const tag = nodeDesc.nodeName.toLowerCase(); - - const htmlResponse = await callCDP('DOM.getOuterHTML', { nodeId }); - const htmlResult = htmlResponse.data?.result as - | Protocol.DOM.GetOuterHTMLResponse - | undefined; - const outerHTML = htmlResult?.outerHTML; - - const node: { - nodeId: number; - tag?: string; - attributes?: Record; - classes?: string[]; - outerHTML?: string; - } = { nodeId }; - - if (tag) node.tag = tag; - if (Object.keys(attributes).length > 0) node.attributes = attributes; - if (classes) node.classes = classes; - if (outerHTML) node.outerHTML = outerHTML; - - return node; - }) - ) - ); + if (width <= 0 || height <= 0) { + const err = elementZeroDimensionsError(); + throw new CommandError( + err.message, + { suggestion: err.suggestion }, + EXIT_CODES.INVALID_ARGUMENTS + ); + } - return { nodes }; + return { x, y, width, height }; } /** @@ -585,8 +604,17 @@ export async function capturePageScreenshot( // Re-scroll after DPR override (setDeviceMetricsOverride resets scroll position) if (options.scroll) { + const escapedSelector = escapeSelector(options.scroll); await callCDP('Runtime.evaluate', { - expression: `document.querySelector(${JSON.stringify(options.scroll)})?.scrollIntoView({ block: 'center', behavior: 'instant' })`, + expression: ` + ${QUERY_ELEMENTS_JS} + (() => { + const elements = __bdgQueryElements('${escapedSelector}'); + if (elements.length > 0) { + elements[0].scrollIntoView({ block: 'center', behavior: 'instant' }); + } + })() + `, returnByValue: true, }); } @@ -692,85 +720,6 @@ export async function capturePageScreenshot( return result; } -/** - * Get element bounding box via CDP DOM.getBoxModel. - * - * Extracts the content box coordinates from the box model quad array. - * The content quad is an array of 8 numbers: [x1,y1, x2,y2, x3,y3, x4,y4] - * representing the four corners of the content box. - * - * @param nodeId - CDP node ID - * @returns Element bounds (x, y, width, height) - * @throws CommandError if element not found or has zero dimensions - */ -export async function getElementBounds(nodeId: number): Promise { - const response = await callCDP('DOM.getBoxModel', { nodeId }); - const boxModel = response.data?.result as Protocol.DOM.GetBoxModelResponse | undefined; - - if (!boxModel?.model?.content) { - const err = elementNotVisibleError(); - throw new CommandError( - err.message, - { suggestion: err.suggestion }, - EXIT_CODES.RESOURCE_NOT_FOUND - ); - } - - const content = boxModel.model.content; - const x = content[0] ?? 0; - const y = content[1] ?? 0; - const width = (content[2] ?? 0) - x; - const height = (content[5] ?? 0) - y; - - if (width <= 0 || height <= 0) { - const err = elementZeroDimensionsError(); - throw new CommandError( - err.message, - { suggestion: err.suggestion }, - EXIT_CODES.INVALID_ARGUMENTS - ); - } - - return { x, y, width, height }; -} - -/** - * Resolve CSS selector to CDP nodeId. - * - * Queries the document for a single element matching the selector. - * - * @param selector - CSS selector string - * @returns CDP nodeId - * @throws CommandError if element not found - */ -export async function resolveSelector(selector: string): Promise { - await callCDP('DOM.enable', {}); - - const docResponse = await callCDP('DOM.getDocument', {}); - const doc = docResponse.data?.result as Protocol.DOM.GetDocumentResponse | undefined; - - if (!doc?.root?.nodeId) { - throw new CDPConnectionError('Failed to get document root', new Error('No root node')); - } - - const queryResponse = await callCDP('DOM.querySelector', { - nodeId: doc.root.nodeId, - selector, - }); - const queryResult = queryResponse.data?.result as Protocol.DOM.QuerySelectorResponse | undefined; - - if (!queryResult?.nodeId) { - const err = noNodesFoundError(selector); - throw new CommandError( - err.message, - { suggestion: err.suggestion }, - EXIT_CODES.RESOURCE_NOT_FOUND - ); - } - - return queryResult.nodeId; -} - /** * Capture screenshot of a specific element. * @@ -779,16 +728,16 @@ export async function resolveSelector(selector: string): Promise { * for Claude Vision token cost (~1,600 tokens max). Use noResize option to disable. * * @param outputPath - Output file path - * @param nodeId - CDP node ID of element + * @param selector - CSS/Playwright selector for element * @param options - Format, quality, and noResize options * @returns Screenshot result with element bounds and resize metadata */ export async function captureElementScreenshot( outputPath: string, - nodeId: number, + selector: string, options: { format?: 'png' | 'jpeg'; quality?: number; noResize?: boolean } = {} ): Promise { - const bounds = await getElementBounds(nodeId); + const bounds = await getElementBounds(selector); const format = options.format ?? 'png'; const quality = format === 'jpeg' ? (options.quality ?? 90) : undefined; diff --git a/src/commands/dom/index.ts b/src/commands/dom/index.ts index 28d3145d..b93f8fb0 100644 --- a/src/commands/dom/index.ts +++ b/src/commands/dom/index.ts @@ -1,7 +1,6 @@ import type { Command } from 'commander'; import type * as FsModule from 'fs'; -import { DomElementResolver } from '@/commands/dom/DomElementResolver.js'; import { registerA11yCommands } from '@/commands/dom/a11y.js'; import { registerFormCommand } from '@/commands/dom/form.js'; import { @@ -9,7 +8,6 @@ import { getDOMElements, capturePageScreenshot, captureElementScreenshot, - resolveSelector, getDomContext, } from '@/commands/dom/helpers.js'; import type { DomGetOptions as DomGetHelperOptions, DomContext } from '@/commands/dom/helpers.js'; @@ -22,7 +20,6 @@ import type { DomEvalCommandOptions, } from '@/commands/shared/optionTypes.js'; import { positiveIntRule } from '@/commands/shared/validation.js'; -import { QueryCacheManager } from '@/session/QueryCacheManager.js'; import { resolveA11yNode } from '@/telemetry/a11y.js'; import { synthesizeA11yNode } from '@/telemetry/roleInference.js'; import type { A11yNode, ScreenshotResult, ElementBounds } from '@/types.js'; @@ -34,11 +31,7 @@ import { formatDomScreenshot, } from '@/ui/formatters/dom.js'; import { createLogger } from '@/ui/logging/index.js'; -import { - missingArgumentError, - elementAtIndexNotFoundError, - noNodesFoundError, -} from '@/ui/messages/errors.js'; +import { noNodesFoundError } from '@/ui/messages/errors.js'; import { EXIT_CODES } from '@/utils/exitCodes.js'; import { filterDefined } from '@/utils/objects.js'; @@ -102,49 +95,14 @@ function buildElementScreenshotOptions( }) as FilteredElementOptions; } -/** - * Check if options specify an element target. - * - * @param options - Screenshot command options - * @returns True if selector or index is specified - */ -function hasElementTarget(options: DomScreenshotCommandOptions): boolean { - return options.selector !== undefined || options.index !== undefined; -} - -/** - * Resolve element nodeId from selector or cached index. - * - * @param options - Options containing selector or index - * @returns CDP nodeId - * @throws CommandError if neither selector nor index provided - */ -async function resolveElementNodeId(options: DomScreenshotCommandOptions): Promise { - if (options.index !== undefined) { - const resolver = DomElementResolver.getInstance(); - const node = await resolver.getNodeIdForIndex(options.index); - return node.nodeId; - } - - if (options.selector !== undefined) { - return resolveSelector(options.selector); - } - - const err = missingArgumentError('--selector "css-selector" or --index N from a previous query'); - throw new CommandError(err.message, { suggestion: err.suggestion }, EXIT_CODES.INVALID_ARGUMENTS); -} - /** * Add element metadata to screenshot result. * * @param result - Base screenshot result - * @param options - Options containing selector or index + * @param selector - Element selector * @returns Screenshot result with element info */ -function addElementInfo( - result: ScreenshotResult, - options: DomScreenshotCommandOptions -): ScreenshotResult { +function addElementInfo(result: ScreenshotResult, selector: string): ScreenshotResult { const bounds: ElementBounds = { x: 0, y: 0, @@ -155,8 +113,7 @@ function addElementInfo( return { ...result, element: { - ...(options.selector !== undefined && { selector: options.selector }), - ...(options.index !== undefined && { index: options.index }), + selector, bounds, }, }; @@ -273,67 +230,27 @@ function formatSemanticNodeWithContext(data: SemanticNodeWithContext): string { * * @param a11yNode - Accessibility node or null * @param domContext - DOM context for synthesis fallback - * @param nodeId - Node ID for synthesis * @returns Resolved node or null */ function resolveNodeWithFallback( a11yNode: A11yNode | null, - domContext: DomContext | null, - nodeId: number | undefined + domContext: DomContext | null ): A11yNode | null { if (a11yNode) return a11yNode; - if (domContext && nodeId) return synthesizeA11yNode(domContext, nodeId); + if (domContext) return synthesizeA11yNode(domContext); return null; } -/** - * Query DOM context by selector when a11y node is unavailable. - * - * @param selector - CSS selector - * @returns Object with nodeId and domContext - */ -async function queryDomContextBySelector( - selector: string -): Promise<{ nodeId: number | undefined; domContext: DomContext | null }> { - const { callCDP } = await import('@/ipc/client.js'); - const docResponse = await callCDP('DOM.getDocument', {}); - const doc = docResponse.data?.result as { root?: { nodeId?: number } } | undefined; - - if (!doc?.root?.nodeId) { - return { nodeId: undefined, domContext: null }; - } - - const queryResponse = await callCDP('DOM.querySelector', { - nodeId: doc.root.nodeId, - selector, - }); - const queryResult = queryResponse.data?.result as { nodeId?: number } | undefined; - - if (!queryResult?.nodeId) { - return { nodeId: undefined, domContext: null }; - } - - const domContext = await getDomContext(queryResult.nodeId); - return { nodeId: queryResult.nodeId, domContext }; -} - /** * Handle bdg dom query command. * - * @param selector - CSS selector to query + * @param selector - CSS/Playwright selector to query * @param options - Command options */ async function handleDomQuery(selector: string, options: DomQueryCommandOptions): Promise { await runCommand( async () => { const result = await queryDOMElements(selector); - const cacheManager = QueryCacheManager.getInstance(); - const navigationId = await cacheManager.getCurrentNavigationId(); - const resultWithNavId = { - ...result, - ...(navigationId !== null && { navigationId }), - }; - await cacheManager.set(resultWithNavId); return { success: true, data: result }; }, options, @@ -342,77 +259,9 @@ async function handleDomQuery(selector: string, options: DomQueryCommandOptions) } /** - * Handle get command with numeric index in raw mode. + * Handle get command with selector in raw mode. * - * @param index - Element index - * @param options - Command options - */ -async function handleIndexGetRaw(index: number, options: DomGetCommandOptions): Promise { - const resolver = DomElementResolver.getInstance(); - await runCommand( - async () => { - const targetNode = await resolver.getNodeIdForIndex(index); - const getOptions = filterDefined({ nodeId: targetNode.nodeId }) as DomGetHelperOptions; - const result = await getDOMElements(getOptions); - return { success: true, data: result }; - }, - options, - formatDomGet - ); -} - -/** - * Handle get command with numeric index in semantic mode. - * - * @param index - Element index - * @param options - Command options - */ -async function handleIndexGetSemantic(index: number, options: DomGetCommandOptions): Promise { - const resolver = DomElementResolver.getInstance(); - await runCommand( - async () => { - const targetNode = await resolver.getNodeIdForIndex(index); - const [a11yNode, domContext] = await Promise.all([ - resolveA11yNode('', targetNode.nodeId), - getDomContext(targetNode.nodeId), - ]); - - const node = resolveNodeWithFallback(a11yNode, domContext, targetNode.nodeId); - - if (!node) { - const err = elementAtIndexNotFoundError(index, 'cached query'); - throw new CommandError( - err.message, - { suggestion: err.suggestion }, - EXIT_CODES.RESOURCE_NOT_FOUND - ); - } - - return { success: true, data: { node, domContext } }; - }, - options, - formatSemanticNodeWithContext - ); -} - -/** - * Handle get command with numeric index. - * - * @param index - Element index - * @param options - Command options - */ -async function handleIndexGet(index: number, options: DomGetCommandOptions): Promise { - if (options.raw) { - await handleIndexGetRaw(index, options); - } else { - await handleIndexGetSemantic(index, options); - } -} - -/** - * Handle get command with CSS selector in raw mode. - * - * @param selector - CSS selector + * @param selector - CSS/Playwright selector * @param options - Command options */ async function handleSelectorGetRaw( @@ -424,8 +273,6 @@ async function handleSelectorGetRaw( const getOptions = filterDefined({ selector, all: options.all, - nth: options.nth, - nodeId: options.nodeId, }) as DomGetHelperOptions; const result = await getDOMElements(getOptions); @@ -437,9 +284,9 @@ async function handleSelectorGetRaw( } /** - * Handle get command with CSS selector in semantic mode. + * Handle get command with selector in semantic mode. * - * @param selector - CSS selector + * @param selector - CSS/Playwright selector * @param options - Command options */ async function handleSelectorGetSemantic( @@ -449,20 +296,8 @@ async function handleSelectorGetSemantic( await runCommand( async () => { const a11yNode = await resolveA11yNode(selector); - - let domContext: DomContext | null = null; - let nodeId: number | undefined; - - if (a11yNode?.backendDOMNodeId) { - nodeId = a11yNode.backendDOMNodeId; - domContext = await getDomContext(nodeId); - } else if (!a11yNode) { - const queryResult = await queryDomContextBySelector(selector); - nodeId = queryResult.nodeId; - domContext = queryResult.domContext; - } - - const node = resolveNodeWithFallback(a11yNode, domContext, nodeId); + const domContext = await getDomContext(selector); + const node = resolveNodeWithFallback(a11yNode, domContext); if (!node) { const err = noNodesFoundError(selector); @@ -481,12 +316,12 @@ async function handleSelectorGetSemantic( } /** - * Handle get command with CSS selector. + * Handle bdg dom get command. * - * @param selector - CSS selector + * @param selector - CSS/Playwright selector * @param options - Command options */ -async function handleSelectorGet(selector: string, options: DomGetCommandOptions): Promise { +async function handleDomGet(selector: string, options: DomGetCommandOptions): Promise { if (options.raw) { await handleSelectorGetRaw(selector, options); } else { @@ -494,22 +329,6 @@ async function handleSelectorGet(selector: string, options: DomGetCommandOptions } } -/** - * Handle bdg dom get command. - * - * @param selectorOrIndex - CSS selector or numeric index - * @param options - Command options - */ -async function handleDomGet(selectorOrIndex: string, options: DomGetCommandOptions): Promise { - const isNumericIndex = /^\d+$/.test(selectorOrIndex); - - if (isNumericIndex) { - await handleIndexGet(parseInt(selectorOrIndex, 10), options); - } else { - await handleSelectorGet(selectorOrIndex, options); - } -} - /** * Handle page-level screenshot capture. * @@ -535,18 +354,19 @@ async function handlePageScreenshot( * Handle element-level screenshot capture. * * @param outputPath - Output file path - * @param options - Screenshot options with selector or index + * @param selector - Element selector + * @param options - Screenshot options */ async function handleElementScreenshot( outputPath: string, + selector: string, options: DomScreenshotCommandOptions ): Promise { await runCommand( async () => { - const nodeId = await resolveElementNodeId(options); const screenshotOptions = buildElementScreenshotOptions(options); - const result = await captureElementScreenshot(outputPath, nodeId, screenshotOptions); - const elementResult = addElementInfo(result, options); + const result = await captureElementScreenshot(outputPath, selector, screenshotOptions); + const elementResult = addElementInfo(result, selector); return { success: true, data: elementResult }; }, options, @@ -564,10 +384,9 @@ async function captureSequenceFrame( outputPath: string, options: DomScreenshotCommandOptions ): Promise { - if (hasElementTarget(options)) { - const nodeId = await resolveElementNodeId(options); + if (options.selector) { const elementOptions = buildElementScreenshotOptions(options); - await captureElementScreenshot(outputPath, nodeId, elementOptions); + await captureElementScreenshot(outputPath, options.selector, elementOptions); } else { const pageOptions = buildPageScreenshotOptions(options); await capturePageScreenshot(outputPath, pageOptions); @@ -634,8 +453,8 @@ async function handleDomScreenshot( return; } - if (hasElementTarget(options)) { - await handleElementScreenshot(outputPath, options); + if (options.selector) { + await handleElementScreenshot(outputPath, options.selector, options); return; } @@ -708,8 +527,11 @@ export function registerDomCommands(program: Command): void { dom .command('query') - .description('Find elements by CSS selector') - .argument('', 'CSS selector (e.g., ".error", "#app", "button")') + .description('Find elements by CSS/Playwright selector') + .argument( + '', + 'CSS selector or Playwright selector (e.g., "button:has-text(\'Submit\')")' + ) .option('-j, --json', 'Output as JSON') .action(async (selector: string, options: DomQueryCommandOptions) => { await handleDomQuery(selector, options); @@ -728,11 +550,9 @@ export function registerDomCommands(program: Command): void { dom .command('get') .description('Get semantic accessibility structure (default) or raw HTML (--raw)') - .argument('', 'CSS selector (e.g., ".error", "#app", "button")') + .argument('', 'CSS selector or Playwright selector') .option('--raw', 'Output raw HTML with all filtering options') .option('--all', 'Get all matches (only with --raw)') - .option('--nth ', 'Get nth match (only with --raw)', parseInt) - .option('--node-id ', 'Use nodeId directly (only with --raw)', parseInt) .option('-j, --json', 'Output as JSON') .action(async (selector: string, options: DomGetCommandOptions) => { await handleDomGet(selector, options); @@ -742,8 +562,7 @@ export function registerDomCommands(program: Command): void { .command('screenshot') .description('Capture page or element screenshot') .argument('', 'Output file path, or directory for --follow mode') - .option('--selector ', 'CSS selector for element capture') - .option('--index ', 'Cached element index (0-based) from previous query', parseInt) + .option('--selector ', 'CSS/Playwright selector for element capture') .option('--format ', 'Image format: png or jpeg (default: png)') .option('--quality ', 'JPEG quality 0-100 (default: 90)', parseInt) .option('--no-full-page', 'Capture viewport only (default: full page)') diff --git a/src/commands/dom/reactEventHelpers.ts b/src/commands/dom/reactEventHelpers.ts index 51ccdf1f..450e39f8 100644 --- a/src/commands/dom/reactEventHelpers.ts +++ b/src/commands/dom/reactEventHelpers.ts @@ -6,6 +6,91 @@ * to properly trigger React's event system. */ +/** + * JavaScript helper body to query elements with Playwright-style selector support. + * + * Supports: + * - `:has-text("text")` - Element contains text (case-insensitive) + * - `:text("text")` - Smallest element with text (case-insensitive) + * - `:text-is("text")` - Exact text match (case-sensitive) + * - `:visible` - Element is visible + * + * This is embedded inside each form interaction script to enable Playwright selectors. + * Must be defined inside an IIFE to be accessible by the script logic. + */ +const QUERY_ELEMENTS_HELPER_BODY = ` + function __bdgQueryElements(sel) { + const pseudoMatches = []; + let cssSelector = sel; + + const hasTextPattern = /:has-text\\((['"])(.*?)\\1\\)/g; + let match; + while ((match = hasTextPattern.exec(sel)) !== null) { + pseudoMatches.push({ type: 'has-text', arg: match[2] }); + } + cssSelector = cssSelector.replace(hasTextPattern, ''); + + const textIsPattern = /:text-is\\((['"])(.*?)\\1\\)/g; + while ((match = textIsPattern.exec(sel)) !== null) { + pseudoMatches.push({ type: 'text-is', arg: match[2] }); + } + cssSelector = cssSelector.replace(textIsPattern, ''); + + const textPattern = /:text\\((['"])(.*?)\\1\\)/g; + while ((match = textPattern.exec(sel)) !== null) { + pseudoMatches.push({ type: 'text', arg: match[2] }); + } + cssSelector = cssSelector.replace(textPattern, ''); + + if (sel.includes(':visible')) { + pseudoMatches.push({ type: 'visible' }); + cssSelector = cssSelector.split(':visible').join(''); + } + + cssSelector = cssSelector.replace(/ +/g, ' ').trim() || '*'; + let elements = Array.from(document.querySelectorAll(cssSelector)); + + for (const pseudo of pseudoMatches) { + elements = elements.filter(el => { + switch (pseudo.type) { + case 'has-text': { + const text = pseudo.arg.toLowerCase(); + return el.textContent && el.textContent.toLowerCase().includes(text); + } + case 'text': { + const text = pseudo.arg.toLowerCase(); + const elText = (el.textContent || '').replace(/ +/g, ' ').trim().toLowerCase(); + if (!elText.includes(text)) return false; + for (const child of el.children) { + const childText = (child.textContent || '').replace(/ +/g, ' ').trim().toLowerCase(); + if (childText.includes(text)) return false; + } + return true; + } + case 'text-is': { + return (el.textContent || '').replace(/ +/g, ' ').trim() === pseudo.arg; + } + case 'visible': { + const style = window.getComputedStyle(el); + if (style.display === 'none') return false; + if (style.visibility === 'hidden') return false; + if (style.opacity === '0') return false; + const rect = el.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + } + default: + return true; + } + }); + } + return elements; + }`; + +/** + * Export for use in other modules that need the helper (e.g., formFillHelpers.ts). + */ +export const QUERY_ELEMENTS_HELPER = QUERY_ELEMENTS_HELPER_BODY; + /** * JavaScript function to fill an input element in a React-compatible way. * @@ -19,7 +104,8 @@ */ export const REACT_FILL_SCRIPT = ` (function(selector, value, options) { - const allMatches = document.querySelectorAll(selector); +${QUERY_ELEMENTS_HELPER_BODY} + const allMatches = __bdgQueryElements(selector); if (allMatches.length === 0) { return { @@ -137,7 +223,8 @@ export const REACT_FILL_SCRIPT = ` */ export const CLICK_ELEMENT_SCRIPT = ` (function(selector, index) { - const allMatches = document.querySelectorAll(selector); +${QUERY_ELEMENTS_HELPER_BODY} + const allMatches = __bdgQueryElements(selector); if (allMatches.length === 0) { return { diff --git a/src/commands/network/shared.ts b/src/commands/network/shared.ts index c4b422c9..11f6616c 100644 --- a/src/commands/network/shared.ts +++ b/src/commands/network/shared.ts @@ -4,8 +4,6 @@ * Contains getCookies and headers commands, plus shared data fetching utilities. */ -import * as fs from 'fs'; - import type { Command } from 'commander'; import { runCommand } from '@/commands/shared/CommandRunner.js'; @@ -16,12 +14,9 @@ import type { } from '@/commands/shared/optionTypes.js'; import { getHARData, callCDP, getNetworkHeaders } from '@/ipc/client.js'; import { validateIPCResponse } from '@/ipc/index.js'; -import { getSessionFilePath } from '@/session/paths.js'; -import type { BdgOutput, NetworkRequest } from '@/types.js'; -import { isDaemonConnectionError } from '@/ui/errors/utils.js'; +import type { NetworkRequest } from '@/types.js'; import type { Cookie } from '@/ui/formatters/index.js'; import { formatCookies, formatNetworkHeaders } from '@/ui/formatters/index.js'; -import { sessionNotActiveError } from '@/ui/messages/errors.js'; import { EXIT_CODES } from '@/utils/exitCodes.js'; /** @@ -42,63 +37,13 @@ export async function fetchFromLiveSession(): Promise { } /** - * Fetch network requests from offline session.json file. + * Get network requests from live daemon session. * * @returns Network requests array - * @throws Error if session file not found or no network data available - */ -export function fetchFromOfflineSession(): NetworkRequest[] { - const sessionPath = getSessionFilePath('OUTPUT'); - - if (!fs.existsSync(sessionPath)) { - throw new Error(sessionNotActiveError('export network data'), { - cause: { code: EXIT_CODES.RESOURCE_NOT_FOUND }, - }); - } - - const sessionData = JSON.parse(fs.readFileSync(sessionPath, 'utf-8')) as BdgOutput; - - if (!sessionData.data?.network) { - throw new Error('No network data in session file', { - cause: { code: EXIT_CODES.RESOURCE_NOT_FOUND }, - }); - } - - return sessionData.data.network; -} - -/** - * Check if error indicates daemon is unavailable. - * - * @param error - Error to check - * @returns True if error indicates no active session or daemon connection failure - */ -export function isDaemonUnavailable(error: unknown): boolean { - if (isDaemonConnectionError(error)) { - return true; - } - - const errorMessage = error instanceof Error ? error.message : String(error); - return errorMessage.includes('No active session'); -} - -/** - * Get network requests from live session or session.json. - * - * Tries live daemon first, falls back to offline session file. - * - * @returns Network requests array - * @throws Error if no session available (live or offline) + * @throws Error if daemon connection fails or no network data available */ export async function getNetworkRequests(): Promise { - try { - return await fetchFromLiveSession(); - } catch (error) { - if (isDaemonUnavailable(error)) { - return fetchFromOfflineSession(); - } - throw error; - } + return fetchFromLiveSession(); } /** diff --git a/src/commands/stop.ts b/src/commands/stop.ts index 8df74b76..855f7102 100644 --- a/src/commands/stop.ts +++ b/src/commands/stop.ts @@ -7,7 +7,6 @@ import type { StopResult } from '@/commands/types.js'; import { stopSession } from '@/ipc/client.js'; import { IPCErrorCode } from '@/ipc/index.js'; import { performSessionCleanup } from '@/session/cleanup.js'; -import { getSessionFilePath } from '@/session/paths.js'; import { joinLines } from '@/ui/formatting.js'; import { createLogger } from '@/ui/logging/index.js'; import { @@ -28,7 +27,7 @@ const log = createLogger('cleanup'); * @param data - Stop result data */ function formatStop(data: StopResult): string { - const outputLine = data.stopped.bdg ? sessionStopped(getSessionFilePath('OUTPUT')) : undefined; + const outputLine = data.stopped.bdg ? sessionStopped() : undefined; const daemonsLine = data.stopped.daemons && data.orphanedDaemonsCount ? orphanedDaemonsCleanedMessage(data.orphanedDaemonsCount) @@ -50,13 +49,9 @@ function formatStop(data: StopResult): string { export function registerStopCommand(program: Command): void { program .command('stop') - .description('Stop daemon and write collected telemetry to ~/.bdg/session.json') + .description('Stop daemon and close browser session') .option('--kill-chrome', 'Also kill Chrome browser process', false) .addOption(jsonOption()) - .addHelpText( - 'after', - '\nOutput Location:\n Default: ~/.bdg/session.json\n Tip: Copy to custom location with: cp ~/.bdg/session.json /path/to/output.json' - ) .action(async (options: StopCommandOptions) => { await runCommand( async (opts) => { diff --git a/src/daemon/lifecycle/workerCleanup.ts b/src/daemon/lifecycle/workerCleanup.ts index 320da9b5..18533a24 100644 --- a/src/daemon/lifecycle/workerCleanup.ts +++ b/src/daemon/lifecycle/workerCleanup.ts @@ -1,13 +1,12 @@ /** * Worker Cleanup * - * Handles worker cleanup: DOM collection, CDP closure, Chrome termination, and output writing. + * Handles worker cleanup: DOM collection, CDP closure, and Chrome termination. */ import type { CDPConnection } from '@/connection/cdp.js'; import type { TelemetryStore } from '@/daemon/worker/TelemetryStore.js'; import { writeChromePid } from '@/session/chrome.js'; -import { writeSessionOutput } from '@/session/output.js'; import { collectDOM } from '@/telemetry/dom.js'; import type { CleanupFunction, LaunchedChrome } from '@/types'; import type { Logger } from '@/ui/logging/index.js'; @@ -19,7 +18,6 @@ import { workerRunningCleanup, workerClosingCDP, workerShutdownComplete, - workerWritingOutput, } from '@/ui/messages/debug.js'; import { delay } from '@/utils/async.js'; import { getErrorMessage } from '@/utils/errors.js'; @@ -100,8 +98,6 @@ export async function cleanupWorker( } // If both chrome and cdp are null, Chrome launch failed - nothing to terminate - writeOutput(reason, telemetryStore, log); - log.debug(workerShutdownComplete()); } catch (error) { console.error(`[worker] Error during cleanup: ${getErrorMessage(error)}`); @@ -150,30 +146,3 @@ async function terminateChrome( console.error(`[worker] Error killing Chrome: ${getErrorMessage(error)}`); } } - -/** - * Write session output. - */ -function writeOutput( - reason: 'normal' | 'crash' | 'timeout', - telemetryStore: TelemetryStore, - log: Logger -): void { - if (reason === 'normal') { - try { - log.debug(workerWritingOutput()); - const finalOutput = telemetryStore.buildOutput(false); - writeSessionOutput(finalOutput); - } catch (error) { - console.error(`[worker] Error writing final output: ${getErrorMessage(error)}`); - } - } else { - try { - log.debug(`[worker] Writing partial output (reason: ${reason})`); - const partialOutput = telemetryStore.buildOutput(true); - writeSessionOutput(partialOutput); - } catch (error) { - console.error(`[worker] Error writing partial output: ${getErrorMessage(error)}`); - } - } -} diff --git a/src/ipc/client.ts b/src/ipc/client.ts index 00aca578..f98f5db5 100644 --- a/src/ipc/client.ts +++ b/src/ipc/client.ts @@ -173,7 +173,7 @@ export async function startSession( /** * Request session stop from the daemon. - * Stops telemetry collection, closes Chrome, and writes output file. + * Stops telemetry collection and closes Chrome. * * @returns Stop session response with termination status * @throws Error if connection fails, times out, or no active session diff --git a/src/selectors/index.ts b/src/selectors/index.ts new file mode 100644 index 00000000..742379f6 --- /dev/null +++ b/src/selectors/index.ts @@ -0,0 +1,13 @@ +/** + * Selector utilities for bdg. + * + * Provides Playwright-compatible selector support, transforming custom pseudo-classes + * into standard CSS + JavaScript filters that can be executed in the browser. + */ + +export { + transformSelector, + generateQueryScript, + hasPlaywrightSelectors, + type TransformedSelector, +} from './playwrightSelectors.js'; diff --git a/src/selectors/playwrightSelectors.ts b/src/selectors/playwrightSelectors.ts new file mode 100644 index 00000000..42527ace --- /dev/null +++ b/src/selectors/playwrightSelectors.ts @@ -0,0 +1,325 @@ +/** + * Playwright-compatible selector support for bdg. + * + * Transforms Playwright-style selectors (`:has-text()`, `:text()`, `:text-is()`, `:visible`) + * into standard CSS selectors plus JavaScript filter functions that can be executed + * in the browser context. + * + * @example + * // Input: "button:has-text('Submit')" + * // Output: cssSelector="button", jsFilter checks text content + */ + +import { + createParser, + render, + ast, + traverse, + type AstSelector, + type AstPseudoClass, + type AstEntity, + type AstRule, +} from 'css-selector-parser'; + +/** + * Custom pseudo-classes supported by bdg (Playwright-compatible). + */ +const CUSTOM_PSEUDO_CLASSES = new Set(['has-text', 'text', 'text-is', 'visible']); + +/** + * Result of transforming a selector. + */ +export interface TransformedSelector { + /** Standard CSS selector (can be used with querySelectorAll) */ + cssSelector: string; + /** JavaScript filter expression to apply after CSS query, or null if not needed */ + jsFilter: string | null; + /** Whether custom pseudo-classes were found and transformed */ + hasCustomSelectors: boolean; + /** List of custom pseudo-classes found */ + customPseudoClasses: string[]; +} + +/** + * Extracted custom pseudo-class info. + */ +interface CustomPseudoInfo { + name: string; + argument: string | null; +} + +/** + * Create CSS selector parser configured to accept unknown pseudo-classes. + */ +const parser = createParser({ + syntax: { + baseSyntax: 'progressive', + pseudoClasses: { + unknown: 'accept', + definitions: { + String: ['has-text', 'text', 'text-is'], + NoArgument: ['visible'], + }, + }, + }, +}); + +/** + * Normalize whitespace in text for matching (like Playwright does). + * Collapses multiple whitespace to single space and trims. + */ +function normalizeWhitespace(text: string): string { + return text.replace(/\s+/g, ' ').trim(); +} + +/** + * Escape a string for use in JavaScript code. + */ +function escapeJsString(str: string): string { + return str + .replace(/\\/g, '\\\\') + .replace(/'/g, "\\'") + .replace(/"/g, '\\"') + .replace(/\n/g, '\\n') + .replace(/\r/g, '\\r'); +} + +/** + * Generate JavaScript filter expression for a custom pseudo-class. + */ +function generateJsFilter(pseudo: CustomPseudoInfo): string { + const arg = pseudo.argument ? escapeJsString(normalizeWhitespace(pseudo.argument)) : ''; + + switch (pseudo.name) { + case 'has-text': + // Element contains text anywhere in subtree (case-insensitive) + return `el.textContent?.toLowerCase().includes('${arg.toLowerCase()}')`; + + case 'text': + // Smallest element containing text (case-insensitive substring match) + // Check that element's direct text content matches, not just descendants + return `(() => { + const text = '${arg.toLowerCase()}'; + const elText = el.textContent?.replace(/\\s+/g, ' ').trim().toLowerCase() || ''; + if (!elText.includes(text)) return false; + // Check no child has the complete match (we want the smallest container) + for (const child of el.children) { + const childText = child.textContent?.replace(/\\s+/g, ' ').trim().toLowerCase() || ''; + if (childText.includes(text)) return false; + } + return true; + })()`; + + case 'text-is': + // Exact text match (case-sensitive, whitespace-normalized) + return `el.textContent?.replace(/\\s+/g, ' ').trim() === '${arg}'`; + + case 'visible': + // Element is visible (not hidden, has dimensions) + return `(() => { + const style = window.getComputedStyle(el); + if (style.display === 'none') return false; + if (style.visibility === 'hidden') return false; + if (style.opacity === '0') return false; + const rect = el.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + })()`; + + default: + // Unknown pseudo-class, return true (no filtering) + return 'true'; + } +} + +/** + * Check if a pseudo-class is one of our custom ones. + */ +function isCustomPseudoClass(name: string): boolean { + return CUSTOM_PSEUDO_CLASSES.has(name); +} + +/** + * Strip surrounding quotes from a string value. + * The css-selector-parser library includes quotes in string values. + */ +function stripQuotes(str: string): string { + if ((str.startsWith('"') && str.endsWith('"')) || (str.startsWith("'") && str.endsWith("'"))) { + return str.slice(1, -1); + } + return str; +} + +/** + * Extract argument from pseudo-class AST node. + */ +function extractPseudoArgument(pseudo: AstPseudoClass): string | null { + if (!pseudo.argument) return null; + + if (ast.isString(pseudo.argument)) { + // Parser includes quotes in string value, strip them + return stripQuotes(pseudo.argument.value); + } + + // For other argument types, render them back to string + return render(pseudo.argument); +} + +/** + * Deep clone an AST entity and filter out custom pseudo-classes from rules. + */ +function cloneAndFilterAst(entity: AstEntity): AstEntity { + if (ast.isSelector(entity)) { + return ast.selector({ + rules: entity.rules.map((rule) => cloneAndFilterAst(rule) as AstRule), + }); + } + + if (ast.isRule(entity)) { + // Filter out custom pseudo-classes from items + const filteredItems = entity.items.filter((item) => { + if (ast.isPseudoClass(item) && isCustomPseudoClass(item.name)) { + return false; + } + return true; + }); + + // Clone each remaining item + const clonedItems = filteredItems.map((item) => cloneAndFilterAst(item)); + + const rule = ast.rule({ + items: clonedItems as AstRule['items'], + }); + + // Handle combinator and nestedRule + if (entity.combinator) { + rule.combinator = entity.combinator; + } + if (entity.nestedRule) { + rule.nestedRule = cloneAndFilterAst(entity.nestedRule) as AstRule; + } + + return rule; + } + + // For other node types, return as-is (they're leaf nodes or don't need filtering) + return entity; +} + +/** + * Transform a Playwright-style selector into standard CSS + JS filter. + * + * Supports: + * - `:has-text("text")` - Element contains text (case-insensitive) + * - `:text("text")` - Smallest element with text (case-insensitive) + * - `:text-is("text")` - Exact text match (case-sensitive) + * - `:visible` - Element is visible + * + * @param selector - Playwright-style selector string + * @returns Transformed selector with CSS and optional JS filter + * @throws Error if selector cannot be parsed + * + * @example + * transformSelector('button:has-text("Submit")') + * // Returns TransformedSelector with cssSelector='button' and jsFilter for text matching + */ +export function transformSelector(selector: string): TransformedSelector { + let parsed: AstSelector; + + try { + parsed = parser(selector); + } catch { + // If parsing fails, return original selector (let browser handle the error) + return { + cssSelector: selector, + jsFilter: null, + hasCustomSelectors: false, + customPseudoClasses: [], + }; + } + + const customPseudos: CustomPseudoInfo[] = []; + + // Walk AST to find and collect custom pseudo-classes + traverse(parsed, (node: AstEntity) => { + if (ast.isPseudoClass(node) && isCustomPseudoClass(node.name)) { + customPseudos.push({ + name: node.name, + argument: extractPseudoArgument(node), + }); + } + }); + + if (customPseudos.length === 0) { + // No custom pseudo-classes, return original selector + return { + cssSelector: selector, + jsFilter: null, + hasCustomSelectors: false, + customPseudoClasses: [], + }; + } + + // Clone AST and filter out custom pseudo-classes + const filteredAst = cloneAndFilterAst(parsed); + + // Render the filtered AST back to CSS string + let cssSelector = render(filteredAst); + + // Handle edge case: if all items were removed, use '*' as fallback + if (!cssSelector || cssSelector.trim() === '') { + cssSelector = '*'; + } + + // Generate combined JS filter from all custom pseudo-classes + const filters = customPseudos.map((pseudo) => generateJsFilter(pseudo)); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const jsFilter = filters.length === 1 ? filters[0]! : `(${filters.join(') && (')})`; + + return { + cssSelector, + jsFilter, + hasCustomSelectors: true, + customPseudoClasses: customPseudos.map((p) => p.name), + }; +} + +/** + * Generate a complete JavaScript expression that queries and filters elements. + * + * This generates code that can be executed via CDP Runtime.evaluate to find + * elements matching the Playwright-style selector. + * + * @param selector - Playwright-style selector string + * @returns JavaScript code string to execute in browser context + * + * @example + * generateQueryScript('button:has-text("Submit")') + * // Returns query script that filters by text content + */ +export function generateQueryScript(selector: string): string { + const transformed = transformSelector(selector); + + const cssSelector = escapeJsString(transformed.cssSelector); + + if (!transformed.jsFilter) { + // No custom filtering needed + return `[...document.querySelectorAll('${cssSelector}')]`; + } + + // Query with CSS, then filter with JS + return `[...document.querySelectorAll('${cssSelector}')].filter(el => ${transformed.jsFilter})`; +} + +/** + * Check if a selector contains any Playwright-style custom pseudo-classes. + * + * Quick check without full parsing - useful for deciding whether to use + * standard CDP DOM.querySelectorAll or custom JS evaluation. + * + * @param selector - CSS selector string + * @returns true if selector likely contains custom pseudo-classes + */ +export function hasPlaywrightSelectors(selector: string): boolean { + // Quick regex check for common patterns + return /:(?:has-text|text-is|text|visible)\s*(?:\(|$)/.test(selector); +} diff --git a/src/session/QueryCacheManager.ts b/src/session/QueryCacheManager.ts deleted file mode 100644 index 9b761ef0..00000000 --- a/src/session/QueryCacheManager.ts +++ /dev/null @@ -1,272 +0,0 @@ -/** - * Query cache manager for DOM element index-based access. - * - * Provides centralized management of DOM query result caching with: - * - Singleton pattern for consistent cache access - * - Navigation-aware staleness detection - * - TTL-based navigation ID caching to reduce IPC calls - * - * The cache enables index-based element access patterns like - * "bdg dom get 0" after running "bdg dom query .selector". - * - * @example - * ```typescript - * const manager = QueryCacheManager.getInstance(); - * - * // Store query results - * await manager.set(queryResult); - * - * // Validate and retrieve (throws if stale) - * const validation = await manager.validate(); - * if (validation.valid) { - * const cache = validation.cache; - * } - * ``` - */ - -import { existsSync } from 'fs'; -import { readFile, rm, writeFile } from 'fs/promises'; -import { join } from 'path'; - -import { getSessionDir } from '@/session/paths.js'; -import type { DomQueryResult } from '@/types.js'; -import { createLogger } from '@/ui/logging/index.js'; -import { getErrorMessage } from '@/utils/errors.js'; - -const log = createLogger('session'); - -/** TTL for cached navigation ID (500ms). */ -const NAVIGATION_ID_CACHE_TTL_MS = 500; - -/** - * Result of validating query cache against current navigation state. - */ -export interface QueryCacheValidation { - /** Whether the cache is valid for use. */ - valid: boolean; - /** The cached query result (if exists). */ - cache: DomQueryResult | null; - /** Error message if cache is invalid. */ - error?: string; - /** Suggestion for fixing the error. */ - suggestion?: string; -} - -/** - * Singleton manager for DOM query result caching. - * - * Centralizes all cache operations with navigation-aware staleness detection. - * Uses file-based persistence for cross-process access. - */ -export class QueryCacheManager { - private static instance: QueryCacheManager | null = null; - - /** Cached navigation ID with timestamp for TTL-based invalidation. */ - private cachedNavigationId: { value: number; timestamp: number } | null = null; - - /** - * Get the singleton instance. - * - * @returns QueryCacheManager instance - */ - static getInstance(): QueryCacheManager { - QueryCacheManager.instance ??= new QueryCacheManager(); - return QueryCacheManager.instance; - } - - /** - * Reset the singleton instance (for testing). - */ - static resetInstance(): void { - QueryCacheManager.instance = null; - } - - /** - * Get path to query cache file. - * - * @returns Absolute path to query-cache.json - */ - private getCachePath(): string { - return join(getSessionDir(), 'query-cache.json'); - } - - /** - * Store query results for index-based access. - * - * Writes results to ~/.bdg/query-cache.json for cross-process access. - * - * @param result - DOM query result to cache - */ - async set(result: DomQueryResult): Promise { - try { - const cachePath = this.getCachePath(); - await writeFile(cachePath, JSON.stringify(result), 'utf-8'); - log.debug(`Cached ${result.nodes.length} query results to ${cachePath}`); - } catch (error) { - log.debug(`Failed to write query cache: ${getErrorMessage(error)}`); - } - } - - /** - * Get validated cache results. - * - * Returns null if cache is stale or doesn't exist. - * Use getRaw() for unchecked access. - * - * @returns Cached query result or null if invalid/missing - */ - async get(): Promise { - const validation = await this.validate(); - return validation.valid ? validation.cache : null; - } - - /** - * Get raw cache without validation. - * - * Reads from ~/.bdg/query-cache.json if it exists. - * Does not check navigation staleness. - * - * @returns Cached query result or null if no cache exists - */ - async getRaw(): Promise { - try { - const cachePath = this.getCachePath(); - if (!existsSync(cachePath)) { - return null; - } - - const content = await readFile(cachePath, 'utf-8'); - const result = JSON.parse(content) as DomQueryResult; - log.debug(`Retrieved ${result.nodes.length} cached query results`); - return result; - } catch (error) { - log.debug(`Failed to read query cache: ${getErrorMessage(error)}`); - return null; - } - } - - /** - * Validate cache against current navigation state. - * - * Checks if the cached query results are still valid by comparing - * the stored navigationId with the current one from the daemon. - * - * @returns Validation result with cache and error info - * - * @example - * ```typescript - * const validation = await manager.validate(); - * if (!validation.valid) { - * throw new CommandError(validation.error, { suggestion: validation.suggestion }); - * } - * const cache = validation.cache; - * ``` - */ - async validate(): Promise { - const cache = await this.getRaw(); - - if (!cache) { - return { - valid: false, - cache: null, - error: 'No cached query results found', - suggestion: 'Run "bdg dom query " first to generate indexed results', - }; - } - - if (cache.navigationId === undefined) { - log.debug('Query cache missing navigationId (legacy format), allowing access'); - return { valid: true, cache }; - } - - const currentNavId = await this.getCurrentNavigationId(); - - if (currentNavId === null) { - log.debug('Could not get current navigationId, allowing cache access'); - return { valid: true, cache }; - } - - if (cache.navigationId !== currentNavId) { - return { - valid: false, - cache, - error: `Query cache is stale (page has navigated since query was run)`, - suggestion: `Re-run "bdg dom query ${cache.selector}" to refresh cached results`, - }; - } - - return { valid: true, cache }; - } - - /** - * Clear the query cache. - * - * Removes ~/.bdg/query-cache.json. - * Called when starting a new query or when the session ends. - */ - async clear(): Promise { - try { - const cachePath = this.getCachePath(); - if (existsSync(cachePath)) { - await rm(cachePath, { force: true }); - log.debug('Cleared query cache'); - } - } catch (error) { - log.debug(`Failed to clear query cache: ${getErrorMessage(error)}`); - } - } - - /** - * Check if cache file exists. - * - * @returns True if cache file exists - */ - exists(): boolean { - return existsSync(this.getCachePath()); - } - - /** - * Get current navigation ID from the daemon. - * - * Caches the result for 500ms to avoid redundant IPC calls within a single - * command execution while ensuring freshness for subsequent commands. - * - * @returns Current navigation ID or null if unavailable - */ - async getCurrentNavigationId(): Promise { - if ( - this.cachedNavigationId && - Date.now() - this.cachedNavigationId.timestamp < NAVIGATION_ID_CACHE_TTL_MS - ) { - return this.cachedNavigationId.value; - } - - try { - const { getStatus } = await import('@/ipc/client.js'); - const response = await getStatus(); - - if (response.status === 'ok' && response.data?.navigationId !== undefined) { - this.cachedNavigationId = { - value: response.data.navigationId, - timestamp: Date.now(), - }; - return response.data.navigationId; - } - - return null; - } catch (error) { - log.debug(`Failed to get current navigation ID: ${getErrorMessage(error)}`); - return null; - } - } - - /** - * Invalidate cached navigation ID. - * - * Forces fresh fetch on next getCurrentNavigationId() call. - * Useful after navigation events. - */ - invalidateNavigationCache(): void { - this.cachedNavigationId = null; - } -} diff --git a/src/session/cleanup.ts b/src/session/cleanup.ts index b47ad724..cb8b2d3a 100644 --- a/src/session/cleanup.ts +++ b/src/session/cleanup.ts @@ -16,12 +16,10 @@ import { getErrorMessage } from '@/utils/errors.js'; import { safeRemoveFile } from '@/utils/file.js'; import { isProcessAlive, killChromeProcess } from '@/utils/process.js'; -import { QueryCacheManager } from './QueryCacheManager.js'; import { readChromePid, clearChromePid } from './chrome.js'; import { acquireSessionLock, releaseSessionLock } from './lock.js'; import { getSessionFilePath, ensureSessionDir } from './paths.js'; import { readPid, cleanupPidFile, readPidFromFile } from './pid.js'; -// Note: QueryCacheManager must be after chrome.js per alphabetical order const log = createLogger('cleanup'); @@ -281,12 +279,6 @@ export function cleanupSession(): void { safeRemoveFile(getSessionFilePath('DAEMON_PID'), 'daemon PID file', log); safeRemoveFile(getSessionFilePath('DAEMON_SOCKET'), 'daemon socket', log); safeRemoveFile(getSessionFilePath('DAEMON_LOCK'), 'daemon lock', log); - - void QueryCacheManager.getInstance() - .clear() - .catch((error) => { - logDebugError(log, 'clear query cache', error); - }); } /** diff --git a/src/session/output.ts b/src/session/output.ts deleted file mode 100644 index 53efb1ca..00000000 --- a/src/session/output.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** - * Session output file management. - * - * Handles writing final session output to disk. - * Note: Live preview/details now use IPC streaming instead of file writes. - */ - -import type { BdgOutput } from '@/types'; -import { AtomicFileWriter } from '@/utils/atomicFile.js'; - -import { getSessionFilePath, ensureSessionDir } from './paths.js'; - -/** - * Write session output to the final JSON file. - * - * This is written once at the end of a session. - * Note: Preview/details data is now accessed via IPC streaming during collection. - * - * @param output - The BdgOutput data to write - * @param compact - If true, use compact JSON format (no indentation) - */ -export function writeSessionOutput(output: BdgOutput, compact: boolean = false): void { - ensureSessionDir(); - const outputPath = getSessionFilePath('OUTPUT'); - const jsonString = compact ? JSON.stringify(output) : JSON.stringify(output, null, 2); - AtomicFileWriter.writeSync(outputPath, jsonString); -} diff --git a/src/telemetry/roleInference.ts b/src/telemetry/roleInference.ts index 41ed5325..81700d66 100644 --- a/src/telemetry/roleInference.ts +++ b/src/telemetry/roleInference.ts @@ -153,14 +153,14 @@ function extractHeadingLevel(tag: string): number | null { * // => { nodeId: '123', role: 'link', name: 'Main page', inferred: true, backendDOMNodeId: 123 } * ``` */ -export function synthesizeA11yNode(domContext: DomContext, nodeId: number): A11yNode { +export function synthesizeA11yNode(domContext: DomContext, nodeId?: number): A11yNode { const role = inferRoleFromTag(domContext.tag); const node: A11yNode = { - nodeId: String(nodeId), + nodeId: nodeId !== undefined ? String(nodeId) : 'synthesized', role, inferred: true, - backendDOMNodeId: nodeId, + ...(nodeId !== undefined && { backendDOMNodeId: nodeId }), }; // Use text preview as accessible name (truncated if needed) diff --git a/src/types.ts b/src/types.ts index 5635a008..27a2cfc5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -369,13 +369,10 @@ export interface DomQueryResult { count: number; nodes: Array<{ index: number; - nodeId: number; tag?: string; classes?: string[]; preview?: string; }>; - /** Navigation ID when query was performed (for staleness detection). */ - navigationId?: number; } /** @@ -383,7 +380,6 @@ export interface DomQueryResult { */ export interface DomGetResult { nodes: Array<{ - nodeId: number; tag?: string; attributes?: Record; classes?: string[]; diff --git a/src/ui/formatters/dom.ts b/src/ui/formatters/dom.ts index c808ea09..f8dc35db 100644 --- a/src/ui/formatters/dom.ts +++ b/src/ui/formatters/dom.ts @@ -48,19 +48,28 @@ export function formatDomQuery(data: DomQueryResult): string { return `[${node.index}] <${node.tag}${classInfo}> ${node.preview}`; }); - const hasMultipleResults = count > 1; - const exampleIndex = hasMultipleResults ? (nodes[0]?.index ?? 0) : 0; - - return fmt - .text(`Found ${count} node${count === 1 ? '' : 's'} matching "${selector}":`) - .list(nodeLines) - .blank() - .section('Next steps:', [ - `Get HTML: bdg dom get ${exampleIndex}`, - `Extract text: bdg cdp Runtime.evaluate --params '{"expression": "document.querySelector('${selector.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}').textContent"}'`, - `Full details: bdg details dom ${exampleIndex}`, - ]) - .build(); + // Escape selector for shell command + const escapedSelector = selector.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + + fmt.text(`Found ${count} node${count === 1 ? '' : 's'} matching "${selector}":`); + fmt.list(nodeLines); + fmt.blank(); + + if (count === 1) { + fmt.section('Next steps:', [ + `Get HTML: bdg dom get "${escapedSelector}"`, + `Click: bdg dom click "${escapedSelector}"`, + `Fill: bdg dom fill "${escapedSelector}" "value"`, + ]); + } else { + fmt.section('Next steps (use --index N to select specific match):', [ + `Click [0]: bdg dom click "${escapedSelector}" --index 0`, + `Click [1]: bdg dom click "${escapedSelector}" --index 1`, + `Fill [0]: bdg dom fill "${escapedSelector}" --index 0 "value"`, + ]); + } + + return fmt.build(); } /** diff --git a/src/ui/formatters/sessionFormatters.ts b/src/ui/formatters/sessionFormatters.ts index 29319a42..71c2dcc1 100644 --- a/src/ui/formatters/sessionFormatters.ts +++ b/src/ui/formatters/sessionFormatters.ts @@ -84,7 +84,7 @@ export function buildSessionManagementSection(): string { return section('Session Management:', [ 'bdg status Check session state', 'bdg status --verbose Include Chrome diagnostics', - 'bdg stop End session & save output', + 'bdg stop End session', 'bdg cleanup Clean stale sessions', ]); } diff --git a/src/ui/formatters/status.ts b/src/ui/formatters/status.ts index cce215b5..2140ea32 100644 --- a/src/ui/formatters/status.ts +++ b/src/ui/formatters/status.ts @@ -114,9 +114,9 @@ export function formatSessionStatus( fmt .blank() .section('Commands:', [ - 'Stop session: bdg stop', 'Peek data: bdg peek', 'Query browser: bdg query