diff --git a/.claude/settings.local.json b/.claude/settings.local.json index ba45a477..dfb82fc7 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -12,7 +12,51 @@ "WebFetch(domain:chromedevtools.github.io)", "WebSearch", "WebFetch(domain:github.com)", - "Bash(sort:*)" + "Bash(sort:*)", + "Bash(npm install)", + "Bash(curl:*)", + "Bash(bdg status:*)", + "Bash(bdg --help:*)", + "Bash(npm link)", + "Bash(while read id)", + "Bash(do curl -s -X DELETE \"http://localhost:9222/json/close/$id\")", + "Bash(done)", + "Bash(jq:*)", + "Bash(while read data)", + "Bash(do echo \"$data\")", + "Bash(lsof:*)", + "Bash(ls:*)", + "Bash(snap list:*)", + "Bash(locate:*)", + "Bash(do echo \"=== MESSAGE ===\")", + "Bash(echo:*)", + "Bash(node:*)", + "Bash(ln:*)", + "Bash(chmod:*)", + "Bash(bdg --version:*)", + "Bash(grep:*)", + "Bash(bdg:*)", + "Bash(nc:*)", + "Bash(timeout 2 bash -c 'exec 3<>/dev/tcp/127.0.0.1/9222; echo -e \"\"\"\"GET /json/list HTTP/1.1\\\\r\\\\nHost: 127.0.0.1:9222\\\\r\\\\n\\\\r\\\\n\"\"\"\" >&3; cat <&3')", + "Bash(export CHROME_PATH=\"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome\")", + "Bash(find:*)", + "Bash(xargs ls:*)", + "Bash(python3:*)", + "Bash(timeout 10 bash -c 'bdg file:///tmp/websocket-test.html >/dev/null 2>&1 &\nsleep 6\nbdg network websockets')", + "Bash(yum list:*)", + "Bash(export CHROME_PATH=\"http://localhost:9222\")", + "Bash(__NEW_LINE_248ab22af6888e8d__ echo \"Checking for existing WebSocket connections:\")", + "Bash(__NEW_LINE_8ce62b300d4fe53b__ echo \"Waiting for WebSocket connections to establish...\")", + "Bash(__NEW_LINE_8ce62b300d4fe53b__ echo \"\")", + "Bash(__NEW_LINE_b4af3f8982a03a13__ echo \"Checking WebSocket connections:\")", + "Bash(__NEW_LINE_ee3e45202438c467__ sleep 1)", + "Bash(__NEW_LINE_ee3e45202438c467__ echo \"Typing code to trigger autocomplete...\")", + "Bash(__NEW_LINE_ee3e45202438c467__ sleep 3)", + "Bash(__NEW_LINE_ee3e45202438c467__ echo \"\")", + "Bash(__NEW_LINE_01743c7a88ac3756__ echo \"\")", + "Bash(__NEW_LINE_f02af70db6f2a488__ bdg network websockets)", + "Bash(__NEW_LINE_f02af70db6f2a488__ echo \"\")", + "Bash(npx eslint:*)" ], "deny": [], "ask": [] 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/CLAUDE.md b/CLAUDE.md index 664856ee..7edd0f0d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,6 +16,41 @@ bdg cdp --search cookie # Search methods **Key Principle:** Discover capabilities programmatically before implementation. +### Chrome Connection Scenarios (Auto-Detect) + +bdg supports multiple Chrome connection modes. Use auto-detection to identify the appropriate mode: + +```bash +CHROME_PORT=${CHROME_DEBUG_PORT:-9222} + +for port in $CHROME_PORT 9222 9223 9224; do + if curl -s http://localhost:$port/json/version &>/dev/null; then + echo "Chrome accessible on port $port" + export CHROME_DEBUG_PORT=$port + break + fi +done + +which google-chrome chromium-browser chromium chrome &>/dev/null && echo "Chrome binary found" +echo "CHROME_PATH: ${CHROME_PATH:-not set}" +``` + +**Decision tree:** +- **Chrome debugging port accessible** → Use `--chrome-ws-url` (external/remote Chrome) +- **Chrome binary found** → Use `bdg ` (bdg launches Chrome) +- **Neither** → Install Chrome or configure remote debugging + +**Environment variables:** +- `CHROME_DEBUG_PORT` - Chrome debugging port (default: 9222, but can be any port) +- `CHROME_PATH` - Path to Chrome binary for managed mode (file path only, not URLs) + +**Port flexibility:** +Chrome's remote debugging port is configurable. Common ports: 9222 (default), 9223, 9224, 9333. +Always check multiple ports or use `CHROME_DEBUG_PORT` to specify. The auto-detection script +checks common ports automatically. + +See "Chrome Connection Modes" section for detailed workflows. + --- ## Essential Patterns @@ -200,14 +235,20 @@ bdg --help # Run (after npm link) ## Common Commands ```bash -# Session +# Session (Managed Chrome) bdg # Start bdg status # Check bdg stop # End +# Session (External/Remote Chrome) +CHROME_PORT=9333 # Or any port Chrome is running on +WS_URL=$(curl -s http://localhost:$CHROME_PORT/json/list | jq -r '.[0].webSocketDebuggerUrl') +bdg --chrome-ws-url "$WS_URL" + # Inspection bdg peek # Preview data bdg network list # Network requests +bdg network websockets # WebSocket connections and frames bdg console # Console messages # DOM (0-based indices) @@ -225,6 +266,186 @@ See `docs/CLI_REFERENCE.md` for complete reference. --- +## Chrome Connection Modes + +### Mode 1: Managed Chrome (Default) + +Chrome is installed locally and bdg launches it. + +```bash +bdg +bdg network websockets --verbose +``` + +**Requirements:** +- Chrome/Chromium binary installed +- `CHROME_PATH` env var set to Chrome binary (optional) +- Process launch permissions + +**WebSocket Capture:** +- CDP events (native support) +- No fallback needed + +--- + +### Mode 2: External Chrome (Same Machine) + +Chrome is already running locally with debugging enabled. + +```bash +CHROME_PORT=9222 +google-chrome --remote-debugging-port=$CHROME_PORT --user-data-dir=/tmp/chrome-debug & + +WS_URL=$(curl -s http://localhost:$CHROME_PORT/json/list | \ + jq -r '.[] | select(.url | contains("your-url")) | .webSocketDebuggerUrl') + +bdg --chrome-ws-url "$WS_URL" +bdg network websockets --verbose +``` + +**Requirements:** +- Chrome running with `--remote-debugging-port=` +- Debugging port accessible locally +- Target tab identified via URL pattern + +**WebSocket Capture:** +- JavaScript fallback (activates after 3s) +- CDP events unavailable for external Chrome + +--- + +### Mode 3: Remote Chrome (Port Forwarding) + +Chrome runs on a different machine with port forwarding. + +**Local machine setup:** +```bash +CHROME_PORT=9222 +/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \ + --remote-debugging-port=$CHROME_PORT \ + --user-data-dir="$HOME/.chrome-debug-profile" +``` + +**Cloud workspace:** +```bash +CHROME_PORT=${CHROME_DEBUG_PORT:-9222} + +curl -s http://localhost:$CHROME_PORT/json/version | jq . + +WS_URL=$(curl -s http://localhost:$CHROME_PORT/json/list | \ + jq -r '.[] | select(.url | contains("localhost:5173")) | .webSocketDebuggerUrl') + +bdg --chrome-ws-url "$WS_URL" +bdg network websockets --verbose --last 20 +``` + +**Requirements:** +- Chrome running on local machine with debugging port +- Port forwarded via SSH/VSCode/IDE (any port supported) +- Stable network connection + +**WebSocket Capture:** +- JavaScript fallback (same as Mode 2) +- CDP events unavailable for remote Chrome + +--- + +### Mode 4: Docker/Headless Chrome + +Chrome runs in containers or CI/CD environments. + +```bash +CHROME_PORT=9222 +docker run -d -p $CHROME_PORT:9222 zenika/alpine-chrome \ + --remote-debugging-address=0.0.0.0 --remote-debugging-port=9222 + +bdg --chrome-ws-url "ws://localhost:$CHROME_PORT/devtools/page/..." +``` + +--- + +### Auto-Detection Script + +```bash +#!/bin/bash +detect_chrome_mode() { + local chrome_port="" + local common_ports=(${CHROME_DEBUG_PORT:-9222} 9222 9223 9224 9333) + + for port in "${common_ports[@]}"; do + if curl -s --max-time 1 http://localhost:$port/json/version &>/dev/null; then + chrome_port=$port + break + fi + done + + if [ -n "$chrome_port" ]; then + echo "Mode: External/Remote Chrome (port $chrome_port)" + + local tabs_json=$(curl -s http://localhost:$chrome_port/json/list) + local tab_count=$(echo "$tabs_json" | jq '. | length') + + echo "Found $tab_count open tab(s)" + echo "$tabs_json" | jq -r '.[] | " [\(.id[0:8])...] \(.title) - \(.url)"' | head -5 + + local ws_url=$(echo "$tabs_json" | jq -r '.[0].webSocketDebuggerUrl') + echo "" + echo "Example command (first tab):" + echo " WS_URL=\"$ws_url\"" + echo " bdg --chrome-ws-url \"\$WS_URL\" " + echo "" + echo "To target specific tab by URL pattern:" + echo " WS_URL=\$(curl -s http://localhost:$chrome_port/json/list | \\" + echo " jq -r '.[] | select(.url | contains(\"your-pattern\")) | .webSocketDebuggerUrl')" + return 0 + fi + + if which google-chrome chromium-browser chromium chrome &>/dev/null || [ -n "$CHROME_PATH" ]; then + echo "Mode: Managed Chrome" + echo "Command: bdg " + [ -n "$CHROME_PATH" ] && echo "Using CHROME_PATH: $CHROME_PATH" + return 0 + fi + + echo "Mode: No Chrome detected" + echo "Options:" + echo " 1. Install Chrome/Chromium locally" + echo " 2. Start Chrome with --remote-debugging-port=" + echo " 3. Forward debugging port from remote machine" + echo " 4. Set CHROME_PATH to Chrome binary location" + return 1 +} + +detect_chrome_mode +``` + +--- + +### Key Differences + +| Feature | Managed Chrome | External/Remote Chrome | +|---------|----------------|------------------------| +| Launch | bdg launches | User launches | +| WebSocket Capture | CDP events (native) | JavaScript fallback | +| Command | `bdg ` | `bdg --chrome-ws-url ` | +| Setup | `CHROME_PATH` to binary | Port 9222 accessible | +| Use Case | Development, local testing | Cloud workspace, existing browser | + +--- + +### Configuration Guidelines + +| Scenario | Correct | Incorrect | +|----------|---------|-----------| +| Remote Chrome | `--chrome-ws-url ws://localhost:/devtools/...` | `CHROME_PATH=http://...` | +| Local binary | `CHROME_PATH=/usr/bin/chrome` | `CHROME_PATH` as URL | +| Port detection | Check multiple ports (9222, 9223, etc.) | Assume port 9222 only | +| WebSocket capture | Start bdg, then reload page | Expect existing connections | +| External Chrome | JavaScript fallback (automatic) | Assume CDP events work | +| Port variable | `CHROME_DEBUG_PORT=9333` | Hardcode port numbers | + +--- + ## Session Files Location: `~/.bdg/` @@ -241,3 +462,61 @@ bdg status --verbose # Diagnostics bdg cleanup --force # Kill stale session bdg cleanup --aggressive # Kill all Chrome processes ``` + +### Common Issues + +**"CHROME_PATH environment variable must be set"** + +Managed Chrome mode active but Chrome binary not found. + +Solutions: +1. Install Chrome/Chromium locally +2. Switch to External/Remote Chrome mode with `--chrome-ws-url` + +Note: `CHROME_PATH` accepts file paths only, not URLs. + +**"No WebSocket connections found"** + +WebSocket connections created before bdg session started. + +Resolution: +```bash +bdg +bdg dom eval "window.location.reload()" +sleep 10 +bdg network websockets --verbose +``` + +JavaScript fallback activates after 3 seconds for external Chrome. + +**"Session target not found (tab may have been closed)"** + +Chrome tab closed or navigated when using `--chrome-ws-url`. + +Solution: Obtain fresh WebSocket URL and reconnect. + +**Port forwarding verification** + +```bash +CHROME_PORT=${CHROME_DEBUG_PORT:-9222} + +for port in $CHROME_PORT 9222 9223 9224; do + if curl -s --max-time 1 http://localhost:$port/json/version &>/dev/null; then + echo "Chrome found on port $port" + curl -s http://localhost:$port/json/version | jq . + curl -s http://localhost:$port/json/list | jq -r '.[] | "\(.title) -> \(.url)"' + export CHROME_DEBUG_PORT=$port + break + fi +done +``` + +Verify: +- Chrome running with `--remote-debugging-port=` +- Port forwarding active (IDE/SSH/VSCode) +- Firewall allows the debugging port +- Check multiple common ports (9222, 9223, 9224, 9333) + +**Mode selection** + +Run auto-detection script in Chrome Connection Modes section. 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.ts b/src/commands.ts index 5be4c5ec..35c33fe2 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -8,6 +8,7 @@ import { registerFormInteractionCommands } from '@/commands/dom/formInteraction. import { registerDomCommands } from '@/commands/dom/index.js'; import { registerNetworkCommands } from '@/commands/network/index.js'; import { registerPeekCommand } from '@/commands/peek.js'; +import { registerScreenshotCommand } from '@/commands/screenshot.js'; import { registerStartCommands } from '@/commands/start.js'; import { registerStatusCommand } from '@/commands/status.js'; import { registerStopCommand } from '@/commands/stop.js'; @@ -43,6 +44,7 @@ export const commandRegistry: CommandRegistrar[] = [ registerPeekCommand, registerTailCommand, registerDetailsCommand, + registerScreenshotCommand, registerDomCommands, registerFormInteractionCommands, 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..c791ea0d 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); @@ -625,17 +444,23 @@ async function handleSequenceCapture( * @param outputPath - Output file path or directory * @param options - Screenshot options */ -async function handleDomScreenshot( +export async function handleDomScreenshot( outputPath: string, options: DomScreenshotCommandOptions ): Promise { + // Set up direct mode if --chrome-ws-url provided + if (options.chromeWsUrl) { + const { setDirectWsUrl } = await import('@/connection/directMode.js'); + setDirectWsUrl(options.chromeWsUrl); + } + if (options.follow) { await handleSequenceCapture(outputPath, options); return; } - if (hasElementTarget(options)) { - await handleElementScreenshot(outputPath, options); + if (options.selector) { + await handleElementScreenshot(outputPath, options.selector, options); return; } @@ -708,8 +533,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 +556,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 +568,11 @@ 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( + '--chrome-ws-url ', + 'Connect directly to Chrome via WebSocket URL (bypasses daemon)' + ) + .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/index.ts b/src/commands/network/index.ts index 24096f49..7713deb7 100644 --- a/src/commands/network/index.ts +++ b/src/commands/network/index.ts @@ -7,6 +7,7 @@ * - getCookies: List cookies * - headers: Show HTTP headers * - document: Show main document headers + * - websockets: Show WebSocket connections and message frames */ import type { Command } from 'commander'; @@ -18,6 +19,7 @@ import { registerHeadersCommand, registerDocumentCommand, } from './shared.js'; +import { registerWebSocketsCommand } from './websockets.js'; /** * Register all network subcommands. @@ -32,4 +34,5 @@ export function registerNetworkCommands(program: Command): void { registerGetCookiesCommand(networkCmd); registerHeadersCommand(networkCmd); registerDocumentCommand(networkCmd); + registerWebSocketsCommand(networkCmd); } 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/network/websockets.ts b/src/commands/network/websockets.ts new file mode 100644 index 00000000..17589cd3 --- /dev/null +++ b/src/commands/network/websockets.ts @@ -0,0 +1,218 @@ +/** + * Network WebSockets command - displays WebSocket connections and message frames. + */ + +import { Option, type Command } from 'commander'; + +import { runCommand } from '@/commands/shared/CommandRunner.js'; +import { jsonOption } from '@/commands/shared/commonOptions.js'; +import type { BaseOptions } from '@/commands/shared/optionTypes.js'; +import { positiveIntRule } from '@/commands/shared/validation.js'; +import { getWebSocketConnections } from '@/ipc/client.js'; +import { validateIPCResponse } from '@/ipc/index.js'; +import type { WebSocketConnection, WebSocketFrame } from '@/types.js'; +import { OutputFormatter } from '@/ui/formatting.js'; +import { VERSION } from '@/utils/version.js'; + +const MIN_FRAMES = 0; +const MAX_FRAMES = 1000; +const DEFAULT_FRAMES = 10; +const PREVIEW_LENGTH_NORMAL = 500; +const PREVIEW_LENGTH_VERBOSE = 5000; + +interface WebSocketsCommandOptions extends BaseOptions { + last?: string; + verbose?: boolean; +} + +interface WebSocketsResultData { + version: string; + success: boolean; + connections: WebSocketConnection[]; +} + +const framesOption = new Option( + '--last ', + `Show last N frames per connection (0 = all, default: ${DEFAULT_FRAMES})` +).default(String(DEFAULT_FRAMES)); + +/** + * Get opcode display name. + * + * @param opcode - WebSocket opcode + * @returns Human-readable opcode name + */ +function getOpcodeName(opcode: number): string { + switch (opcode) { + case 1: + return 'TEXT'; + case 2: + return 'BINARY'; + default: + return `OPCODE_${opcode}`; + } +} + +/** + * Truncate payload data to specified length. + * + * @param data - Payload data + * @param maxLength - Maximum length before truncation + * @returns Truncated payload with ellipsis if needed + */ +function truncatePayload(data: string, maxLength: number): string { + return data.length > maxLength ? `${data.substring(0, maxLength)}...` : data; +} + +/** + * Format frame direction for display. + * + * @param direction - Frame direction + * @returns Formatted direction string + */ +function formatDirection(direction: 'sent' | 'received'): string { + return direction === 'sent' ? 'SENT' : 'RECV'; +} + +/** + * Format a single WebSocket frame for display. + * + * @param frame - WebSocket frame + * @param frameIdx - Frame index + * @param verbose - Whether to show verbose output + * @param fmt - Output formatter + */ +function formatFrame( + frame: WebSocketFrame, + frameIdx: number, + verbose: boolean, + fmt: OutputFormatter +): void { + const direction = formatDirection(frame.direction); + const time = new Date(frame.timestamp).toISOString(); + const opcodeName = getOpcodeName(frame.opcode); + + fmt.text(` [${frameIdx}] ${direction} ${opcodeName} @ ${time}`); + + const previewLength = verbose ? PREVIEW_LENGTH_VERBOSE : PREVIEW_LENGTH_NORMAL; + const payloadData = frame.payloadData ?? '[No payload data]'; + const preview = truncatePayload(payloadData, previewLength); + fmt.text(` ${preview}`); +} + +/** + * Format a single WebSocket connection for display. + * + * @param conn - WebSocket connection + * @param idx - Connection index + * @param framesPerConnection - Number of frames to display + * @param options - Command options + * @param fmt - Output formatter + */ +function formatConnection( + conn: WebSocketConnection, + idx: number, + framesPerConnection: number, + options: WebSocketsCommandOptions, + fmt: OutputFormatter +): void { + fmt.text(`[${idx}] ${conn.url}`); + fmt.text(` Created: ${new Date(conn.timestamp).toISOString()}`); + fmt.text(` Status: ${conn.status ?? 'N/A'}`); + fmt.text(` Total Frames: ${conn.frames.length}`); + + if (conn.closedTime) { + fmt.text(` Closed: ${new Date(conn.closedTime).toISOString()}`); + } + + if (conn.errorMessage) { + fmt.text(` Error: ${conn.errorMessage}`); + } + + const framesToShow = + framesPerConnection === 0 ? conn.frames : conn.frames.slice(-framesPerConnection); + + if (framesToShow.length > 0) { + fmt.blank(); + fmt.text(` Recent Frames (showing ${framesToShow.length} of ${conn.frames.length}):`); + + framesToShow.forEach((frame, frameIdx) => { + formatFrame(frame, frameIdx, options.verbose ?? false, fmt); + }); + } +} + +/** + * Format WebSocket connections for human display. + * + * @param connections - WebSocket connections + * @param options - Command options + * @returns Formatted output + */ +function formatWebSocketConnectionsHuman( + connections: WebSocketConnection[], + options: WebSocketsCommandOptions +): string { + const fmt = new OutputFormatter(); + const framesPerConnection = positiveIntRule({ + min: MIN_FRAMES, + max: MAX_FRAMES, + allowZeroForAll: true, + }).validate(options.last); + + if (connections.length === 0) { + fmt.text('No WebSocket connections found.'); + fmt.blank(); + fmt.text('Tip: WebSocket connections are captured when the session is active.'); + return fmt.build(); + } + + fmt.text(`WebSocket Connections (${connections.length})`); + fmt.separator('─', 80); + + connections.forEach((conn, idx) => { + formatConnection(conn, idx, framesPerConnection, options, fmt); + if (idx < connections.length - 1) { + fmt.blank(); + } + }); + + return fmt.build(); +} + +/** + * Register the websockets command. + * + * @param networkCmd - Network command group + */ +export function registerWebSocketsCommand(networkCmd: Command): void { + networkCmd + .command('websockets') + .description('Show WebSocket connections and message frames') + .addOption(framesOption) + .addOption( + new Option('-v, --verbose', 'Show full message content (longer previews)').default(false) + ) + .addOption(jsonOption()) + .action(async (options: WebSocketsCommandOptions) => { + await runCommand( + async () => { + const response = await getWebSocketConnections(); + validateIPCResponse(response); + + const connections = response.data?.connections ?? []; + + return { + success: true, + data: { + version: VERSION, + success: true, + connections, + }, + }; + }, + options, + (data: WebSocketsResultData) => formatWebSocketConnectionsHuman(data.connections, options) + ); + }); +} diff --git a/src/commands/optionBehaviors.ts b/src/commands/optionBehaviors.ts index f5479ee1..8896a241 100644 --- a/src/commands/optionBehaviors.ts +++ b/src/commands/optionBehaviors.ts @@ -98,6 +98,19 @@ const OPTION_BEHAVIORS: Record = { automaticBehavior: 'Network wait helps ensure React/Vue state updates complete before next action', }, + + 'websockets:--last': { + default: 'Shows last 10 frames per WebSocket connection', + whenEnabled: 'Shows specified number of recent frames (use 0 to show all frames)', + automaticBehavior: + 'Frames are always shown in chronological order (oldest to newest within the limit)', + }, + 'websockets:--verbose': { + default: 'Truncates message payloads to 500 characters', + whenEnabled: 'Shows up to 5,000 characters of message payload data', + automaticBehavior: + 'Falls back to JavaScript-based interception if CDP WebSocket events not firing (e.g., external Chrome)', + }, 'fill:--no-blur': { default: 'Triggers blur event after filling (validates most form fields)', whenDisabled: 'Keeps focus on element after filling', diff --git a/src/commands/screenshot.ts b/src/commands/screenshot.ts new file mode 100644 index 00000000..6fc6fa21 --- /dev/null +++ b/src/commands/screenshot.ts @@ -0,0 +1,38 @@ +/** + * Top-level screenshot command - alias for `dom screenshot`. + * + * Provides convenient access to screenshot functionality without + * requiring the `dom` prefix. + */ + +import type { Command } from 'commander'; + +import { handleDomScreenshot } from '@/commands/dom/index.js'; +import type { DomScreenshotCommandOptions } from '@/commands/shared/optionTypes.js'; + +/** + * Register the top-level screenshot command as an alias for dom screenshot. + */ +export function registerScreenshotCommand(program: Command): void { + program + .command('screenshot') + .description('Capture page or element screenshot (alias for dom screenshot)') + .argument('', 'Output file path, or directory for --follow mode') + .option( + '--chrome-ws-url ', + 'Connect directly to Chrome via WebSocket URL (bypasses daemon)' + ) + .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)') + .option('--no-resize', 'Disable auto-resize (full resolution)') + .option('--scroll ', 'Scroll element into view before capture') + .option('-f, --follow', 'Continuous capture mode to directory') + .option('--interval ', 'Capture interval for --follow (default: 1000)') + .option('--limit ', 'Max frames for --follow') + .option('-j, --json', 'Output as JSON') + .action(async (path: string, options: DomScreenshotCommandOptions) => { + await handleDomScreenshot(path, options); + }); +} diff --git a/src/commands/shared/optionTypes.ts b/src/commands/shared/optionTypes.ts index 5f4cae58..206b0d11 100644 --- a/src/commands/shared/optionTypes.ts +++ b/src/commands/shared/optionTypes.ts @@ -164,7 +164,11 @@ export type DomQueryCommandOptions = BaseOptions; export type DomGetCommandOptions = BaseOptions & RawOptions & SelectionOptions; /** Options for DOM screenshot command */ -export type DomScreenshotCommandOptions = BaseOptions & ScreenshotOptions; +export type DomScreenshotCommandOptions = BaseOptions & + ScreenshotOptions & { + /** Connect directly to Chrome via WebSocket URL (bypasses daemon) */ + chromeWsUrl?: string; + }; /** Options for DOM eval command */ export type DomEvalCommandOptions = BaseOptions & PortOptions; 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/connection/directMode.ts b/src/connection/directMode.ts new file mode 100644 index 00000000..0834d578 --- /dev/null +++ b/src/connection/directMode.ts @@ -0,0 +1,95 @@ +/** + * Direct CDP mode for one-shot commands without daemon. + * + * Allows commands like `bdg dom screenshot` to work with `--chrome-ws-url` + * directly connecting to a Chrome instance without needing a running session. + */ + +import type { ClientResponse } from '@/ipc/protocol/index.js'; + +import { CDPConnection } from './cdp.js'; + +// Use global to ensure singleton across dynamic imports +declare global { + + var __bdgDirectMode: + | { + wsUrl: string | null; + connection: CDPConnection | null; + } + | undefined; +} + +function getState(): { wsUrl: string | null; connection: CDPConnection | null } { + global.__bdgDirectMode ??= { wsUrl: null, connection: null }; + return global.__bdgDirectMode; +} + +/** + * Set the direct WebSocket URL for one-shot CDP commands. + * When set, CDP calls will use this connection instead of the daemon. + */ +export function setDirectWsUrl(wsUrl: string): void { + getState().wsUrl = wsUrl; +} + +/** + * Get the current direct WebSocket URL. + */ +export function getDirectWsUrl(): string | null { + return getState().wsUrl; +} + +/** + * Check if direct mode is active. + */ +export function isDirectMode(): boolean { + return getState().wsUrl !== null; +} + +/** + * Get or create the direct CDP connection. + */ +async function getDirectConnection(): Promise { + const state = getState(); + if (!state.wsUrl) { + throw new Error('Direct mode not configured - no WebSocket URL set'); + } + + if (!state.connection) { + state.connection = new CDPConnection(); + await state.connection.connect(state.wsUrl); + } + + return state.connection; +} + +/** + * Execute a CDP call in direct mode. + */ +export async function callCDPDirect( + method: string, + params?: Record +): Promise> { + const connection = await getDirectConnection(); + const cdpResult = await connection.send(method, params ?? {}); + + return { + type: 'cdp_call_response', + status: 'ok', + data: { result: cdpResult }, + sessionId: 'direct', + }; +} + +/** + * Close the direct connection if open. + */ +export function closeDirectConnection(): void { + const state = getState(); + if (state.connection) { + state.connection.close(); + state.connection = null; + } + state.wsUrl = null; +} diff --git a/src/daemon/handlers/QueryHandlers.ts b/src/daemon/handlers/QueryHandlers.ts index 30598d51..e516441b 100644 --- a/src/daemon/handlers/QueryHandlers.ts +++ b/src/daemon/handlers/QueryHandlers.ts @@ -15,6 +15,7 @@ import { type StatusRequest, type StatusResponse, type StatusResponseData, + type WebSocketConnectionsRequest, type WorkerRequest, type WorkerRequestUnion, } from '@/ipc/index.js'; @@ -163,4 +164,35 @@ export class QueryHandlers extends BaseHandler { workerRequest: workerRequest as WorkerRequestUnion, }); } + + /** + * Handle WebSocket connections request - forward to worker via IPC. + */ + handleWebSocketConnections(socket: Socket, request: WebSocketConnectionsRequest): void { + console.error( + `[daemon] WebSocket connections request received (sessionId: ${request.sessionId})` + ); + + if (!this.hasActiveWorker()) { + this.sendNoWorkerResponse( + socket, + request.sessionId, + 'websocket_connections', + 'No active session' + ); + return; + } + + const workerRequest: WorkerRequest<'worker_websockets'> = { + type: 'worker_websockets_request', + requestId: generateRequestId('worker_websockets'), + }; + + this.forwardToWorker({ + socket, + sessionId: request.sessionId, + commandName: 'worker_websockets', + workerRequest: workerRequest as WorkerRequestUnion, + }); + } } diff --git a/src/daemon/handlers/requestHandlers.ts b/src/daemon/handlers/requestHandlers.ts index 476f412c..3cea35ff 100644 --- a/src/daemon/handlers/requestHandlers.ts +++ b/src/daemon/handlers/requestHandlers.ts @@ -18,6 +18,7 @@ import { type StartSessionRequest, type StatusRequest, type StopSessionRequest, + type WebSocketConnectionsRequest, } from '@/ipc/index.js'; import { CommandHandlers } from './CommandHandlers.js'; @@ -86,6 +87,13 @@ export class RequestHandlers { this.queryHandlers.handleHARData(socket, request); } + /** + * Handle WebSocket connections request. + */ + handleWebSocketConnectionsRequest(socket: Socket, request: WebSocketConnectionsRequest): void { + this.queryHandlers.handleWebSocketConnections(socket, request); + } + /** * Handle start session request. */ diff --git a/src/daemon/handlers/responseHandler.ts b/src/daemon/handlers/responseHandler.ts index 0cf59934..1eadeb55 100644 --- a/src/daemon/handlers/responseHandler.ts +++ b/src/daemon/handlers/responseHandler.ts @@ -17,6 +17,7 @@ import { type PeekResponse, type StatusResponse, type StatusResponseData, + type WebSocketConnectionsResponse, type WorkerResponse, type WorkerResponseUnion, getCommandName, @@ -115,6 +116,17 @@ export class ResponseHandler { continue; } + if (pending.commandName === 'worker_websockets') { + const websocketConnectionsResponse: WebSocketConnectionsResponse = { + type: 'websocket_connections_response', + sessionId: pending.sessionId, + status: 'error', + error: errorMessage, + }; + this.sendResponse(pending.socket, websocketConnectionsResponse); + continue; + } + if (pending.commandName) { const response = { type: `${pending.commandName}_response` as const, @@ -226,6 +238,33 @@ export class ResponseHandler { return; } + if (commandName === 'worker_websockets') { + const { + requestId: _requestId, + success, + data, + error, + } = workerResponse as WorkerResponse<'worker_websockets'>; + const websocketConnectionsResponse = { + type: 'websocket_connections_response' as const, + sessionId, + status: success ? ('ok' as const) : ('error' as const), + ...(success && + data && { + data: { + sessionPid: this.sessionService.readPid() ?? 0, + connections: data.connections, + }, + }), + ...(error && { error }), + }; + this.sendResponse(socket, websocketConnectionsResponse); + console.error( + '[daemon] Forwarded worker_websockets_response to client (transformed to WebSocketConnectionsResponse)' + ); + return; + } + const { requestId: _requestId, success, ...rest } = workerResponse; const response: ClientResponse = { diff --git a/src/daemon/ipcServer.ts b/src/daemon/ipcServer.ts index 03484d40..ab36ef0d 100644 --- a/src/daemon/ipcServer.ts +++ b/src/daemon/ipcServer.ts @@ -161,6 +161,9 @@ export class IPCServer { case 'har_data_request': this.requestHandlers.handleHARDataRequest(socket, message); break; + case 'websocket_connections_request': + this.requestHandlers.handleWebSocketConnectionsRequest(socket, message); + break; case 'start_session_request': void this.requestHandlers.handleStartSessionRequest(socket, message); break; @@ -171,6 +174,7 @@ export class IPCServer { case 'status_response': case 'peek_response': case 'har_data_response': + case 'websocket_connections_response': case 'start_session_response': case 'stop_session_response': log.debug(`Unexpected response message received: ${message.type}`); 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/daemon/worker/commandRegistry.ts b/src/daemon/worker/commandRegistry.ts index a6153fc8..edf3ad88 100644 --- a/src/daemon/worker/commandRegistry.ts +++ b/src/daemon/worker/commandRegistry.ts @@ -320,6 +320,12 @@ export function createCommandRegistry(store: TelemetryStore): CommandRegistry { }); }, + worker_websockets: async (_cdp, _params) => { + return Promise.resolve({ + connections: store.websocketConnections, + }); + }, + cdp_call: async (cdp, params) => { const result = await cdp.send(params.method, params.params ?? {}); diff --git a/src/ipc/client.ts b/src/ipc/client.ts index 00aca578..dc10a170 100644 --- a/src/ipc/client.ts +++ b/src/ipc/client.ts @@ -21,6 +21,8 @@ import type { StatusResponse, StopSessionRequest, StopSessionResponse, + WebSocketConnectionsRequest, + WebSocketConnectionsResponse, } from './session/index.js'; import type { NoType } from './utils/index.js'; @@ -173,7 +175,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 @@ -285,6 +287,8 @@ export async function getNetworkHeaders(options?: { * Execute arbitrary CDP method via the daemon's worker. * Forwards CDP commands to the worker's active CDP connection. * + * If direct mode is active (--chrome-ws-url), uses direct connection instead. + * * @param method - CDP method name (e.g., 'Network.getCookies') * @param params - Optional method parameters * @returns Response with CDP method result @@ -302,5 +306,37 @@ export async function callCDP( method: string, params?: Record ): Promise> { + // Check if direct mode is active (--chrome-ws-url without daemon) + const { isDirectMode, callCDPDirect } = await import('@/connection/directMode.js'); + if (isDirectMode()) { + return callCDPDirect(method, params); + } + return sendCommand('cdp_call', { method, ...(params && { params }) }); } + +/** + * Get WebSocket connections from active session. + * Retrieves all captured WebSocket connections with their message frames. + * + * @returns WebSocket connections response + * @throws Error if connection fails, no active session, or permission denied + * + * @example + * ```typescript + * const response = await getWebSocketConnections(); + * if (response.status === 'ok' && response.data) { + * console.log('Total connections:', response.data.connections.length); + * } + * ``` + */ +export async function getWebSocketConnections(): Promise { + const request: WebSocketConnectionsRequest = withSession({ + type: 'websocket_connections_request', + }); + return sendRequest( + request, + 'WebSocket connections', + 'websocket_connections_response' + ); +} diff --git a/src/ipc/protocol/commands.ts b/src/ipc/protocol/commands.ts index a7c3ced3..6a8d459e 100644 --- a/src/ipc/protocol/commands.ts +++ b/src/ipc/protocol/commands.ts @@ -6,7 +6,7 @@ */ import type { PageState, SessionActivity } from '@/ipc/session/types.js'; -import type { NetworkRequest } from '@/types.js'; +import type { NetworkRequest, WebSocketConnection } from '@/types.js'; /** * Worker peek command request schema. @@ -139,6 +139,19 @@ export interface WorkerNetworkHeadersData { responseHeaders: Record; } +/** + * Worker websockets command request schema (no parameters required). + */ +export type WorkerWebSocketsCommand = Record; + +/** + * Worker websockets command response data. + */ +export interface WorkerWebSocketsData { + /** All collected WebSocket connections with frames. */ + connections: WebSocketConnection[]; +} + /** * Command definition structure. */ @@ -153,6 +166,7 @@ export type RegistryShape = { worker_status: CommandDef; worker_har_data: CommandDef; worker_network_headers: CommandDef; + worker_websockets: CommandDef; cdp_call: CommandDef; }; @@ -178,6 +192,10 @@ export const COMMANDS = { requestSchema: {} as WorkerNetworkHeadersCommand, responseSchema: {} as WorkerNetworkHeadersData, }, + worker_websockets: { + requestSchema: {} as WorkerWebSocketsCommand, + responseSchema: {} as WorkerWebSocketsData, + }, cdp_call: { requestSchema: {} as CdpCallCommand, responseSchema: {} as CdpCallData }, } as const satisfies RegistryShape; diff --git a/src/ipc/session/queries.ts b/src/ipc/session/queries.ts index efcf51c3..b20d9fe8 100644 --- a/src/ipc/session/queries.ts +++ b/src/ipc/session/queries.ts @@ -7,7 +7,7 @@ import type { IPCMessage } from './lifecycle.js'; import type { PageState, SessionActivity } from './types.js'; -import type { NetworkRequest, TelemetryType } from '@/types.js'; +import type { NetworkRequest, TelemetryType, WebSocketConnection } from '@/types.js'; /** * Status request (client → daemon). @@ -120,6 +120,33 @@ export interface HARDataResponse extends IPCMessage { error?: string; } +/** + * WebSocket connections request (client → daemon). + */ +export interface WebSocketConnectionsRequest extends IPCMessage { + type: 'websocket_connections_request'; +} + +/** + * WebSocket connections response data. + */ +export interface WebSocketConnectionsResponseData { + /** Worker process ID. */ + sessionPid: number; + /** WebSocket connections with frames. */ + connections: WebSocketConnection[]; +} + +/** + * WebSocket connections response (daemon → client). + */ +export interface WebSocketConnectionsResponse extends IPCMessage { + type: 'websocket_connections_response'; + status: 'ok' | 'error'; + data?: WebSocketConnectionsResponseData; + error?: string; +} + /** * Union of all session query message types. */ @@ -129,4 +156,6 @@ export type QueryMessageType = | PeekRequest | PeekResponse | HARDataRequest - | HARDataResponse; + | HARDataResponse + | WebSocketConnectionsRequest + | WebSocketConnectionsResponse; 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/network.ts b/src/telemetry/network.ts index 0bf342d1..018128e1 100644 --- a/src/telemetry/network.ts +++ b/src/telemetry/network.ts @@ -369,12 +369,255 @@ const MAX_FRAMES_PER_CONNECTION = 1000; /** Maximum payload size to capture per frame (100KB) */ const MAX_FRAME_PAYLOAD_SIZE = 100 * 1024; +/** + * JavaScript code to inject into the page for WebSocket interception. + * This fallback is used when CDP WebSocket events aren't firing (e.g., external Chrome). + */ +const WEBSOCKET_INTERCEPTOR_SCRIPT = ` +(function() { + if (window.__bdgWebSocketInterceptor) return; // Already injected + window.__bdgWebSocketInterceptor = { connections: [], nextId: 0 }; + + const OriginalWebSocket = window.WebSocket; + window.WebSocket = function(...args) { + const ws = new OriginalWebSocket(...args); + const connectionId = window.__bdgWebSocketInterceptor.nextId++; + const connection = { + id: connectionId, + url: args[0], + timestamp: Date.now(), + frames: [], + readyState: ws.readyState, + closed: false + }; + window.__bdgWebSocketInterceptor.connections.push(connection); + + ws.addEventListener('open', () => { + connection.readyState = ws.readyState; + }); + + ws.addEventListener('message', (e) => { + let messageData = '[Binary Data]'; + if (typeof e.data === 'string') { + messageData = e.data; + } else if (e.data instanceof Blob) { + // For Blob, we'll try to read it as text asynchronously + const reader = new FileReader(); + reader.onload = function() { + const result = reader.result; + if (typeof result === 'string') { + // Update the frame data with decoded text + const frame = connection.frames[connection.frames.length - 1]; + if (frame) frame.data = result; + } + }; + reader.readAsText(e.data); + messageData = '[Blob - decoding...]'; + } else if (e.data instanceof ArrayBuffer) { + // Try to decode ArrayBuffer as UTF-8 text + try { + const decoder = new TextDecoder('utf-8', { fatal: false }); + let decoded = decoder.decode(e.data); + + // For Jupyter kernel protocol: extract JSON payloads from binary framing + // Format: + // Look for common delimiters and channel names (shell, iopub, stdin, control) + const channelMatch = decoded.match(/(shell|iopub|stdin|control)({.+)/); + if (channelMatch) { + // Found Jupyter protocol format - extract JSON payloads + const channel = channelMatch[1]; + const jsonPart = channelMatch[2]; + + // Try to extract all JSON objects from the message + const jsonObjects = []; + let currentPos = 0; + let depth = 0; + let startPos = -1; + + for (let i = 0; i < jsonPart.length; i++) { + if (jsonPart[i] === '{') { + if (depth === 0) startPos = i; + depth++; + } else if (jsonPart[i] === '}') { + depth--; + if (depth === 0 && startPos >= 0) { + jsonObjects.push(jsonPart.substring(startPos, i + 1)); + startPos = -1; + } + } + } + + if (jsonObjects.length > 0) { + messageData = 'Channel: ' + channel + '\\n' + jsonObjects.map((obj, idx) => { + try { + // Pretty print JSON for readability + const parsed = JSON.parse(obj); + return 'Part ' + (idx + 1) + ':\\n' + JSON.stringify(parsed, null, 2); + } catch { + return obj; + } + }).join('\\n\\n'); + } else { + messageData = 'Channel: ' + channel + '\\n' + jsonPart; + } + } else { + // Not Jupyter format, just clean up control chars + messageData = decoded.replace(/[\x00-\x08\x0B-\x0C\x0E-\x1F\x7F]/g, ' '); + } + } catch (err) { + messageData = '[Binary Data - ' + e.data.byteLength + ' bytes]'; + } + } + connection.frames.push({ + direction: 'received', + data: messageData, + timestamp: Date.now() + }); + }); + + const originalSend = ws.send.bind(ws); + ws.send = function(data) { + let messageData = '[Binary Data]'; + if (typeof data === 'string') { + messageData = data; + } else if (data instanceof Blob) { + messageData = '[Blob - ' + data.size + ' bytes]'; + } else if (data instanceof ArrayBuffer) { + // Try to decode ArrayBuffer as UTF-8 text + try { + const decoder = new TextDecoder('utf-8', { fatal: false }); + let decoded = decoder.decode(data); + + // For Jupyter kernel protocol: extract JSON payloads from binary framing + const channelMatch = decoded.match(/(shell|iopub|stdin|control)({.+)/); + if (channelMatch) { + const channel = channelMatch[1]; + const jsonPart = channelMatch[2]; + + // Try to extract all JSON objects + const jsonObjects = []; + let depth = 0; + let startPos = -1; + + for (let i = 0; i < jsonPart.length; i++) { + if (jsonPart[i] === '{') { + if (depth === 0) startPos = i; + depth++; + } else if (jsonPart[i] === '}') { + depth--; + if (depth === 0 && startPos >= 0) { + jsonObjects.push(jsonPart.substring(startPos, i + 1)); + startPos = -1; + } + } + } + + if (jsonObjects.length > 0) { + messageData = 'Channel: ' + channel + '\\n' + jsonObjects.map((obj, idx) => { + try { + const parsed = JSON.parse(obj); + return 'Part ' + (idx + 1) + ':\\n' + JSON.stringify(parsed, null, 2); + } catch { + return obj; + } + }).join('\\n\\n'); + } else { + messageData = 'Channel: ' + channel + '\\n' + jsonPart; + } + } else { + messageData = decoded.replace(/[\x00-\x08\x0B-\x0C\x0E-\x1F\x7F]/g, ' '); + } + } catch (err) { + messageData = '[Binary Data - ' + data.byteLength + ' bytes]'; + } + } + connection.frames.push({ + direction: 'sent', + data: messageData, + timestamp: Date.now() + }); + return originalSend(data); + }; + + ws.addEventListener('close', () => { + connection.closed = true; + connection.closedTime = Date.now(); + connection.readyState = ws.readyState; + }); + + ws.addEventListener('error', (e) => { + connection.error = e.message || 'WebSocket error'; + }); + + return ws; + }; + window.WebSocket.prototype = OriginalWebSocket.prototype; +})(); +`; + +/** + * Retrieve WebSocket data captured by JavaScript interceptor. + * + * @param cdp - CDP connection instance + * @returns Captured WebSocket connections + */ +async function getInterceptedWebSockets(cdp: CDPConnection): Promise { + try { + const result = (await cdp.send('Runtime.evaluate', { + expression: 'JSON.stringify(window.__bdgWebSocketInterceptor?.connections || [])', + returnByValue: true, + })) as { result?: { value?: string } }; + + if (!result.result?.value) { + return []; + } + + const interceptedConnections = JSON.parse(result.result.value) as Array<{ + id: number; + url: string; + timestamp: number; + frames: Array<{ direction: string; data: string; timestamp: number }>; + closed: boolean; + closedTime?: number; + error?: string; + }>; + + return interceptedConnections.map((conn) => { + const connection: WebSocketConnection = { + requestId: `js-intercepted-${conn.id}`, + url: conn.url, + timestamp: conn.timestamp, + frames: conn.frames.map((frame) => ({ + timestamp: frame.timestamp, + direction: frame.direction as 'sent' | 'received', + opcode: 1, // TEXT frame (we can't determine actual opcode from JS) + payloadData: frame.data, + })), + }; + + if (conn.closedTime !== undefined) { + connection.closedTime = conn.closedTime; + } + if (conn.error !== undefined) { + connection.errorMessage = conn.error; + } + + return connection; + }); + } catch (error) { + log.debug(`Failed to retrieve intercepted WebSockets: ${getErrorMessage(error)}`); + return []; + } +} + /** * Start collecting WebSocket connections and frames via CDP Network domain. * * Tracks WebSocket lifecycle (creation, handshake, frames, close) separately from HTTP requests. * Network.enable must be called before this (typically by startNetworkCollection). * + * Falls back to JavaScript-based interception if CDP events aren't firing (e.g., external Chrome). + * * @param cdp - CDP connection instance * @param connections - Array to populate with WebSocket connections * @returns Cleanup function to remove event handlers @@ -386,8 +629,13 @@ export function startWebSocketCollection( const connectionMap = new Map(); const registry = new CDPHandlerRegistry(); const typed = new TypedCDPConnection(cdp); + let cdpEventsReceived = false; + let fallbackInterval: NodeJS.Timeout | null = null; + let jsInterceptorInjected = false; registry.registerTyped(typed, 'Network.webSocketCreated', (params) => { + cdpEventsReceived = true; // Mark that CDP events are working + if (connectionMap.size >= MAX_WEBSOCKET_CONNECTIONS) { log.debug( `WebSocket connection limit reached (${MAX_WEBSOCKET_CONNECTIONS}), skipping new connection` @@ -490,15 +738,94 @@ export function startWebSocketCollection( log.debug(`WebSocket closed: ${connection.url} (${connection.frames.length} frames captured)`); }); - return () => { + // Set up fallback mechanism: if no CDP events received after 3 seconds, inject JS interceptor + const fallbackTimer = setTimeout(() => { + void (async () => { + if (!cdpEventsReceived && !jsInterceptorInjected) { + log.debug( + 'No CDP WebSocket events received, enabling JavaScript-based interception fallback' + ); + + try { + // Inject the interceptor script + await cdp.send('Runtime.evaluate', { + expression: WEBSOCKET_INTERCEPTOR_SCRIPT, + }); + jsInterceptorInjected = true; + log.debug('WebSocket interceptor injected successfully'); + + // Start polling for intercepted data every 2 seconds + fallbackInterval = setInterval(() => { + void (async () => { + const intercepted = await getInterceptedWebSockets(cdp); + if (intercepted.length > 0) { + // Merge intercepted connections with existing ones (avoid duplicates) + for (const conn of intercepted) { + const existing = connections.find((c) => c.requestId === conn.requestId); + if (!existing) { + connections.push(conn); + log.debug( + `WebSocket captured via JS interceptor: ${conn.url} (${conn.frames.length} frames)` + ); + } else { + // Update existing connection with new frames + const newFrames = conn.frames.slice(existing.frames.length); + existing.frames.push(...newFrames); + if (conn.closedTime && !existing.closedTime) { + existing.closedTime = conn.closedTime; + } + } + } + } + })(); + }, 2000); + } catch (error) { + log.debug(`Failed to inject WebSocket interceptor: ${getErrorMessage(error)}`); + } + } + })(); + }, 3000); + + return async () => { + clearTimeout(fallbackTimer); + if (fallbackInterval) { + clearInterval(fallbackInterval); + } + + // Flush any remaining connections from CDP for (const connection of connectionMap.values()) { connections.push(connection); } + // Flush final intercepted WebSocket data if using fallback + if (jsInterceptorInjected) { + try { + const intercepted = await getInterceptedWebSockets(cdp); + for (const conn of intercepted) { + const existing = connections.find((c) => c.requestId === conn.requestId); + if (!existing) { + connections.push(conn); + } else { + const newFrames = conn.frames.slice(existing.frames.length); + existing.frames.push(...newFrames); + if (conn.closedTime && !existing.closedTime) { + existing.closedTime = conn.closedTime; + } + if (conn.errorMessage && !existing.errorMessage) { + existing.errorMessage = conn.errorMessage; + } + } + } + } catch (error) { + log.debug(`Failed to flush intercepted WebSocket data: ${getErrorMessage(error)}`); + } + } + const totalFrames = connections.reduce((sum, c) => sum + c.frames.length, 0); if (connections.length > 0) { + const method = cdpEventsReceived ? 'CDP' : jsInterceptorInjected ? 'JS interceptor' : 'N/A'; log.debug( - `[PERF] WebSockets: ${connections.length} connections, ${totalFrames} frames captured` + `[PERF] WebSockets: ${connections.length} connections, ${totalFrames} frames captured (via ${method})` ); } 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