Initial commit

This commit is contained in:
Zhongwei Li
2025-11-29 18:18:56 +08:00
commit 3aef0f6c84
70 changed files with 14222 additions and 0 deletions

View File

@@ -0,0 +1,12 @@
{
"name": "browser-pilot",
"description": "Chrome DevTools Protocol (CDP) browser automation, web scraping, and crawling with React compatibility",
"version": "1.10.0",
"author": {
"name": "Dev GOM",
"url": "https://github.com/Dev-GOM/claude-code-marketplace"
},
"skills": [
"./skills"
]
}

3
README.md Normal file
View File

@@ -0,0 +1,3 @@
# browser-pilot
Chrome DevTools Protocol (CDP) browser automation, web scraping, and crawling with React compatibility

309
plugin.lock.json Normal file
View File

@@ -0,0 +1,309 @@
{
"$schema": "internal://schemas/plugin.lock.v1.json",
"pluginId": "gh:Dev-GOM/claude-code-marketplace:plugins/browser-pilot",
"normalized": {
"repo": null,
"ref": "refs/tags/v20251128.0",
"commit": "4efa229eee19c084e4c09e6633ef82485bfc263e",
"treeHash": "9ce781902752f5a5275646ca73c877a51eb6d2567786b72371e0ee666ddad875",
"generatedAt": "2025-11-28T10:10:17.589693Z",
"toolVersion": "publish_plugins.py@0.2.0"
},
"origin": {
"remote": "git@github.com:zhongweili/42plugin-data.git",
"branch": "master",
"commit": "aa1497ed0949fd50e99e70d6324a29c5b34f9390",
"repoRoot": "/Users/zhongweili/projects/openmind/42plugin-data"
},
"manifest": {
"name": "browser-pilot",
"description": "Chrome DevTools Protocol (CDP) browser automation, web scraping, and crawling with React compatibility",
"version": "1.10.0"
},
"content": {
"files": [
{
"path": "README.md",
"sha256": "a6b6d4720c9297fbdec226d7bf1785a8112c744b7ae0b88e62284b3a8d8b6016"
},
{
"path": ".claude-plugin/plugin.json",
"sha256": "4b52d22927abfab2bff34f8b2adf142058b2605251f571cbd2e419f88f24fa58"
},
{
"path": "skills/SKILL.md",
"sha256": "113530dfc519f84a65faaa311d0cef4634536df5f562bc928bef0274d51b32ba"
},
{
"path": "skills/references/commands-reference.md",
"sha256": "deb78776496e97fac9ab389a2424b8de683907a1d941fa0949a8ce01dacddee3"
},
{
"path": "skills/references/selector-guide.md",
"sha256": "65a872fa0f67b3191cba7934670334904e58817adfed19cd222d767399ae4feb"
},
{
"path": "skills/references/interaction-map.md",
"sha256": "b1de092948922f1619edd0eb1bef616c796cefb35bfca858615d645a1a60a755"
},
{
"path": "skills/scripts/package.json",
"sha256": "a3eea0902133ffbb7c09d8e5ddfa04daeba2a3a3f0c41177968e3a75bd48d1ec"
},
{
"path": "skills/scripts/tsconfig.json",
"sha256": "dbac69ac1ad7ecb10ff1dc9cae7059ffc1c7f24d5d7342409813799efe4f49a9"
},
{
"path": "skills/scripts/eslint.config.mjs",
"sha256": "1147567cc49454b578398e51774ef908ac51bc069611bd9aaf66ed6b9293818f"
},
{
"path": "skills/scripts/src/cdp/utils.ts",
"sha256": "dd109a1810185f336db050937b8cfbb403d0b3908778ae02c12b3a8fa6a7af57"
},
{
"path": "skills/scripts/src/cdp/browser.ts",
"sha256": "19ec63fc421c737d8f4d972387c7ebaaf41bea87cb76e2fe5c9aa642d6d95059"
},
{
"path": "skills/scripts/src/cdp/actions.ts",
"sha256": "dd37c8e62c782f8ae19bf2004078dd3f29c4f79080d90ac927b62575dad2a98e"
},
{
"path": "skills/scripts/src/cdp/client.ts",
"sha256": "75985cc843baae6c624120bb9585ffd874e36cee9ad9e541c09e2d831c10268f"
},
{
"path": "skills/scripts/src/cdp/config.ts",
"sha256": "00b62151b233701b6f077620125d9c2050056e11339bfefc9cb3b566f6f0b16a"
},
{
"path": "skills/scripts/src/cdp/map/generate-interaction-map.ts",
"sha256": "7eb0140eaebf53eadfc5b8a5422201dfafb3b1599204ce5ade23603f7460bcbe"
},
{
"path": "skills/scripts/src/cdp/map/query-map.ts",
"sha256": "46c3617d898cf73749528caf426ad6e38cd21f9de19708dd62a796d225d843be"
},
{
"path": "skills/scripts/src/cdp/actions/data.ts",
"sha256": "bce34a1ac5e7fc49c2b0d15706e5ffcd0db276bcde6288ee48012c110b2b1195"
},
{
"path": "skills/scripts/src/cdp/actions/helpers.ts",
"sha256": "483503eae2e810f88b000431457799f498e69ff364727a87a69998cccd8f5707"
},
{
"path": "skills/scripts/src/cdp/actions/dialogs.ts",
"sha256": "d34eec0a3c9d5ea6254efeeff13e5753a01623a22729e97bbb5985c32169892d"
},
{
"path": "skills/scripts/src/cdp/actions/verify.ts",
"sha256": "764ea4e0029787ff194a153ec43f4319ccfccb97263583f7aaf427cb070f8bb3"
},
{
"path": "skills/scripts/src/cdp/actions/navigation.ts",
"sha256": "633961ac587a9d8a1ad9df7fb49d857e647efc4a8431c126ac89d033aac3afe1"
},
{
"path": "skills/scripts/src/cdp/actions/network.ts",
"sha256": "ea5ace071fb8569c22795487563a579b027b05c287e4498bca5a33c797e385f1"
},
{
"path": "skills/scripts/src/cdp/actions/input.ts",
"sha256": "acc78b56750ca5edcdc6ba15f62dc7a29ab52ad1f10f225bc7685ecbc2b6146c"
},
{
"path": "skills/scripts/src/cdp/actions/capture.ts",
"sha256": "65e0a1cb263719bee7b289f50f58e0a7c04c3705e5ae9e38987c6ed580ccb5b1"
},
{
"path": "skills/scripts/src/cdp/actions/forms.ts",
"sha256": "67a6a5df967f560a2136b257ba7119c7fb48559f7957ad8436c4973b1c4dc1dd"
},
{
"path": "skills/scripts/src/cdp/actions/debugging.ts",
"sha256": "20080fb55d16f24821aa5a2d620ff91b622932d7d52f19762a4740465a1dd4b4"
},
{
"path": "skills/scripts/src/cdp/actions/emulation.ts",
"sha256": "506b58b0ac69771788693c73631faad4f69cdc031ec56dad6f0deadad46c33c4"
},
{
"path": "skills/scripts/src/cdp/actions/wait.ts",
"sha256": "9aa1e410a023e9b0e3122cc9ee14ae8fefa807c5c24e333b355c4a8c47f3dd26"
},
{
"path": "skills/scripts/src/cdp/actions/scroll.ts",
"sha256": "636b3becbca5bfa66c63b0be5ee0dd1f5fc50a2bb45b1cdccc64c97b016cfeb6"
},
{
"path": "skills/scripts/src/cdp/actions/tabs.ts",
"sha256": "bd77b56f88c79c9792b46febb1c9effcecc49995f4ed9d449b08214aff2e3b95"
},
{
"path": "skills/scripts/src/cdp/actions/interaction.ts",
"sha256": "9e79e9bb2526fffa2f30b149055dd3a2cb7d665983cf37492bdcea58b3a21bd9"
},
{
"path": "skills/scripts/src/cdp/actions/cookies.ts",
"sha256": "e98bf8723a083b2f29a87c4615f81bbf8fd130c64abaa3156abb0cd90e9279a0"
},
{
"path": "skills/scripts/src/constants/index.ts",
"sha256": "9cb84b6e98052b3d23c2beb5ecef14bddbdf509ba7bd4c8dfcd681dab8a1590e"
},
{
"path": "skills/scripts/src/utils/timestamp.ts",
"sha256": "f995aef19dd989911e8d27c0120f17d6d556e6425d7e6db3b715e837eb82f11c"
},
{
"path": "skills/scripts/src/utils/logger.ts",
"sha256": "985b830942754994ce13104095047c43d58a68a686b2cd6f868f10549f8958d1"
},
{
"path": "skills/scripts/src/cli/daemon-helper.ts",
"sha256": "ffe42c17fa1d198ee6b1add5d2ce352ca155836b7f0f5a1878257513031822b1"
},
{
"path": "skills/scripts/src/cli/cli.ts",
"sha256": "2bab2d5370eedbcf29b9bbabee9e56599d140411f450062a93d25dceee7bf287"
},
{
"path": "skills/scripts/src/cli/commands/focus.ts",
"sha256": "bc977c0e3617c22daeab2283b90ce7f0e79514890f4587f08d021dd28fb1e09c"
},
{
"path": "skills/scripts/src/cli/commands/daemon.ts",
"sha256": "bc9848f7cb0cf0123ada9edad54ab3a65989b7d5db68a30964abacf8f07e9174"
},
{
"path": "skills/scripts/src/cli/commands/data.ts",
"sha256": "42bc1b2f00800f9a24110ddcce1067cebe588335e3b474d6d16e68c8a80692e8"
},
{
"path": "skills/scripts/src/cli/commands/selector-helper.ts",
"sha256": "10ad7e8c5b746802014ab4608f70653890eba05ab5d9c138702b9b5454315981"
},
{
"path": "skills/scripts/src/cli/commands/dialogs.ts",
"sha256": "cf0a667b9696a479f0fc5a6b77ca1cb18440e04f5d6549e56ffd42d4ed7e2b88"
},
{
"path": "skills/scripts/src/cli/commands/accessibility.ts",
"sha256": "771180962ab7dc9a751441b927cbbb5991178a29e1c0e0ed59449b1c4ee5fa70"
},
{
"path": "skills/scripts/src/cli/commands/navigation.ts",
"sha256": "01b5e2ac44e7b9244094f52426337349819e03c485ff8ca58a5d6fde58bbc9a2"
},
{
"path": "skills/scripts/src/cli/commands/network.ts",
"sha256": "5e578c8b29239f9506daa4bc323b02dd5d66f99f5a4826de5cdf87d3e5a96528"
},
{
"path": "skills/scripts/src/cli/commands/capture.ts",
"sha256": "b1e734d4bfd3731e4472a12c2d700826d34c9967a69fed9d6575222c2ff615e0"
},
{
"path": "skills/scripts/src/cli/commands/forms.ts",
"sha256": "f8b958e96a225d96d7d9e3d9a44e575357b95bd047e9524eaeb3de794982d5a9"
},
{
"path": "skills/scripts/src/cli/commands/emulation.ts",
"sha256": "dd130d9ab619de79c457cbec4aed3b006d1b33c021be0fa877decaab20aeb0ae"
},
{
"path": "skills/scripts/src/cli/commands/wait.ts",
"sha256": "5a077ac6ff83e25f07fc571858a439c99f4430f53d2be222abb96526f11b4fed"
},
{
"path": "skills/scripts/src/cli/commands/scroll.ts",
"sha256": "6e9e8db9ab8be035ee2e2449759868feceb04ba6a355ab25b7a488b7b99cbe3f"
},
{
"path": "skills/scripts/src/cli/commands/chain.ts",
"sha256": "089722859c841463a376f35c2cc621b5117ff754f87c388c964b8cbd61e991e7"
},
{
"path": "skills/scripts/src/cli/commands/tabs.ts",
"sha256": "9f6d8c55e13ce759f90bf701b9c2462d81656cc230db80a07b22053d5adb6dda"
},
{
"path": "skills/scripts/src/cli/commands/interaction.ts",
"sha256": "82424470f754304ae321f3cd0777b4fb4434cd563b2634a3dc697dbf6c5164db"
},
{
"path": "skills/scripts/src/cli/commands/query.ts",
"sha256": "0908d9ef05b229c0348b71ae1511e6322781f42ede28a19c0f78491d23790aec"
},
{
"path": "skills/scripts/src/cli/commands/system.ts",
"sha256": "16543760c9a4b1f097a4b19b31bdd5a137fbff0ef3bb398f0510d6ff25fdaff9"
},
{
"path": "skills/scripts/src/cli/commands/console.ts",
"sha256": "f81b7d246a96c41a15d85bc5af9e449156cebcf780d3591e11e91ce2ed789f4e"
},
{
"path": "skills/scripts/src/cli/commands/cookies.ts",
"sha256": "49c946bbc040a7fd0b387a1eb4b92e152d82218e26fcdb0274d6558272079607"
},
{
"path": "skills/scripts/src/daemon/manager.ts",
"sha256": "d73ea3290bfc8fa752dbcf2c3a0b283ec0e9d1689849bdde270b046fa5db89f8"
},
{
"path": "skills/scripts/src/daemon/client.ts",
"sha256": "a377f3d24386cc08a29648aa2ced40ec27509eafa5037447f2674e31fc50df3e"
},
{
"path": "skills/scripts/src/daemon/protocol.ts",
"sha256": "c3293fa4073152f76169b30440b7bf13a7e04337a0ca53fb067cf2c8fa3a2542"
},
{
"path": "skills/scripts/src/daemon/map-manager.ts",
"sha256": "e32adcf83b9e22c486bd45ab07255eaf77e62911d0b54b9003dffd5fc5a0602d"
},
{
"path": "skills/scripts/src/daemon/server.ts",
"sha256": "8d374b0b021ae5db412e5445e539f79aeadbe2f37df9c453a4de4abf7b9e6bd8"
},
{
"path": "skills/scripts/src/daemon/handlers/utility-handlers.ts",
"sha256": "2195ec7c2a54c8ba1d605bfce52361f1f3d754303dbbd0335790a22b3caba0fb"
},
{
"path": "skills/scripts/src/daemon/handlers/capture-handlers.ts",
"sha256": "60e736208c164cd8341b6c51ee21112e94774f9570ae17cbf65dfca32675154c"
},
{
"path": "skills/scripts/src/daemon/handlers/index.ts",
"sha256": "264bee94f51371a75391bcb43be0b8c0a446ca0095a27f1cb58df30d8b58ba04"
},
{
"path": "skills/scripts/src/daemon/handlers/interaction-handlers.ts",
"sha256": "dfc9492608b14c36711b5d747821ecfdbd5ba2707350f736823f876e28aff264"
},
{
"path": "skills/scripts/src/daemon/handlers/map-handlers.ts",
"sha256": "520d96f88ec1a8534434b114d8e0b1caa54857bd7736d7654a52e0cc4e8cf4a1"
},
{
"path": "skills/scripts/src/daemon/handlers/data-handlers.ts",
"sha256": "fa80d68e312df5eefb46b9f774405abefde0629758fa1ddced3333d01f8652fd"
},
{
"path": "skills/scripts/src/daemon/handlers/navigation-handlers.ts",
"sha256": "ad18d102c89bfdd2e5ea0d2e8fe936084bb381975a4c9d57c7d8741a6aab518c"
}
],
"dirSha256": "9ce781902752f5a5275646ca73c877a51eb6d2567786b72371e0ee666ddad875"
},
"security": {
"scannedAt": null,
"scannerVersion": null,
"flags": []
}
}

240
skills/SKILL.md Normal file
View File

@@ -0,0 +1,240 @@
---
name: browser-pilot
description: |
Chrome DevTools Protocol (CDP) browser automation, web scraping, crawling. 브라우저 자동화, 웹 스크래핑, 크롤링.
Features/기능: screenshot with region control 영역지정스크린샷, viewport control 뷰포트제어, PDF generation PDF생성, web scraping 웹스크래핑, data extraction 데이터추출, form filling 폼작성, login automation 로그인자동화, click/input 클릭/입력, element finder 요소찾기, tab management 탭관리, cookie control 쿠키제어, JavaScript execution JS실행, page navigation 페이지이동, wait for element 요소대기, scroll 스크롤, accessibility tree 접근성트리, console messages 콘솔메시지, network idle 네트워크대기, back/forward 뒤로/앞으로, reload 새로고침, file upload 파일업로드, React compatibility React호환성, Smart Mode with Interaction Map 스마트모드.
Selectors 셀렉터: CSS selectors (ID, class, attribute), XPath selectors with wildcard * (text-based, structural), XPath indexing (select N-th element with same text). Smart Mode: text-based element search with automatic selector generation.
Bot detection bypass 봇감지우회 (navigator.webdriver=false). Auto Chrome connection 자동크롬연결. Headless/headed mode. Daemon-based architecture 데몬기반. Interaction Map System 인터랙션맵. React/framework compatibility React/프레임워크호환성.
---
# browser-pilot
## Purpose
Automate Chrome browser using Chrome DevTools Protocol (CDP) with a daemon-based architecture. Maintains persistent browser connection for instant command execution. Features Smart Mode with Interaction Map for reliable element targeting using text-based search instead of brittle selectors.
**Always run scripts with `--help` first** to see usage. DO NOT read the source until you try running the script first and find that a customized solution is abslutely necessary. These scripts can be very large and thus pollute your context window. They exist to be called directly as black-box scripts rather than ingested into your context window.
## When to Use
Use browser-pilot when tasks involve:
- Browser automation, web scraping, data extraction
- Screenshot capture, PDF generation
- Form filling, login automation, element interaction
- Tab management, cookie control, JavaScript execution
- Tasks requiring text-based element selection ("click the 3rd Delete button")
- Bot detection bypass requirements (navigator.webdriver = false)
## ⚠️ Important Guidelines
**When to Ask User:** Use AskUserQuestion tool if:
- Task requirements unclear or ambiguous
- Multiple implementation approaches possible
- Element selectors not working despite troubleshooting
- User intent uncertain (e.g., "automate this" without specifics)
**DO NOT** guess or assume user requirements. Always clarify first.
## Prerequisites
Chrome must be installed. Local scripts initialize automatically on session start (no manual setup required).
## Getting Help
All commands support `--help` for detailed options:
```bash
# See all available commands
node .browser-pilot/bp --help
# Get help for specific command
node .browser-pilot/bp <command> --help
```
## Architecture
**Daemon-based design:**
- Background daemon maintains persistent CDP connection
- CLI commands communicate via IPC
- Auto-starts on first command, stops at session end
- 30-minute inactivity timeout
**Interaction Map System:**
- Auto-generates JSON map of interactive elements on page load
- Enables text-based search with automatic selector generation
- Handles duplicates with indexing
- 10-minute cache with auto-regeneration
## Core Workflow
### 1. Extract Required Information
From user's request, identify:
- Target URL(s) to visit
- Actions to perform (screenshot, click, fill, etc.)
- Element identifiers (text content, CSS selectors, or XPath)
- Output file names (for screenshots/PDFs)
- Data to extract or forms to fill
When information is missing or ambiguous, use AskUserQuestion tool.
### 2. Execute Commands
All commands use `.browser-pilot/bp` wrapper script. Replace placeholders with actual values.
**Navigation:**
```bash
node .browser-pilot/bp navigate -u <url>
node .browser-pilot/bp back
node .browser-pilot/bp forward
node .browser-pilot/bp reload
```
**Interaction (Smart Mode - Recommended):**
```bash
# Text-based element search (map auto-generated)
# No quotes for single words
node .browser-pilot/bp click --text Login --type button
node .browser-pilot/bp fill --text Email -v <value>
# Use quotes when text contains spaces
node .browser-pilot/bp click --text "Sign In" --type button
node .browser-pilot/bp fill --text "Email Address" -v <value>
# Handle duplicates with indexing
node .browser-pilot/bp click --text Delete --index 2
# Filter visible elements only
node .browser-pilot/bp click --text Submit --viewport-only
# Type aliases (auto-expanded)
node .browser-pilot/bp click --text Search --type input # Matches: input, input-text, input-search, etc.
# Tag-based filtering (HTML tag)
node .browser-pilot/bp click --text Submit --tag button # Matches all <button> tags
node .browser-pilot/bp fill --text Email --tag input -v user@example.com
# 3-stage fallback (automatic)
# Stage 1: Type search (with alias expansion)
# Stage 2: Tag search (if type fails)
# Stage 3: Map regeneration + retry (up to 3 attempts)
```
**Interaction (Direct Mode - fallback for unique IDs):**
```bash
node .browser-pilot/bp click -s "#login-button"
node .browser-pilot/bp fill -s "input[name='email']" -v <value>
```
**Capture:**
```bash
# Screenshots saved to .browser-pilot/screenshots/
node .browser-pilot/bp screenshot -o <filename>.png
# Capture specific region
node .browser-pilot/bp screenshot -o region.png --clip-x 100 --clip-y 200 --clip-width 800 --clip-height 600
# Set viewport size for responsive testing
node .browser-pilot/bp set-viewport -w 375 -h 667 --scale 2 --mobile
# Get current viewport size
node .browser-pilot/bp get-viewport
# Get screen and viewport information
node .browser-pilot/bp get-screen-info
# PDFs saved to .browser-pilot/pdfs/
node .browser-pilot/bp pdf -o <filename>.pdf
```
**Chain Mode (multiple commands):**
```bash
# Basic chain (no quotes needed for single words)
node .browser-pilot/bp chain navigate -u <url> click --text Submit extract -s .result
# With spaces (quotes required)
node .browser-pilot/bp chain navigate -u <url> click --text "Sign In" fill --text Email -v <email>
# Login workflow
node .browser-pilot/bp chain navigate -u <url> fill --text Email -v <email> fill --text Password -v <password> click --text Login
# Screenshot workflow
node .browser-pilot/bp chain navigate -u <url> wait -s .content-loaded screenshot -o result.png
```
**Chain-specific options:**
- `--timeout <ms>`: Map wait timeout after navigation (default: 10000ms)
- `--delay <ms>`: Fixed delay between commands (overrides random 300-800ms)
**Data Extraction:**
```bash
node .browser-pilot/bp extract -s <selector>
node .browser-pilot/bp content
node .browser-pilot/bp console
node .browser-pilot/bp cookies
```
**Other Actions:**
```bash
node .browser-pilot/bp wait -s <selector> -t <timeout-ms>
node .browser-pilot/bp scroll -s <selector>
node .browser-pilot/bp eval -e <javascript-expression>
```
### 3. Query Interaction Map (when needed)
```bash
# List all element types
node .browser-pilot/bp query --list-types
# Find elements by text
node .browser-pilot/bp query --text <text>
# Check map status
node .browser-pilot/bp map-status
# Force regenerate map
node .browser-pilot/bp regen-map
```
## Best Practices
1. **🌟 Use Smart Mode by default**: Text-based search (`--text`) is more stable than CSS selectors
- Recommended: `click --text Login`
- Fallback: `click -s #login-btn` (only for unique IDs)
2. **Maps auto-generate**: No manual map generation needed, happens on page load
3. **Handle duplicates with indexing**: `--index 2` selects 2nd match when multiple elements have same text
4. **Filter with type aliases**: `--type input` auto-expands to match `input`, `input-text`, `input-search`, etc.
- Generic: `--type input` (matches all input types)
- Specific: `--type input-search` (exact match only)
5. **Use tag-based search for flexibility**: `--tag button` matches all `<button>` elements regardless of type
6. **3-stage fallback is automatic**: If element not found, system automatically:
- Tries type-based search (with alias expansion)
- Falls back to tag-based search
- Regenerates map and retries (up to 3 attempts)
7. **Verify element visibility**: `--viewport-only` ensures element is on screen
8. **Use Chain Mode for workflows**: Execute multiple commands in sequence for complex automation
9. **Check console for errors**: `node .browser-pilot/bp console` after actions fail
10. **Let daemon auto-manage**: Starts on first command, stops at session end
## References
Detailed documentation in `references/` folder (load as needed):
- **`references/commands-reference.md`**: Complete command list with all options and examples
- **`references/interaction-map.md`**: Smart Mode system, map structure, and query API
- **`references/selector-guide.md`**: Selector strategies, best practices, and troubleshooting
Load references when user needs detailed information about specific features, advanced usage patterns, or troubleshooting guidance.

View File

@@ -0,0 +1,574 @@
# Browser Pilot Commands Reference
Complete reference for all Browser Pilot CLI commands using the project-local wrapper script.
## Command Entry Points
Browser Pilot uses the CLI wrapper script `.browser-pilot/bp`:
**Single command execution:**
```bash
node .browser-pilot/bp <command> [options]
```
**Chain mode (multiple commands):**
```bash
node .browser-pilot/bp chain <command1> [args1] <command2> [args2] ...
```
**Daemon management:**
```bash
node .browser-pilot/bp daemon-<action>
```
## Single Command Execution
### Navigation Commands
**navigate** - Navigate to URL (auto-generates interaction map on page load)
```bash
node .browser-pilot/bp navigate -u "<url>"
```
**back** - Navigate back in history
```bash
node .browser-pilot/bp back
```
**forward** - Navigate forward in history
```bash
node .browser-pilot/bp forward
```
**reload** - Reload current page (regenerates interaction map)
```bash
node .browser-pilot/bp reload
```
### Interaction Commands
**click** - Click an element
Smart Mode (Recommended):
```bash
node .browser-pilot/bp click --text "<text>" [options]
Options:
--text <text> Text content to search for
--index <number> Select nth match (1-based)
--type <type> Element type filter (supports aliases: "input""input-*")
--tag <tag> HTML tag filter (e.g., "button", "input")
--viewport-only Only search visible elements
--verify Verify action success
Examples:
node .browser-pilot/bp click --text Submit
node .browser-pilot/bp click --text "Sign In" --type button
node .browser-pilot/bp click --text Delete --index 2
node .browser-pilot/bp click --text Search --type input # Auto-expands to all input types
node .browser-pilot/bp click --text Submit --tag button # Tag-based search
```
Direct Mode (fallback for unique IDs):
```bash
node .browser-pilot/bp click -s "<selector>"
node .browser-pilot/bp click -u "<url>" -s "<selector>"
```
**fill** - Fill input field
Smart Mode (Recommended):
```bash
node .browser-pilot/bp fill --text "<label>" -v "<value>" [options]
Options:
--text <label> Label or placeholder text to search
--type <type> Input type filter (supports aliases: "input""input-*")
--tag <tag> HTML tag filter (e.g., "input", "textarea")
--viewport-only Only search visible elements
--verify Verify action success
Examples:
node .browser-pilot/bp fill --text Email -v user@example.com
node .browser-pilot/bp fill --text Password -v secret --type input-password
node .browser-pilot/bp fill --text Email --tag input -v user@example.com # Tag-based search
```
Direct Mode (fallback for unique IDs):
```bash
node .browser-pilot/bp fill -s "<selector>" -v "<value>"
```
**hover** - Hover over element
```bash
node .browser-pilot/bp hover -s "<selector>"
```
**press** - Press keyboard key
```bash
node .browser-pilot/bp press -k "<key>"
Keys: Enter, Tab, Escape, ArrowUp, ArrowDown, etc.
```
**type** - Type text character by character
```bash
node .browser-pilot/bp type -t "<text>" -d <delay-ms>
```
**upload** - Upload file to input element
```bash
node .browser-pilot/bp upload -s "<selector>" -f "<file-path>"
```
### Data Extraction Commands
**extract** - Extract text content from element
```bash
node .browser-pilot/bp extract -s "<selector>"
```
**content** - Get full page HTML
```bash
node .browser-pilot/bp content
```
**console** - Get console messages with powerful filtering and formatting
```bash
node .browser-pilot/bp console [options]
Options:
-u, --url <url> Navigate to URL before getting console messages
Level Filtering:
-e, --errors-only Show only error messages
-l, --level <level> Filter by level: error, warning, log, info, verbose
--warnings Show only warning messages
--logs Show only log messages
Message Limiting:
--limit <number> Maximum number of messages to display
--skip <number> Skip first N messages
Text Filtering:
-f, --filter <pattern> Show only messages matching regex pattern
-x, --exclude <pattern> Exclude messages matching regex pattern
Output Format:
-j, --json Output in JSON format
-t, --timestamp Show timestamps
--no-color Disable colored output
File Output:
-o, --output <file> Save output to file
Source Filtering:
--url-filter <pattern> Filter by source URL (regex)
Examples:
# Get all console messages
node .browser-pilot/bp console
# Get only errors with timestamps
node .browser-pilot/bp console -e -t
# Filter messages containing "API"
node .browser-pilot/bp console -f "API"
# Get warnings and exclude messages containing "deprecated"
node .browser-pilot/bp console --warnings -x "deprecated"
# Get first 10 log messages in JSON format
node .browser-pilot/bp console --logs --limit 10 -j
# Save all console messages to file
node .browser-pilot/bp console -o console-output.txt
# Get errors from specific source file
node .browser-pilot/bp console -e --url-filter "app.js"
# Navigate and get console errors
node .browser-pilot/bp console -u "http://localhost:3000" -e
```
**cookies** - Get page cookies
```bash
node .browser-pilot/bp cookies
```
### Capture Commands
**screenshot** - Capture screenshot (saved to `.browser-pilot/screenshots/`)
```bash
node .browser-pilot/bp screenshot -o "<filename>.png" [options]
Options:
-u, --url <url> URL to capture (optional)
-o, --output <path> Output filename (saved to .browser-pilot/screenshots/)
--full-page Capture full page (default: true)
--clip-x <x> Clip region X coordinate (pixels)
--clip-y <y> Clip region Y coordinate (pixels)
--clip-width <width> Clip region width (pixels)
--clip-height <height> Clip region height (pixels)
--clip-scale <scale> Clip region scale factor (default: 1)
--headless Run in headless mode
Note: Clip region options take priority over --full-page. When clip options are specified,
only the specified region will be captured regardless of --full-page setting.
Examples:
# Full page screenshot
node .browser-pilot/bp screenshot -o result.png --full-page
# Saves to: .browser-pilot/screenshots/result.png
# Capture specific region (clip takes priority over full-page)
node .browser-pilot/bp screenshot -o region.png --clip-x 100 --clip-y 200 --clip-width 800 --clip-height 600
# Saves to: .browser-pilot/screenshots/region.png
```
**pdf** - Generate PDF (saved to `.browser-pilot/pdfs/`)
```bash
node .browser-pilot/bp pdf -o "<filename>.pdf" [options]
Options:
-u, --url <url> URL to capture (optional)
-o, --output <path> Output filename (saved to .browser-pilot/pdfs/)
--landscape Landscape orientation
--headless Run in headless mode
Example:
node .browser-pilot/bp pdf -o document.pdf --landscape
# Saves to: .browser-pilot/pdfs/document.pdf
```
**set-viewport** - Set browser viewport size (useful for responsive design testing)
```bash
node .browser-pilot/bp set-viewport -w <width> -h <height> [options]
Options:
-w, --width <width> Viewport width in pixels (required)
-h, --height <height> Viewport height in pixels (required)
--scale <scale> Device scale factor (default: 1)
--mobile Emulate mobile device (default: false)
Examples:
# Desktop viewport
node .browser-pilot/bp set-viewport -w 1920 -h 1080
# Mobile viewport (iPhone 12)
node .browser-pilot/bp set-viewport -w 390 -h 844 --scale 3 --mobile
# Tablet viewport (iPad)
node .browser-pilot/bp set-viewport -w 768 -h 1024 --scale 2
```
**get-viewport** - Get current viewport size
```bash
node .browser-pilot/bp get-viewport
Output:
=== Viewport Information ===
Size: 1920x1080
Scale: 1
```
**get-screen-info** - Get screen and viewport information
```bash
node .browser-pilot/bp get-screen-info
Output:
=== Screen Information ===
Screen: 2560x1440 # Physical screen resolution
Available: 2560x1392 # Available screen (excluding taskbar)
Viewport: 1920x1080 # Current browser viewport
Scale: 1 # Device pixel ratio
```
### Tab Management Commands
**tabs** - List all open tabs
```bash
node .browser-pilot/bp tabs
```
**new-tab** - Open new tab
```bash
node .browser-pilot/bp new-tab -u "<url>"
```
**close-tab** - Close tab by index
```bash
node .browser-pilot/bp close-tab -i <index>
```
**close** - Close browser
```bash
node .browser-pilot/bp close
```
### Utility Commands
**eval** - Execute JavaScript in browser context
```bash
node .browser-pilot/bp eval -e "<javascript-expression>"
Example:
node .browser-pilot/bp eval -e "document.title"
```
**wait** - Wait for element to appear
```bash
node .browser-pilot/bp wait -s "<selector>" -t <timeout-ms>
```
**scroll** - Scroll page or element
```bash
# Scroll to position
node .browser-pilot/bp scroll -x <x-pos> -y <y-pos>
# Scroll element into view
node .browser-pilot/bp scroll -s "<selector>"
```
**select** - Select dropdown option
```bash
node .browser-pilot/bp select -s "<selector>" -v "<option-value>"
```
**find** - Find elements matching selector
```bash
node .browser-pilot/bp find -s "<selector>"
```
**query** - Query interaction map for elements
```bash
# List all element types with counts
node .browser-pilot/bp query --list-types
# List all text contents (paginated, default 20)
node .browser-pilot/bp query --list-texts
# List text contents with type filter
node .browser-pilot/bp query --list-texts --type button
# Find elements by text
node .browser-pilot/bp query --text "<text-content>"
# Find all elements of a type (paginated)
node .browser-pilot/bp query --type <element-type>
# Type aliases (auto-expanded)
node .browser-pilot/bp query --type input # Matches: input, input-text, input-search, etc.
node .browser-pilot/bp query --type input-search # Exact match only
# Find by HTML tag
node .browser-pilot/bp query --tag button # All <button> elements
node .browser-pilot/bp query --text Submit --tag button
# Show detailed information
node .browser-pilot/bp query --type button --verbose
# Pagination options
node .browser-pilot/bp query --type button --limit 50 --offset 20
# Unlimited results
node .browser-pilot/bp query --type button --limit 0
# Other options:
# --index <n> Select nth match (1-based)
# --viewport-only Only visible elements
# --id <id> Direct element ID lookup
```
**map-status** - Check interaction map status
```bash
node .browser-pilot/bp map-status
```
**regen-map** - Force regenerate interaction map
```bash
node .browser-pilot/bp regen-map
```
## Chain Mode
Execute multiple commands sequentially in a single call with automatic map synchronization.
**Syntax:**
```bash
node .browser-pilot/bp chain <command1> [args1] <command2> [args2] ...
```
**Key Features:**
- Auto-waits for page load and map generation after navigation
- Supports Smart Mode (--text) for reliable element targeting
- Adds random human-like delay (300-800ms) between commands
- Stops execution if any command fails
- Each command executes after the previous one completes
**Chain-specific Options:**
```bash
--timeout <ms> Timeout for waiting map ready after navigation (default: 10000ms)
--delay <ms> Fixed delay between commands (overrides random 300-800ms delay)
```
**Quote Rules:**
- No quotes needed when values have no spaces
- Use quotes when values contain spaces
**Examples:**
```bash
# Basic chain (no quotes needed)
node .browser-pilot/bp chain navigate -u <url> click --text Submit extract -s .result
# With spaces (quotes required)
node .browser-pilot/bp chain navigate -u <url> click --text "Sign In" fill -s #email -v "user@example.com"
# Login workflow with Smart Mode
node .browser-pilot/bp chain navigate -u <url> fill --text Email -v <email> fill --text Password -v <password> click --text Login
# Multi-step workflow with Korean text
node .browser-pilot/bp chain navigate -u <url> click --text "메인 메뉴" click --text "설정 변경" click --text "저장"
# Screenshot workflow
node .browser-pilot/bp chain navigate -u <url> wait -s .content-loaded screenshot -o result.png
# Custom timing
node .browser-pilot/bp chain --timeout 15000 --delay 1000 navigate -u <url> click --text Submit
```
## Daemon Commands
Daemon starts automatically on first command and stops automatically at session end.
**daemon-start** - Start daemon (auto-starts on first command)
```bash
node .browser-pilot/bp daemon-start
```
**daemon-stop** - Stop daemon and close browser
```bash
node .browser-pilot/bp daemon-stop
```
**daemon-restart** - Restart daemon
```bash
node .browser-pilot/bp daemon-restart
```
**daemon-status** - Check daemon status
```bash
node .browser-pilot/bp daemon-status
```
## System Maintenance Commands
**reinstall** - Reinstall Browser Pilot scripts
Removes the `.browser-pilot` directory to force complete reinstallation on next command. Useful when:
- Installation or build is corrupted
- Scripts are not updating properly
- Troubleshooting persistent issues
```bash
# Show confirmation prompt
node .browser-pilot/bp reinstall
# Skip confirmation and reinstall immediately
node .browser-pilot/bp reinstall --yes
# Quiet mode (no output)
node .browser-pilot/bp reinstall --yes --quiet
```
**What it does:**
1. Stops the daemon if running
2. Removes `.browser-pilot` directory completely
3. Next command will trigger automatic reinstallation via SessionStart hook
**Options:**
- `-y, --yes`: Skip confirmation prompt
- `-q, --quiet`: Suppress output messages
**Example workflow:**
```bash
# Reinstall scripts
node .browser-pilot/bp reinstall --yes
# Next command triggers automatic reinstallation
node .browser-pilot/bp navigate -u "https://example.com"
```
## Common Options
Most commands support:
- `-u, --url <url>`: Navigate to URL before action
- `--headless`: Run in headless mode (no visible browser)
- `--timeout <ms>`: Custom timeout for operations
## Smart Mode vs Direct Mode
**🌟 Recommendation: Use Smart Mode by default for better reliability**
| Feature | Smart Mode (Recommended) | Direct Mode |
|---------|--------------------------|-------------|
| Selector | Text content | CSS or XPath |
| Reliability | ⭐⭐⭐⭐⭐ High (stable) | ⭐⭐ Low (brittle) |
| Duplicates | Auto indexing | Manual indexing |
| Map Required | Yes (auto-generated) | No |
| Speed | Medium | Fast |
| Best For | Most cases, text-based UI | Unique IDs only |
| Maintenance | Low (text rarely changes) | High (selectors break often) |
**When to use Direct Mode:**
- Element has a unique, stable ID (e.g., `#user-profile-button`)
- Performance-critical operations requiring maximum speed
- Element has no visible text content
## Exit Codes
- `0`: Success
- `1`: General error
- Non-zero: Command failed
## Examples
**Take screenshot:**
```bash
node .browser-pilot/bp screenshot -u "https://example.com" -o "example.png" --full-page
```
**Login flow (Direct Mode):**
```bash
node .browser-pilot/bp navigate -u "https://example.com/login"
node .browser-pilot/bp fill -s "#email" -v "user@example.com"
node .browser-pilot/bp fill -s "#password" -v "secret"
node .browser-pilot/bp click -s "#login-btn"
```
**Login flow (Chain Mode):**
```bash
node .browser-pilot/bp chain navigate -u "https://example.com/login" fill -s "#email" -v "user@example.com" fill -s "#password" -v "secret" click -s "#login-btn"
```
**Smart mode workflow (map auto-generated on navigate):**
```bash
node .browser-pilot/bp navigate -u "https://example.com"
node .browser-pilot/bp click --text "Login" --type button
node .browser-pilot/bp fill --text "Email" -v "user@example.com"
node .browser-pilot/bp fill --text "Password" -v "secret"
node .browser-pilot/bp click --text "Submit" --verify
```
**Smart mode workflow (Chain Mode):**
```bash
node .browser-pilot/bp chain navigate -u "https://example.com" click --text "Login" --type button fill --text "Email" -v "user@example.com" fill --text "Password" -v "secret" click --text "Submit" --verify
```
**Extract data:**
```bash
node .browser-pilot/bp navigate -u "https://example.com/products"
node .browser-pilot/bp extract -s ".product-title"
node .browser-pilot/bp extract -s ".product-price"
```

View File

@@ -0,0 +1,434 @@
# Interaction Map System
## Overview
The Interaction Map system provides reliable element targeting for browser automation by generating a structured JSON representation of all interactive elements on a webpage. This eliminates brittle CSS selectors and enables text-based element search with automatic selector generation.
## Architecture
### Components
1. **Map Generator** (`src/cdp/map/generate-interaction-map.ts`)
- Browser-side script that extracts all interactive elements
- Generates multiple selector types for each element
- Handles SVG elements, disabled states, React components
2. **Map Manager** (`src/daemon/map-manager.ts`)
- Daemon-level automatic map generation on page load
- 10-minute cache with auto-regeneration
- URL-based cache validation
- Event-driven DOM stabilization detection
3. **Map Query Module** (`src/cdp/map/query-map.ts`)
- Loads and queries interaction maps
- Searches by text, type, ID, visibility
- Returns best selector with alternatives
4. **CLI Integration** (`src/cli/commands/interaction.ts`)
- Smart Mode options: `--text`, `--index`, `--type`, `--viewport-only`
- Automatic map querying before action execution
- Fallback to alternative selectors on failure
### Automatic Map Generation
Maps are automatically generated when:
- Navigating to a new page (`node .browser-pilot/bp navigate -u "<url>"`)
- Page reload (`node .browser-pilot/bp reload`)
- Cache expires (10 minutes)
- Manual force generation (daemon command)
No manual map generation needed - the daemon handles it automatically.
Output location: `.browser-pilot/interaction-map.json`
## JSON Structure
Maps use a hybrid structure optimized for both direct access and search:
```json
{
"url": "https://example.com",
"timestamp": "2025-11-05T14:39:03.598+09:00",
"viewport": {
"width": 2560,
"height": 1305
},
"elements": {
"elem_0": {
"id": "elem_0",
"type": "button",
"tag": "button",
"text": "Submit",
"value": null,
"selectors": {
"byText": "//button[contains(text(), 'Submit')]",
"byId": "#submit-btn",
"byCSS": "button.btn.btn-primary",
"byRole": "[role='button']",
"byAriaLabel": "[aria-label='Submit form']"
},
"attributes": {
"id": "submit-btn",
"class": "btn btn-primary",
"disabled": false
},
"position": {
"x": 1275,
"y": 650
},
"visibility": {
"inViewport": true,
"visible": true,
"obscured": false
},
"context": {
"section": "Form"
}
}
},
"indexes": {
"byText": {
"Submit": ["elem_0", "elem_15"],
"Delete": ["elem_5", "elem_6", "elem_7"]
},
"byType": {
"button": ["elem_0", "elem_1", "elem_2"],
"input-text": ["elem_10", "elem_11"]
},
"inViewport": ["elem_0", "elem_1", "elem_2", "elem_10"]
},
"statistics": {
"total": 45,
"byType": {
"button": 12,
"input-text": 5,
"a": 8
},
"duplicates": 3
}
}
```
### Key Features
**1. Key-Value Structure** (`elements`)
- Direct ID access: `map.elements["elem_0"]`
- Avoids array iteration for known IDs
**2. Indexes** (fast lookup)
- `byText`: Maps text content → element IDs
- `byType`: Maps element types → element IDs
- `inViewport`: Array of visible element IDs
**3. Multiple Selectors**
- `byText`: XPath with tag name (e.g., `//button[contains(text(), 'Submit')]`)
- `byId`: CSS ID selector (highest priority)
- `byCSS`: CSS class selector
- `byRole`: ARIA role selector
- `byAriaLabel`: ARIA label selector
**4. Automatic Indexing**
- Duplicate text elements get indexed: `(//button[contains(text(), 'Delete')])[2]`
- Enables "click the 3rd Delete button" functionality
**5. Auto-Caching**
- 10-minute cache TTL
- Automatically regenerates on expiration or navigation
- URL-based validation to prevent stale maps
## Element Detection
### Interactive Element Types
The map generator detects:
- Standard inputs: `<input>`, `<button>`, `<select>`, `<textarea>`
- Links: `<a href="...">`
- ARIA roles: `button`, `link`, `textbox`, `checkbox`, `radio`, etc.
- Click handlers: Elements with `onclick`, React event handlers
- Cursor style: `cursor: pointer`
- Tab-navigable: `tabindex >= 0`
### Special Cases
**SVG Elements:**
```typescript
// Handles SVGAnimatedString className
const className = typeof el.className === 'string'
? el.className
: (el.className.baseVal || '');
```
**Disabled Buttons:**
```typescript
// Standard interactive elements included even if disabled
const isStandardInteractive = ['INPUT', 'BUTTON', 'SELECT', 'TEXTAREA', 'A'].includes(tag);
if (!isStandardInteractive && style.pointerEvents === 'none') {
return false; // Skip
}
```
**React Components:**
```typescript
// Detect React event handlers
const reactProps = Object.keys(el).filter(key => key.startsWith('__react'));
const hasReactHandlers = reactProps.some(prop => {
const value = el[prop];
return value && typeof value === 'object' && value.onClick;
});
```
## Selector Generation
### Priority Order
Query system selects best selector with this priority:
1. **byId** (highest priority)
- Most stable, unique identifier
- Example: `#login-button`
2. **byText** (indexed for duplicates)
- Tag-specific XPath: `//button[contains(text(), 'Submit')]`
- With indexing: `(//button[contains(text(), 'Delete')])[2]`
3. **byCSS**
- Safe classes only (alphanumeric, hyphens, underscores)
- Example: `button.btn.btn-primary`
- Skips generic tag-only selectors
4. **byRole**
- ARIA role attribute
- Example: `[role="button"]`
5. **byAriaLabel** (lowest priority)
- ARIA label attribute
- Example: `[aria-label="Submit form"]`
### Text-Based XPath
XPath selectors include tag names for precision:
**Before:** `//*[contains(text(), 'Submit')]`
- Problem: Matches any element with that text (div, span, button, etc.)
**After:** `//button[contains(text(), 'Submit')]`
- Solution: Only matches `<button>` elements
- More precise, faster execution
## Query API
### Query Options
```typescript
interface QueryOptions {
text?: string; // Search by text content
type?: string; // Filter by element type (supports aliases: "input" → "input-*")
tag?: string; // Filter by HTML tag (e.g., "input", "button")
index?: number; // Select nth match (1-based)
viewportOnly?: boolean; // Only visible elements
id?: string; // Direct ID lookup
}
```
**Type Aliases:**
- Generic types auto-expand to match all subtypes
- `type: "input"` → matches `input`, `input-text`, `input-search`, `input-password`, etc.
- `type: "button"` → matches `button`, `button-submit`, `button-reset`, etc.
- Specific types match exactly: `type: "input-search"` → only `input-search`
**Tag vs Type:**
- `tag`: Filters by HTML tag name (e.g., `<input>`, `<button>`)
- `type`: Filters by interaction map type classification (more specific, includes subtypes)
- Use `tag` for broader matching, `type` for precise targeting
**3-Stage Fallback (Automatic):**
When element not found, system automatically:
1. Tries type-based search (with alias expansion)
2. Falls back to tag-based search (if type specified)
3. Regenerates map and retries (up to 3 attempts)
### Usage Examples
**Direct ID lookup:**
```typescript
const results = queryMap(map, { id: 'elem_0' });
// Returns: Single element with that ID
```
**Text search:**
```typescript
const results = queryMap(map, { text: 'Delete' });
// Returns: All elements containing "Delete"
```
**Text + index:**
```typescript
const results = queryMap(map, { text: 'Delete', index: 2 });
// Returns: Second element containing "Delete"
```
**Type filter:**
```typescript
const results = queryMap(map, { type: 'button' });
// Returns: All button elements
```
**Text + type:**
```typescript
const results = queryMap(map, { text: 'Submit', type: 'button' });
// Returns: Button elements containing "Submit"
```
**Visibility filter:**
```typescript
const results = queryMap(map, { text: 'Add to Cart', viewportOnly: true });
// Returns: Only "Add to Cart" elements currently visible
```
### Fuzzy Search
When exact text match fails, falls back to fuzzy search:
```typescript
// Query: { text: 'menu' }
// Matches: "메뉴로 돌아가기", "Main Menu", "menu button"
// Case-insensitive, substring matching
```
## CLI Smart Mode
### Click Command
```bash
# Search by text
node .browser-pilot/bp click --text "Submit"
# With index for duplicates
node .browser-pilot/bp click --text "Delete" --index 2
# Filter by type
node .browser-pilot/bp click --text "Add to Cart" --type button
# Visible elements only
node .browser-pilot/bp click --text "Next" --viewport-only
```
### Fill Command
```bash
# Search input by label
node .browser-pilot/bp fill --text "Username" -v "testuser"
# Filter by input type
node .browser-pilot/bp fill --text "Password" -v "secret" --type input-password
# Visible inputs only
node .browser-pilot/bp fill --text "Email" -v "test@example.com" --viewport-only
```
## Cache Management
### Automatic Cache
Maps are cached for 10 minutes with automatic management:
- Auto-generated on first page load
- Auto-regenerated after 10 minutes
- Auto-regenerated on navigation
- URL validation prevents stale maps
Cache location: `.browser-pilot/map-cache.json`
### Manual Control (Daemon Commands)
Force regenerate map:
```bash
npm run bp:daemon-send -- --command MAP_GENERATE --params '{"force":true}'
```
Query current map:
```bash
npm run bp:daemon-send -- --command MAP_QUERY --params '{"text":"Submit","type":"button"}'
```
## Best Practices
1. **Let daemon auto-manage**
- Maps generate automatically on page load
- No manual generation needed
2. **Use text + index for duplicates**
- Better than CSS classes that may change
- More readable: "click 2nd Delete" vs complex selector
3. **Filter by type**
- Narrows results when text is ambiguous
- `--type button` excludes links, divs with same text
4. **Verify visibility**
- `--viewport-only` ensures element is on screen
- Avoids clicking hidden/off-screen elements
5. **Check map statistics**
- Review duplicates count in map JSON
- Helps determine if indexing is needed
6. **Fallback handling**
- Smart Mode automatically tries alternative selectors
- Check console for errors if action fails
## Troubleshooting
### Element not found in map
**Cause:** Element may not be detected as interactive
**Solutions:**
1. Check if element has click handler: Look for `onclick`, React handlers
2. Verify cursor style: Should be `pointer` for clickable elements
3. Check ARIA role: Element should have appropriate role
4. Force regenerate map if recently added to page
### Wrong element selected
**Cause:** Multiple elements with same text
**Solutions:**
1. Use `--index` to select specific match
2. Add `--type` filter to narrow results
3. Use `--viewport-only` to exclude off-screen elements
4. Check element position in map JSON
### Map out of date
**Cause:** Page changed after map generation
**Solutions:**
1. Maps auto-regenerate after 10 minutes
2. Force regenerate with daemon command
3. Check timestamp in map JSON
4. Verify URL matches current page
### Cache not updating
**Cause:** URL changed but cache still returns old map
**Solutions:**
1. Daemon validates URL before returning cache
2. Force regenerate with `force:true` parameter
3. Check cache file for URL mismatch
4. Restart daemon if persists
## Future Enhancements
Current status (v1.3.0):
- ✓ Automatic map generation on page load
- ✓ Daemon-level map caching and management
- ✓ Action verification with automatic retry
- ✓ URL-based cache validation
- ✓ Chain mode with automatic map synchronization
- ✓ Handler architecture refactoring for maintainability
Planned improvements:
- Visual map inspector tool
- Map diff for debugging selector changes
- Performance metrics and optimization
- Additional daemon commands (wait-idle, sleep)

View File

@@ -0,0 +1,447 @@
# Selector Strategies Guide
## Selector Types Overview
| Selector Type | Stability | Speed | Best For |
|--------------|-----------|-------|----------|
| CSS ID | ⭐⭐⭐⭐⭐ | Fast | Unique IDs |
| Smart Mode (text) | ⭐⭐⭐⭐ | Medium | Text-based UI |
| XPath (text) | ⭐⭐⭐⭐ | Medium | Text content |
| CSS Class | ⭐⭐ | Fast | Stable classes |
| XPath (structure) | ⭐⭐ | Medium | DOM structure |
## Decision Tree
```
Does element have unique ID?
├─ YES → Use CSS: #element-id
└─ NO ↓
Is element identified by text?
├─ YES → Use Smart Mode: --text "Submit"
└─ NO ↓
Does element have stable class?
├─ YES → Use CSS: .stable-class
└─ NO ↓
Can use ARIA attributes?
├─ YES → Use XPath: //*[@role='button']
└─ NO ↓
Use structural XPath
```
## CSS Selectors
### By ID (Most Stable)
```bash
node .browser-pilot/bp click -s "#login-button"
node .browser-pilot/bp click -s "#user-menu"
```
### By Class
```bash
node .browser-pilot/bp click -s ".submit-btn"
node .browser-pilot/bp click -s ".modal .close-button"
```
### By Attribute
```bash
node .browser-pilot/bp fill -s "input[name='email']" -v "test@example.com"
node .browser-pilot/bp click -s "button[data-action='submit']"
```
### Complex Selectors
```bash
# Direct child
node .browser-pilot/bp click -s "div.modal > button.primary"
# Descendant
node .browser-pilot/bp click -s "form .submit-button"
# Nth-child
node .browser-pilot/bp click -s "ul > li:nth-child(3)"
# Multiple classes
node .browser-pilot/bp click -s "button.btn.btn-primary.btn-lg"
```
## XPath Selectors
### Text-Based (Most Reliable)
**With Tag Name (Recommended):**
```bash
# Specific tag with text
node .browser-pilot/bp click -s "//button[contains(text(), 'Submit')]"
node .browser-pilot/bp click -s "//a[contains(text(), 'Learn More')]"
# Exact text match
node .browser-pilot/bp click -s "//button[text()='Sign In']"
```
**With Wildcard (Avoid):**
```bash
# Searches all elements (slow, imprecise)
node .browser-pilot/bp click -s "//*[contains(text(), 'Submit')]"
```
### Indexed XPath (Duplicates)
```bash
# First match
node .browser-pilot/bp click -s "(//button[contains(text(), 'Delete')])[1]"
# Third match
node .browser-pilot/bp click -s "(//button[contains(text(), 'Delete')])[3]"
# Last match (if 5 exist)
node .browser-pilot/bp click -s "(//button[contains(text(), 'Delete')])[5]"
```
### Attribute-Based
```bash
# By type
node .browser-pilot/bp fill -s "//*[@type='email']" -v "test@example.com"
# By role
node .browser-pilot/bp click -s "//*[@role='button']"
# By data attribute
node .browser-pilot/bp click -s "//*[@data-testid='submit']"
# Partial match
node .browser-pilot/bp click -s "//*[contains(@href, 'checkout')]"
```
### Structural XPath
```bash
# Parent-child
node .browser-pilot/bp click -s "//div[@class='modal']//button[@type='submit']"
# Following sibling
node .browser-pilot/bp click -s "//h1[contains(text(), 'Welcome')]/following-sibling::button"
# Preceding sibling
node .browser-pilot/bp click -s "//button[contains(text(), 'Next')]/preceding-sibling::button"
# Parent
node .browser-pilot/bp click -s "//button[@type='submit']/parent::form"
```
## Smart Mode Selectors
### Basic Text Search
```bash
node .browser-pilot/bp click --text "Submit"
node .browser-pilot/bp click --text "Add to Cart"
node .browser-pilot/bp fill --text "Username" -v "test"
```
### With Type Filter
```bash
# Only buttons
node .browser-pilot/bp click --text "Submit" --type button
# Only links
node .browser-pilot/bp click --text "Learn More" --type a
# Only text inputs
node .browser-pilot/bp fill --text "Email" -v "test@example.com" --type input-text
```
### Type Aliases (Auto-Expanded)
```bash
# Generic type (matches all subtypes)
node .browser-pilot/bp click --text "Search" --type input
# Expands to: input, input-text, input-search, input-password, etc.
# Specific type (exact match)
node .browser-pilot/bp fill --text "Email" --type input-search -v "query"
# Matches only: input-search
# Button aliases
node .browser-pilot/bp click --text "Submit" --type button
# Expands to: button, button-submit, button-reset, etc.
```
### Tag-Based Search
```bash
# Filter by HTML tag (broader matching)
node .browser-pilot/bp click --text "Submit" --tag button
# Matches all <button> elements regardless of type
node .browser-pilot/bp fill --text "Email" --tag input -v "user@example.com"
# Matches all <input> elements regardless of type
# Combined with text search
node .browser-pilot/bp click --text "Next" --tag button --viewport-only
```
**Tag vs Type:**
- `--tag`: Matches HTML tag name (`<button>`, `<input>`)
- `--type`: Matches interaction map classification (more specific)
- Use `--tag` when type filtering fails or for broader matches
### With Indexing
```bash
# Second "Delete" button
node .browser-pilot/bp click --text "Delete" --index 2 --type button
# First "Submit" button
node .browser-pilot/bp click --text "Submit" --index 1
```
### With Visibility Filter
```bash
# Only visible elements
node .browser-pilot/bp click --text "Next" --viewport-only
# Visible "Add to Cart" buttons
node .browser-pilot/bp click --text "Add to Cart" --viewport-only --type button
```
## Common Patterns
### Form Filling
**Direct mode (fast but brittle):**
```bash
node .browser-pilot/bp fill -s "#email" -v "user@example.com"
node .browser-pilot/bp fill -s "#password" -v "secret"
node .browser-pilot/bp click -s "#login-btn"
```
**Smart mode (slower but reliable, map auto-generated on page load):**
```bash
node .browser-pilot/bp fill --text "Email" -v "user@example.com"
node .browser-pilot/bp fill --text "Password" -v "secret"
node .browser-pilot/bp click --text "Login" --type button
```
**Chain mode (Direct):**
```bash
node .browser-pilot/bp chain navigate -u <url> fill -s #email -v <email> fill -s #password -v <password> click -s #login-btn
```
**Chain mode (Smart - recommended):**
```bash
node .browser-pilot/bp chain navigate -u <url> fill --text Email -v <email> fill --text Password -v <password> click --text Login --type button
```
**Note:** Chain mode auto-waits for map generation after navigation and adds human-like delays between commands.
### Clicking Nth Item
**CSS nth-child:**
```bash
node .browser-pilot/bp click -s "ul.products > li:nth-child(3) button"
```
**XPath indexing:**
```bash
node .browser-pilot/bp click -s "(//ul[@class='products']//button)[3]"
```
**Smart mode indexing:**
```bash
node .browser-pilot/bp click --text "Add to Cart" --index 3
```
### Modal Interactions
**CSS scoping:**
```bash
node .browser-pilot/bp click -s ".modal .btn-primary"
node .browser-pilot/bp click -s "#confirm-modal button.submit"
```
**XPath scoping:**
```bash
node .browser-pilot/bp click -s "//div[@class='modal']//button[contains(text(), 'Confirm')]"
```
**Smart mode:**
```bash
node .browser-pilot/bp click --text "Confirm" --type button --viewport-only
```
### Dynamic Content
**Wait then interact:**
```bash
node .browser-pilot/bp wait -s ".loading-complete" -t 5000
node .browser-pilot/bp click --text "Load More" --viewport-only
```
**Chain mode:**
```bash
node .browser-pilot/bp chain wait -s ".loading-complete" -t 5000 click --text "Load More" --viewport-only
```
## Best Practices
### 1. Prefer Stable Identifiers
**Good: Unique ID**
```bash
node .browser-pilot/bp click -s "#checkout-button"
```
**Good: Data attribute**
```bash
node .browser-pilot/bp click -s "button[data-testid='submit']"
```
**Avoid: Generated classes**
```bash
node .browser-pilot/bp click -s ".btn-a7s9d2f" # Likely to change
```
### 2. Use Smart Mode for Text-Based UI
**Good: Text content is stable**
```bash
node .browser-pilot/bp click --text "Continue to Checkout"
```
**Avoid: CSS classes for text buttons**
```bash
node .browser-pilot/bp click -s ".checkout-btn-primary-lg"
```
### 3. Scope Selectors When Possible
**Good: Scoped to container**
```bash
node .browser-pilot/bp click -s "#user-menu button.logout"
```
**Avoid: Global selector**
```bash
node .browser-pilot/bp click -s "button.logout" # May match wrong button
```
### 4. Handle Duplicates Explicitly
**Good: Specific index**
```bash
node .browser-pilot/bp click --text "Delete" --index 2
```
**Good: Scoped selector**
```bash
node .browser-pilot/bp click -s "#product-123 button.delete"
```
**Avoid: Ambiguous selector**
```bash
node .browser-pilot/bp click --text "Delete" # Which Delete button?
```
### 5. Verify Element Visibility
**Good: Checks visibility**
```bash
node .browser-pilot/bp click --text "Submit" --viewport-only
```
**Consider: May be off-screen**
```bash
node .browser-pilot/bp click --text "Submit"
```
## Troubleshooting
### "Element not found"
**Solution 1: Use Smart Mode**
```bash
# Map auto-generates on page load, just use text search
node .browser-pilot/bp click --text "Submit"
```
**Solution 2: Wait for element**
```bash
node .browser-pilot/bp wait -s "#submit-button" -t 5000
node .browser-pilot/bp click -s "#submit-button"
```
**Solution 3: Check selector in DevTools**
```javascript
// In browser console:
document.querySelector('#submit-button') // CSS
$x("//button[contains(text(), 'Submit')]") // XPath
```
### "Multiple elements match"
**Solution 1: Use indexing**
```bash
node .browser-pilot/bp click --text "Delete" --index 2
```
**Solution 2: Add more specificity**
```bash
# Before
node .browser-pilot/bp click -s "button"
# After
node .browser-pilot/bp click -s "#product-list button.delete"
```
**Solution 3: Filter by type**
```bash
node .browser-pilot/bp click --text "Submit" --type button
```
### "Wrong element clicked"
**Solution 1: Inspect map**
```bash
# Check interaction map JSON
cat .browser-pilot/interaction-map.json | jq '.indexes.byText'
# Find element IDs with your text
# Verify positions and types
```
**Solution 2: Use visibility filter**
```bash
node .browser-pilot/bp click --text "Add to Cart" --viewport-only
```
**Solution 3: Be more specific**
```bash
# Before
node .browser-pilot/bp click --text "Submit"
# After
node .browser-pilot/bp click --text "Submit Order" --type button
```
## Framework-Specific Tips
### React Applications
- Use `data-testid` attributes if available
- Text-based XPath works well (stable)
- Smart Mode recommended for dynamic classes
- Avoid CSS classes (often generated)
### Angular Applications
- Use `ng-` attributes if available
- Text-based selectors are stable
- Smart Mode recommended
- Avoid dynamic `_ngcontent` attributes
### Vue Applications
- Use `data-` attributes if available
- Text-based XPath works well
- Smart Mode recommended
- Avoid scoped CSS classes
### Plain HTML
- CSS selectors work well
- IDs and classes are usually stable
- Direct mode is often sufficient
- Use Smart Mode for dynamic content

View File

@@ -0,0 +1,43 @@
import typescriptEslint from '@typescript-eslint/eslint-plugin';
import typescriptParser from '@typescript-eslint/parser';
export default [
{
ignores: [
'node_modules/**',
'dist/**',
'*.backup/**'
]
},
{
files: ['src/**/*.ts'],
languageOptions: {
parser: typescriptParser,
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
project: './tsconfig.json'
}
},
plugins: {
'@typescript-eslint': typescriptEslint
},
rules: {
// TypeScript 규칙
'@typescript-eslint/no-explicit-any': 'error', // any 사용 금지 - 타입 가드 또는 명시적 타입 사용
'@typescript-eslint/no-unused-vars': ['error', {
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_'
}],
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-non-null-assertion': 'warn',
// 일반 규칙
'no-console': 'off', // CLI 도구이므로 console 사용 허용
'prefer-const': 'error',
'no-var': 'error'
}
}
];

View File

@@ -0,0 +1,47 @@
{
"name": "browser-pilot-cli",
"version": "1.10.0",
"description": "Chrome DevTools Protocol browser automation CLI",
"main": "dist/cli/cli.js",
"bin": {
"cdp-browser": "./dist/cli/cli.js"
},
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"clean": "rm -rf dist",
"lint": "eslint src/**/*.ts",
"lint:fix": "eslint src/**/*.ts --fix",
"type-check": "tsc --noEmit",
"bp:run": "node dist/cli/cli.js",
"bp:chain": "node dist/cli/cli.js chain",
"bp:daemon-start": "node dist/cli/cli.js daemon-start",
"bp:daemon-stop": "node dist/cli/cli.js daemon-stop",
"bp:daemon-restart": "node dist/cli/cli.js daemon-restart",
"bp:daemon-status": "node dist/cli/cli.js daemon-status",
"bp:query": "node dist/cli/cli.js query",
"bp:regen-map": "node dist/cli/cli.js regen-map",
"bp:map-status": "node dist/cli/cli.js map-status"
},
"keywords": [
"cdp",
"chrome",
"automation",
"browser",
"scraping"
],
"author": "",
"license": "Apache-2.0",
"devDependencies": {
"@types/node": "^24.9.2",
"@types/ws": "^8.18.1",
"@typescript-eslint/eslint-plugin": "^8.46.3",
"@typescript-eslint/parser": "^8.46.3",
"eslint": "^9.39.1",
"typescript": "^5.9.3"
},
"dependencies": {
"commander": "^14.0.2",
"ws": "^8.18.3"
}
}

View File

@@ -0,0 +1,28 @@
/**
* Core CDP actions for browser automation.
*
* This file serves as the main index for all action modules.
* Modularized for better organization and maintainability.
*/
// Export ActionResult type
export type { ActionResult } from './actions/helpers';
// Re-export helper functions (excluding ActionResult to avoid conflict)
export { sleep, checkConsoleErrors, ensureOutputPath } from './actions/helpers';
// Re-export modular actions
export * from './actions/navigation';
export * from './actions/interaction';
export * from './actions/capture';
export * from './actions/data';
export * from './actions/cookies';
export * from './actions/tabs';
export * from './actions/forms';
export * from './actions/input';
export * from './actions/scroll';
export * from './actions/wait';
export * from './actions/debugging';
export * from './actions/emulation';
export * from './actions/dialogs';
export * from './actions/network';

View File

@@ -0,0 +1,179 @@
/**
* Capture actions (screenshot, PDF) for Browser Pilot.
*/
import { ChromeBrowser } from '../browser';
import { writeFileSync } from 'fs';
import { join } from 'path';
import { ActionResult, ActionOptions, mergeOptions, ensureOutputPath } from './helpers';
import { logger } from '../../utils/logger';
import { FS } from '../../constants';
// CDP Types for Page domain
interface LayoutMetrics {
contentSize: {
width: number;
height: number;
};
}
interface ScreenshotParams {
clip?: {
x: number;
y: number;
width: number;
height: number;
scale: number;
};
}
interface ScreenshotResult {
data: string;
}
interface PDFParams {
printBackground: boolean;
landscape: boolean;
paperWidth: number;
paperHeight: number;
marginTop: number;
marginBottom: number;
marginLeft: number;
marginRight: number;
}
interface PDFResult {
data: string;
}
// PDF Constants
const PDF_PAPER_LETTER_WIDTH = 8.5; // inches
const PDF_PAPER_LETTER_HEIGHT = 11.0; // inches
const PDF_DEFAULT_MARGIN = 0.4; // inches
export interface ClipOptions {
x: number;
y: number;
width: number;
height: number;
scale?: number;
}
/**
* Take screenshot.
* @param browser - ChromeBrowser instance
* @param filename - Screenshot filename (automatically saved to .browser-pilot/screenshots/)
* @param fullPage - Capture full page or viewport only
* @param clip - Optional clip region (x, y, width, height, scale)
* @param options - Action options
*/
export async function screenshot(
browser: ChromeBrowser,
filename: string,
fullPage = true,
clip?: ClipOptions,
options?: ActionOptions
): Promise<ActionResult> {
const opts = mergeOptions(options);
// Construct path within screenshots folder
const screenshotPath = join(FS.SCREENSHOTS_DIR, filename);
if (opts.verbose) logger.info(`📸 Taking screenshot: ${screenshotPath}`);
// Enable Page domain
await browser.sendCommand('Page.enable');
let params: ScreenshotParams = {};
// Clip region has priority over fullPage
if (clip) {
params = {
clip: {
x: clip.x,
y: clip.y,
width: clip.width,
height: clip.height,
scale: clip.scale || 1
}
};
if (opts.verbose) {
logger.info(` Region: (${clip.x}, ${clip.y}) ${clip.width}x${clip.height} scale=${clip.scale || 1}`);
}
} else if (fullPage) {
// Get page dimensions
const metrics = await browser.sendCommand<LayoutMetrics>('Page.getLayoutMetrics');
const contentSize = metrics.contentSize;
params = {
clip: {
x: 0,
y: 0,
width: contentSize.width,
height: contentSize.height,
scale: 1
}
};
}
const result = await browser.sendCommand<ScreenshotResult>('Page.captureScreenshot', params);
// Decode and save
const imageData = Buffer.from(result.data, 'base64');
// Ensure output directory exists (creates .browser-pilot/screenshots/ if needed)
const absolutePath = ensureOutputPath(screenshotPath);
writeFileSync(absolutePath, imageData);
if (opts.verbose) logger.info(`✅ Screenshot saved: ${absolutePath}`);
return { success: true, path: absolutePath };
}
/**
* Generate PDF from current page.
* @param browser - ChromeBrowser instance
* @param filename - PDF filename (automatically saved to .browser-pilot/pdfs/)
* @param landscape - Use landscape orientation
* @param printBackground - Print background graphics
* @param options - Action options
*/
export async function generatePdf(
browser: ChromeBrowser,
filename: string,
landscape = false,
printBackground = true,
options?: ActionOptions
): Promise<ActionResult> {
const opts = mergeOptions(options);
// Construct path within pdfs folder
const pdfPath = join(FS.PDFS_DIR, filename);
if (opts.verbose) logger.info(`📄 Generating PDF: ${pdfPath}`);
await browser.sendCommand('Page.enable');
const params: PDFParams = {
printBackground,
landscape,
paperWidth: PDF_PAPER_LETTER_WIDTH,
paperHeight: PDF_PAPER_LETTER_HEIGHT,
marginTop: PDF_DEFAULT_MARGIN,
marginBottom: PDF_DEFAULT_MARGIN,
marginLeft: PDF_DEFAULT_MARGIN,
marginRight: PDF_DEFAULT_MARGIN
};
const result = await browser.sendCommand<PDFResult>('Page.printToPDF', params);
const pdfData = Buffer.from(result.data, 'base64');
// Ensure output directory exists (creates .browser-pilot/pdfs/ if needed)
const absolutePath = ensureOutputPath(pdfPath);
writeFileSync(absolutePath, pdfData);
if (opts.verbose) logger.info(`✅ PDF saved: ${absolutePath}`);
return { success: true, path: absolutePath };
}

View File

@@ -0,0 +1,122 @@
/**
* Cookie management actions for Browser Pilot.
*/
import { ChromeBrowser } from '../browser';
import { ActionResult, ActionOptions, mergeOptions } from './helpers';
import { logger } from '../../utils/logger';
// CDP Types for Network.Cookie
interface Cookie {
name: string;
value: string;
domain?: string;
path?: string;
expires?: number;
size?: number;
httpOnly?: boolean;
secure?: boolean;
session?: boolean;
sameSite?: string;
}
interface GetCookiesResult {
cookies: Cookie[];
}
interface SetCookieParams {
name: string;
value: string;
domain?: string;
path: string;
secure: boolean;
httpOnly: boolean;
expires?: number;
sameSite?: string;
}
/**
* Get all cookies.
*/
export async function getCookies(
browser: ChromeBrowser,
options?: ActionOptions
): Promise<ActionResult> {
const opts = mergeOptions(options);
if (opts.verbose) logger.info('🍪 Getting cookies...');
const result = await browser.sendCommand<GetCookiesResult>('Network.getCookies');
const cookies = result.cookies || [];
if (opts.verbose) logger.info(`✅ Retrieved ${cookies.length} cookie(s)`);
return { success: true, cookies, count: cookies.length };
}
/**
* Set a cookie.
*/
export async function setCookie(
browser: ChromeBrowser,
name: string,
value: string,
domain?: string,
path = '/',
secure = false,
httpOnly = false,
options?: ActionOptions
): Promise<ActionResult> {
const opts = mergeOptions(options);
if (opts.verbose) logger.info(`🍪 Setting cookie: ${name}`);
const cookieParams: SetCookieParams = {
name,
value,
path,
secure,
httpOnly,
...(domain && { domain })
};
await browser.sendCommand('Network.setCookie', cookieParams);
if (opts.verbose) logger.info(`✅ Cookie set successfully`);
return { success: true, name };
}
/**
* Delete cookies.
*/
export async function deleteCookies(
browser: ChromeBrowser,
name?: string,
options?: ActionOptions
): Promise<ActionResult> {
const opts = mergeOptions(options);
if (name) {
if (opts.verbose) logger.info(`🍪 Deleting cookie: ${name}`);
// Get all cookies to find the domain
const result = await browser.sendCommand<GetCookiesResult>('Network.getCookies');
const cookies = result.cookies || [];
// Find matching cookies
const matchingCookies = cookies.filter((c: Cookie) => c.name === name);
if (matchingCookies.length > 0) {
for (const cookie of matchingCookies) {
await browser.sendCommand('Network.deleteCookies', {
name,
domain: cookie.domain || ''
});
}
if (opts.verbose) logger.info(`✅ Deleted ${matchingCookies.length} cookie(s) with name '${name}'`);
} else {
if (opts.verbose) logger.warn(`⚠️ Warning: Cookie '${name}' not found`);
}
} else {
if (opts.verbose) logger.info('🍪 Deleting all cookies...');
await browser.sendCommand('Network.clearBrowserCookies');
if (opts.verbose) logger.info(`✅ All cookies deleted`);
}
return { success: true };
}

View File

@@ -0,0 +1,207 @@
/**
* Data extraction and evaluation actions for Browser Pilot.
*/
import { ChromeBrowser } from '../browser';
import { getFindElementScript } from '../utils';
import { ActionResult, ActionOptions, mergeOptions, checkErrors, RuntimeEvaluateResult } from './helpers';
import { logger } from '../../utils/logger';
/**
* Evaluate JavaScript.
*/
export async function evaluate(
browser: ChromeBrowser,
script: string,
options?: ActionOptions
): Promise<ActionResult> {
const opts = mergeOptions(options);
if (opts.verbose) logger.info(`⚙️ Evaluating JavaScript...`);
const result = await browser.sendCommand<RuntimeEvaluateResult>('Runtime.evaluate', {
expression: script,
returnByValue: true
});
if (opts.verbose) logger.info(`✅ Evaluation complete`);
checkErrors(browser, opts.logLevel);
return { success: true, result: result.result?.value };
}
/**
* Extract text from element or body.
* Supports both CSS selectors and XPath (when selector starts with '//').
*/
export async function extractText(
browser: ChromeBrowser,
selector?: string,
options?: ActionOptions
): Promise<ActionResult> {
const opts = mergeOptions(options);
if (opts.verbose) {
if (selector) {
logger.info(`📝 Extracting text from: ${selector}`);
} else {
logger.info(`📝 Extracting text from page body`);
}
}
const script = selector
? `(function() {
const selector = ${JSON.stringify(selector)};
${getFindElementScript()}
return findElement(selector)?.textContent || '';
})()`
: `document.body.textContent || ''`;
const result = await browser.sendCommand<RuntimeEvaluateResult>('Runtime.evaluate', {
expression: script,
returnByValue: true
});
const text = (result.result?.value as string) || '';
if (opts.verbose) logger.info(`✅ Extracted ${text.length} characters`);
checkErrors(browser, opts.logLevel);
return { success: true, text };
}
/**
* Extract data using multiple selectors.
*/
export async function extractData(
browser: ChromeBrowser,
selectors: Record<string, string>,
options?: ActionOptions
): Promise<ActionResult> {
const opts = mergeOptions(options);
if (opts.verbose) logger.info(`📊 Extracting data with ${Object.keys(selectors).length} selectors`);
const data: Record<string, unknown> = {};
for (const [key, selector] of Object.entries(selectors)) {
try {
const script = `
(function() {
const selector = ${JSON.stringify(selector)};
const elements = document.querySelectorAll(selector);
if (elements.length === 0) return null;
if (elements.length === 1) return elements[0].innerText;
return Array.from(elements).map(el => el.innerText);
})()
`;
const result = await browser.sendCommand<RuntimeEvaluateResult>('Runtime.evaluate', {
expression: script,
returnByValue: true
});
data[key] = result.result?.value;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
data[key] = `Error: ${errorMessage}`;
}
}
if (opts.verbose) logger.info(`✅ Extracted data for ${Object.keys(data).length} keys`);
checkErrors(browser, opts.logLevel);
return { success: true, data };
}
/**
* Get page HTML content.
*/
export async function getContent(
browser: ChromeBrowser,
options?: ActionOptions
): Promise<ActionResult> {
const opts = mergeOptions(options);
if (opts.verbose) logger.info('📄 Getting page HTML content');
const script = `document.documentElement.outerHTML`;
const result = await browser.sendCommand<RuntimeEvaluateResult>('Runtime.evaluate', {
expression: script,
returnByValue: true
});
const content = (result.result?.value as string) || '';
if (opts.verbose) logger.info(`✅ Retrieved ${content.length} characters of HTML`);
return {
success: true,
content,
length: content.length
};
}
/**
* Get element property value.
* Supports both CSS selectors and XPath (when selector starts with '//').
*/
export async function getElementProperty(
browser: ChromeBrowser,
selector: string,
propertyName: string,
options?: ActionOptions
): Promise<ActionResult> {
const opts = mergeOptions(options);
if (opts.verbose) logger.info(`🔍 Getting property '${propertyName}' from: ${selector}`);
const script = `
(function() {
const selector = ${JSON.stringify(selector)};
const propertyName = ${JSON.stringify(propertyName)};
${getFindElementScript()}
const el = findElement(selector);
if (!el) throw new Error('Element not found: ' + selector);
return el[propertyName];
})()
`;
try {
const result = await browser.sendCommand<RuntimeEvaluateResult>('Runtime.evaluate', {
expression: script,
returnByValue: true
});
if (result.exceptionDetails) {
const errorMsg = result.exceptionDetails.exception?.description ||
result.exceptionDetails.text ||
'Unknown error';
if (opts.verbose) {
logger.error(`❌ Get property failed: ${selector}`);
logger.error(` Error: ${errorMsg}`);
}
return {
success: false,
error: errorMsg
};
}
if (opts.verbose) logger.info(`✅ Property '${propertyName}': ${result.result?.value}`);
checkErrors(browser, opts.logLevel);
return {
success: true,
selector,
property: propertyName,
value: result.result?.value
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
if (opts.verbose) {
logger.error(`❌ Get property failed: ${selector}`);
logger.error(` Error: ${errorMessage}`);
}
return {
success: false,
error: errorMessage
};
}
}

View File

@@ -0,0 +1,165 @@
/**
* Debugging actions for Browser Pilot.
*/
import { ChromeBrowser, FormattedConsoleMessage } from '../browser';
import { getFindElementScript } from '../utils';
import { ActionResult, ActionOptions, mergeOptions, checkErrors, RuntimeEvaluateResult } from './helpers';
import { logger } from '../../utils/logger';
/**
* Get console messages.
*
* Returns console messages that have been collected since the browser connected.
* Messages are automatically collected when Log domain is enabled during connection.
*/
export async function getConsoleMessages(
browser: ChromeBrowser,
errorOnly = false,
options?: ActionOptions
): Promise<ActionResult> {
const opts = mergeOptions(options);
if (opts.verbose) logger.info('📋 Getting console messages...');
// Get all collected messages from browser
const allMessages = browser.getConsoleMessages();
// Filter by error level if requested
const messages = errorOnly
? allMessages.filter(msg => msg.level === 'error')
: allMessages;
// Format messages for display
const formattedMessages: FormattedConsoleMessage[] = messages.map(msg => ({
level: msg.level,
text: msg.text,
timestamp: new Date(msg.timestamp).toISOString(),
url: msg.url,
lineNumber: msg.lineNumber
}));
const errorCount = allMessages.filter(msg => msg.level === 'error').length;
const warningCount = allMessages.filter(msg => msg.level === 'warning').length;
if (opts.verbose) {
logger.info(`✅ Retrieved ${formattedMessages.length} message(s) (${errorCount} errors, ${warningCount} warnings)`);
}
return {
success: true,
messages: formattedMessages,
count: formattedMessages.length,
errorCount,
warningCount,
logCount: allMessages.filter(msg => msg.level === 'log' || msg.level === 'info').length
};
}
/**
* Get accessibility tree snapshot.
*/
export async function getAccessibilitySnapshot(
browser: ChromeBrowser,
options?: ActionOptions
): Promise<ActionResult> {
const opts = mergeOptions(options);
if (opts.verbose) logger.info('♿ Getting accessibility snapshot...');
try {
await browser.sendCommand('Accessibility.enable');
const result = await browser.sendCommand<{ nodes: unknown[] }>('Accessibility.getFullAXTree');
const nodes = result.nodes || [];
const formattedNodes = nodes.slice(0, 50).map((node: unknown) => {
const n = node as { role?: { value?: string }; name?: { value?: string }; description?: { value?: string } };
return {
role: n.role?.value,
name: n.name?.value,
description: n.description?.value
};
});
if (opts.verbose) logger.info(`✅ Retrieved ${nodes.length} accessibility nodes (showing first 50)`);
return {
success: true,
nodeCount: nodes.length,
nodes: formattedNodes
};
} catch (error) {
if (opts.verbose) {
logger.error(`❌ Get accessibility snapshot failed`, error);
}
throw error;
}
}
/**
* Find element and return its information.
* Supports both CSS selectors and XPath (when selector starts with '//').
*/
export async function findElement(
browser: ChromeBrowser,
selector: string,
options?: ActionOptions
): Promise<ActionResult> {
const opts = mergeOptions(options);
if (opts.verbose) logger.info(`🔍 Finding element: ${selector}`);
const script = `
(function() {
const selector = ${JSON.stringify(selector)};
${getFindElementScript()}
const el = findElement(selector);
if (!el) return null;
const rect = el.getBoundingClientRect();
return {
tagName: el.tagName.toLowerCase(),
id: el.id,
className: el.className,
textContent: el.textContent?.substring(0, 100),
visible: rect.width > 0 && rect.height > 0,
position: {
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height
},
attributes: Array.from(el.attributes).reduce((acc, attr) => {
acc[attr.name] = attr.value;
return acc;
}, {})
};
})()
`;
const result = await browser.sendCommand<RuntimeEvaluateResult>('Runtime.evaluate', {
expression: script,
returnByValue: true
});
const elementInfo = result.result?.value as { tagName?: string; id?: string; className?: string; attributes?: Record<string, string> } | null | undefined;
if (!elementInfo) {
if (opts.verbose) logger.info(`❌ Element not found: ${selector}`);
return {
success: false,
error: `Element not found: ${selector}`
};
}
if (opts.verbose) logger.info(`✅ Found <${elementInfo.tagName}> element`);
checkErrors(browser, opts.logLevel);
return {
success: true,
selector,
element: elementInfo
};
}

View File

@@ -0,0 +1,141 @@
/**
* Dialog handling actions for Browser Pilot.
*/
import { ChromeBrowser } from '../browser';
import { ActionResult, ActionOptions, mergeOptions, RuntimeEvaluateResult } from './helpers';
import { logger } from '../../utils/logger';
/**
* Handle JavaScript dialogs (alert, confirm, prompt).
* Must be called BEFORE the dialog appears.
*/
export async function handleDialog(
browser: ChromeBrowser,
accept: boolean = true,
promptText?: string,
options?: ActionOptions
): Promise<ActionResult> {
const opts = mergeOptions(options);
if (opts.verbose) {
logger.info(`💬 Setting up dialog handler - accept: ${accept}, promptText: ${promptText || 'none'}`);
}
try {
// Enable Page domain for dialog events
await browser.sendCommand('Page.enable');
// Set up dialog handler
await browser.sendCommand('Page.setInterceptFileChooserDialog', {
enabled: false
});
// Note: CDP doesn't have a way to pre-register dialog handlers
// This returns a handler configuration that should be used with Page.javascriptDialogOpening event
if (opts.verbose) logger.info(`✅ Dialog handler configured`);
return {
success: true,
accept,
promptText: promptText || null,
note: 'Dialog handler configured. Use getDialogMessage() to check for dialogs.'
};
} catch (error: unknown) {
if (opts.verbose) {
logger.error(`❌ Dialog handler setup failed`);
if (error instanceof Error) {
logger.error(` Error: ${error.message}`);
} else {
logger.error(` Error: ${String(error)}`);
}
}
throw error;
}
}
/**
* Get current dialog message if one is open.
* This should be called in response to Page.javascriptDialogOpening event.
*/
export async function getDialogMessage(
browser: ChromeBrowser,
options?: ActionOptions
): Promise<ActionResult> {
const opts = mergeOptions(options);
if (opts.verbose) logger.info(`💬 Checking for dialog...`);
// This function is a placeholder for dialog detection
// In real CDP usage, you'd listen for Page.javascriptDialogOpening events
const script = `
(function() {
// Check if there's an active dialog by trying to access document
try {
document.body;
return null; // No dialog
} catch (e) {
return { blocked: true }; // Dialog is blocking
}
})()
`;
const result = await browser.sendCommand<RuntimeEvaluateResult>('Runtime.evaluate', {
expression: script,
returnByValue: true
});
const dialogActive = result.result?.value !== null;
if (opts.verbose) {
logger.info(dialogActive ? `⚠️ Dialog is active` : `✅ No dialog active`);
}
return {
success: true,
dialogActive
};
}
/**
* Accept or dismiss a JavaScript dialog.
*/
export async function respondToDialog(
browser: ChromeBrowser,
accept: boolean = true,
promptText?: string,
options?: ActionOptions
): Promise<ActionResult> {
const opts = mergeOptions(options);
if (opts.verbose) logger.info(`💬 Responding to dialog - accept: ${accept}`);
try {
await browser.sendCommand('Page.handleJavaScriptDialog', {
accept,
promptText: promptText || ''
});
if (opts.verbose) logger.info(`✅ Dialog ${accept ? 'accepted' : 'dismissed'}`);
return {
success: true,
accept,
promptText: promptText || null
};
} catch (error: unknown) {
if (opts.verbose) {
logger.error(`❌ Respond to dialog failed`);
if (error instanceof Error) {
logger.error(` Error: ${error.message}`);
} else {
logger.error(` Error: ${String(error)}`);
}
}
throw error;
}
}

View File

@@ -0,0 +1,184 @@
/**
* Emulation actions for Browser Pilot.
*/
import { ChromeBrowser } from '../browser';
import { ActionResult, ActionOptions, mergeOptions, logActionError } from './helpers';
import { logger } from '../../utils/logger';
export interface ViewportOptions {
width: number;
height: number;
deviceScaleFactor?: number;
mobile?: boolean;
}
/**
* Emulate media type or color scheme.
*/
export async function emulateMedia(
browser: ChromeBrowser,
mediaType?: 'screen' | 'print',
colorScheme?: 'light' | 'dark' | 'no-preference',
options?: ActionOptions
): Promise<ActionResult> {
const opts = mergeOptions(options);
if (opts.verbose) {
logger.info(`🎨 Emulating media - type: ${mediaType || 'none'}, colorScheme: ${colorScheme || 'none'}`);
}
try {
await browser.sendCommand('Emulation.setEmulatedMedia', {
media: mediaType || '',
features: colorScheme ? [{
name: 'prefers-color-scheme',
value: colorScheme
}] : []
});
if (opts.verbose) logger.info(`✅ Media emulation set`);
return {
success: true,
mediaType: mediaType || null,
colorScheme: colorScheme || null
};
} catch (error: unknown) {
if (opts.verbose) {
logger.error(`❌ Emulate media failed`);
if (error instanceof Error) {
logger.error(` Error: ${error.message}`);
} else {
logger.error(` Error: ${String(error)}`);
}
}
throw error;
}
}
/**
* Set viewport size.
* @param browser - ChromeBrowser instance
* @param width - Viewport width in pixels
* @param height - Viewport height in pixels
* @param deviceScaleFactor - Device scale factor (default: 1)
* @param mobile - Whether to emulate mobile device (default: false)
* @param options - Action options
*/
export async function setViewportSize(
browser: ChromeBrowser,
width: number,
height: number,
deviceScaleFactor = 1,
mobile = false,
options?: ActionOptions
): Promise<ActionResult> {
const opts = mergeOptions(options);
if (opts.verbose) {
logger.info(`📐 Setting viewport size: ${width}x${height} (scale: ${deviceScaleFactor}, mobile: ${mobile})`);
}
try {
await browser.sendCommand('Emulation.setDeviceMetricsOverride', {
width,
height,
deviceScaleFactor,
mobile
});
if (opts.verbose) logger.info(`✅ Viewport size set to ${width}x${height}`);
return {
success: true,
width,
height,
deviceScaleFactor,
mobile
};
} catch (error: unknown) {
logActionError('Set viewport size failed', error, opts.verbose);
throw error;
}
}
/**
* Get current viewport size.
* @param browser - ChromeBrowser instance
* @param options - Action options
*/
export async function getViewport(
browser: ChromeBrowser,
options?: ActionOptions
): Promise<ActionResult> {
const opts = mergeOptions(options);
if (opts.verbose) {
logger.info(`📏 Getting viewport size...`);
}
try {
const result = await browser.sendCommand<{ result: { value: unknown } }>('Runtime.evaluate', {
expression: 'JSON.stringify({width: window.innerWidth, height: window.innerHeight, devicePixelRatio: window.devicePixelRatio})',
returnByValue: true
});
const viewport = JSON.parse(result.result.value as string);
if (opts.verbose) {
logger.info(`✅ Viewport: ${viewport.width}x${viewport.height} (scale: ${viewport.devicePixelRatio})`);
}
return {
success: true,
viewport
};
} catch (error: unknown) {
logActionError('Get viewport failed', error, opts.verbose);
throw error;
}
}
/**
* Get screen and viewport information.
* @param browser - ChromeBrowser instance
* @param options - Action options
*/
export async function getScreenInfo(
browser: ChromeBrowser,
options?: ActionOptions
): Promise<ActionResult> {
const opts = mergeOptions(options);
if (opts.verbose) {
logger.info(`📊 Getting screen information...`);
}
try {
const result = await browser.sendCommand<{ result: { value: unknown } }>('Runtime.evaluate', {
expression: 'JSON.stringify({viewport: {width: window.innerWidth, height: window.innerHeight}, screen: {width: window.screen.width, height: window.screen.height, availWidth: window.screen.availWidth, availHeight: window.screen.availHeight}, devicePixelRatio: window.devicePixelRatio})',
returnByValue: true
});
const screenInfo = JSON.parse(result.result.value as string);
if (opts.verbose) {
logger.info(`✅ Screen: ${screenInfo.screen.width}x${screenInfo.screen.height}`);
logger.info(` Viewport: ${screenInfo.viewport.width}x${screenInfo.viewport.height}`);
logger.info(` Scale: ${screenInfo.devicePixelRatio}`);
}
return {
success: true,
...screenInfo
};
} catch (error: unknown) {
logActionError('Get screen info failed', error, opts.verbose);
throw error;
}
}

View File

@@ -0,0 +1,259 @@
/**
* Form actions for Browser Pilot.
*/
import { ChromeBrowser } from '../browser';
import { readFileSync, statSync } from 'fs';
import { getFindElementScript } from '../utils';
import { ActionResult, ActionOptions, mergeOptions, checkErrors, RuntimeEvaluateResult } from './helpers';
import { logger } from '../../utils/logger';
/**
* Select option from dropdown.
* Supports both CSS selectors and XPath (when selector starts with '//').
*/
export async function selectOption(
browser: ChromeBrowser,
selector: string,
value: string,
options?: ActionOptions
): Promise<ActionResult> {
const opts = mergeOptions(options);
if (opts.verbose) logger.info(`🔽 Selecting option ${value} in: ${selector}`);
const script = `
(function() {
const selector = ${JSON.stringify(selector)};
const value = ${JSON.stringify(value)};
${getFindElementScript()}
const el = findElement(selector);
if (!el) throw new Error('Element not found: ' + selector);
el.value = value;
el.dispatchEvent(new Event('change', { bubbles: true }));
return true;
})()
`;
try {
await browser.sendCommand('Runtime.evaluate', {
expression: script,
returnByValue: true
});
if (opts.verbose) logger.info(`✅ Selected option: ${value}`);
checkErrors(browser, opts.logLevel);
return { success: true, selector, value };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
if (opts.verbose) {
logger.error(`❌ Select failed: ${selector}`);
logger.error(` Error: ${errorMessage}`);
}
checkErrors(browser, opts.logLevel);
throw error;
}
}
/**
* Helper function to toggle checkbox state.
* @param browser - ChromeBrowser instance
* @param selector - Checkbox selector
* @param targetState - Desired checkbox state (true = checked, false = unchecked)
* @param actionName - Action name for logging ("check" or "uncheck")
* @param options - Action options
*/
async function _toggleCheckbox(
browser: ChromeBrowser,
selector: string,
targetState: boolean,
actionName: string,
options?: ActionOptions
): Promise<ActionResult> {
const opts = mergeOptions(options);
const emoji = targetState ? '☑️' : '☐';
const stateName = targetState ? 'checked' : 'unchecked';
if (opts.verbose) logger.info(`${emoji} ${actionName}: ${selector}`);
// Step 1: Find element and get coordinates
const script = `
(function() {
const selector = ${JSON.stringify(selector)};
${getFindElementScript()}
const el = findElement(selector);
if (!el) throw new Error('Element not found: ' + selector);
if (el.type !== 'checkbox') throw new Error('Element is not a checkbox: ' + selector);
// Scroll element into view
el.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' });
// Get bounding box and calculate center point
const box = el.getBoundingClientRect();
return {
x: box.left + box.width / 2,
y: box.top + box.height / 2,
checked: el.checked
};
})()
`;
try {
const result = await browser.sendCommand<RuntimeEvaluateResult>('Runtime.evaluate', {
expression: script,
returnByValue: true
});
if (!result.result || !result.result.value) {
logger.error('❌ Element not found or error occurred');
if (result.exceptionDetails) {
logger.error('Error:', result.exceptionDetails.exception?.description || result.exceptionDetails.text);
}
throw new Error(`Element not found: ${selector}`);
}
const { x, y, checked: isChecked } = result.result.value as { x: number; y: number; checked: boolean };
// Step 2: Click only if current state differs from target state
if (isChecked === targetState) {
if (opts.verbose) logger.info(`✓ Checkbox already ${stateName}`);
} else {
if (opts.verbose) logger.info(`🖱️ Clicking checkbox at (${Math.round(x)}, ${Math.round(y)})`);
await browser.sendCommand('Input.dispatchMouseEvent', {
type: 'mousePressed',
button: 'left',
clickCount: 1,
x,
y
});
await browser.sendCommand('Input.dispatchMouseEvent', {
type: 'mouseReleased',
button: 'left',
clickCount: 1,
x,
y
});
}
if (opts.verbose) logger.info(`✅ Checkbox ${stateName}`);
checkErrors(browser, opts.logLevel);
return { success: true, selector };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
if (opts.verbose) {
logger.error(`${actionName} failed: ${selector}`);
logger.error(` Error: ${errorMessage}`);
}
checkErrors(browser, opts.logLevel);
throw error;
}
}
/**
* Check checkbox.
* Supports both CSS selectors and XPath (when selector starts with '//').
* Uses CDP click for proper React compatibility.
*/
export async function check(
browser: ChromeBrowser,
selector: string,
options?: ActionOptions
): Promise<ActionResult> {
return _toggleCheckbox(browser, selector, true, 'Checking', options);
}
/**
* Uncheck checkbox.
* Supports both CSS selectors and XPath (when selector starts with '//').
* Uses CDP click for proper React compatibility.
*/
export async function uncheck(
browser: ChromeBrowser,
selector: string,
options?: ActionOptions
): Promise<ActionResult> {
return _toggleCheckbox(browser, selector, false, 'Unchecking', options);
}
/**
* Upload file to input element.
* Supports both CSS selectors and XPath (when selector starts with '//').
*/
export async function uploadFile(
browser: ChromeBrowser,
selector: string,
filePath: string,
options?: ActionOptions
): Promise<ActionResult> {
const opts = mergeOptions(options);
if (opts.verbose) logger.info(`📁 Uploading file ${filePath} to: ${selector}`);
// File size validation (10MB limit)
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
const stats = statSync(filePath);
if (stats.size > MAX_FILE_SIZE) {
const error = `File too large: ${stats.size} bytes (max: ${MAX_FILE_SIZE} bytes = 10MB)`;
if (opts.verbose) logger.error(`${error}`);
throw new Error(error);
}
const fileData = readFileSync(filePath, 'base64');
const fileName = filePath.split(/[/\\]/).pop() || 'file';
const script = `
(function() {
const selector = ${JSON.stringify(selector)};
const fileData = ${JSON.stringify(fileData)};
const fileName = ${JSON.stringify(fileName)};
${getFindElementScript()}
const el = findElement(selector);
if (!el) throw new Error('Element not found: ' + selector);
if (el.tagName !== 'INPUT' || el.type !== 'file') {
throw new Error('Element is not a file input');
}
const dataTransfer = new DataTransfer();
const file = new File(
[Uint8Array.from(atob(fileData), c => c.charCodeAt(0))],
fileName
);
dataTransfer.items.add(file);
el.files = dataTransfer.files;
el.dispatchEvent(new Event('change', { bubbles: true }));
return true;
})()
`;
try {
await browser.sendCommand('Runtime.evaluate', {
expression: script,
returnByValue: true
});
if (opts.verbose) logger.info(`✅ File uploaded: ${fileName}`);
checkErrors(browser, opts.logLevel);
return { success: true, selector, file: filePath };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
if (opts.verbose) {
logger.error(`❌ Upload failed: ${selector}`);
logger.error(` Error: ${errorMessage}`);
}
checkErrors(browser, opts.logLevel);
throw error;
}
}

View File

@@ -0,0 +1,225 @@
/**
* Helper functions for Browser Pilot actions.
*/
import { ChromeBrowser } from '../browser';
import { resolve, dirname } from 'path';
import { mkdirSync, existsSync } from 'fs';
import { getOutputDir } from '../config';
import { waitForNetworkIdle } from './wait';
import { logger } from '../../utils/logger';
import { TIMING, FS } from '../../constants';
// ActionResult interface - will be exported from main actions.ts
interface ActionResult {
success: boolean;
[key: string]: unknown;
}
// Export for internal use within actions modules
export type { ActionResult };
/**
* CDP Runtime.evaluate response type
*/
export interface RuntimeEvaluateResult {
result?: {
type?: string;
value?: unknown;
description?: string;
};
exceptionDetails?: {
exception?: {
description?: string;
};
text?: string;
timestamp?: number;
url?: string;
lineNumber?: number;
};
}
/**
* Log level for error and warning reporting
*/
export type LogLevel = 'all' | 'errors-only' | 'none';
/**
* Constants for error checking and timing
*/
const RECENT_MESSAGE_TIMEOUT_MS = TIMING.RECENT_MESSAGE_WINDOW;
const NAVIGATION_WAIT_DELAY_MS = TIMING.NETWORK_IDLE_TIMEOUT;
/**
* Constants for selector retry logic
*/
export const SELECTOR_RETRY_CONFIG = {
MAX_ATTEMPTS: 3,
MAP_FILENAME: FS.INTERACTION_MAP_FILE,
MAP_FOLDER: FS.OUTPUT_DIR
} as const;
/**
* Action options interface
*/
export interface ActionOptions {
verbose?: boolean; // Enable/disable logging (default: true)
logLevel?: LogLevel; // Log level for errors/warnings (default: 'all')
waitForNavigation?: boolean; // Wait for page navigation after action (default: false)
}
/**
* Default action options
*/
export const DEFAULT_OPTIONS: ActionOptions = {
verbose: true,
logLevel: 'all',
waitForNavigation: false
};
/**
* Helper: Merge user options with defaults
*/
export function mergeOptions(options?: ActionOptions): Required<ActionOptions> {
return {
verbose: options?.verbose ?? (DEFAULT_OPTIONS.verbose as boolean),
logLevel: options?.logLevel ?? (DEFAULT_OPTIONS.logLevel as LogLevel),
waitForNavigation: options?.waitForNavigation ?? (DEFAULT_OPTIONS.waitForNavigation as boolean)
};
}
/**
* Helper: Sleep for specified milliseconds.
*/
export function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* Helper: Check browser console and network for errors and warnings after an action.
* @param browser - ChromeBrowser instance
* @param logLevel - Log level ('all', 'errors-only', 'none')
*/
export function checkErrors(browser: ChromeBrowser, logLevel: LogLevel = 'all'): void {
if (logLevel === 'none') {
return; // Skip logging
}
const messages = browser.getConsoleMessages();
const networkErrors = browser.getNetworkErrors();
// Filter for errors and warnings from recent messages
const recentMessages = messages.filter(msg => {
const age = Date.now() - msg.timestamp;
return age < RECENT_MESSAGE_TIMEOUT_MS;
});
const recentNetworkErrors = networkErrors.filter(err => {
const age = Date.now() - err.timestamp;
return age < RECENT_MESSAGE_TIMEOUT_MS;
});
const consoleErrors = recentMessages.filter(msg => msg.level === 'error');
const consoleWarnings = recentMessages.filter(msg => msg.level === 'warning');
// Console Errors
if (consoleErrors.length > 0) {
logger.error(`\n❌ ${consoleErrors.length} console error(s) detected:`);
consoleErrors.forEach((err, idx) => {
logger.error(` ${idx + 1}. ${err.text}`);
if (err.url) {
logger.error(` at ${err.url}:${err.lineNumber || 0}`);
}
});
}
// Console Warnings (only if logLevel is 'all')
if (logLevel === 'all' && consoleWarnings.length > 0) {
logger.warn(`\n⚠ ${consoleWarnings.length} console warning(s) detected:`);
consoleWarnings.forEach((warn, idx) => {
logger.warn(` ${idx + 1}. ${warn.text}`);
});
}
// Network Errors
if (recentNetworkErrors.length > 0) {
logger.error(`\n🌐 ${recentNetworkErrors.length} network error(s) detected:`);
recentNetworkErrors.forEach((err, idx) => {
logger.error(` ${idx + 1}. ${err.url}`);
logger.error(` ${err.errorText}`);
if (err.statusCode) {
logger.error(` Status: ${err.statusCode}`);
}
});
}
}
/**
* @deprecated Use checkErrors instead
*/
export function checkConsoleErrors(browser: ChromeBrowser): void {
checkErrors(browser, 'all');
}
/**
* Helper: Wait for action completion (navigation + errors check).
* Reduces code duplication across click, fill, and other interactive actions.
*/
export async function waitForActionComplete(
browser: ChromeBrowser,
opts: Required<ActionOptions>
): Promise<void> {
if (opts.waitForNavigation) {
if (opts.verbose) logger.info(`⏳ Waiting for page navigation...`);
await waitForNetworkIdle(browser, TIMING.ACTION_DELAY_NAVIGATION, 0, { verbose: false });
await sleep(NAVIGATION_WAIT_DELAY_MS); // Additional delay for errors to surface
}
checkErrors(browser, opts.logLevel);
}
/**
* Helper: Log action error with consistent formatting
* @param context - Error context (e.g., 'Get viewport failed')
* @param error - Error object
* @param verbose - Whether to log the error
*/
export function logActionError(context: string, error: unknown, verbose: boolean): void {
if (!verbose) return;
logger.error(`${context}`);
if (error instanceof Error) {
logger.error(` Error: ${error.message}`);
} else {
logger.error(` Error: ${String(error)}`);
}
}
/**
* Helper: Ensure output path (convert relative to .browser-pilot/).
* Security: Prevents path traversal attacks and rejects absolute paths.
* Uses getOutputDir() from config to get project-specific output directory.
*/
export function ensureOutputPath(path: string): string {
// Reject absolute paths
if (resolve(path) === path) {
throw new Error('Absolute paths are not allowed. Use relative paths only.');
}
// Get output directory from project config (auto-creates .browser-pilot/)
const outputDir = getOutputDir();
const absolutePath = resolve(outputDir, path);
// Prevent path traversal attacks
if (!absolutePath.startsWith(outputDir)) {
throw new Error('Path traversal detected. Files must be within .browser-pilot directory.');
}
// Ensure subdirectory exists (if path includes subdirectories)
const dir = dirname(absolutePath);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
return absolutePath;
}

View File

@@ -0,0 +1,106 @@
/**
* Keyboard input actions for Browser Pilot.
*/
import { ChromeBrowser } from '../browser';
import { ActionResult, ActionOptions, mergeOptions, sleep, checkErrors } from './helpers';
import { logger } from '../../utils/logger';
/**
* Press keyboard key.
* Uses CDP Input.dispatchKeyEvent for proper React compatibility.
* Supports special keys like 'Enter', 'Escape', 'Tab', etc.
*/
export async function pressKey(
browser: ChromeBrowser,
key: string,
options?: ActionOptions
): Promise<ActionResult> {
const opts = mergeOptions(options);
if (opts.verbose) logger.info(`⌨️ Pressing key: ${key}`);
try {
// Send keyDown event
await browser.sendCommand('Input.dispatchKeyEvent', {
type: 'keyDown',
key: key
});
// Send keyUp event
await browser.sendCommand('Input.dispatchKeyEvent', {
type: 'keyUp',
key: key
});
if (opts.verbose) logger.info(`✅ Key pressed: ${key}`);
checkErrors(browser, opts.logLevel);
return { success: true, key };
} catch (error: unknown) {
if (opts.verbose) {
logger.error(`❌ Press key failed: ${key}`);
if (error instanceof Error) {
logger.error(` Error: ${error.message}`);
} else {
logger.error(` Error: ${String(error)}`);
}
}
checkErrors(browser, opts.logLevel);
throw error;
}
}
/**
* Type text character by character.
* Uses CDP Input.insertText for proper React compatibility.
* Supports delay between characters for typing simulation.
*/
export async function typeText(
browser: ChromeBrowser,
text: string,
delay = 0,
options?: ActionOptions
): Promise<ActionResult> {
const opts = mergeOptions(options);
if (opts.verbose) {
logger.info(`⌨️ Typing: "${text}"`);
if (delay > 0) logger.info(` Delay: ${delay}ms per character`);
}
try {
if (delay > 0) {
// Type character by character with delay using CDP
for (const char of text) {
await browser.sendCommand('Input.insertText', {
text: char
});
await sleep(delay);
}
if (opts.verbose) logger.info(`✅ Typed ${text.length} characters with ${delay}ms delay`);
} else {
// Type all at once using CDP
await browser.sendCommand('Input.insertText', {
text: text
});
if (opts.verbose) logger.info(`✅ Typed ${text.length} characters`);
}
checkErrors(browser, opts.logLevel);
return { success: true, text };
} catch (error: unknown) {
if (opts.verbose) {
logger.error(`❌ Type text failed`);
if (error instanceof Error) {
logger.error(` Error: ${error.message}`);
} else {
logger.error(` Error: ${String(error)}`);
}
}
checkErrors(browser, opts.logLevel);
throw error;
}
}

View File

@@ -0,0 +1,712 @@
/**
* Interaction actions for Browser Pilot.
*/
import { ChromeBrowser } from '../browser';
import { getFindElementScript } from '../utils';
import { ActionResult, ActionOptions, mergeOptions, waitForActionComplete, sleep, RuntimeEvaluateResult, SELECTOR_RETRY_CONFIG } from './helpers';
import { findSelectorWithFallback } from '../map/query-map';
import { existsSync } from 'fs';
import { join } from 'path';
import { logger } from '../../utils/logger';
import { TIMING } from '../../constants';
/**
* Click element core logic (without retry).
* Supports both CSS selectors and XPath (when selector starts with '//').
* XPath supports indexing: (//button[text()='Click'])[2] selects the 2nd button.
*/
async function clickCore(
browser: ChromeBrowser,
selector: string,
options?: ActionOptions
): Promise<ActionResult> {
const opts = mergeOptions(options);
if (opts.verbose) logger.info(`🔍 Finding element: ${selector}`);
// Step 1: Find element and scroll into view
const script = `
(function() {
const selector = ${JSON.stringify(selector)};
${getFindElementScript()}
const el = findElement(selector);
if (!el) throw new Error('Element not found: ' + selector);
// Scroll element into view
el.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' });
// Get bounding box and calculate center point
const box = el.getBoundingClientRect();
return {
x: box.left + box.width / 2,
y: box.top + box.height / 2,
tag: el.tagName,
text: el.textContent?.substring(0, 50) || '',
visible: box.width > 0 && box.height > 0
};
})()
`;
try {
const result = await browser.sendCommand<RuntimeEvaluateResult>('Runtime.evaluate', {
expression: script,
returnByValue: true
});
if (!result.result || !result.result.value) {
logger.error('❌ Element not found or error occurred');
if (result.exceptionDetails) {
logger.error('Error:', result.exceptionDetails.exception?.description || result.exceptionDetails.text);
}
throw new Error(`Element not found: ${selector}`);
}
const { x, y, tag, text, visible } = result.result.value as { x: number; y: number; tag: string; text: string; visible: boolean };
if (opts.verbose) {
logger.info(`✓ Element found: <${tag.toLowerCase()}> "${text}"`);
logger.info(` Position: (${Math.round(x)}, ${Math.round(y)}), Visible: ${visible}`);
}
// Step 2: Dispatch CDP mouse events (Puppeteer way)
if (opts.verbose) logger.info(`🖱️ Mouse down at (${Math.round(x)}, ${Math.round(y)})`);
await browser.sendCommand('Input.dispatchMouseEvent', {
type: 'mousePressed',
button: 'left',
clickCount: 1,
x,
y
});
if (opts.verbose) logger.info(`🖱️ Mouse up at (${Math.round(x)}, ${Math.round(y)})`);
await browser.sendCommand('Input.dispatchMouseEvent', {
type: 'mouseReleased',
button: 'left',
clickCount: 1,
x,
y
});
if (opts.verbose) logger.info(`✅ Clicked: ${selector}`);
// Wait for navigation and check errors
await waitForActionComplete(browser, opts);
return {
success: true,
selector,
coordinates: { x: Math.round(x), y: Math.round(y) },
element: { tag, text }
};
} catch (error: unknown) {
if (opts.verbose) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error(`❌ Click failed: ${selector}`);
logger.error(` Error: ${errorMessage}`);
}
await waitForActionComplete(browser, opts);
throw error;
}
}
/**
* Click element with automatic retry using interaction map fallback.
* Supports both CSS selectors and XPath (when selector starts with '//').
* XPath supports indexing: (//button[text()='Click'])[2] selects the 2nd button.
*
* On failure, attempts to find alternative selectors from interaction map and retries.
*/
export async function click(
browser: ChromeBrowser,
selector: string,
options?: ActionOptions
): Promise<ActionResult> {
const opts = mergeOptions(options);
try {
// First attempt with provided selector
return await clickCore(browser, selector, options);
} catch (error: unknown) {
// Check if map file exists
const mapPath = join(process.cwd(), SELECTOR_RETRY_CONFIG.MAP_FOLDER, SELECTOR_RETRY_CONFIG.MAP_FILENAME);
if (!existsSync(mapPath)) {
if (opts.verbose) {
logger.info('⚠️ No interaction map found for fallback. Rethrowing error.');
}
throw error;
}
if (opts.verbose) {
logger.info('🔄 Attempting to find alternative selectors from map...');
}
// Try to find alternative selectors
let fallbackResult: { selector: string; alternatives: string[] } | null = null;
try {
// If original selector looks like an ID, try querying by ID
if (selector.startsWith('#')) {
const id = selector.slice(1);
fallbackResult = findSelectorWithFallback(mapPath, { id });
}
// If selector contains text in XPath format, extract and search
else if (selector.includes('contains(text()')) {
const textMatch = selector.match(/contains\(text\(\),\s*['"](.+?)['"]\)/);
if (textMatch && textMatch[1]) {
fallbackResult = findSelectorWithFallback(mapPath, { text: textMatch[1] });
}
}
} catch (mapError: unknown) {
if (opts.verbose) {
const mapErrorMessage = mapError instanceof Error ? mapError.message : String(mapError);
logger.info(`⚠️ Map query failed: ${mapErrorMessage}`);
}
throw error; // Rethrow original error
}
if (!fallbackResult || fallbackResult.alternatives.length === 0) {
if (opts.verbose) {
logger.info('⚠️ No alternative selectors found in map.');
}
throw error; // Rethrow original error
}
// Try alternative selectors (limit to MAX_ATTEMPTS - 1, since we already tried once)
const maxRetries = Math.min(
fallbackResult.alternatives.length,
SELECTOR_RETRY_CONFIG.MAX_ATTEMPTS - 1
);
for (let i = 0; i < maxRetries; i++) {
const altSelector = fallbackResult.alternatives[i];
if (opts.verbose) {
logger.info(`🔄 Retry ${i + 1}/${maxRetries} with selector: ${altSelector}`);
}
try {
return await clickCore(browser, altSelector, options);
} catch (_retryError: unknown) {
if (i === maxRetries - 1) {
// Last retry failed, throw original error
if (opts.verbose) {
logger.info('❌ All retry attempts exhausted.');
}
throw error;
}
// Continue to next alternative
}
}
// Should not reach here, but throw original error as fallback
throw error;
}
}
/**
* Fill input field core logic (without retry).
* Supports both CSS selectors and XPath (when selector starts with '//').
* XPath supports indexing: (//input[@type='text'])[2] selects the 2nd input.
* Uses CDP click + insertText for proper React compatibility.
*/
async function fillCore(
browser: ChromeBrowser,
selector: string,
value: string,
options?: ActionOptions
): Promise<ActionResult> {
const opts = mergeOptions(options);
if (opts.verbose) {
logger.info(`✍️ Filling input: ${selector}`);
logger.info(` Value: "${value}"`);
}
// Step 1: Find element, get coordinates, and clear existing value
const script = `
(function() {
const selector = ${JSON.stringify(selector)};
${getFindElementScript()}
const el = findElement(selector);
if (!el) throw new Error('Element not found: ' + selector);
// Scroll element into view
el.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' });
// Get bounding box and calculate center point
const box = el.getBoundingClientRect();
// Clear existing value
el.value = '';
el.dispatchEvent(new Event('input', { bubbles: true }));
return {
x: box.left + box.width / 2,
y: box.top + box.height / 2,
tag: el.tagName,
type: el.type || 'text'
};
})()
`;
try {
const result = await browser.sendCommand<RuntimeEvaluateResult>('Runtime.evaluate', {
expression: script,
returnByValue: true
});
if (!result.result || !result.result.value) {
logger.error('❌ Element not found or error occurred');
if (result.exceptionDetails) {
logger.error('Error:', result.exceptionDetails.exception?.description || result.exceptionDetails.text);
}
throw new Error(`Element not found: ${selector}`);
}
const { x, y, tag, type } = result.result.value as { x: number; y: number; tag: string; type: string };
if (opts.verbose) {
logger.info(`✓ Element found: <${tag.toLowerCase()} type="${type}">`);
logger.info(` Position: (${Math.round(x)}, ${Math.round(y)})`);
}
// Step 2: Click to focus
if (opts.verbose) logger.info(`🖱️ Clicking to focus...`);
await browser.sendCommand('Input.dispatchMouseEvent', {
type: 'mousePressed',
button: 'left',
clickCount: 1,
x,
y
});
await browser.sendCommand('Input.dispatchMouseEvent', {
type: 'mouseReleased',
button: 'left',
clickCount: 1,
x,
y
});
// Small delay to ensure focus
await sleep(TIMING.ACTION_DELAY_SHORT);
// Step 3: Insert text using CDP
if (opts.verbose) logger.info(`⌨️ Inserting text: "${value}"`);
await browser.sendCommand('Input.insertText', {
text: value
});
if (opts.verbose) logger.info(`✅ Fill successful`);
// Wait for navigation and check errors
await waitForActionComplete(browser, opts);
return { success: true, selector, value };
} catch (error: unknown) {
if (opts.verbose) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error(`❌ Fill failed: ${selector}`);
logger.error(` Error: ${errorMessage}`);
}
await waitForActionComplete(browser, opts);
throw error;
}
}
/**
* Fill input field with automatic retry using interaction map fallback.
* Supports both CSS selectors and XPath (when selector starts with '//').
* XPath supports indexing: (//input[@type='text'])[2] selects the 2nd input.
* Uses CDP click + insertText for proper React compatibility.
*
* On failure, attempts to find alternative selectors from interaction map and retries.
*/
export async function fill(
browser: ChromeBrowser,
selector: string,
value: string,
options?: ActionOptions
): Promise<ActionResult> {
const opts = mergeOptions(options);
try {
// First attempt with provided selector
return await fillCore(browser, selector, value, options);
} catch (error: unknown) {
// Check if map file exists
const mapPath = join(process.cwd(), SELECTOR_RETRY_CONFIG.MAP_FOLDER, SELECTOR_RETRY_CONFIG.MAP_FILENAME);
if (!existsSync(mapPath)) {
if (opts.verbose) {
logger.info('⚠️ No interaction map found for fallback. Rethrowing error.');
}
throw error;
}
if (opts.verbose) {
logger.info('🔄 Attempting to find alternative selectors from map...');
}
// Try to find alternative selectors
let fallbackResult: { selector: string; alternatives: string[] } | null = null;
try {
// If original selector looks like an ID, try querying by ID
if (selector.startsWith('#')) {
const id = selector.slice(1);
fallbackResult = findSelectorWithFallback(mapPath, { id });
}
// If selector contains text in XPath format, extract and search
else if (selector.includes('contains(text()')) {
const textMatch = selector.match(/contains\(text\(\),\s*['"](.+?)['"]\)/);
if (textMatch && textMatch[1]) {
fallbackResult = findSelectorWithFallback(mapPath, { text: textMatch[1] });
}
}
// For input fields, try to find by type
else if (selector.includes('input')) {
fallbackResult = findSelectorWithFallback(mapPath, { type: 'input' });
}
} catch (mapError: unknown) {
if (opts.verbose) {
const mapErrorMessage = mapError instanceof Error ? mapError.message : String(mapError);
logger.info(`⚠️ Map query failed: ${mapErrorMessage}`);
}
throw error; // Rethrow original error
}
if (!fallbackResult || fallbackResult.alternatives.length === 0) {
if (opts.verbose) {
logger.info('⚠️ No alternative selectors found in map.');
}
throw error; // Rethrow original error
}
// Try alternative selectors (limit to MAX_ATTEMPTS - 1, since we already tried once)
const maxRetries = Math.min(
fallbackResult.alternatives.length,
SELECTOR_RETRY_CONFIG.MAX_ATTEMPTS - 1
);
for (let i = 0; i < maxRetries; i++) {
const altSelector = fallbackResult.alternatives[i];
if (opts.verbose) {
logger.info(`🔄 Retry ${i + 1}/${maxRetries} with selector: ${altSelector}`);
}
try {
return await fillCore(browser, altSelector, value, options);
} catch (_retryError: unknown) {
if (i === maxRetries - 1) {
// Last retry failed, throw original error
if (opts.verbose) {
logger.info('❌ All retry attempts exhausted.');
}
throw error;
}
// Continue to next alternative
}
}
// Should not reach here, but throw original error as fallback
throw error;
}
}
/**
* Hover over element.
* Supports both CSS selectors and XPath (when selector starts with '//').
* Uses CDP mouseMoved event for proper React compatibility.
*/
export async function hover(
browser: ChromeBrowser,
selector: string,
options?: ActionOptions
): Promise<ActionResult> {
const opts = mergeOptions(options);
if (opts.verbose) logger.info(`🔍 Hovering: ${selector}`);
// Step 1: Find element and scroll into view
const script = `
(function() {
const selector = ${JSON.stringify(selector)};
${getFindElementScript()}
const el = findElement(selector);
if (!el) throw new Error('Element not found: ' + selector);
// Scroll element into view
el.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' });
// Get bounding box and calculate center point
const box = el.getBoundingClientRect();
return {
x: box.left + box.width / 2,
y: box.top + box.height / 2,
tag: el.tagName,
text: el.textContent?.substring(0, 50) || '',
visible: box.width > 0 && box.height > 0
};
})()
`;
try {
const result = await browser.sendCommand<RuntimeEvaluateResult>('Runtime.evaluate', {
expression: script,
returnByValue: true
});
if (!result.result || !result.result.value) {
logger.error('❌ Element not found or error occurred');
if (result.exceptionDetails) {
logger.error('Error:', result.exceptionDetails.exception?.description || result.exceptionDetails.text);
}
throw new Error(`Element not found: ${selector}`);
}
const { x, y, tag, text, visible } = result.result.value as { x: number; y: number; tag: string; text: string; visible: boolean };
if (opts.verbose) {
logger.info(`✓ Element found: <${tag.toLowerCase()}> "${text}"`);
logger.info(` Position: (${Math.round(x)}, ${Math.round(y)}), Visible: ${visible}`);
logger.info(`🖱️ Moving mouse to (${Math.round(x)}, ${Math.round(y)})`);
}
// Step 2: Dispatch CDP mouse move event
await browser.sendCommand('Input.dispatchMouseEvent', {
type: 'mouseMoved',
x,
y
});
if (opts.verbose) logger.info(`✅ Hover successful`);
await waitForActionComplete(browser, opts);
return {
success: true,
selector,
coordinates: { x: Math.round(x), y: Math.round(y) },
element: { tag, text }
};
} catch (error: unknown) {
if (opts.verbose) {
logger.error(`❌ Hover failed: ${selector}`);
if (error instanceof Error) {
logger.error(` Error: ${error.message}`);
} else {
logger.error(` Error: ${String(error)}`);
}
}
await waitForActionComplete(browser, opts);
throw error;
}
}
/**
* Focus element.
* Supports both CSS selectors and XPath (when selector starts with '//').
*/
export async function focus(
browser: ChromeBrowser,
selector: string,
options?: ActionOptions
): Promise<ActionResult> {
const opts = mergeOptions(options);
if (opts.verbose) logger.info(`🔍 Focusing: ${selector}`);
const script = `
(function() {
const selector = ${JSON.stringify(selector)};
${getFindElementScript()}
const el = findElement(selector);
if (!el) throw new Error('Element not found: ' + selector);
el.focus();
return true;
})()
`;
await browser.sendCommand('Runtime.evaluate', {
expression: script,
returnByValue: true
});
if (opts.verbose) logger.info(`✅ Focus successful`);
await waitForActionComplete(browser, opts);
return { success: true, selector };
}
/**
* Blur element.
* Supports both CSS selectors and XPath (when selector starts with '//').
*/
export async function blur(
browser: ChromeBrowser,
selector: string,
options?: ActionOptions
): Promise<ActionResult> {
const opts = mergeOptions(options);
if (opts.verbose) logger.info(`🔍 Blurring: ${selector}`);
const script = `
(function() {
const selector = ${JSON.stringify(selector)};
${getFindElementScript()}
const el = findElement(selector);
if (!el) throw new Error('Element not found: ' + selector);
el.blur();
return true;
})()
`;
await browser.sendCommand('Runtime.evaluate', {
expression: script,
returnByValue: true
});
if (opts.verbose) logger.info(`✅ Blur successful`);
await waitForActionComplete(browser, opts);
return { success: true, selector };
}
/**
* Drag and drop from one element to another.
* Uses CDP mouse events for proper React/framework compatibility.
*/
export async function dragAndDrop(
browser: ChromeBrowser,
sourceSelector: string,
targetSelector: string,
options?: ActionOptions
): Promise<ActionResult> {
const opts = mergeOptions(options);
if (opts.verbose) logger.info(`🔍 Dragging ${sourceSelector} to ${targetSelector}`);
// Step 1: Get coordinates for both elements
const script = `
(function() {
const sourceSelector = ${JSON.stringify(sourceSelector)};
const targetSelector = ${JSON.stringify(targetSelector)};
${getFindElementScript()}
const source = findElement(sourceSelector);
const target = findElement(targetSelector);
if (!source) throw new Error('Source element not found: ' + sourceSelector);
if (!target) throw new Error('Target element not found: ' + targetSelector);
// Scroll both into view
source.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' });
const sourceRect = source.getBoundingClientRect();
target.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' });
const targetRect = target.getBoundingClientRect();
return {
source: {
x: sourceRect.left + sourceRect.width / 2,
y: sourceRect.top + sourceRect.height / 2,
tag: source.tagName,
text: source.textContent?.substring(0, 30) || ''
},
target: {
x: targetRect.left + targetRect.width / 2,
y: targetRect.top + targetRect.height / 2,
tag: target.tagName,
text: target.textContent?.substring(0, 30) || ''
}
};
})()
`;
try {
const result = await browser.sendCommand<RuntimeEvaluateResult>('Runtime.evaluate', {
expression: script,
returnByValue: true
});
if (!result.result || !result.result.value) {
logger.error('❌ Element(s) not found');
throw new Error('Could not find source or target element');
}
const { source, target } = result.result.value as {
source: { x: number; y: number; tag: string; text: string };
target: { x: number; y: number; tag: string; text: string };
};
if (opts.verbose) {
logger.info(`✓ Source: <${source.tag.toLowerCase()}> "${source.text}" at (${Math.round(source.x)}, ${Math.round(source.y)})`);
logger.info(`✓ Target: <${target.tag.toLowerCase()}> "${target.text}" at (${Math.round(target.x)}, ${Math.round(target.y)})`);
}
// Step 2: Perform CDP drag operation
if (opts.verbose) logger.info(`🖱️ Mouse down at source (${Math.round(source.x)}, ${Math.round(source.y)})`);
await browser.sendCommand('Input.dispatchMouseEvent', {
type: 'mousePressed',
button: 'left',
clickCount: 1,
x: source.x,
y: source.y
});
// Small delay to simulate drag start
await sleep(TIMING.ACTION_DELAY_MEDIUM);
if (opts.verbose) logger.info(`🖱️ Dragging to target (${Math.round(target.x)}, ${Math.round(target.y)})`);
await browser.sendCommand('Input.dispatchMouseEvent', {
type: 'mouseMoved',
button: 'left',
x: target.x,
y: target.y
});
// Small delay before release
await sleep(TIMING.ACTION_DELAY_MEDIUM);
if (opts.verbose) logger.info(`🖱️ Mouse up at target (${Math.round(target.x)}, ${Math.round(target.y)})`);
await browser.sendCommand('Input.dispatchMouseEvent', {
type: 'mouseReleased',
button: 'left',
clickCount: 1,
x: target.x,
y: target.y
});
if (opts.verbose) logger.info(`✅ Drag and drop successful`);
await waitForActionComplete(browser, opts);
return {
success: true,
sourceSelector,
targetSelector,
source: { x: Math.round(source.x), y: Math.round(source.y) },
target: { x: Math.round(target.x), y: Math.round(target.y) }
};
} catch (error: unknown) {
if (opts.verbose) {
logger.error(`❌ Drag and drop failed`);
if (error instanceof Error) {
logger.error(` Error: ${error.message}`);
} else {
logger.error(` Error: ${String(error)}`);
}
}
await waitForActionComplete(browser, opts);
throw error;
}
}

View File

@@ -0,0 +1,217 @@
/**
* Navigation actions for Browser Pilot.
*/
import { ChromeBrowser } from '../browser';
import { ActionResult, ActionOptions, mergeOptions, sleep, checkErrors } from './helpers';
import { logger } from '../../utils/logger';
import { TIMING } from '../../constants';
/**
* Navigate to URL.
*/
export async function navigate(
browser: ChromeBrowser,
url: string,
options?: ActionOptions
): Promise<ActionResult> {
const opts = mergeOptions(options);
if (opts.verbose) logger.info(`🧭 Navigating to: ${url}`);
try {
await browser.sendCommand('Page.navigate', { url });
await sleep(TIMING.ACTION_DELAY_NAVIGATION); // Wait for initial page load
if (opts.verbose) logger.info(`✓ Page loaded: ${url}`);
checkErrors(browser, opts.logLevel);
return { success: true, url };
} catch (error: unknown) {
if (opts.verbose) {
logger.error(`❌ Navigation failed: ${url}`);
if (error instanceof Error) {
logger.error(` Error: ${error.message}`);
} else {
logger.error(` Error: ${String(error)}`);
}
}
throw error;
}
}
/**
* Wait for page load complete.
*/
export async function waitForLoad(
browser: ChromeBrowser,
timeout = 30000,
options?: ActionOptions
): Promise<ActionResult> {
const opts = mergeOptions(options);
if (opts.verbose) logger.info(`⏳ Waiting for page load (timeout: ${timeout}ms)...`);
const script = `
new Promise((resolve, reject) => {
const startTime = Date.now();
const checkReady = () => {
if (document.readyState === 'complete') {
resolve(true);
} else if (Date.now() - startTime > ${timeout}) {
reject(new Error('Timeout waiting for page load'));
} else {
setTimeout(checkReady, ${TIMING.POLLING_INTERVAL_FAST});
}
};
checkReady();
})
`;
try {
await browser.sendCommand('Runtime.evaluate', {
expression: script,
awaitPromise: true,
returnByValue: true
});
if (opts.verbose) logger.info(`✅ Page load complete`);
return { success: true, state: 'complete' };
} catch (error: unknown) {
if (opts.verbose) {
logger.error(`❌ Page load failed`);
if (error instanceof Error) {
logger.error(` Error: ${error.message}`);
} else {
logger.error(` Error: ${String(error)}`);
}
}
throw error;
}
}
/**
* Reload page.
*/
export async function reload(
browser: ChromeBrowser,
hard = false,
options?: ActionOptions
): Promise<ActionResult> {
const opts = mergeOptions(options);
if (opts.verbose) logger.info(`🔄 Reloading page (hard: ${hard})...`);
try {
await browser.sendCommand('Page.reload', { ignoreCache: hard });
if (opts.verbose) logger.info(`✅ Page reloaded`);
return { success: true, hardReload: hard };
} catch (error: unknown) {
if (opts.verbose) {
logger.error(`❌ Reload failed`);
if (error instanceof Error) {
logger.error(` Error: ${error.message}`);
} else {
logger.error(` Error: ${String(error)}`);
}
}
throw error;
}
}
/**
* Navigate back in history.
*/
export async function goBack(
browser: ChromeBrowser,
options?: ActionOptions
): Promise<ActionResult> {
const opts = mergeOptions(options);
if (opts.verbose) logger.info(`◀️ Navigating back...`);
try {
const history = await browser.sendCommand<{
currentIndex: number;
entries: Array<{ id: number; url: string; title: string }>;
}>('Page.getNavigationHistory');
const currentIndex = history.currentIndex || 0;
if (currentIndex > 0) {
const previousEntry = history.entries[currentIndex - 1];
await browser.sendCommand('Page.navigateToHistoryEntry', {
entryId: previousEntry.id
});
if (opts.verbose) logger.info(`✅ Navigated back to: ${previousEntry.url}`);
return { success: true, url: previousEntry.url };
}
if (opts.verbose) logger.info(`⚠️ No previous page in history`);
return { success: false, error: 'No previous page in history' };
} catch (error: unknown) {
if (opts.verbose) {
logger.error(`❌ Go back failed`);
if (error instanceof Error) {
logger.error(` Error: ${error.message}`);
} else {
logger.error(` Error: ${String(error)}`);
}
}
throw error;
}
}
/**
* Navigate forward in history.
*/
export async function goForward(
browser: ChromeBrowser,
options?: ActionOptions
): Promise<ActionResult> {
const opts = mergeOptions(options);
if (opts.verbose) logger.info(`▶️ Navigating forward...`);
try {
const history = await browser.sendCommand<{
currentIndex: number;
entries: Array<{ id: number; url: string; title: string }>;
}>('Page.getNavigationHistory');
const currentIndex = history.currentIndex || 0;
const totalEntries = history.entries?.length || 0;
if (currentIndex < totalEntries - 1) {
const nextEntry = history.entries[currentIndex + 1];
await browser.sendCommand('Page.navigateToHistoryEntry', {
entryId: nextEntry.id
});
if (opts.verbose) logger.info(`✅ Navigated forward to: ${nextEntry.url}`);
return { success: true, url: nextEntry.url };
}
if (opts.verbose) logger.info(`⚠️ No next page in history`);
return { success: false, error: 'No next page in history' };
} catch (error: unknown) {
if (opts.verbose) {
logger.error(`❌ Go forward failed`);
if (error instanceof Error) {
logger.error(` Error: ${error.message}`);
} else {
logger.error(` Error: ${String(error)}`);
}
}
throw error;
}
}

View File

@@ -0,0 +1,194 @@
/**
* Network interception and mocking actions for Browser Pilot.
*/
import { ChromeBrowser } from '../browser';
import { ActionResult, ActionOptions, mergeOptions } from './helpers';
import { logger } from '../../utils/logger';
/**
* Set up network request interception.
*/
export async function enableRequestInterception(
browser: ChromeBrowser,
options?: ActionOptions
): Promise<ActionResult> {
const opts = mergeOptions(options);
if (opts.verbose) logger.info(`🌐 Enabling network request interception...`);
try {
await browser.sendCommand('Fetch.enable', {
patterns: [{ urlPattern: '*' }]
});
if (opts.verbose) logger.info(`✅ Request interception enabled`);
return {
success: true,
note: 'Request interception enabled. Use interceptRequest() to handle requests.'
};
} catch (error: unknown) {
if (opts.verbose) {
logger.error(`❌ Enable request interception failed`);
if (error instanceof Error) {
logger.error(` Error: ${error.message}`);
} else {
logger.error(` Error: ${String(error)}`);
}
}
throw error;
}
}
/**
* Disable network request interception.
*/
export async function disableRequestInterception(
browser: ChromeBrowser,
options?: ActionOptions
): Promise<ActionResult> {
const opts = mergeOptions(options);
if (opts.verbose) logger.info(`🌐 Disabling network request interception...`);
try {
await browser.sendCommand('Fetch.disable');
if (opts.verbose) logger.info(`✅ Request interception disabled`);
return {
success: true
};
} catch (error: unknown) {
if (opts.verbose) {
logger.error(`❌ Disable request interception failed`);
if (error instanceof Error) {
logger.error(` Error: ${error.message}`);
} else {
logger.error(` Error: ${String(error)}`);
}
}
throw error;
}
}
/**
* Mock a network request response.
*/
export async function mockRequest(
browser: ChromeBrowser,
urlPattern: string,
responseBody: string,
statusCode: number = 200,
headers?: Record<string, string>,
options?: ActionOptions
): Promise<ActionResult> {
const opts = mergeOptions(options);
if (opts.verbose) logger.info(`🌐 Mocking request: ${urlPattern} -> ${statusCode}`);
try {
// This is a simplified version - full implementation requires event handling
await browser.sendCommand('Fetch.enable', {
patterns: [{ urlPattern }]
});
if (opts.verbose) logger.info(`✅ Mock configured for: ${urlPattern}`);
return {
success: true,
urlPattern,
statusCode,
note: 'Mock configured. Use Fetch.continueRequest or Fetch.fulfillRequest in event handler.'
};
} catch (error: unknown) {
if (opts.verbose) {
logger.error(`❌ Mock request failed`);
if (error instanceof Error) {
logger.error(` Error: ${error.message}`);
} else {
logger.error(` Error: ${String(error)}`);
}
}
throw error;
}
}
/**
* Block network requests matching pattern.
*/
export async function blockRequest(
browser: ChromeBrowser,
urlPattern: string,
options?: ActionOptions
): Promise<ActionResult> {
const opts = mergeOptions(options);
if (opts.verbose) logger.info(`🚫 Blocking requests matching: ${urlPattern}`);
try {
await browser.sendCommand('Network.enable');
await browser.sendCommand('Network.setBlockedURLs', {
urls: [urlPattern]
});
if (opts.verbose) logger.info(`✅ Requests blocked: ${urlPattern}`);
return {
success: true,
urlPattern,
blocked: true
};
} catch (error: unknown) {
if (opts.verbose) {
logger.error(`❌ Block request failed`);
if (error instanceof Error) {
logger.error(` Error: ${error.message}`);
} else {
logger.error(` Error: ${String(error)}`);
}
}
throw error;
}
}
/**
* Unblock all network requests.
*/
export async function unblockRequests(
browser: ChromeBrowser,
options?: ActionOptions
): Promise<ActionResult> {
const opts = mergeOptions(options);
if (opts.verbose) logger.info(`🌐 Unblocking all requests...`);
try {
await browser.sendCommand('Network.setBlockedURLs', {
urls: []
});
if (opts.verbose) logger.info(`✅ All requests unblocked`);
return {
success: true,
blocked: false
};
} catch (error: unknown) {
if (opts.verbose) {
logger.error(`❌ Unblock requests failed`);
if (error instanceof Error) {
logger.error(` Error: ${error.message}`);
} else {
logger.error(` Error: ${String(error)}`);
}
}
throw error;
}
}

View File

@@ -0,0 +1,71 @@
/**
* Scroll actions for Browser Pilot.
*/
import { ChromeBrowser } from '../browser';
import { getFindElementScript } from '../utils';
import { ActionResult, ActionOptions, mergeOptions, checkErrors, RuntimeEvaluateResult } from './helpers';
import { logger } from '../../utils/logger';
/**
* Scroll page or element.
* Supports both CSS selectors and XPath (when selector starts with '//').
* Note: x and y are both optional - you can scroll on just one axis if needed.
*/
export async function scroll(
browser: ChromeBrowser,
options?: { x?: number; y?: number; selector?: string } & ActionOptions
): Promise<ActionResult> {
const opts = mergeOptions(options);
const x = options?.x ?? 0;
const y = options?.y ?? 0;
const selector = options?.selector;
if (opts.verbose) logger.info(`📜 Scrolling to (${x}, ${y})${selector ? ` on ${selector}` : ''}`);
const script = selector
? `
(function() {
const selector = ${JSON.stringify(selector)};
const x = ${JSON.stringify(x)};
const y = ${JSON.stringify(y)};
${getFindElementScript()}
const el = findElement(selector);
if (!el) throw new Error('Element not found: ' + selector);
el.scrollTo(x, y);
return { x: el.scrollLeft, y: el.scrollTop };
})()
`
: `
(function() {
const x = ${JSON.stringify(x)};
const y = ${JSON.stringify(y)};
window.scrollTo(x, y);
return { x: window.scrollX, y: window.scrollY };
})()
`;
try {
const result = await browser.sendCommand<RuntimeEvaluateResult>('Runtime.evaluate', {
expression: script,
returnByValue: true
});
if (opts.verbose) logger.info(`✅ Scrolled successfully`);
checkErrors(browser, opts.logLevel);
return {
success: true,
position: result.result?.value
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
if (opts.verbose) {
logger.error(`❌ Scroll failed`);
logger.error(` Error: ${errorMessage}`);
}
checkErrors(browser, opts.logLevel);
throw error;
}
}

View File

@@ -0,0 +1,164 @@
/**
* Tab management actions for Browser Pilot.
*/
import { ChromeBrowser } from '../browser';
import { ActionResult, ActionOptions, mergeOptions } from './helpers';
import { logger } from '../../utils/logger';
import { CDP } from '../../constants';
// Target interface (from CDP)
interface Target {
id: string;
type: string;
url: string;
title: string;
webSocketDebuggerUrl?: string;
}
/**
* Create new tab.
*/
export async function newTab(
browser: ChromeBrowser,
url = 'about:blank',
options?: ActionOptions
): Promise<ActionResult> {
const opts = mergeOptions(options);
if (opts.verbose) logger.info(`📑 Opening new tab: ${url}`);
const result = await browser.sendCommand('Target.createTarget', { url });
if (opts.verbose) logger.info(`✅ New tab created`);
return {
success: true,
targetId: result.targetId,
url
};
}
/**
* List all tabs.
*/
export async function listTabs(
browser: ChromeBrowser,
options?: ActionOptions
): Promise<ActionResult> {
const opts = mergeOptions(options);
if (opts.verbose) logger.info(`📑 Listing all tabs...`);
const debugPort = browser.debugPort;
const response = await fetch(`http://${CDP.LOCALHOST}:${debugPort}/json`);
const targets = await response.json() as Target[];
const pageTabs = targets
.filter((t: Target) => t.type === 'page')
.map((t: Target, index: number) => ({
index,
targetId: t.id,
url: t.url,
title: t.title
}));
if (opts.verbose) logger.info(`✅ Found ${pageTabs.length} tab(s)`);
return {
success: true,
tabs: pageTabs,
count: pageTabs.length
};
}
/**
* Switch to tab.
*/
export async function switchTab(
browser: ChromeBrowser,
targetId?: string,
index?: number,
options?: ActionOptions
): Promise<ActionResult> {
const opts = mergeOptions(options);
if (opts.verbose) {
if (targetId) {
logger.info(`📑 Switching to tab: ${targetId}`);
} else if (index !== undefined) {
logger.info(`📑 Switching to tab index: ${index}`);
}
}
const debugPort = browser.debugPort;
const response = await fetch(`http://${CDP.LOCALHOST}:${debugPort}/json`);
const targets = await response.json() as Target[];
const pageTabs = targets.filter((t: Target) => t.type === 'page');
let target: Target | undefined = undefined;
if (targetId) {
target = pageTabs.find((t: Target) => t.id === targetId);
} else if (index !== undefined) {
target = pageTabs[index];
}
if (!target) {
if (opts.verbose) logger.info(`❌ Target not found`);
return { success: false, error: 'Target not found' };
}
await browser.sendCommand('Target.activateTarget', { targetId: target.id });
if (opts.verbose) logger.info(`✅ Switched to tab: ${target.title}`);
return {
success: true,
targetId: target.id,
url: target.url,
title: target.title
};
}
/**
* Close tab.
*/
export async function closeTab(
browser: ChromeBrowser,
targetId?: string,
index?: number,
options?: ActionOptions
): Promise<ActionResult> {
const opts = mergeOptions(options);
if (opts.verbose) {
if (targetId) {
logger.info(`📑 Closing tab: ${targetId}`);
} else if (index !== undefined) {
logger.info(`📑 Closing tab index: ${index}`);
}
}
const debugPort = browser.debugPort;
const response = await fetch(`http://${CDP.LOCALHOST}:${debugPort}/json`);
const targets = await response.json() as Target[];
const pageTabs = targets.filter((t: Target) => t.type === 'page');
let target: Target | undefined = undefined;
if (targetId) {
target = pageTabs.find((t: Target) => t.id === targetId);
} else if (index !== undefined) {
target = pageTabs[index];
}
if (!target) {
if (opts.verbose) logger.info(`❌ Target not found`);
return { success: false, error: 'Target not found' };
}
await browser.sendCommand('Target.closeTarget', { targetId: target.id });
if (opts.verbose) logger.info(`✅ Closed tab: ${target.title}`);
return {
success: true,
targetId: target.id,
message: `Closed tab: ${target.title}`
};
}

View File

@@ -0,0 +1,248 @@
/**
* Verification utilities for browser actions
*/
import { ChromeBrowser } from '../browser';
import { TIMING as GLOBAL_TIMING } from '../../constants';
// Timing constants for verification operations
const TIMING = {
DEFAULT_VERIFY_TIMEOUT: GLOBAL_TIMING.ACTION_DELAY_NAVIGATION,
DOM_CHANGE_CHECK_DELAY: GLOBAL_TIMING.NETWORK_IDLE_TIMEOUT,
NAVIGATION_CHECK_DELAY: GLOBAL_TIMING.NETWORK_IDLE_TIMEOUT,
MIN_DOM_CHANGE_THRESHOLD: 10, // Minimum change in DOM size to consider significant
} as const;
export interface VerifyOptions {
checkDOMChange?: boolean; // Check for DOM mutations
checkNavigation?: boolean; // Check for page navigation
timeout?: number; // Wait timeout in ms (default: 1000)
}
export interface VerificationResult {
success: boolean;
reason?: string;
domChanged?: boolean;
navigated?: boolean;
}
/**
* Check if an element exists in the DOM
*/
export async function elementExists(
browser: ChromeBrowser,
selector: string
): Promise<boolean> {
try {
const result = await browser.sendCommand('Runtime.evaluate', {
expression: `
(function() {
const selector = ${JSON.stringify(selector)};
let element = null;
// Try XPath first
if (selector.startsWith('//')) {
const xpathResult = document.evaluate(
selector,
document,
null,
XPathResult.FIRST_ORDERED_NODE_TYPE,
null
);
element = xpathResult.singleNodeValue;
}
// Try CSS selector
else {
element = document.querySelector(selector);
}
return element !== null;
})()
`,
returnByValue: true
}) as { result?: { value?: boolean } };
return result?.result?.value === true;
} catch (_error: unknown) {
return false;
}
}
/**
* Wait for DOM changes (using MutationObserver simulation)
*/
export async function waitForDOMChange(
browser: ChromeBrowser,
timeout: number = TIMING.DEFAULT_VERIFY_TIMEOUT
): Promise<boolean> {
try {
// Get initial DOM snapshot
const initialSnapshot = await browser.sendCommand('Runtime.evaluate', {
expression: 'document.body.innerHTML.length',
returnByValue: true
}) as { result?: { value?: number } };
const initialLength = initialSnapshot?.result?.value || 0;
// Wait a bit
await new Promise(resolve => setTimeout(resolve, Math.min(timeout, TIMING.DOM_CHANGE_CHECK_DELAY)));
// Get new DOM snapshot
const finalSnapshot = await browser.sendCommand('Runtime.evaluate', {
expression: 'document.body.innerHTML.length',
returnByValue: true
}) as { result?: { value?: number } };
const finalLength = finalSnapshot?.result?.value || 0;
// Check if DOM changed significantly
return Math.abs(finalLength - initialLength) > TIMING.MIN_DOM_CHANGE_THRESHOLD;
} catch (_error: unknown) {
return false;
}
}
/**
* Check for page navigation
*/
export async function checkNavigation(
browser: ChromeBrowser,
initialURL: string,
timeout: number = TIMING.DEFAULT_VERIFY_TIMEOUT
): Promise<boolean> {
try {
// Wait a bit for navigation to complete
await new Promise(resolve => setTimeout(resolve, Math.min(timeout, TIMING.NAVIGATION_CHECK_DELAY)));
// Get current URL
const urlResult = await browser.sendCommand('Target.getTargetInfo', {
targetId: (browser as unknown as { targetId: string }).targetId
}) as { targetInfo: { url: string } };
const currentURL = urlResult.targetInfo.url;
return currentURL !== initialURL;
} catch (_error: unknown) {
return false;
}
}
/**
* Verify that an action was successful
*/
export async function verifyAction(
browser: ChromeBrowser,
options: VerifyOptions = {}
): Promise<VerificationResult> {
const {
checkDOMChange = true,
checkNavigation: shouldCheckNavigation = true,
timeout = TIMING.DEFAULT_VERIFY_TIMEOUT
} = options;
const result: VerificationResult = {
success: false
};
try {
// Get initial URL
let initialURL = '';
if (shouldCheckNavigation) {
const urlResult = await browser.sendCommand('Target.getTargetInfo', {
targetId: (browser as unknown as { targetId: string }).targetId
}) as { targetInfo: { url: string } };
initialURL = urlResult.targetInfo.url;
}
// Check for DOM changes
if (checkDOMChange) {
result.domChanged = await waitForDOMChange(browser, timeout);
}
// Check for navigation
if (shouldCheckNavigation) {
result.navigated = await checkNavigation(browser, initialURL, timeout);
}
// Success if either DOM changed or navigation occurred
result.success = !!(result.domChanged || result.navigated);
if (!result.success) {
result.reason = 'No DOM changes or navigation detected';
}
return result;
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
return {
success: false,
reason: `Verification failed: ${errorMessage}`
};
}
}
/**
* Verify element interactivity before action
*/
export async function verifyElementInteractive(
browser: ChromeBrowser,
selector: string
): Promise<{ interactive: boolean; reason?: string }> {
try {
const result = await browser.sendCommand('Runtime.evaluate', {
expression: `
(function() {
const selector = ${JSON.stringify(selector)};
let element = null;
// Try XPath first
if (selector.startsWith('//')) {
const xpathResult = document.evaluate(
selector,
document,
null,
XPathResult.FIRST_ORDERED_NODE_TYPE,
null
);
element = xpathResult.singleNodeValue;
}
// Try CSS selector
else {
element = document.querySelector(selector);
}
if (!element) {
return { interactive: false, reason: 'Element not found' };
}
// Check visibility
const style = window.getComputedStyle(element);
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') {
return { interactive: false, reason: 'Element not visible' };
}
// Check if disabled
if (element.disabled || element.getAttribute('aria-disabled') === 'true') {
return { interactive: false, reason: 'Element is disabled' };
}
// Check if element is in viewport
const rect = element.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) {
return { interactive: false, reason: 'Element has no size' };
}
return { interactive: true };
})()
`,
returnByValue: true
}) as { result?: { value?: { interactive: boolean; reason?: string } } };
const value = result?.result?.value;
if (value && typeof value === 'object') {
return value;
}
return { interactive: false, reason: 'Verification failed' };
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
return { interactive: false, reason: errorMessage };
}
}

View File

@@ -0,0 +1,217 @@
/**
* Wait actions for Browser Pilot.
*/
import { ChromeBrowser } from '../browser';
import { getFindElementScript } from '../utils';
import { ActionResult, ActionOptions, mergeOptions, sleep, checkErrors, RuntimeEvaluateResult } from './helpers';
import { logger } from '../../utils/logger';
import { TIMING, CDP } from '../../constants';
/**
* Wait for specified milliseconds.
*/
export async function waitMilliseconds(
browser: ChromeBrowser,
ms: number,
options?: ActionOptions
): Promise<ActionResult> {
const opts = mergeOptions(options);
if (opts.verbose) logger.info(`⏳ Waiting for ${ms}ms...`);
await sleep(ms);
if (opts.verbose) logger.info(`✅ Wait complete`);
return { success: true, waitedMs: ms };
}
/**
* Wait for element to appear.
* Supports both CSS selectors and XPath (when selector starts with '//').
*/
export async function waitFor(
browser: ChromeBrowser,
selector: string,
timeout = TIMING.WAIT_FOR_NAVIGATION,
options?: ActionOptions
): Promise<ActionResult> {
const opts = mergeOptions(options);
if (opts.verbose) logger.info(`⏳ Waiting for: ${selector}`);
const startTime = Date.now();
while (Date.now() - startTime < timeout) {
const script = `(function() {
const selector = ${JSON.stringify(selector)};
${getFindElementScript()}
return findElement(selector) !== null;
})()`;
const result = await browser.sendCommand<RuntimeEvaluateResult>('Runtime.evaluate', {
expression: script,
returnByValue: true
});
if (result.result?.value) {
if (opts.verbose) logger.info(`✅ Element appeared: ${selector}`);
checkErrors(browser, opts.logLevel);
return { success: true, selector };
}
await sleep(TIMING.POLLING_INTERVAL_FAST);
}
if (opts.verbose) logger.info(`❌ Timeout waiting for: ${selector}`);
throw new Error(`Timeout waiting for: ${selector}`);
}
/**
* Wait for network to be idle.
*/
export async function waitForNetworkIdle(
browser: ChromeBrowser,
timeout: number = TIMING.WAIT_FOR_ELEMENT,
_maxInflight = 0,
options?: ActionOptions
): Promise<ActionResult> {
const opts = mergeOptions(options);
if (opts.verbose) logger.info(`⏳ Waiting for network idle (timeout: ${timeout}ms)...`);
await browser.sendCommand('Network.enable');
const script = `
new Promise((resolve) => {
const waitForNavigationComplete = () => {
if (performance.timing.loadEventEnd > 0) {
setTimeout(() => resolve(true), ${timeout});
} else {
setTimeout(waitForNavigationComplete, ${TIMING.POLLING_INTERVAL_FAST});
}
};
waitForNavigationComplete();
})
`;
try {
await browser.sendCommand('Runtime.evaluate', {
expression: script,
awaitPromise: true,
returnByValue: true
});
if (opts.verbose) logger.info(`✅ Network idle`);
return { success: true, state: 'network_idle' };
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
if (opts.verbose) {
logger.error(`❌ Network idle wait failed`);
logger.error(` Error: ${errorMessage}`);
}
throw error;
}
}
/**
* Wait for DOM to stabilize (no mutations for specified time).
* Uses MutationObserver to detect when DOM changes stop.
*/
export async function waitForDomStable(
browser: ChromeBrowser,
stableTime: number = TIMING.NETWORK_IDLE_TIMEOUT,
timeout: number = CDP.EVALUATION_TIMEOUT,
options?: ActionOptions
): Promise<ActionResult> {
const opts = mergeOptions(options);
if (opts.verbose) logger.info(`⏳ Waiting for DOM to stabilize (stable: ${stableTime}ms, timeout: ${timeout}ms)...`);
const script = `
new Promise((resolve, reject) => {
const stableTime = ${stableTime};
const timeout = ${timeout};
let lastMutationTime = Date.now();
let stabilityTimer = null;
let timeoutTimer = null;
// Timeout handler
timeoutTimer = setTimeout(() => {
observer.disconnect();
resolve({ stable: false, reason: 'timeout' });
}, timeout);
// Check if stable
const checkStability = () => {
const timeSinceLastMutation = Date.now() - lastMutationTime;
if (timeSinceLastMutation >= stableTime) {
clearTimeout(timeoutTimer);
observer.disconnect();
resolve({ stable: true, waitedMs: Date.now() - startTime });
}
};
const startTime = Date.now();
// MutationObserver to detect DOM changes
const observer = new MutationObserver((mutations) => {
// Filter out trivial mutations (like class changes on same element)
const significantMutations = mutations.filter(m => {
// Ignore attribute changes unless they're critical
if (m.type === 'attributes' && !['style', 'class'].includes(m.attributeName)) {
return false;
}
// Count childList and subtree changes as significant
return m.type === 'childList' || m.addedNodes.length > 0 || m.removedNodes.length > 0;
});
if (significantMutations.length > 0) {
lastMutationTime = Date.now();
// Reset stability timer
if (stabilityTimer) clearTimeout(stabilityTimer);
stabilityTimer = setTimeout(checkStability, stableTime);
}
});
// Start observing
observer.observe(document.body, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['style', 'class']
});
// Initial stability check (in case DOM is already stable)
stabilityTimer = setTimeout(checkStability, stableTime);
})
`;
try {
const result = await browser.sendCommand<RuntimeEvaluateResult>('Runtime.evaluate', {
expression: script,
awaitPromise: true,
returnByValue: true
});
const data = result.result?.value as { stable: boolean; waitedMs?: number; reason?: string };
if (data.stable) {
if (opts.verbose) logger.info(`✅ DOM stabilized (waited: ${data.waitedMs}ms)`);
return { success: true, stable: true, waitedMs: data.waitedMs };
} else {
if (opts.verbose) logger.warn(`⚠️ DOM stabilization timeout (reason: ${data.reason})`);
return { success: true, stable: false, reason: data.reason };
}
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
if (opts.verbose) {
logger.error(`❌ DOM stability wait failed`);
logger.error(` Error: ${errorMessage}`);
}
throw error;
}
}

View File

@@ -0,0 +1,580 @@
/**
* Chrome browser launcher and connection manager.
*/
import { spawn, ChildProcess } from 'child_process';
import { homedir, platform } from 'os';
import { existsSync } from 'fs';
import { join } from 'path';
import { CDPClient } from './client';
import {
getProjectConfig as _getProjectConfig,
getProjectPort,
updateProjectLastUsed,
cleanupProjectIfNeeded,
isPortAvailable
} from './config';
import { logger } from '../utils/logger';
import { TIMING, CDP } from '../constants';
interface Target {
type: string;
webSocketDebuggerUrl: string;
id?: string;
title?: string;
url?: string;
[key: string]: unknown;
}
// CDP Event Supporting Interfaces
export interface StackTrace {
callFrames?: Array<{
url?: string;
lineNumber?: number;
columnNumber?: number;
functionName?: string;
}>;
}
export interface RemoteObject {
type?: string;
value?: unknown;
description?: string;
[key: string]: unknown;
}
// Page Navigation Interfaces
export interface PageNavigatedWithinDocumentPayload {
frameId: string;
url: string;
navigationType: 'fragment' | 'historyApi' | 'other';
}
// Console Message Interfaces
export interface ConsoleMessage {
level: string;
text: string;
timestamp: number;
url?: string;
lineNumber?: number;
stackTrace?: StackTrace;
}
export interface FormattedConsoleMessage {
level: string;
text: string;
timestamp: string; // ISO string format
url?: string;
lineNumber?: number;
}
// Network Error Interfaces
export interface NetworkError {
url: string;
errorText: string;
timestamp: number;
statusCode?: number;
requestId: string;
}
export interface NetworkFailedPayload {
requestId: string;
timestamp: number;
type?: string;
errorText: string;
canceled?: boolean;
}
export interface NetworkResponsePayload {
requestId: string;
response: {
url: string;
status: number;
statusText: string;
};
}
// Network Request Tracking Interface
export interface NetworkRequestPayload {
requestId: string;
request: {
url: string;
method?: string;
headers?: Record<string, string>;
};
timestamp: number;
type?: string;
}
// CDP Event Handler Type
type CDPEventHandler =
| ((params: LogEntryAddedPayload) => void)
| ((params: ConsoleAPICalledPayload) => void)
| ((params: ExceptionThrownPayload) => void)
| ((params: NetworkRequestPayload) => void)
| ((params: NetworkFailedPayload) => void)
| ((params: NetworkResponsePayload) => void);
// CDP Event Payload Interfaces
interface LogEntry {
level?: 'verbose' | 'info' | 'warning' | 'error';
text?: string;
timestamp?: number;
url?: string;
lineNumber?: number;
stackTrace?: StackTrace;
}
interface LogEntryAddedPayload {
entry: LogEntry;
}
interface ConsoleAPICalledPayload {
type?: string;
args?: RemoteObject[];
timestamp?: number;
stackTrace?: StackTrace;
}
interface ExceptionDetails {
exception?: {
description?: string;
};
text?: string;
timestamp?: number;
url?: string;
lineNumber?: number;
stackTrace?: StackTrace;
}
interface ExceptionThrownPayload {
exceptionDetails: ExceptionDetails;
}
export class ChromeBrowser {
private readonly headless: boolean;
public debugPort: number | null = null;
private chromeProcess: ChildProcess | null = null;
public client: CDPClient | null = null;
private consoleMessages: ConsoleMessage[] = [];
private networkErrors: NetworkError[] = [];
private readonly MAX_CONSOLE_MESSAGES = TIMING.MAP_CACHE_TTL / 600; // 1000 messages (10min / 600ms)
private readonly MAX_NETWORK_ERRORS = TIMING.MAP_CACHE_TTL / 600; // 1000 errors
private pendingRequests: Map<string, { url: string; timestamp: number; processed: boolean }> = new Map();
private readonly REQUEST_TIMEOUT = TIMING.DAEMON_IDLE_TIMEOUT / 30; // ~60 seconds
private cleanupInterval: NodeJS.Timeout | null = null;
private eventListeners: Map<string, CDPEventHandler> = new Map();
constructor(headless = false) {
this.headless = headless;
// Debug port will be loaded from shared config in launch/connect methods
}
/**
* Add console message with size limit to prevent memory issues.
*/
private addConsoleMessage(message: ConsoleMessage): void {
this.consoleMessages.push(message);
// Keep only the most recent messages
if (this.consoleMessages.length > this.MAX_CONSOLE_MESSAGES) {
this.consoleMessages = this.consoleMessages.slice(-this.MAX_CONSOLE_MESSAGES);
}
}
/**
* Add network error with size limit to prevent memory issues.
*/
private addNetworkError(error: NetworkError): void {
this.networkErrors.push(error);
// Keep only the most recent errors
if (this.networkErrors.length > this.MAX_NETWORK_ERRORS) {
this.networkErrors = this.networkErrors.slice(-this.MAX_NETWORK_ERRORS);
}
}
/**
* Clean up stale pending requests to prevent memory leak.
*/
private cleanupStaleRequests(): void {
const now = Date.now();
for (const [requestId, request] of this.pendingRequests.entries()) {
if (request.processed || (now - request.timestamp > this.REQUEST_TIMEOUT)) {
this.pendingRequests.delete(requestId);
}
}
}
/**
* Find Chrome executable path.
*/
private getChromePath(): string {
const system = platform();
let paths: string[] = [];
if (system === 'win32') {
paths = [
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
join(homedir(), 'AppData', 'Local', 'Google', 'Chrome', 'Application', 'chrome.exe')
];
} else if (system === 'darwin') {
paths = [
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
];
} else {
paths = [
'/usr/bin/google-chrome',
'/usr/bin/chromium-browser',
'/usr/bin/chromium'
];
}
for (const path of paths) {
if (existsSync(path)) {
return path;
}
}
throw new Error('Chrome not found. Please install Google Chrome.');
}
/**
* Connect to already running Chrome instance.
*/
async connect(): Promise<void> {
// Get project port from shared config
const port = await getProjectPort();
// Check if the port is in use (browser running)
const portAvailable = await isPortAvailable(port);
if (!portAvailable) {
// Port is in use, browser is running
this.debugPort = port;
logger.info(`Connecting to existing Chrome on port ${this.debugPort}...`);
await this.connectToPage();
updateProjectLastUsed();
return;
}
// No running browser found
throw new Error(`No running browser found on port ${port}`);
}
/**
* Launch Chrome in debugging mode.
* @param initialUrl - Optional initial URL to open (defaults to about:blank)
*/
async launch(initialUrl?: string): Promise<void> {
// Get project port from shared config (auto-creates if not exists)
this.debugPort = await getProjectPort();
const chromePath = this.getChromePath();
const args = [
`--remote-debugging-port=${this.debugPort}`,
'--remote-allow-origins=*',
'--no-first-run',
'--no-default-browser-check',
`--user-data-dir=${join(homedir(), `.cdp_browser_profile_${this.debugPort}`)}`
];
if (this.headless) {
args.push('--headless=new', '--disable-gpu');
}
// Add initial URL or default to blank page
if (initialUrl) {
args.push(initialUrl);
logger.info(`Launching Chrome with initial URL: ${initialUrl}`);
} else {
args.push('about:blank');
}
logger.info(`Launching Chrome on port ${this.debugPort} (headless: ${this.headless})...`);
this.chromeProcess = spawn(chromePath, args, {
stdio: 'ignore',
detached: true
});
// Detach the process so it continues running when Node exits
this.chromeProcess.unref();
// Update last used timestamp
updateProjectLastUsed();
// Wait for Chrome to be ready by polling the JSON endpoint
let attempts = 0;
const maxAttempts = 20; // 10 seconds (20 * 500ms)
let connected = false;
while (attempts < maxAttempts) {
try {
const response = await fetch(`http://${CDP.LOCALHOST}:${this.debugPort}/json/version`);
if (response.ok) {
connected = true;
break;
}
} catch (_error) {
// Connection may be refused while browser is starting up
}
attempts++;
await this.sleep(TIMING.NETWORK_IDLE_TIMEOUT);
}
if (!connected) {
throw new Error(`Failed to connect to Chrome within the timeout period (${maxAttempts * TIMING.NETWORK_IDLE_TIMEOUT / TIMING.ACTION_DELAY_NAVIGATION} seconds).`);
}
// Connect to page target
await this.connectToPage();
}
/**
* Connect to a Chrome page target.
*/
private async connectToPage(): Promise<void> {
try {
// Get list of targets
const url = `http://${CDP.LOCALHOST}:${this.debugPort}/json`;
const response = await fetch(url);
const targets = await response.json() as Target[];
// Find or create a page target
let pageTarget = targets.find(t => t.type === 'page');
if (!pageTarget) {
// Create new target
const newUrl = `http://${CDP.LOCALHOST}:${this.debugPort}/json/new`;
const newResponse = await fetch(newUrl);
pageTarget = await newResponse.json() as Target;
}
const wsUrl = pageTarget.webSocketDebuggerUrl;
logger.info(`Connecting to: ${wsUrl}`);
this.client = new CDPClient(wsUrl);
await this.client.connect();
logger.info('Connected to Chrome DevTools Protocol');
// Enable Log domain to receive console messages
await this.client.sendCommand('Log.enable');
await this.client.sendCommand('Runtime.enable');
// Enable Network domain to track network errors
await this.client.sendCommand('Network.enable');
// Set up event listeners with references for cleanup
const logEntryHandler = (params: LogEntryAddedPayload) => {
const entry = params.entry;
this.addConsoleMessage({
level: entry.level || 'log',
text: entry.text || '',
timestamp: entry.timestamp || Date.now(),
url: entry.url,
lineNumber: entry.lineNumber,
stackTrace: entry.stackTrace
});
};
const consoleApiHandler = (params: ConsoleAPICalledPayload) => {
const args = params.args || [];
const text = args.map((arg: RemoteObject) => arg.value || arg.description || '').join(' ');
this.addConsoleMessage({
level: params.type || 'log',
text: text,
timestamp: params.timestamp || Date.now(),
url: params.stackTrace?.callFrames?.[0]?.url,
lineNumber: params.stackTrace?.callFrames?.[0]?.lineNumber
});
};
const exceptionHandler = (params: ExceptionThrownPayload) => {
const exception = params.exceptionDetails;
const text = exception.exception?.description || exception.text || 'Unknown error';
this.addConsoleMessage({
level: 'error',
text: text,
timestamp: exception.timestamp || Date.now(),
url: exception.url,
lineNumber: exception.lineNumber,
stackTrace: exception.stackTrace
});
};
const requestWillBeSentHandler = (params: NetworkRequestPayload) => {
this.pendingRequests.set(params.requestId, {
url: params.request.url,
timestamp: params.timestamp !== undefined ? params.timestamp * TIMING.ACTION_DELAY_NAVIGATION : Date.now(),
processed: false
});
};
const loadingFailedHandler = (params: NetworkFailedPayload) => {
const request = this.pendingRequests.get(params.requestId);
if (request && !request.processed && !params.canceled) {
this.addNetworkError({
url: request.url,
errorText: params.errorText,
timestamp: params.timestamp !== undefined ? params.timestamp * TIMING.ACTION_DELAY_NAVIGATION : Date.now(),
requestId: params.requestId
});
request.processed = true;
}
};
const responseReceivedHandler = (params: NetworkResponsePayload) => {
const { requestId, response } = params;
const request = this.pendingRequests.get(requestId);
if (request && !request.processed && response.status >= 400) {
this.addNetworkError({
url: response.url,
errorText: `HTTP ${response.status} ${response.statusText}`,
timestamp: Date.now(),
statusCode: response.status,
requestId: requestId
});
request.processed = true;
}
};
// Register event listeners
this.client.on('Log.entryAdded', logEntryHandler);
this.eventListeners.set('Log.entryAdded', logEntryHandler);
this.client.on('Runtime.consoleAPICalled', consoleApiHandler);
this.eventListeners.set('Runtime.consoleAPICalled', consoleApiHandler);
this.client.on('Runtime.exceptionThrown', exceptionHandler);
this.eventListeners.set('Runtime.exceptionThrown', exceptionHandler);
this.client.on('Network.requestWillBeSent', requestWillBeSentHandler);
this.eventListeners.set('Network.requestWillBeSent', requestWillBeSentHandler);
this.client.on('Network.loadingFailed', loadingFailedHandler);
this.eventListeners.set('Network.loadingFailed', loadingFailedHandler);
this.client.on('Network.responseReceived', responseReceivedHandler);
this.eventListeners.set('Network.responseReceived', responseReceivedHandler);
// Start periodic cleanup of stale requests
this.cleanupInterval = setInterval(() => this.cleanupStaleRequests(), TIMING.POLLING_INTERVAL_SLOW * 10);
} catch (error) {
throw new Error(`Failed to connect to Chrome: ${error}`);
}
}
/**
* Send CDP command.
*/
async sendCommand<T = Record<string, unknown>>(
method: string,
params?: unknown
): Promise<T> {
if (!this.client) {
throw new Error('Not connected to Chrome');
}
return this.client.sendCommand<T>(method, params);
}
/**
* Get collected console messages.
*/
getConsoleMessages(): ConsoleMessage[] {
return [...this.consoleMessages];
}
/**
* Clear console messages buffer.
*/
clearConsoleMessages(): void {
this.consoleMessages = [];
}
/**
* Get collected network errors.
*/
getNetworkErrors(): NetworkError[] {
return [...this.networkErrors];
}
/**
* Clear network errors buffer.
*/
clearNetworkErrors(): void {
this.networkErrors = [];
}
/**
* Close browser and cleanup.
*/
async close(): Promise<void> {
logger.info('Closing browser...');
// Stop cleanup interval
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval);
this.cleanupInterval = null;
}
// Remove all event listeners
for (const [event, handler] of this.eventListeners.entries()) {
this.client?.off(event, handler);
}
this.eventListeners.clear();
if (this.client) {
try {
// Send Browser.close command to gracefully close the browser
await this.client.sendCommand('Browser.close');
logger.info('Browser closed via CDP command');
} catch (_error) {
logger.info('Could not close browser via CDP, it may already be closed');
}
// Close WebSocket connection
this.client.close();
}
// Force kill Chrome process if it's still running
if (this.chromeProcess) {
try {
// Check if process is still alive
if (!this.chromeProcess.killed) {
this.chromeProcess.kill('SIGTERM');
logger.info('Chrome process terminated (SIGTERM)');
// Wait a bit for graceful shutdown
await this.sleep(1000);
// Force kill if still alive
if (!this.chromeProcess.killed) {
this.chromeProcess.kill('SIGKILL');
logger.info('Chrome process force-killed (SIGKILL)');
}
}
} catch (error) {
logger.warn(`Failed to kill Chrome process: ${error instanceof Error ? error.message : String(error)}`);
}
this.chromeProcess = null;
}
// Clear pending requests
this.pendingRequests.clear();
// Clean up project config if autoCleanup is enabled
cleanupProjectIfNeeded();
}
/**
* Sleep for specified milliseconds.
*/
private sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
}

View File

@@ -0,0 +1,144 @@
/**
* CDP WebSocket Client for Chrome DevTools Protocol communication.
*/
import WebSocket from 'ws';
import { EventEmitter } from 'events';
export interface CDPMessage {
id: number;
method: string;
params?: unknown;
}
export interface CDPResponse {
id: number;
result?: unknown;
error?: {
code: number;
message: string;
};
}
export interface CDPEvent {
method: string;
params?: unknown;
}
export class CDPClient extends EventEmitter {
private ws: WebSocket | null = null;
private messageId = 0;
private readonly wsUrl: string;
constructor(wsUrl: string) {
super();
this.wsUrl = wsUrl;
}
/**
* Connect to Chrome via WebSocket.
*/
async connect(): Promise<void> {
return new Promise((resolve, reject) => {
this.ws = new WebSocket(this.wsUrl);
this.ws.on('open', () => {
// Set up global message handler for CDP events
if (this.ws) {
this.ws.on('message', (data: WebSocket.Data) => {
try {
const message = JSON.parse(data.toString());
// CDP events don't have 'id' field, only 'method' and 'params'
if (!message.id && message.method) {
this.emit('event', message as CDPEvent);
this.emit(message.method, message.params);
}
} catch (_error) {
// Ignore parse errors
}
});
}
resolve();
});
this.ws.on('error', (error) => {
reject(error);
});
});
}
/**
* Send CDP command and wait for response.
*/
async sendCommand<T = Record<string, unknown>>(
method: string,
params?: unknown
): Promise<T> {
if (!this.ws) {
throw new Error('Not connected to Chrome');
}
this.messageId++;
const message: CDPMessage = {
id: this.messageId,
method,
params: params || {}
};
return new Promise((resolve, reject) => {
const currentMessageId = this.messageId;
const cleanup = () => {
this.ws?.removeListener('message', messageHandler);
};
const messageHandler = (data: WebSocket.Data) => {
try {
const response: CDPResponse = JSON.parse(data.toString());
if (response.id === currentMessageId) {
cleanup();
if (response.error) {
reject(new Error(`CDP Error: ${JSON.stringify(response.error)}`));
} else {
resolve((response.result || {}) as T);
}
}
} catch (_error) {
// Ignore parse errors for other messages
}
};
if (!this.ws) {
reject(new Error('WebSocket connection lost'));
return;
}
this.ws.on('message', messageHandler);
try {
this.ws.send(JSON.stringify(message));
} catch (error) {
cleanup();
reject(error);
}
});
}
/**
* Close WebSocket connection.
*/
close(): void {
if (this.ws) {
try {
this.ws.close();
} catch (_error) {
// Ignore close errors
}
this.ws = null;
}
}
}

View File

@@ -0,0 +1,329 @@
/**
* Configuration management for browser debugging port and state.
* Uses a shared config file in the plugin folder for multi-project support.
*/
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
import { join, basename } from 'path';
import { createServer } from 'net';
import { findProjectRoot } from './utils';
import { CDP, FS } from '../constants';
import { logger } from '../utils/logger';
export interface ProjectConfig {
rootPath: string;
port: number;
outputDir: string;
lastUsed: string | null;
autoCleanup: boolean;
autoRestore?: boolean; // Auto-restore last visited URL (default: true)
}
export interface SharedBrowserPilotConfig {
projects: {
[projectName: string]: ProjectConfig;
};
}
/**
* Get local timestamp string (same format as logger)
* Format: YYYY-MM-DD HH:MM:SS.mmm
*/
function getLocalTimestamp(): string {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
const hours = String(now.getHours()).padStart(2, '0');
const minutes = String(now.getMinutes()).padStart(2, '0');
const seconds = String(now.getSeconds()).padStart(2, '0');
const milliseconds = String(now.getMilliseconds()).padStart(3, '0');
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}.${milliseconds}`;
}
/**
* Get shared config file path in plugin skills folder
* Uses hardcoded home directory path for reliability
*/
function getSharedConfigPath(): string {
const { homedir } = require('os');
const homeDir = homedir();
return join(
homeDir,
'.claude',
'plugins',
'marketplaces',
'dev-gom-plugins',
'plugins',
'browser-pilot',
'skills',
'browser-pilot-config.json'
);
}
/**
* Get project name from root folder name
*/
function getProjectName(projectRoot: string): string {
return basename(projectRoot);
}
/**
* Get output directory for the current project
* Creates .browser-pilot folder in project root
*/
export function getOutputDir(): string {
const projectRoot = findProjectRoot();
const outputDir = join(projectRoot, FS.OUTPUT_DIR);
// Ensure .browser-pilot directory exists
if (!existsSync(outputDir)) {
mkdirSync(outputDir, { recursive: true });
}
// Always ensure .gitignore exists in .browser-pilot
const gitignorePath = join(outputDir, '.gitignore');
if (!existsSync(gitignorePath)) {
writeFileSync(gitignorePath, FS.GITIGNORE_CONTENT, 'utf-8');
}
return outputDir;
}
/**
* Load shared configuration from plugin folder
* Auto-creates default config if not exists
*/
export function loadSharedConfig(): SharedBrowserPilotConfig {
const configPath = getSharedConfigPath();
if (!existsSync(configPath)) {
// Auto-create default config
const defaultConfig: SharedBrowserPilotConfig = {
projects: {}
};
saveSharedConfig(defaultConfig);
return defaultConfig;
}
try {
const data = readFileSync(configPath, 'utf-8');
return JSON.parse(data);
} catch (error) {
logger.error('Failed to load shared config', error);
logger.warn('Returning empty config - existing project settings may be lost');
logger.warn(`Config path: ${configPath}`);
return {
projects: {}
};
}
}
/**
* Save shared configuration to plugin folder
*/
export function saveSharedConfig(config: SharedBrowserPilotConfig): void {
const configPath = getSharedConfigPath();
try {
writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
} catch (error) {
logger.error('Failed to save shared config', error);
logger.warn(`Config path: ${configPath}`);
throw new Error('Configuration save failed. Please check file permissions.');
}
}
/**
* Get configuration for current project
* Auto-creates with available port if not exists
*/
export async function getProjectConfig(): Promise<ProjectConfig> {
const projectRoot = findProjectRoot();
const projectName = getProjectName(projectRoot);
const sharedConfig = loadSharedConfig();
// Find existing config by rootPath (in case name changed)
const existingEntry = Object.entries(sharedConfig.projects).find(
([_, config]) => config.rootPath === projectRoot
);
if (existingEntry) {
const [existingName, config] = existingEntry;
// If name changed, update key
if (existingName !== projectName) {
delete sharedConfig.projects[existingName];
sharedConfig.projects[projectName] = config;
saveSharedConfig(sharedConfig);
logger.info(`📝 Updated project name: ${existingName}${projectName}`);
}
return config;
}
// Check if name already exists (different path)
if (sharedConfig.projects[projectName]) {
logger.warn(`⚠️ Project name "${projectName}" already exists with different path`);
logger.warn(` Existing: ${sharedConfig.projects[projectName].rootPath}`);
logger.warn(` Current: ${projectRoot}`);
throw new Error(`Project name conflict: "${projectName}"`);
}
// Create new project config with available port
const basePort = parseInt(process.env.CDP_DEBUG_PORT || String(CDP.DEFAULT_PORT));
// Find next available port that's not used by any project
const usedPorts = Object.values(sharedConfig.projects).map(p => p.port);
let port = basePort;
// Find first available port not in use by other projects
while (usedPorts.includes(port) || !(await isPortAvailable(port))) {
port++;
if (port > basePort + CDP.PORT_RANGE_MAX) {
throw new Error(`No available port found in range ${basePort}-${basePort + CDP.PORT_RANGE_MAX}`);
}
}
const projectConfig: ProjectConfig = {
rootPath: projectRoot,
port,
outputDir: FS.OUTPUT_DIR,
lastUsed: getLocalTimestamp(),
autoCleanup: false // Default to false for safety
};
// Save new project config
sharedConfig.projects[projectName] = projectConfig;
saveSharedConfig(sharedConfig);
logger.info(`📝 Created config for project: ${projectName}`);
logger.info(` Path: ${projectRoot}`);
logger.info(` Port: ${port}`);
return projectConfig;
}
/**
* Update last used timestamp for current project
*/
export function updateProjectLastUsed(): void {
const projectRoot = findProjectRoot();
const projectName = getProjectName(projectRoot);
const sharedConfig = loadSharedConfig();
if (sharedConfig.projects[projectName]) {
sharedConfig.projects[projectName].lastUsed = getLocalTimestamp();
saveSharedConfig(sharedConfig);
}
}
/**
* Get debug port for current project
*/
export async function getProjectPort(): Promise<number> {
const config = await getProjectConfig();
return config.port;
}
/**
* Clean up project config if autoCleanup is enabled
*/
export function cleanupProjectIfNeeded(): void {
const projectRoot = findProjectRoot();
const projectName = getProjectName(projectRoot);
const sharedConfig = loadSharedConfig();
const projectConfig = sharedConfig.projects[projectName];
if (projectConfig && projectConfig.autoCleanup) {
delete sharedConfig.projects[projectName];
saveSharedConfig(sharedConfig);
logger.info(`🗑️ Auto-cleaned config for project: ${projectName}`);
}
}
/**
* Set autoCleanup flag for current project
*/
export function setAutoCleanup(enabled: boolean): void {
const projectRoot = findProjectRoot();
const projectName = getProjectName(projectRoot);
const sharedConfig = loadSharedConfig();
if (sharedConfig.projects[projectName]) {
sharedConfig.projects[projectName].autoCleanup = enabled;
saveSharedConfig(sharedConfig);
logger.info(`${enabled ? '✅' : '❌'} Auto-cleanup ${enabled ? 'enabled' : 'disabled'} for: ${projectName}`);
}
}
/**
* Reset configuration for current project
*/
export function resetProjectConfig(): void {
const projectRoot = findProjectRoot();
const projectName = getProjectName(projectRoot);
const sharedConfig = loadSharedConfig();
delete sharedConfig.projects[projectName];
saveSharedConfig(sharedConfig);
logger.info(`🗑️ Removed config for project: ${projectName}`);
}
/**
* List all configured projects
*/
export function listProjects(): void {
const sharedConfig = loadSharedConfig();
const projects = Object.entries(sharedConfig.projects);
if (projects.length === 0) {
logger.info('No projects configured yet.');
return;
}
logger.info(`\n📋 Configured Projects (${projects.length}):\n`);
projects.forEach(([name, config]) => {
logger.info(` ${name}`);
logger.info(` ├─ Path: ${config.rootPath}`);
logger.info(` ├─ Port: ${config.port}`);
logger.info(` ├─ Output: ${config.outputDir}`);
logger.info(` ├─ Auto-cleanup: ${config.autoCleanup ? 'Yes' : 'No'}`);
logger.info(` └─ Last Used: ${config.lastUsed || 'Never'}\n`);
});
}
/**
* Check if a port is available (not in use)
*/
export async function isPortAvailable(port: number): Promise<boolean> {
return new Promise((resolve) => {
const server = createServer();
server.once('error', () => {
resolve(false);
});
server.once('listening', () => {
server.close();
resolve(true);
});
// Listen on 127.0.0.1 specifically (same as Chrome)
server.listen(port, CDP.LOCALHOST);
});
}
/**
* Find an available port starting from startPort
*/
export async function findAvailablePort(startPort = CDP.DEFAULT_PORT, maxAttempts = 10): Promise<number> {
for (let port = startPort; port < startPort + maxAttempts; port++) {
if (await isPortAvailable(port)) {
return port;
}
}
throw new Error(`No available port found in range ${startPort}-${startPort + maxAttempts - 1}`);
}

View File

@@ -0,0 +1,309 @@
/**
* Browser script to generate interaction map.
* This script runs in the browser context via Runtime.evaluate.
*/
export interface InteractionElement {
id: string;
type: string;
tag: string;
text?: string;
value?: string;
selectors: {
byText?: string; // Text-based XPath: //*[contains(text(), '...')]
byId?: string; // ID selector: #button-id
byCSS?: string; // CSS selector: button.class-name
byRole?: string; // Role-based: button, input, etc.
byAriaLabel?: string; // ARIA label: [aria-label="..."]
};
attributes: Record<string, unknown>;
position: { x: number; y: number };
visibility: {
inViewport: boolean;
visible: boolean;
obscured: boolean;
};
context?: {
parent?: string;
section?: string;
};
}
/**
* Generate the browser script that finds all interactive elements.
* Returns a string that can be executed via Runtime.evaluate.
*/
export function getInteractionMapScript(): string {
return `
(function() {
const elements = [];
let idCounter = 0;
// Helper: Escape text for XPath (handles both single and double quotes)
function escapeXPath(text) {
// If no quotes, use single quotes
if (!text.includes("'") && !text.includes('"')) {
return "'" + text + "'";
}
// If only single quotes, use double quotes
if (text.includes("'") && !text.includes('"')) {
return '"' + text + '"';
}
// If only double quotes, use single quotes
if (!text.includes("'") && text.includes('"')) {
return "'" + text + "'";
}
// Both present: use concat()
const parts = text.split("'");
const escaped = parts.map((part, i) => {
if (i === 0) return "'" + part + "'";
return "\\"'\\"," + "'" + part + "'";
}).join(',');
return "concat(" + escaped + ")";
}
// Helper: Generate Browser Pilot compatible selectors
function getBrowserPilotSelectors(el) {
const selectors = {};
// 1. Text-based XPath (most stable for Browser Pilot)
const text = el.textContent?.trim();
if (text && text.length > 0 && text.length <= 50) {
const tagName = el.tagName.toLowerCase();
selectors.byText = "//" + tagName + "[contains(text(), " + escapeXPath(text) + ")]";
}
// 2. ID selector (best if available)
if (el.id) {
selectors.byId = '#' + el.id;
}
// 3. CSS selector (with safe classes only)
if (el.className) {
const className = typeof el.className === 'string'
? el.className
: (el.className.baseVal || '');
if (className && className.trim) {
const classes = className.trim().split(/\\s+/)
.filter(cls => /^[a-zA-Z0-9_-]+$/.test(cls))
.slice(0, 3);
if (classes.length > 0) {
selectors.byCSS = el.tagName.toLowerCase() + '.' + classes.join('.');
}
}
}
// Fallback CSS selector (tag only)
if (!selectors.byCSS) {
selectors.byCSS = el.tagName.toLowerCase();
}
// 4. Role-based selector
const role = el.getAttribute('role');
if (role) {
selectors.byRole = '[role="' + role + '"]';
}
// 5. ARIA label selector
const ariaLabel = el.getAttribute('aria-label');
if (ariaLabel) {
// For CSS selectors, escape both quotes properly
const escapedLabel = ariaLabel.replace(/\\\\/g, '\\\\\\\\').replace(/'/g, "\\\\'").replace(/"/g, '\\\\"');
selectors.byAriaLabel = "[aria-label='" + escapedLabel + "']";
}
return selectors;
}
// Helper: Check if element is actually visible and interactive
function isInteractive(el) {
const style = window.getComputedStyle(el);
const rect = el.getBoundingClientRect();
// Check visibility
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') {
return false;
}
// Check size
if (rect.width === 0 || rect.height === 0) {
return false;
}
// Check pointer events (but allow standard interactive elements even if disabled)
const isStandardInteractive = ['BUTTON', 'INPUT', 'SELECT', 'TEXTAREA', 'A'].includes(el.tagName);
if (style.pointerEvents === 'none' && !isStandardInteractive) {
return false;
}
return true;
}
// Helper: Check if element is obscured
function isObscured(el) {
const rect = el.getBoundingClientRect();
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
const topElement = document.elementFromPoint(centerX, centerY);
return topElement !== el && !el.contains(topElement);
}
// Helper: Get parent context
function getContext(el) {
const context = {};
// Find parent with aria-label or role
let parent = el.parentElement;
while (parent && parent !== document.body) {
if (parent.getAttribute('aria-label')) {
context.section = parent.getAttribute('aria-label');
break;
}
if (parent.getAttribute('role') === 'group' || parent.getAttribute('role') === 'region') {
context.parent = parent.tagName.toLowerCase();
break;
}
parent = parent.parentElement;
}
return context;
}
// Helper: Check if element has React/Vue event handlers
function hasEventHandlers(el) {
// React event handlers
if (el._reactProps?.onClick) return true;
if (el.__reactEventHandlers$?.onClick) return true;
// Check for React fiber props
const keys = Object.keys(el);
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
if (key.startsWith('__reactProps') || key.startsWith('__reactEventHandlers')) {
const props = el[key];
if (props && props.onClick) return true;
}
}
return false;
}
// Find all interactive elements
const selectors = [
// Standard interactive elements
'button',
'a[href]',
'input',
'select',
'textarea',
// ARIA roles (only interactive ones)
'[role="button"]',
'[role="link"]',
'[role="checkbox"]',
'[role="radio"]',
'[role="radiogroup"]',
'[role="combobox"]',
'[role="tab"]',
'[role="menuitem"]',
'[role="switch"]',
'[role="slider"]',
// Event handlers
'[onclick]',
'[onmousedown]',
'[onmouseup]'
];
const foundElements = new Set();
// Collect elements by selector
selectors.forEach(selector => {
document.querySelectorAll(selector).forEach(el => {
if (!foundElements.has(el) && isInteractive(el)) {
foundElements.add(el);
}
});
});
// Additional: Find clickable elements by cursor:pointer or React handlers
// Check ALL elements for cursor:pointer or event handlers
document.querySelectorAll('*').forEach(el => {
if (foundElements.has(el)) return;
const style = window.getComputedStyle(el);
// Include if has cursor:pointer AND is interactive
if (style.cursor === 'pointer' && isInteractive(el)) {
foundElements.add(el);
return;
}
// Include if has React event handlers (only check button-like classes for performance)
if (el.className && typeof el.className === 'string') {
const hasButtonClass = /button|btn|card|item|clickable/.test(el.className);
if (hasButtonClass && hasEventHandlers(el) && isInteractive(el)) {
foundElements.add(el);
}
}
});
// Process found elements
Array.from(foundElements).forEach(el => {
const rect = el.getBoundingClientRect();
// Calculate absolute position (viewport coordinates + scroll offset)
const absoluteX = Math.round(rect.left + window.pageXOffset + rect.width / 2);
const absoluteY = Math.round(rect.top + window.pageYOffset + rect.height / 2);
// Check if element is currently in viewport
const inViewport = (
rect.top >= 0 &&
rect.left >= 0 &&
rect.bottom <= window.innerHeight &&
rect.right <= window.innerWidth
);
const element = {
id: 'elem_' + (idCounter++),
type: el.tagName.toLowerCase() === 'input' ? 'input-' + (el.type || 'text') :
el.tagName.toLowerCase() === 'select' ? 'select' :
el.tagName.toLowerCase() === 'textarea' ? 'textarea' :
el.getAttribute('role') ? 'role-' + el.getAttribute('role') :
el.tagName.toLowerCase(),
tag: el.tagName.toLowerCase(),
text: el.textContent?.trim().substring(0, 100) || null,
value: el.value || null,
selectors: getBrowserPilotSelectors(el),
attributes: {
id: el.id || null,
class: el.className || null,
name: el.getAttribute('name') || null,
type: el.getAttribute('type') || null,
role: el.getAttribute('role') || null,
'aria-label': el.getAttribute('aria-label') || null,
disabled: el.disabled || el.getAttribute('aria-disabled') === 'true',
placeholder: el.getAttribute('placeholder') || null
},
position: {
x: absoluteX,
y: absoluteY
},
visibility: {
inViewport: inViewport,
visible: true,
obscured: inViewport ? isObscured(el) : false
},
context: getContext(el)
};
elements.push(element);
});
return elements;
})()
`;
}

View File

@@ -0,0 +1,386 @@
/**
* Query interaction map to find elements by various criteria
*/
import * as fs from 'fs';
import * as path from 'path';
import { execSync } from 'child_process';
import { InteractionElement } from './generate-interaction-map';
import { SELECTOR_RETRY_CONFIG } from '../actions/helpers';
import { getOutputDir } from '../config';
import { CDP, TIMING } from '../../constants';
import { logger } from '../../utils/logger';
// Re-export for protocol usage
export type { InteractionElement };
export interface InteractionMap {
url: string;
timestamp: string;
ready?: boolean; // Map is fully generated and ready for use
viewport: { width: number; height: number };
elements: Record<string, InteractionElement>;
indexes: {
byText: Record<string, string[]>;
byType: Record<string, string[]>;
inViewport: string[];
};
statistics: {
total: number;
byType: Record<string, number>;
duplicates: number;
};
}
export interface QueryOptions {
text?: string; // Search by text content
type?: string; // Filter by element type (supports aliases: "input" → "input-*")
tag?: string; // Filter by HTML tag (e.g., "input", "button")
index?: number; // Select nth match (1-based)
viewportOnly?: boolean; // Only visible elements
id?: string; // Direct ID lookup
listTypes?: boolean; // List all element types with counts
listTexts?: boolean; // List all text contents
limit?: number; // Maximum results to return (default: 20)
offset?: number; // Number of results to skip (default: 0)
verbose?: boolean; // Include detailed information
}
export interface QueryResult {
element: InteractionElement;
selector: string; // Best selector to use
alternatives: string[]; // Alternative selectors
}
/**
* Load interaction map from file with ready flag check
* @param mapPath Optional path to map file
* @param waitForReady If true, poll until map is ready (default: false)
* @param timeout Maximum wait time in milliseconds (default: 10000)
*/
export function loadMap(
mapPath?: string,
waitForReady: boolean = false,
timeout: number = 10000
): InteractionMap {
const defaultPath = path.join(getOutputDir(), SELECTOR_RETRY_CONFIG.MAP_FILENAME);
const filePath = mapPath || defaultPath;
if (!fs.existsSync(filePath)) {
throw new Error(`Map file not found: ${filePath}`);
}
const content = fs.readFileSync(filePath, 'utf-8');
const map = JSON.parse(content) as InteractionMap;
// If not waiting for ready or already ready, return immediately
if (!waitForReady || map.ready === true) {
return map;
}
// Poll until ready or timeout
const startTime = Date.now();
const pollInterval = TIMING.POLLING_INTERVAL_FAST;
while (Date.now() - startTime < timeout) {
// Sleep using platform-appropriate command
try {
if (process.platform === 'win32') {
execSync(`ping -n 1 -w ${pollInterval} ${CDP.LOCALHOST} >nul`, { stdio: 'ignore' });
} else {
execSync(`sleep ${pollInterval / TIMING.ACTION_DELAY_NAVIGATION}`, { stdio: 'ignore' });
}
} catch {
// Ignore errors, just continue polling
}
// Re-read map
if (!fs.existsSync(filePath)) {
throw new Error(`Map file disappeared during polling: ${filePath}`);
}
const newContent = fs.readFileSync(filePath, 'utf-8');
const newMap = JSON.parse(newContent) as InteractionMap;
if (newMap.ready === true) {
return newMap;
}
}
throw new Error(`Map did not become ready within ${timeout}ms`);
}
/**
* Select best selector for an element
* Priority: byId > byText(indexed) > byCSS > byRole > byAriaLabel
*/
export function selectBestSelector(element: InteractionElement): string {
const { selectors } = element;
if (selectors.byId) {
return selectors.byId;
}
if (selectors.byText) {
return selectors.byText;
}
if (selectors.byCSS && selectors.byCSS !== element.tag) {
// Skip generic tag-only selectors
return selectors.byCSS;
}
if (selectors.byRole) {
return selectors.byRole;
}
if (selectors.byAriaLabel) {
return selectors.byAriaLabel;
}
// Fallback to CSS
return selectors.byCSS || element.tag;
}
/**
* Get all alternative selectors for an element
*/
export function getAlternativeSelectors(element: InteractionElement): string[] {
const alternatives: string[] = [];
const { selectors } = element;
if (selectors.byId) alternatives.push(selectors.byId);
if (selectors.byText) alternatives.push(selectors.byText);
if (selectors.byCSS) alternatives.push(selectors.byCSS);
if (selectors.byRole) alternatives.push(selectors.byRole);
if (selectors.byAriaLabel) alternatives.push(selectors.byAriaLabel);
return alternatives;
}
/**
* Escape special regex characters in a string
*/
function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* Expand type alias to include all matching types
* Examples:
* - "input" → ["input", "input-text", "input-search", "input-password", ...]
* - "button" → ["button", "button-submit", "button-reset", ...]
* - "input-search" → ["input-search"] (exact match, no expansion)
*/
export function expandTypeAlias(type: string, availableTypes: string[]): string[] {
// If type contains a hyphen, it's a specific type (no expansion)
if (type.includes('-')) {
return [type];
}
// Escape regex special characters to prevent regex injection
const escapedType = escapeRegex(type);
// Expand to include all types starting with the alias
const pattern = new RegExp(`^${escapedType}(-.*)?$`);
const matches = availableTypes.filter(t => pattern.test(t));
// If no matches found, return original type
return matches.length > 0 ? matches : [type];
}
/**
* Query map for elements matching criteria
*/
export function queryMap(map: InteractionMap, options: QueryOptions): QueryResult[] {
let candidateIds: string[] = [];
// Direct ID lookup
if (options.id) {
const element = map.elements[options.id];
if (!element) {
return [];
}
return [{
element,
selector: selectBestSelector(element),
alternatives: getAlternativeSelectors(element)
}];
}
// Text-based search
if (options.text) {
candidateIds = map.indexes.byText[options.text] || [];
if (candidateIds.length === 0) {
// Fuzzy search: find texts containing the query
const matchingTexts = Object.keys(map.indexes.byText).filter(text =>
options.text && text.toLowerCase().includes(options.text.toLowerCase())
);
matchingTexts.forEach(text => {
candidateIds.push(...map.indexes.byText[text]);
});
}
} else {
// No text filter: start with all elements
candidateIds = Object.keys(map.elements);
}
// Type filter (with alias expansion)
if (options.type) {
const availableTypes = Object.keys(map.indexes.byType);
const expandedTypes = expandTypeAlias(options.type, availableTypes);
const typeIds = expandedTypes.flatMap(type => map.indexes.byType[type] || []);
candidateIds = candidateIds.filter(id => typeIds.includes(id));
// Log type expansion if expansion occurred
if (expandedTypes.length > 1) {
logger.debug(`Type alias "${options.type}" expanded to: ${expandedTypes.join(', ')}`);
}
}
// Tag filter (HTML tag name)
if (options.tag) {
const tagLower = options.tag.toLowerCase();
candidateIds = candidateIds.filter(id => {
const element = map.elements[id];
return element && element.tag.toLowerCase() === tagLower;
});
}
// Viewport filter
if (options.viewportOnly) {
const viewportIds = map.indexes.inViewport;
candidateIds = candidateIds.filter(id => viewportIds.includes(id));
}
// Remove duplicates
candidateIds = Array.from(new Set(candidateIds));
// Convert IDs to QueryResults
const results: QueryResult[] = candidateIds.map(id => {
const element = map.elements[id];
return {
element,
selector: selectBestSelector(element),
alternatives: getAlternativeSelectors(element)
};
});
// Apply pagination (limit/offset)
const limit = options.limit !== undefined ? options.limit : 20;
const offset = options.offset || 0;
// Index selection (takes priority over pagination)
if (options.index !== undefined && options.index > 0) {
const selected = results[options.index - 1];
return selected ? [selected] : [];
}
// Apply offset and limit
if (limit === 0) {
// 0 means unlimited
return results.slice(offset);
}
return results.slice(offset, offset + limit);
}
/**
* Find element and return best selector
* @param mapPath Path to map file
* @param options Query options
* @param waitForReady If true, wait for map to be ready before querying (default: true)
* @param timeout Maximum wait time in milliseconds (default: 10000)
*/
export function findSelector(
mapPath: string | undefined,
options: QueryOptions,
waitForReady: boolean = true,
timeout: number = 10000
): string | null {
try {
const map = loadMap(mapPath, waitForReady, timeout);
const results = queryMap(map, options);
if (results.length === 0) {
return null;
}
// Return the best selector of the first result
return results[0].selector;
} catch (error: unknown) {
logger.error('Error querying map', error);
return null;
}
}
/**
* Find element with fallback to alternatives
*/
export function findSelectorWithFallback(
mapPath: string | undefined,
options: QueryOptions
): { selector: string; alternatives: string[] } | null {
try {
const map = loadMap(mapPath);
const results = queryMap(map, options);
if (results.length === 0) {
return null;
}
return {
selector: results[0].selector,
alternatives: results[0].alternatives
};
} catch (error: unknown) {
logger.error('Error querying map', error);
return null;
}
}
/**
* List all element types with counts from map
*/
export function listTypes(map: InteractionMap): Record<string, number> {
return map.statistics.byType;
}
/**
* List all text contents with their types from map
*/
export function listTexts(
map: InteractionMap,
options?: { type?: string; limit?: number; offset?: number }
): Array<{ text: string; type: string; count: number }> {
const limit = options?.limit !== undefined ? options.limit : 20;
const offset = options?.offset || 0;
const typeFilter = options?.type;
const textList: Array<{ text: string; type: string; count: number }> = [];
// Iterate through text index
for (const [text, elementIds] of Object.entries(map.indexes.byText)) {
// Get first element to determine type
const firstElement = map.elements[elementIds[0]];
if (!firstElement) continue;
// Apply type filter if specified
if (typeFilter && firstElement.type !== typeFilter) {
continue;
}
textList.push({
text,
type: firstElement.type,
count: elementIds.length
});
}
// Apply pagination
if (limit === 0) {
return textList.slice(offset);
}
return textList.slice(offset, offset + limit);
}

View File

@@ -0,0 +1,179 @@
/**
* Utility functions for Browser Pilot
*/
import { readFileSync, existsSync } from 'fs';
import { join, normalize, resolve } from 'path';
import { logger } from '../utils/logger';
import { TIMING } from '../constants';
interface ProjectConfig {
rootPath: string;
port: number;
outputDir: string;
lastUsed: string | null;
autoCleanup: boolean;
}
interface SharedBrowserPilotConfig {
projects: {
[projectName: string]: ProjectConfig;
};
}
/**
* Get shared config file path in plugin skills folder
* Uses hardcoded home directory path for reliability
*/
function getSharedConfigPath(): string {
const { homedir } = require('os');
const homeDir = homedir();
return join(
homeDir,
'.claude',
'plugins',
'marketplaces',
'dev-gom-plugins',
'plugins',
'browser-pilot',
'skills',
'browser-pilot-config.json'
);
}
/**
* Load shared configuration from plugin folder
*/
function loadSharedConfig(): SharedBrowserPilotConfig {
const configPath = getSharedConfigPath();
if (!existsSync(configPath)) {
return { projects: {} };
}
try {
const data = readFileSync(configPath, 'utf-8');
return JSON.parse(data);
} catch (_error) {
return { projects: {} };
}
}
/**
* Compare two paths for equality (cross-platform, case-insensitive on Windows)
*/
function pathsEqual(path1: string, path2: string): boolean {
return normalize(resolve(path1)).toLowerCase() ===
normalize(resolve(path2)).toLowerCase();
}
/**
* Get project root directory.
*
* Strategy (in order of priority):
* 1. CLAUDE_PROJECT_DIR environment variable
* 2. Shared config file (if running from scripts folder)
*
* No fallback to process.cwd() - requires explicit project configuration.
*/
export function findProjectRoot(): string {
// 1. Environment variable has highest priority
if (process.env.CLAUDE_PROJECT_DIR) {
return process.env.CLAUDE_PROJECT_DIR;
}
const cwd = process.cwd();
// 2. If running from scripts folder, check shared config
// More robust check: compare exact path (cross-platform, case-insensitive)
const scriptsDir = join(__dirname, '..', '..');
if (pathsEqual(cwd, scriptsDir)) {
try {
const config = loadSharedConfig();
const projects = Object.values(config.projects);
if (projects.length === 1) {
// Only one project configured, use it
return projects[0].rootPath;
} else if (projects.length > 1) {
// Multiple projects: use the most recently used one
const sorted = projects.sort((a, b) => {
// Handle invalid dates: treat as 0 to ensure predictable sorting
const aTime = new Date(a.lastUsed || 0).getTime();
const bTime = new Date(b.lastUsed || 0).getTime();
return (isNaN(bTime) ? 0 : bTime) - (isNaN(aTime) ? 0 : aTime);
});
return sorted[0].rootPath;
}
} catch (error) {
logger.error(`Failed to load shared config: ${error}`);
throw new Error('Could not determine project root: CLAUDE_PROJECT_DIR not set and no projects in shared config');
}
}
// No fallback to process.cwd() - require explicit project configuration
throw new Error('Could not determine project root: CLAUDE_PROJECT_DIR not set');
}
/**
* Returns the findElement helper function as a JavaScript string
* for injection into browser context.
*
* Supports:
* - CSS selectors: 'button.primary'
* - XPath selectors: '//button[@id="submit"]'
* - XPath with indexing: '(//button[text()="Click"])[2]'
*/
export function getFindElementScript(): string {
return `
function findElement(sel) {
if (sel.startsWith('//') || sel.startsWith('(//')) {
// XPath selector - check for indexing pattern: (...)[N]
const indexMatch = sel.match(/^\\((.*)\\)\\[(\\d+)\\]$/);
if (indexMatch) {
// Has indexing: (//xpath)[N]
const xpath = indexMatch[1];
const index = parseInt(indexMatch[2]) - 1; // XPath is 1-based, JS is 0-based
const result = document.evaluate(
xpath,
document,
null,
XPathResult.ORDERED_NODE_SNAPSHOT_TYPE,
null
);
return result.snapshotItem(index);
} else {
// No indexing - return first match
const result = document.evaluate(
sel,
document,
null,
XPathResult.FIRST_ORDERED_NODE_TYPE,
null
);
return result.singleNodeValue;
}
} else {
// CSS selector
return document.querySelector(sel);
}
}
`;
}
/**
* Human-like random delay to avoid bot detection
* @param minMs Minimum delay in milliseconds (default: ACTION_DELAY_MEDIUM * 3)
* @param maxMs Maximum delay in milliseconds (default: ACTION_DELAY_LONG + ACTION_DELAY_MEDIUM * 3)
* @returns Promise that resolves after the delay
*/
export function humanDelay(
minMs: number = TIMING.ACTION_DELAY_MEDIUM * 3,
maxMs: number = TIMING.ACTION_DELAY_LONG + TIMING.ACTION_DELAY_MEDIUM * 3
): Promise<void> {
const delayMs = Math.floor(Math.random() * (maxMs - minMs + 1)) + minMs;
return new Promise(resolve => setTimeout(resolve, delayMs));
}

View File

@@ -0,0 +1,58 @@
#!/usr/bin/env node
/**
* CDP Browser CLI - Chrome DevTools Protocol browser automation tool.
*/
import { Command } from 'commander';
import { registerNavigationCommands } from './commands/navigation';
import { registerInteractionCommands } from './commands/interaction';
import { registerFormsCommands } from './commands/forms';
import { registerCaptureCommands } from './commands/capture';
import { registerTabsCommands } from './commands/tabs';
import { registerCookiesCommands } from './commands/cookies';
import { registerConsoleCommands } from './commands/console';
import { registerNetworkCommands } from './commands/network';
import { registerEmulationCommands } from './commands/emulation';
import { registerDialogsCommands } from './commands/dialogs';
import { registerScrollCommands } from './commands/scroll';
import { registerWaitCommands } from './commands/wait';
import { registerDataCommands } from './commands/data';
import { registerFocusCommands } from './commands/focus';
import { registerAccessibilityCommands } from './commands/accessibility';
import { registerDaemonCommands } from './commands/daemon';
import { registerChainCommands } from './commands/chain';
import { registerQueryCommands } from './commands/query';
import { registerSystemCommands } from './commands/system';
const program = new Command();
program
.name('cdp-browser')
.description('Chrome DevTools Protocol browser automation CLI')
.version('1.0.0')
.addHelpText('after', '\nTip: Use "<command> --help" to see detailed options for each command.\nExample: cdp-browser navigate --help');
// Register all command groups
registerDaemonCommands(program); // Daemon management first
registerSystemCommands(program); // System maintenance commands
registerChainCommands(program); // Chain mode for sequential execution
registerNavigationCommands(program);
registerInteractionCommands(program);
registerFormsCommands(program);
registerCaptureCommands(program);
registerTabsCommands(program);
registerCookiesCommands(program);
registerConsoleCommands(program);
registerNetworkCommands(program);
registerEmulationCommands(program);
registerDialogsCommands(program);
registerScrollCommands(program);
registerWaitCommands(program);
registerDataCommands(program);
registerFocusCommands(program);
registerAccessibilityCommands(program);
registerQueryCommands(program); // Query interaction map
// Parse command line arguments
program.parse();

View File

@@ -0,0 +1,30 @@
import { Command } from 'commander';
import { ChromeBrowser } from '../../cdp/browser';
import * as actions from '../../cdp/actions';
import { logger } from '../../utils/logger';
export function registerAccessibilityCommands(program: Command) {
// Get accessibility snapshot
program
.command('accessibility')
.description('Get accessibility tree snapshot (ARIA roles, labels, and screen reader info)')
.option('-u, --url <url>', 'Navigate to URL first')
.action(async (options) => {
const browser = new ChromeBrowser(false);
try {
await browser.connect();
if (options.url) {
await actions.navigate(browser, options.url);
await actions.waitForLoad(browser);
}
const result = await actions.getAccessibilitySnapshot(browser);
console.log(`Accessibility nodes: ${result.nodeCount}`);
console.log('First 50 nodes:', JSON.stringify(result.nodes, null, 2));
console.log('Browser remains open. Use "close" command to close it.');
process.exit(0);
} catch (error) {
logger.error('Command execution failed', error);
process.exit(1);
}
});
}

View File

@@ -0,0 +1,237 @@
import { Command } from 'commander';
import { executeViaDaemon } from '../daemon-helper';
/**
* Type guard for viewport response data
*/
function isViewportResponse(data: unknown): data is { viewport: { width: number; height: number; devicePixelRatio: number } } {
return (
typeof data === 'object' &&
data !== null &&
'viewport' in data &&
typeof (data as Record<string, unknown>).viewport === 'object' &&
(data as Record<string, unknown>).viewport !== null &&
'width' in ((data as Record<string, unknown>).viewport as object) &&
'height' in ((data as Record<string, unknown>).viewport as object) &&
'devicePixelRatio' in ((data as Record<string, unknown>).viewport as object) &&
typeof ((data as Record<string, unknown>).viewport as Record<string, unknown>).width === 'number' &&
typeof ((data as Record<string, unknown>).viewport as Record<string, unknown>).height === 'number' &&
typeof ((data as Record<string, unknown>).viewport as Record<string, unknown>).devicePixelRatio === 'number'
);
}
/**
* Type guard for screen info response data
*/
function isScreenInfoResponse(data: unknown): data is {
viewport: { width: number; height: number };
screen: { width: number; height: number; availWidth: number; availHeight: number };
devicePixelRatio: number;
} {
if (typeof data !== 'object' || data === null) return false;
const d = data as Record<string, unknown>;
// Check viewport
if (typeof d.viewport !== 'object' || d.viewport === null) return false;
const viewport = d.viewport as Record<string, unknown>;
if (typeof viewport.width !== 'number' || typeof viewport.height !== 'number') return false;
// Check screen
if (typeof d.screen !== 'object' || d.screen === null) return false;
const screen = d.screen as Record<string, unknown>;
if (
typeof screen.width !== 'number' ||
typeof screen.height !== 'number' ||
typeof screen.availWidth !== 'number' ||
typeof screen.availHeight !== 'number'
) return false;
// Check devicePixelRatio
if (typeof d.devicePixelRatio !== 'number') return false;
return true;
}
export function registerCaptureCommands(program: Command) {
// Screenshot command
program
.command('screenshot')
.description('Capture screenshot of webpage (saved to .browser-pilot/screenshots/)')
.option('-u, --url <url>', 'URL to capture (optional, uses current page if not specified)')
.option('-o, --output <path>', 'Output file path', 'screenshot.png')
.option('--headless', 'Run in headless mode', false)
.option('--full-page', 'Capture full page', true)
.option('--clip-x <x>', 'Clip region X coordinate (pixels)')
.option('--clip-y <y>', 'Clip region Y coordinate (pixels)')
.option('--clip-width <width>', 'Clip region width (pixels)')
.option('--clip-height <height>', 'Clip region height (pixels)')
.option('--clip-scale <scale>', 'Clip region scale factor (default: 1)', '1')
.action(async (options) => {
try {
// Navigate if URL provided
if (options.url) {
await executeViaDaemon('navigate', { url: options.url });
}
// Build screenshot params
const params: Record<string, unknown> = {
filename: options.output,
fullPage: options.fullPage
};
// Add clip options if provided
if (options.clipX && options.clipY && options.clipWidth && options.clipHeight) {
params.clipX = parseFloat(options.clipX);
params.clipY = parseFloat(options.clipY);
params.clipWidth = parseFloat(options.clipWidth);
params.clipHeight = parseFloat(options.clipHeight);
params.clipScale = parseFloat(options.clipScale);
}
// Take screenshot
const response = await executeViaDaemon('screenshot', params);
if (response.success) {
const data = response.data as { success: boolean; path: string };
console.log('Screenshot saved:', data.path);
console.log('Browser will stay open. Use "daemon-stop" to close it.');
} else {
console.error('Screenshot failed:', response.error);
}
process.exit(response.success ? 0 : 1);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
// Set viewport size command
program
.command('set-viewport')
.description('Set browser viewport size')
.requiredOption('-w, --width <width>', 'Viewport width in pixels')
.requiredOption('-h, --height <height>', 'Viewport height in pixels')
.option('--scale <scale>', 'Device scale factor (default: 1)', '1')
.option('--mobile', 'Emulate mobile device', false)
.action(async (options) => {
try {
const response = await executeViaDaemon('set-viewport', {
width: parseInt(options.width),
height: parseInt(options.height),
deviceScaleFactor: parseFloat(options.scale),
mobile: options.mobile
});
if (response.success) {
const data = response.data as { width: number; height: number };
console.log(`Viewport size set to: ${data.width}x${data.height}`);
console.log('Browser will stay open. Use "daemon-stop" to close it.');
} else {
console.error('Set viewport failed:', response.error);
}
process.exit(response.success ? 0 : 1);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
// Get viewport command
program
.command('get-viewport')
.description('Get current viewport size')
.action(async () => {
try {
const response = await executeViaDaemon('get-viewport', {});
if (response.success) {
if (!isViewportResponse(response.data)) {
console.error('Get viewport failed: Invalid response format');
process.exit(1);
}
const data = response.data;
console.log('=== Viewport Information ===');
console.log(`Size: ${data.viewport.width}x${data.viewport.height}`);
console.log(`Scale: ${data.viewport.devicePixelRatio}`);
console.log('Browser will stay open. Use "daemon-stop" to close it.');
} else {
console.error('Get viewport failed:', response.error);
}
process.exit(response.success ? 0 : 1);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
// Get screen info command
program
.command('get-screen-info')
.description('Get screen and viewport information')
.action(async () => {
try {
const response = await executeViaDaemon('get-screen-info', {});
if (response.success) {
if (!isScreenInfoResponse(response.data)) {
console.error('Get screen info failed: Invalid response format');
process.exit(1);
}
const data = response.data;
console.log('=== Screen Information ===');
console.log(`Screen: ${data.screen.width}x${data.screen.height}`);
console.log(`Available: ${data.screen.availWidth}x${data.screen.availHeight}`);
console.log(`Viewport: ${data.viewport.width}x${data.viewport.height}`);
console.log(`Scale: ${data.devicePixelRatio}`);
console.log('Browser will stay open. Use "daemon-stop" to close it.');
} else {
console.error('Get screen info failed:', response.error);
}
process.exit(response.success ? 0 : 1);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
// Generate PDF command
program
.command('pdf')
.description('Generate PDF from webpage (saved to .browser-pilot/pdfs/)')
.option('-u, --url <url>', 'URL to navigate to (optional, uses current page if not specified)')
.option('-o, --output <path>', 'Output file path', 'page.pdf')
.option('--headless', 'Run in headless mode', false)
.option('--landscape', 'Use landscape orientation', false)
.action(async (options) => {
try {
// Navigate if URL provided
if (options.url) {
await executeViaDaemon('navigate', { url: options.url });
}
// Generate PDF
const response = await executeViaDaemon('pdf', {
filename: options.output,
landscape: options.landscape
});
if (response.success) {
const data = response.data as { success: boolean; path: string };
console.log('PDF saved:', data.path);
console.log('Browser will stay open. Use "daemon-stop" to close it.');
} else {
console.error('PDF generation failed:', response.error);
}
process.exit(response.success ? 0 : 1);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
}

View File

@@ -0,0 +1,438 @@
import { Command } from 'commander';
import { executeViaDaemon } from '../daemon-helper';
import { findSelector } from '../../cdp/map/query-map';
import { findSelectorWithRetry } from './selector-helper';
import { SELECTOR_RETRY_CONFIG } from '../../cdp/actions/helpers';
import { getOutputDir } from '../../cdp/config';
import * as path from 'path';
/**
* List of all available commands
*/
const AVAILABLE_COMMANDS = [
// Navigation
'navigate', 'back', 'forward', 'reload',
// Interaction
'click', 'hover', 'press', 'type', 'upload',
// Forms
'fill', 'select', 'check', 'uncheck',
// Capture
'screenshot', 'pdf',
// Tabs
'tabs', 'new-tab', 'close-tab', 'switch-tab', 'close',
// Cookies
'cookies', 'set-cookie', 'delete-cookies',
// Console
'console',
// Scroll
'scroll',
// Wait
'wait', 'wait-idle', 'sleep',
// Data extraction
'extract', 'content', 'extract-data', 'find', 'get-property',
// Focus
'focus', 'blur',
// Accessibility
'accessibility',
// Network
'block-url', 'unblock-urls',
// Dialogs
'dialog', 'enable-interception', 'disable-interception',
// Emulation
'emulate-media',
// Drag
'drag'
];
interface ChainCommand {
command: string;
args: string[];
}
/**
* Parse raw arguments into command groups
*/
function parseChainArgs(rawArgs: string[]): ChainCommand[] {
const commands: ChainCommand[] = [];
let currentCommand: ChainCommand | null = null;
for (const arg of rawArgs) {
// Check if this is a command name
if (AVAILABLE_COMMANDS.includes(arg)) {
// Save previous command
if (currentCommand) {
commands.push(currentCommand);
}
// Start new command
currentCommand = {
command: arg,
args: []
};
} else if (currentCommand) {
// Add argument to current command
currentCommand.args.push(arg);
} else {
// Argument before any command (error)
throw new Error(`Unexpected argument "${arg}" before any command`);
}
}
// Save last command
if (currentCommand) {
commands.push(currentCommand);
}
return commands;
}
/**
* Parse command arguments into params object
*/
function parseCommandArgs(command: string, args: string[]): Record<string, unknown> {
const params: Record<string, unknown> = {};
for (let i = 0; i < args.length; i++) {
const arg = args[i];
// Long option: --option value or --option=value
if (arg.startsWith('--')) {
const optionName = arg.slice(2);
// Check for --option=value format
if (optionName.includes('=')) {
const [key, value] = optionName.split('=', 2);
params[key] = value;
continue;
}
// Check if next arg is a value (doesn't start with -)
const nextArg = args[i + 1];
if (nextArg && !nextArg.startsWith('-')) {
// Parse value type
let value: unknown = nextArg;
// Try to parse as number
const num = Number(nextArg);
if (!isNaN(num)) {
value = num;
}
// Check for boolean strings
else if (nextArg === 'true') {
value = true;
} else if (nextArg === 'false') {
value = false;
}
params[optionName] = value;
i++; // Skip next arg
} else {
// Boolean flag
params[optionName] = true;
}
}
// Short option: -s value or -s=value
else if (arg.startsWith('-') && arg.length > 1) {
const optionName = arg.slice(1);
// Check for -s=value format
if (optionName.includes('=')) {
const [key, value] = optionName.split('=', 2);
params[key] = value;
continue;
}
// Check if next arg is a value
const nextArg = args[i + 1];
if (nextArg && !nextArg.startsWith('-')) {
// Parse value type
let value: unknown = nextArg;
const num = Number(nextArg);
if (!isNaN(num)) {
value = num;
} else if (nextArg === 'true') {
value = true;
} else if (nextArg === 'false') {
value = false;
}
params[optionName] = value;
i++;
} else {
params[optionName] = true;
}
}
// Positional argument
else {
// Store as _args array
if (!params._args) {
params._args = [];
}
(params._args as unknown[]).push(arg);
}
}
return params;
}
/**
* Convert parsed params to daemon-compatible format
*/
function convertParamsForDaemon(command: string, params: Record<string, unknown>): Record<string, unknown> {
const converted: Record<string, unknown> = { ...params };
// Map common option names
const mappings: Record<string, string> = {
'u': 'url',
's': 'selector',
'v': 'value',
't': 'timeout',
'k': 'key',
'o': 'output',
'f': 'file',
'i': 'index',
'x': 'x',
'y': 'y',
'e': 'expression',
'd': 'delay'
};
// Apply mappings
for (const [short, long] of Object.entries(mappings)) {
if (short in converted && !(long in converted)) {
converted[long] = converted[short];
delete converted[short];
}
}
// Handle Smart Mode options for click/fill
if (command === 'click' || command === 'fill') {
// Convert kebab-case to camelCase
if ('viewport-only' in converted) {
converted.viewportOnly = converted['viewport-only'];
delete converted['viewport-only'];
}
}
// Remove _args if present
delete converted._args;
return converted;
}
export function registerChainCommands(program: Command) {
program
.command('chain [args...]')
.description('Execute multiple commands in sequence with automatic map synchronization\n' +
' Format: chain <cmd1> [opts1] <cmd2> [opts2] ...\n' +
' Examples:\n' +
' • No quotes: chain navigate -u http://example.com click --text Submit screenshot -o result.png\n' +
' • With quotes (when values have spaces): chain click --text "Sign In" fill -s #email -v "user@example.com"\n' +
' • Supports Smart Mode (--text) for click/fill commands\n' +
' • Auto-waits for page load and map generation after navigation\n' +
' • Adds random human-like delay (300-800ms) between commands')
.option('--timeout <ms>', 'Timeout for waiting map ready after navigation (default: 10000ms)', parseInt, 10000)
.option('--delay <ms>', 'Fixed delay between commands in milliseconds (overrides random 300-800ms delay)', parseInt)
.allowUnknownOption()
.action(async (args: string[] = [], options, _cmd) => {
try {
// Extract chain-level options
const chainTimeout = options.timeout || 10000;
const chainDelay = options.delay;
// Use provided args array
const rawArgs = args;
if (rawArgs.length === 0) {
console.error('Error: No commands provided');
console.log('Usage: npm run bp:chain -- <command1> [options1] <command2> [options2] ...');
console.log('Example: npm run bp:chain -- navigate -u "http://example.com" click --text "Submit"');
console.log('Options:');
console.log(' --timeout <ms> Timeout for waiting map ready (default: 10000ms)');
console.log(' --delay <ms> Delay between commands (default: random 300-800ms)');
process.exit(1);
}
// Parse chain arguments
const commands = parseChainArgs(rawArgs);
console.log(`Executing ${commands.length} command(s) in sequence...\n`);
// Execute commands sequentially
for (let i = 0; i < commands.length; i++) {
const { command, args } = commands[i];
console.log(`[${i + 1}/${commands.length}] Executing: ${command} ${args.join(' ')}`);
// Parse command arguments
const params = parseCommandArgs(command, args);
const daemonParams = convertParamsForDaemon(command, params);
// Smart Mode: query map for click/fill commands with text option
if ((command === 'click' || command === 'fill') && daemonParams.text && !daemonParams.selector) {
console.log(`⏳ Waiting for map to be ready (timeout: ${chainTimeout}ms)...`);
const mapPath = path.join(getOutputDir(), SELECTOR_RETRY_CONFIG.MAP_FILENAME);
// Get current URL for debugging
let currentUrl = 'unknown';
try {
const fs = require('fs');
if (fs.existsSync(mapPath)) {
const mapData = JSON.parse(fs.readFileSync(mapPath, 'utf-8'));
currentUrl = mapData.url || 'unknown';
}
} catch (_e) {
// Ignore errors, just use 'unknown'
}
// Try to find selector with retry on failure
let selector = findSelector(
mapPath,
{
text: daemonParams.text as string,
index: daemonParams.index as number | undefined,
type: daemonParams.type as string | undefined,
viewportOnly: daemonParams.viewportOnly as boolean | undefined
},
true, // waitForReady
chainTimeout // timeout
);
// Fallback: regenerate map if element not found
if (!selector) {
selector = await findSelectorWithRetry({
text: daemonParams.text as string,
index: daemonParams.index as number | undefined,
type: daemonParams.type as string | undefined,
viewportOnly: daemonParams.viewportOnly as boolean | undefined
}, 'element');
if (!selector) {
console.error(` in URL: ${currentUrl}`);
process.exit(1);
}
} else {
console.log(`✓ Map ready, found selector: ${selector}`);
}
daemonParams.selector = selector;
delete daemonParams.text;
delete daemonParams.index;
delete daemonParams.type;
delete daemonParams.viewportOnly;
}
// Execute via daemon
const response = await executeViaDaemon(command, daemonParams, { verbose: false });
if (!response.success) {
console.error(`✗ Command failed: ${command}`);
console.error(`Error: ${response.error}`);
process.exit(1);
}
console.log(`✓ Success: ${command}`);
// Wait for map generation to complete after navigation-triggering commands
const navigationCommands = ['navigate', 'click', 'back', 'forward', 'reload'];
if (navigationCommands.includes(command)) {
console.log('⏳ Waiting for interaction map to be ready...');
const mapPath = path.join(getOutputDir(), SELECTOR_RETRY_CONFIG.MAP_FILENAME);
const startTime = Date.now();
const mapTimeout = chainTimeout;
// For navigate command: verify URL matches target
const expectedUrl = command === 'navigate' && daemonParams.url
? String(daemonParams.url)
: null;
// Poll map file until ready: true AND URL matches (for navigate)
while (Date.now() - startTime < mapTimeout) {
try {
const fs = require('fs');
if (fs.existsSync(mapPath)) {
const mapData = JSON.parse(fs.readFileSync(mapPath, 'utf-8'));
// Check if map is ready
if (mapData.ready !== true) {
await new Promise(resolve => setTimeout(resolve, 100));
continue;
}
// For navigate: verify URL matches exactly
if (expectedUrl) {
const mapUrl = String(mapData.url || '');
// Normalize URLs (remove trailing slash, compare as lowercase)
const normalizedExpected = expectedUrl.replace(/\/$/, '').toLowerCase();
const normalizedMap = mapUrl.replace(/\/$/, '').toLowerCase();
// Exact match required
if (normalizedExpected !== normalizedMap) {
// URL mismatch - keep waiting
await new Promise(resolve => setTimeout(resolve, 100));
continue;
}
}
// Map is ready and URL matches (if applicable)
console.log(`✓ Interaction map ready (${mapData.statistics?.total || 0} elements)`);
if (expectedUrl) {
console.log(` → URL verified: ${mapData.url}`);
}
break;
}
} catch (_e) {
// Ignore parse errors, continue polling
}
// Wait 100ms before next check
await new Promise(resolve => setTimeout(resolve, 100));
}
}
// Display result if present
if (response.data) {
const data = response.data as Record<string, unknown>;
// Display relevant info based on command
if (command === 'navigate' && data.url) {
console.log(` → Navigated to: ${data.url}`);
} else if (command === 'click' && data.selector) {
console.log(` → Clicked: ${data.selector}`);
} else if (command === 'fill' && data.selector) {
console.log(` → Filled: ${data.selector}`);
} else if (command === 'extract' && data.text) {
console.log(` → Extracted: ${data.text}`);
} else if (command === 'screenshot' && data.path) {
console.log(` → Saved to: ${data.path}`);
} else if (command === 'tabs') {
console.log(`${JSON.stringify(data, null, 2)}`);
}
}
console.log();
// Add delay before next command (except for last command)
if (i < commands.length - 1) {
let delayMs: number;
if (chainDelay !== undefined) {
delayMs = chainDelay;
} else {
// Random human-like delay (300-800ms)
delayMs = Math.floor(Math.random() * (800 - 300 + 1)) + 300;
}
console.log(`⏱️ Waiting ${delayMs}ms before next command...`);
await new Promise(resolve => setTimeout(resolve, delayMs));
}
}
console.log(`✓ All ${commands.length} command(s) completed successfully`);
process.exit(0);
} catch (error) {
console.error('Chain execution error:', error);
process.exit(1);
}
});
}

View File

@@ -0,0 +1,230 @@
import { Command } from 'commander';
import { FormattedConsoleMessage } from '../../cdp/browser';
import { executeViaDaemon } from '../daemon-helper';
import { TIMING } from '../../constants';
import * as fs from 'fs';
import * as path from 'path';
interface ConsoleOptions {
url?: string;
errorsOnly?: boolean;
level?: string;
warnings?: boolean;
logs?: boolean;
limit?: string;
skip?: string;
filter?: string;
exclude?: string;
json?: boolean;
timestamp?: boolean;
noColor?: boolean;
output?: string;
urlFilter?: string;
}
export function registerConsoleCommands(program: Command) {
// Get console messages
program
.command('console')
.description('Retrieve console messages from the page with powerful filtering and formatting options')
.option('-u, --url <url>', 'Navigate to URL before getting console messages')
// Level filtering options
.option('-e, --errors-only', 'Show only error messages', false)
.option('-l, --level <level>', 'Filter by level: error, warning, log, info, verbose')
.option('--warnings', 'Show only warning messages', false)
.option('--logs', 'Show only log messages', false)
// Message limiting options
.option('--limit <number>', 'Maximum number of messages to display')
.option('--skip <number>', 'Skip first N messages')
// Text filtering options
.option('-f, --filter <pattern>', 'Show only messages matching regex pattern')
.option('-x, --exclude <pattern>', 'Exclude messages matching regex pattern')
// Output format options
.option('-j, --json', 'Output in JSON format', false)
.option('-t, --timestamp', 'Show timestamps', false)
.option('--no-color', 'Disable colored output')
// File output
.option('-o, --output <file>', 'Save output to file')
// Source filtering
.option('--url-filter <pattern>', 'Filter by source URL (regex)')
.action(async (options: ConsoleOptions) => {
try {
// Navigate if URL provided
if (options.url) {
await executeViaDaemon('navigate', { url: options.url });
// Wait a bit for console messages to appear
await new Promise(resolve => setTimeout(resolve, TIMING.ACTION_DELAY_NAVIGATION));
}
// Get console messages
const response = await executeViaDaemon('console', { errorsOnly: options.errorsOnly });
if (response.success) {
const result = response.data as { count: number; errorCount: number; warningCount: number; logCount: number; messages: FormattedConsoleMessage[] };
// Apply filters
let filteredMessages = filterMessages(result.messages, options);
// Generate output
const output = formatOutput(filteredMessages, result, options);
// Save to file or print to console
if (options.output) {
const outputPath = path.resolve(options.output);
fs.writeFileSync(outputPath, output, 'utf-8');
console.log(`Console messages saved to: ${outputPath}`);
} else {
console.log(output);
}
if (!options.json && !options.output) {
console.log('\nBrowser will stay open. Use "daemon-stop" to close it.');
}
} else {
console.error('Console retrieval failed:', response.error);
}
process.exit(response.success ? 0 : 1);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
}
/**
* Filter messages based on provided options
*/
function filterMessages(messages: FormattedConsoleMessage[], options: ConsoleOptions): FormattedConsoleMessage[] {
let filtered = [...messages];
// Level filtering
if (options.level) {
const level = options.level.toLowerCase();
filtered = filtered.filter(msg => msg.level.toLowerCase() === level);
} else if (options.warnings) {
filtered = filtered.filter(msg => msg.level.toLowerCase() === 'warning');
} else if (options.logs) {
filtered = filtered.filter(msg => msg.level.toLowerCase() === 'log');
}
// errorsOnly is handled by daemon
// Text filtering
if (options.filter) {
const regex = new RegExp(options.filter, 'i');
filtered = filtered.filter(msg => regex.test(msg.text));
}
if (options.exclude) {
const regex = new RegExp(options.exclude, 'i');
filtered = filtered.filter(msg => !regex.test(msg.text));
}
// URL filtering
if (options.urlFilter) {
const regex = new RegExp(options.urlFilter, 'i');
filtered = filtered.filter(msg => msg.url && regex.test(msg.url));
}
// Skip messages
const skip = options.skip ? parseInt(options.skip, 10) : 0;
if (skip > 0) {
filtered = filtered.slice(skip);
}
// Limit messages
const limit = options.limit ? parseInt(options.limit, 10) : undefined;
if (limit && limit > 0) {
filtered = filtered.slice(0, limit);
}
return filtered;
}
/**
* Format output based on options
*/
function formatOutput(
messages: FormattedConsoleMessage[],
result: { count: number; errorCount: number; warningCount: number; logCount: number },
options: ConsoleOptions
): string {
if (options.json) {
return JSON.stringify({
total: result.count,
filtered: messages.length,
counts: {
errors: result.errorCount,
warnings: result.warningCount,
logs: result.logCount
},
messages: messages
}, null, 2);
}
// Text format
const lines: string[] = [];
// Header
lines.push(`\n=== Console Messages (Total: ${result.count}, Filtered: ${messages.length}) ===`);
lines.push(`Errors: ${result.errorCount}, Warnings: ${result.warningCount}, Logs: ${result.logCount}\n`);
if (messages.length === 0) {
lines.push('No console messages found.');
} else {
messages.forEach((msg: FormattedConsoleMessage) => {
const parts: string[] = [];
// Timestamp
if (options.timestamp) {
parts.push(`[${msg.timestamp}]`);
}
// Level with color
const levelStr = `[${msg.level.toUpperCase()}]`;
if (options.noColor === false) {
const coloredLevel = colorizeLevel(levelStr, msg.level);
parts.push(coloredLevel);
} else {
parts.push(levelStr);
}
// Location
if (msg.url) {
parts.push(`(${msg.url}:${msg.lineNumber || '?'})`);
}
// Message text
parts.push(msg.text);
lines.push(parts.join(' '));
});
}
return lines.join('\n');
}
/**
* Colorize level string based on message level
*/
function colorizeLevel(levelStr: string, level: string): string {
const colors: Record<string, string> = {
error: '\x1b[31m', // Red
warning: '\x1b[33m', // Yellow
log: '\x1b[36m', // Cyan
info: '\x1b[34m', // Blue
verbose: '\x1b[90m', // Gray
};
const reset = '\x1b[0m';
const color = colors[level.toLowerCase()] || '';
return `${color}${levelStr}${reset}`;
}

View File

@@ -0,0 +1,92 @@
import { Command } from 'commander';
import { ChromeBrowser } from '../../cdp/browser';
import * as actions from '../../cdp/actions';
export function registerCookiesCommands(program: Command) {
// Get cookies command
program
.command('cookies')
.description('Retrieve all cookies from the current page (or navigate to a URL first with -u)')
.option('-u, --url <url>', 'URL to navigate to (optional, uses current page if not specified)')
.option('--headless', 'Run in headless mode', false)
.action(async (options) => {
const browser = new ChromeBrowser(options.headless);
try {
// Try to connect to existing browser first, launch new one if failed
try {
await browser.connect();
} catch {
await browser.launch();
}
// Only navigate if URL is provided
if (options.url) {
await actions.navigate(browser, options.url);
await actions.waitForLoad(browser);
}
const result = await actions.getCookies(browser);
console.log(`Found ${result.count} cookies:`);
console.log(JSON.stringify(result.cookies, null, 2));
console.log('Browser remains open. Use "close" command to close it.');
process.exit(0);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
// Set cookie
program
.command('set-cookie')
.description('Set a cookie with specified name and value (supports domain, path, secure, and httpOnly options)')
.requiredOption('-n, --name <name>', 'Cookie name')
.requiredOption('-v, --value <value>', 'Cookie value')
.option('-d, --domain <domain>', 'Cookie domain')
.option('-p, --path <path>', 'Cookie path', '/')
.option('--secure', 'Secure cookie', false)
.option('--http-only', 'HTTP only cookie', false)
.action(async (options) => {
const browser = new ChromeBrowser(false);
try {
await browser.connect();
const result = await actions.setCookie(
browser,
options.name,
options.value,
options.domain,
options.path,
options.secure,
options.httpOnly
);
console.log('Cookie set:', result.cookie);
console.log('Browser remains open. Use "close" command to close it.');
process.exit(0);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
// Delete cookies
program
.command('delete-cookies')
.description('Delete cookies by name (deletes all cookies if no name is specified with -n)')
.option('-n, --name <name>', 'Cookie name to delete (deletes all if not specified)')
.option('-u, --url <url>', 'Navigate to URL first')
.action(async (options) => {
const browser = new ChromeBrowser(false);
try {
await browser.connect();
if (options.url) {
await actions.navigate(browser, options.url);
await actions.waitForLoad(browser);
}
const result = await actions.deleteCookies(browser, options.name);
console.log(result.message);
console.log('Browser remains open. Use "close" command to close it.');
process.exit(0);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
}

View File

@@ -0,0 +1,137 @@
/**
* Daemon management commands
*/
import { Command } from 'commander';
import { DaemonManager } from '../../daemon/manager';
export function registerDaemonCommands(program: Command) {
// Start daemon
program
.command('daemon-start')
.description('Start Browser Pilot daemon (persistent background browser)')
.option('-q, --quiet', 'Suppress output')
.action(async (options) => {
const manager = new DaemonManager();
try {
await manager.start({ verbose: !options.quiet });
process.exit(0);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
// Stop daemon
program
.command('daemon-stop')
.description('Stop Browser Pilot daemon and close browser')
.option('-q, --quiet', 'Suppress output')
.option('-f, --force', 'Force kill the daemon')
.action(async (options) => {
const manager = new DaemonManager();
try {
await manager.stop({ verbose: !options.quiet, force: options.force });
process.exit(0);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
// Restart daemon
program
.command('daemon-restart')
.description('Restart Browser Pilot daemon')
.option('-q, --quiet', 'Suppress output')
.action(async (options) => {
const manager = new DaemonManager();
try {
await manager.restart({ verbose: !options.quiet });
process.exit(0);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
// Daemon status
program
.command('daemon-status')
.description('Check daemon status and browser info')
.option('-q, --quiet', 'Suppress output')
.action(async (options) => {
const manager = new DaemonManager();
try {
const state = await manager.getStatus({ verbose: !options.quiet });
process.exit(state ? 0 : 1);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
// Query interaction map
program
.command('daemon-query-map')
.description('Query interaction map by text, type, or ID')
.option('-t, --text <text>', 'Search by text content')
.option('-T, --type <type>', 'Filter by element type (button, link, input, etc)')
.option('-i, --index <number>', 'Select nth match (1-based)', parseInt)
.option('--id <id>', 'Direct ID lookup')
.option('-v, --viewport-only', 'Only visible elements in viewport')
.option('-q, --quiet', 'Suppress output')
.action(async (options) => {
const manager = new DaemonManager();
try {
const params: Record<string, unknown> = {};
if (options.text) params.text = options.text;
if (options.type) params.type = options.type;
if (options.index) params.index = options.index;
if (options.id) params.id = options.id;
if (options.viewportOnly) params.viewportOnly = true;
await manager.queryMap(params, { verbose: !options.quiet });
process.exit(0);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
// Generate interaction map
program
.command('daemon-generate-map')
.description('Generate interaction map for current page (use -f to force)')
.option('-f, --force', 'Force regeneration (ignore cache)')
.option('-q, --quiet', 'Suppress output')
.action(async (options) => {
const manager = new DaemonManager();
try {
const params: Record<string, unknown> = {};
if (options.force) params.force = true;
await manager.generateMap(params, { verbose: !options.quiet });
process.exit(0);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
// Get map status
program
.command('daemon-map-status')
.description('Get interaction map status (URL, element count, cache)')
.option('-q, --quiet', 'Suppress output')
.action(async (options) => {
const manager = new DaemonManager();
try {
await manager.getMapStatus({ verbose: !options.quiet });
process.exit(0);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
}

View File

@@ -0,0 +1,160 @@
import { Command } from 'commander';
import { ChromeBrowser } from '../../cdp/browser';
import * as actions from '../../cdp/actions';
import { executeViaDaemon } from '../daemon-helper';
export function registerDataCommands(program: Command) {
// Extract text command
program
.command('extract')
.description('Extract text from element (use -s for selector)')
.option('-u, --url <url>', 'URL to extract from (optional, uses current page if not specified)')
.option('-s, --selector <selector>', 'CSS selector (optional)')
.option('--headless', 'Run in headless mode', false)
.action(async (options) => {
const browser = new ChromeBrowser(options.headless);
try {
// Try to connect to existing browser first, launch new one if failed
try {
await browser.connect();
} catch {
await browser.launch();
}
if (options.url) {
await actions.navigate(browser, options.url);
await actions.waitForLoad(browser);
}
const result = await actions.extractText(browser, options.selector);
console.log('Extracted text:', result.text);
console.log('Browser remains open. Use "close" command to close it.');
process.exit(0);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
// Evaluate command
program
.command('eval')
.description('Execute JavaScript on page (requires -e/--expression)')
.option('-u, --url <url>', 'URL to navigate to (optional, uses current page if not specified)')
.requiredOption('-e, --expression <script>', 'JavaScript expression to evaluate')
.option('--headless', 'Run in headless mode', false)
.action(async (options) => {
try {
// Navigate if URL provided
if (options.url) {
await executeViaDaemon('navigate', { url: options.url });
}
// Execute JavaScript
const response = await executeViaDaemon('eval', { expression: options.expression });
if (response.success) {
const data = response.data as { result: unknown };
console.log('Result:', data.result);
console.log('Browser will stay open. Use "daemon-stop" to close it.');
} else {
console.error('Eval failed:', response.error);
}
process.exit(response.success ? 0 : 1);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
// Get content command
program
.command('content')
.description('Get page HTML content')
.action(async () => {
const browser = new ChromeBrowser(false);
try {
await browser.connect();
const result = await actions.getContent(browser);
console.log('HTML content length:', result.length);
console.log(result.content);
process.exit(0);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
// Extract data
program
.command('extract-data')
.description('Extract data using multiple selectors (requires -s/--selectors)')
.requiredOption('-s, --selectors <json>', 'JSON object of key-selector pairs')
.option('-u, --url <url>', 'Navigate to URL first')
.action(async (options) => {
const browser = new ChromeBrowser(false);
try {
await browser.connect();
if (options.url) {
await actions.navigate(browser, options.url);
await actions.waitForLoad(browser);
}
const selectors = JSON.parse(options.selectors);
const result = await actions.extractData(browser, selectors);
console.log('Extracted data:', JSON.stringify(result.data, null, 2));
console.log('Browser remains open. Use "close" command to close it.');
process.exit(0);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
// Find element
program
.command('find')
.description('Find element and return info (requires -s/--selector)')
.requiredOption('-s, --selector <selector>', 'CSS selector')
.option('-u, --url <url>', 'Navigate to URL first')
.action(async (options) => {
const browser = new ChromeBrowser(false);
try {
await browser.connect();
if (options.url) {
await actions.navigate(browser, options.url);
await actions.waitForLoad(browser);
}
const result = await actions.findElement(browser, options.selector);
console.log('Element info:', JSON.stringify(result.element, null, 2));
console.log('Browser remains open. Use "close" command to close it.');
process.exit(0);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
// Get element property
program
.command('get-property')
.description('Get element property value (requires -s and -p)')
.requiredOption('-s, --selector <selector>', 'CSS selector')
.requiredOption('-p, --property <property>', 'Property name')
.option('-u, --url <url>', 'Navigate to URL first')
.action(async (options) => {
const browser = new ChromeBrowser(false);
try {
await browser.connect();
if (options.url) {
await actions.navigate(browser, options.url);
await actions.waitForLoad(browser);
}
const result = await actions.getElementProperty(browser, options.selector, options.property);
console.log(`${options.property}:`, result.value);
console.log('Browser remains open. Use "close" command to close it.');
process.exit(0);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
}

View File

@@ -0,0 +1,29 @@
import { Command } from 'commander';
import { ChromeBrowser } from '../../cdp/browser';
import * as actions from '../../cdp/actions';
export function registerDialogsCommands(program: Command) {
// Dialog response command
program
.command('dialog')
.description('Respond to JavaScript dialogs (alert/confirm/prompt) by accepting, dismissing, or entering text for prompts')
.option('-a, --accept', 'Accept dialog (default: true)', true)
.option('-d, --dismiss', 'Dismiss dialog')
.option('-t, --text <text>', 'Text for prompt dialog')
.action(async (options) => {
const browser = new ChromeBrowser(false);
try {
await browser.connect();
const accept = !options.dismiss;
const result = await actions.respondToDialog(browser, accept, options.text);
console.log('Dialog', result.accept ? 'accepted' : 'dismissed');
if (result.promptText) {
console.log('Prompt text:', result.promptText);
}
process.exit(0);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
}

View File

@@ -0,0 +1,28 @@
import { Command } from 'commander';
import { ChromeBrowser } from '../../cdp/browser';
import * as actions from '../../cdp/actions';
export function registerEmulationCommands(program: Command) {
// Emulate media command
program
.command('emulate-media')
.description('Emulate media type (screen/print) or color scheme (light/dark/no-preference) for testing responsive designs and dark mode')
.option('-m, --media <type>', 'Media type: screen or print')
.option('-c, --color-scheme <scheme>', 'Color scheme: light, dark, or no-preference')
.action(async (options) => {
const browser = new ChromeBrowser(false);
try {
await browser.connect();
const result = await actions.emulateMedia(
browser,
options.media as 'screen' | 'print' | undefined,
options.colorScheme as 'light' | 'dark' | 'no-preference' | undefined
);
console.log('Emulated media:', result.mediaType || 'none', 'colorScheme:', result.colorScheme || 'none');
process.exit(0);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
}

View File

@@ -0,0 +1,53 @@
import { Command } from 'commander';
import { ChromeBrowser } from '../../cdp/browser';
import * as actions from '../../cdp/actions';
export function registerFocusCommands(program: Command) {
// Focus element
program
.command('focus')
.description('Set focus on a specific element (for keyboard input)')
.requiredOption('-s, --selector <selector>', 'CSS selector')
.option('-u, --url <url>', 'Navigate to URL first')
.action(async (options) => {
const browser = new ChromeBrowser(false);
try {
await browser.connect();
if (options.url) {
await actions.navigate(browser, options.url);
await actions.waitForLoad(browser);
}
const result = await actions.focus(browser, options.selector);
console.log('Focused:', result.selector);
console.log('Browser will stay open. Use "close" command to close it.');
process.exit(0);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
// Blur element
program
.command('blur')
.description('Remove focus from an element (deactivate active element)')
.requiredOption('-s, --selector <selector>', 'CSS selector')
.option('-u, --url <url>', 'Navigate to URL first')
.action(async (options) => {
const browser = new ChromeBrowser(false);
try {
await browser.connect();
if (options.url) {
await actions.navigate(browser, options.url);
await actions.waitForLoad(browser);
}
const result = await actions.blur(browser, options.selector);
console.log('Blurred:', result.selector);
console.log('Browser will stay open. Use "close" command to close it.');
process.exit(0);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
}

View File

@@ -0,0 +1,81 @@
import { Command } from 'commander';
import { ChromeBrowser } from '../../cdp/browser';
import * as actions from '../../cdp/actions';
export function registerFormsCommands(program: Command) {
// Select option command
program
.command('select')
.description('Select option from dropdown (requires -s and -v)')
.option('-u, --url <url>', 'URL to navigate to (optional, uses current page if not specified)')
.requiredOption('-s, --selector <selector>', 'CSS selector of select element')
.requiredOption('-v, --value <value>', 'Option value to select')
.option('--headless', 'Run in headless mode', false)
.action(async (options) => {
const browser = new ChromeBrowser(options.headless);
try {
try { await browser.connect(); } catch { await browser.launch(); }
if (options.url) {
await actions.navigate(browser, options.url);
await actions.waitForLoad(browser);
}
const result = await actions.selectOption(browser, options.selector, options.value);
console.log('Selected:', result.value, 'in', result.selector);
console.log('Browser will stay open. Use "close" command to close it.');
process.exit(0);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
// Check checkbox command
program
.command('check')
.description('Check a checkbox (requires -s/--selector)')
.option('-u, --url <url>', 'URL to navigate to (optional, uses current page if not specified)')
.requiredOption('-s, --selector <selector>', 'CSS selector of checkbox')
.option('--headless', 'Run in headless mode', false)
.action(async (options) => {
const browser = new ChromeBrowser(options.headless);
try {
try { await browser.connect(); } catch { await browser.launch(); }
if (options.url) {
await actions.navigate(browser, options.url);
await actions.waitForLoad(browser);
}
const result = await actions.check(browser, options.selector);
console.log('Checked:', result.selector);
console.log('Browser will stay open. Use "close" command to close it.');
process.exit(0);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
// Uncheck checkbox command
program
.command('uncheck')
.description('Uncheck a checkbox (requires -s/--selector)')
.option('-u, --url <url>', 'URL to navigate to (optional, uses current page if not specified)')
.requiredOption('-s, --selector <selector>', 'CSS selector of checkbox')
.option('--headless', 'Run in headless mode', false)
.action(async (options) => {
const browser = new ChromeBrowser(options.headless);
try {
try { await browser.connect(); } catch { await browser.launch(); }
if (options.url) {
await actions.navigate(browser, options.url);
await actions.waitForLoad(browser);
}
const result = await actions.uncheck(browser, options.selector);
console.log('Unchecked:', result.selector);
console.log('Browser will stay open. Use "close" command to close it.');
process.exit(0);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
}

View File

@@ -0,0 +1,243 @@
import { Command } from 'commander';
import { executeViaDaemon } from '../daemon-helper';
import { findSelectorWithRetry } from './selector-helper';
import { getOutputDir } from '../../cdp/config';
import { SELECTOR_RETRY_CONFIG } from '../../cdp/actions/helpers';
import * as path from 'path';
export function registerInteractionCommands(program: Command) {
// Click command
program
.command('click')
.description('Click an element (use -s for selector or --text for smart mode)')
.option('-u, --url <url>', 'URL to navigate to (optional, uses current page if not specified)')
.option('-s, --selector <selector>', 'CSS selector to click (direct mode)')
.option('--text <text>', 'Text content to search for (smart mode)')
.option('--index <number>', 'Select nth match (1-based, for duplicate text)', parseInt)
.option('--type <type>', 'Element type filter (e.g., button, input)')
.option('--viewport-only', 'Only search visible elements', false)
.option('--verify', 'Verify action success', false)
.option('--headless', 'Run in headless mode', false)
.action(async (options) => {
try {
if (options.url) {
await executeViaDaemon('navigate', { url: options.url, waitForLoad: true });
}
let selector = options.selector;
// Smart mode: query map if text option provided
if (options.text && !selector) {
console.log(`🔍 Searching for: "${options.text}"${options.index ? ` (match #${options.index})` : ''}`);
console.log(`📁 Map path: ${path.join(getOutputDir(), SELECTOR_RETRY_CONFIG.MAP_FILENAME)}`);
selector = await findSelectorWithRetry({
text: options.text,
index: options.index,
type: options.type,
viewportOnly: options.viewportOnly
}, 'element');
if (!selector) {
console.error(' Try using --selector for direct mode');
process.exit(1);
}
console.log(`✓ Found element with selector: ${selector}`);
}
// Validate selector
if (!selector) {
console.error('❌ No selector provided. Use either:');
console.error(' --selector <selector> (direct mode)');
console.error(' --text <text> (smart mode)');
process.exit(1);
}
const response = await executeViaDaemon('click', {
selector,
verify: options.verify
});
if (response.success) {
console.log('✓ Clicked:', selector);
console.log('Browser will stay open. Use "daemon-stop" to close it.');
} else {
console.error('❌ Click failed:', response.error);
}
process.exit(response.success ? 0 : 1);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
// Fill command
program
.command('fill')
.description('Fill an input field (requires -v/--value)')
.option('-u, --url <url>', 'URL to navigate to (optional, uses current page if not specified)')
.option('-s, --selector <selector>', 'CSS selector of input field (direct mode)')
.option('--label <label>', 'Label or placeholder text to search for (smart mode)')
.option('--type <type>', 'Input type filter (e.g., input-text, input-password)', 'input')
.option('--viewport-only', 'Only search visible elements', false)
.requiredOption('-v, --value <value>', 'Value to fill')
.option('--verify', 'Verify action success', false)
.option('--headless', 'Run in headless mode', false)
.action(async (options) => {
try {
if (options.url) {
await executeViaDaemon('navigate', { url: options.url, waitForLoad: true });
}
let selector = options.selector;
// Smart mode: query map if label option provided
if (options.label && !selector) {
console.log(`🔍 Searching for input: "${options.label}"`);
selector = await findSelectorWithRetry({
text: options.label,
type: options.type,
viewportOnly: options.viewportOnly
}, 'input field');
if (!selector) {
console.error(' Try using --selector for direct mode');
process.exit(1);
}
console.log(`✓ Found input field with selector: ${selector}`);
}
// Validate selector
if (!selector) {
console.error('❌ No selector provided. Use either:');
console.error(' --selector <selector> (direct mode)');
console.error(' --label <label> (smart mode)');
process.exit(1);
}
const response = await executeViaDaemon('fill', {
selector,
value: options.value,
verify: options.verify
});
if (response.success) {
console.log('✓ Filled:', selector, 'with:', options.value);
console.log('Browser will stay open. Use "daemon-stop" to close it.');
} else {
console.error('❌ Fill failed:', response.error);
}
process.exit(response.success ? 0 : 1);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
// Hover command
program
.command('hover')
.description('Hover over an element (requires -s/--selector)')
.option('-u, --url <url>', 'URL to navigate to (optional, uses current page if not specified)')
.requiredOption('-s, --selector <selector>', 'CSS selector to hover')
.option('--headless', 'Run in headless mode', false)
.action(async (options) => {
try {
if (options.url) {
await executeViaDaemon('navigate', { url: options.url, waitForLoad: true });
}
const response = await executeViaDaemon('hover', { selector: options.selector });
if (response.success) {
console.log('✓ Hovered over:', options.selector);
console.log('Browser will stay open. Use "daemon-stop" to close it.');
} else {
console.error('❌ Hover failed:', response.error);
}
process.exit(response.success ? 0 : 1);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
// Press key command
program
.command('press')
.description('Press a keyboard key (requires -k/--key, e.g., Enter, Tab, Escape)')
.requiredOption('-k, --key <key>', 'Key to press (e.g., Enter, Tab, Escape)')
.option('--headless', 'Run in headless mode', false)
.action(async (options) => {
try {
const response = await executeViaDaemon('press', { key: options.key });
if (response.success) {
console.log('✓ Pressed key:', options.key);
console.log('Browser will stay open. Use "daemon-stop" to close it.');
} else {
console.error('❌ Press failed:', response.error);
}
process.exit(response.success ? 0 : 1);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
// Type text command
program
.command('type')
.description('Type text character by character (requires -t/--text)')
.requiredOption('-t, --text <text>', 'Text to type')
.option('-d, --delay <ms>', 'Delay between characters (ms)', parseInt, 0)
.option('--headless', 'Run in headless mode', false)
.action(async (options) => {
try {
const response = await executeViaDaemon('type', {
text: options.text,
delay: options.delay
});
if (response.success) {
console.log('✓ Typed text:', options.text);
console.log('Browser will stay open. Use "daemon-stop" to close it.');
} else {
console.error('❌ Type failed:', response.error);
}
process.exit(response.success ? 0 : 1);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
// Upload file command (not implemented in server yet, skip for now)
program
.command('upload')
.description('Upload file to input element (requires -s/--selector and -f/--file)')
.option('-u, --url <url>', 'URL to navigate to (optional, uses current page if not specified)')
.requiredOption('-s, --selector <selector>', 'CSS selector of file input')
.requiredOption('-f, --file <path>', 'File path to upload')
.option('--headless', 'Run in headless mode', false)
.action(async () => {
console.error('Upload command not yet implemented in daemon mode');
process.exit(1);
});
// Drag and drop command (not implemented in server yet, skip for now)
program
.command('drag')
.description('Drag and drop element (requires --from and --to selectors)')
.option('-u, --url <url>', 'URL to navigate to (optional, uses current page if not specified)')
.requiredOption('--from <selector>', 'Source element selector')
.requiredOption('--to <selector>', 'Target element selector')
.option('--headless', 'Run in headless mode', false)
.action(async () => {
console.error('Drag command not yet implemented in daemon mode');
process.exit(1);
});
}

View File

@@ -0,0 +1,104 @@
import { Command } from 'commander';
import { executeViaDaemon } from '../daemon-helper';
export function registerNavigationCommands(program: Command) {
// Navigate command
program
.command('navigate')
.description('Navigate to a URL (requires -u/--url)')
.requiredOption('-u, --url <url>', 'URL to navigate to')
.option('--headless', 'Run in headless mode', false)
.action(async (options) => {
try {
const response = await executeViaDaemon('navigate', { url: options.url });
if (response.success) {
console.log('Navigated to:', options.url);
console.log('Browser will stay open. Use "daemon-stop" to close it.');
} else {
console.error('Navigation failed:', response.error);
}
process.exit(response.success ? 0 : 1);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
// Go back command
program
.command('back')
.description('Navigate back in browser history')
.action(async () => {
try {
const response = await executeViaDaemon('back', {});
if (response.success) {
const data = response.data as { success: boolean; url?: string; error?: string };
if (data.success) {
console.log('Navigated back to:', data.url);
} else {
console.log(data.error);
}
} else {
console.error('Back navigation failed:', response.error);
}
process.exit(response.success ? 0 : 1);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
// Go forward command
program
.command('forward')
.description('Navigate forward in history')
.action(async () => {
try {
const response = await executeViaDaemon('forward', {});
if (response.success) {
const data = response.data as { success: boolean; url?: string; error?: string };
if (data.success) {
console.log('Navigated forward to:', data.url);
} else {
console.log(data.error);
}
} else {
console.error('Forward navigation failed:', response.error);
}
process.exit(response.success ? 0 : 1);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
// Reload command
program
.command('reload')
.description('Reload the current page')
.option('--hard', 'Hard reload (ignore cache)', false)
.action(async (options) => {
try {
const response = await executeViaDaemon('reload', { hard: options.hard });
if (response.success) {
const data = response.data as { success: boolean; hardReload: boolean };
console.log('Page reloaded (hard:', data.hardReload, ')');
console.log('Browser will stay open. Use "daemon-stop" to close it.');
} else {
console.error('Reload failed:', response.error);
}
process.exit(response.success ? 0 : 1);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
}

View File

@@ -0,0 +1,77 @@
import { Command } from 'commander';
import { ChromeBrowser } from '../../cdp/browser';
import * as actions from '../../cdp/actions';
export function registerNetworkCommands(program: Command) {
// Block URL command
program
.command('block-url')
.description('Block network requests matching a URL pattern (e.g., "*.jpg", "*ads*", "*analytics*")')
.requiredOption('-p, --pattern <pattern>', 'URL pattern to block (e.g., "*.jpg", "*ads*")')
.action(async (options) => {
const browser = new ChromeBrowser(false);
try {
await browser.connect();
const result = await actions.blockRequest(browser, options.pattern);
console.log('Blocked URL pattern:', result.urlPattern);
process.exit(0);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
// Unblock URLs command
program
.command('unblock-urls')
.description('Remove all network request blocks and allow all URLs to load')
.action(async () => {
const browser = new ChromeBrowser(false);
try {
await browser.connect();
await actions.unblockRequests(browser);
console.log('All URL blocks removed');
process.exit(0);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
// Enable request interception
program
.command('enable-interception')
.description('Enable network request interception for monitoring and modifying HTTP requests')
.action(async () => {
const browser = new ChromeBrowser(false);
try {
await browser.connect();
const result = await actions.enableRequestInterception(browser);
console.log('Request interception enabled');
console.log('Note:', result.note);
console.log('Browser remains open. Use "close" command to close it.');
process.exit(0);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
// Disable request interception
program
.command('disable-interception')
.description('Disable network request interception and return to normal browsing mode')
.action(async () => {
const browser = new ChromeBrowser(false);
try {
await browser.connect();
await actions.disableRequestInterception(browser);
console.log('Request interception disabled');
console.log('Browser remains open. Use "close" command to close it.');
process.exit(0);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
}

View File

@@ -0,0 +1,199 @@
import { Command } from 'commander';
import { executeViaDaemon } from '../daemon-helper';
export function registerQueryCommands(program: Command) {
// Query interaction map
program
.command('query')
.description('Search interaction map for elements (by text, type, tag, or ID with pagination support)')
.option('-t, --text <text>', 'Search by text content')
.option('--type <type>', 'Filter by element type (supports aliases: "input" → "input-*")')
.option('--tag <tag>', 'Filter by HTML tag (e.g., "input", "button")')
.option('-i, --index <number>', 'Select nth match (1-based)', parseInt)
.option('--viewport-only', 'Only search visible elements in viewport', false)
.option('--id <id>', 'Direct element ID lookup')
.option('--list-types', 'List all element types with counts')
.option('--list-texts', 'List all text contents')
.option('--limit <number>', 'Maximum results to return (default: 20, 0=unlimited)', parseInt)
.option('--offset <number>', 'Number of results to skip (default: 0)', parseInt)
.option('--verbose', 'Include detailed information', false)
.action(async (options) => {
try {
// Build query parameters
const params: Record<string, unknown> = {};
if (options.text) params.text = options.text;
if (options.type) params.type = options.type;
if (options.tag) params.tag = options.tag;
if (options.index) params.index = options.index;
if (options.viewportOnly) params.viewportOnly = true;
if (options.id) params.id = options.id;
if (options.listTypes) params.listTypes = true;
if (options.listTexts) params.listTexts = true;
if (options.limit !== undefined) params.limit = options.limit;
if (options.offset !== undefined) params.offset = options.offset;
if (options.verbose) params.verbose = true;
// Execute query
const response = await executeViaDaemon('query-map', params);
if (response.success) {
const result = response.data as {
count: number;
results: Array<{
selector: string;
alternatives: string[];
element: {
tag: string;
text: string | undefined;
position: { x: number; y: number };
};
}>;
types?: Record<string, number>;
texts?: Array<{ text: string; type: string; count: number }>;
total?: number;
};
// Handle listTypes output
if (options.listTypes && result.types) {
console.log(`\n=== Element Types ===`);
const sortedTypes = Object.entries(result.types).sort((a, b) => b[1] - a[1]);
sortedTypes.forEach(([type, count]) => {
console.log(`${type}: ${count}`);
});
console.log(`\nTotal: ${result.total} elements`);
}
// Handle listTexts output
else if (options.listTexts && result.texts) {
const limit = options.limit !== undefined ? options.limit : 20;
const showingText = limit === 0 ? 'all' : `${result.count}/${result.total}`;
console.log(`\n=== Text Contents (showing ${showingText}) ===\n`);
result.texts.forEach((item, idx) => {
const num = (options.offset || 0) + idx + 1;
const textPreview = item.text.length > 50 ? item.text.substring(0, 50) + '...' : item.text;
console.log(`${num}. "${textPreview}" (${item.type}${item.count > 1 ? `, ${item.count} matches` : ''})`);
});
if (limit > 0 && result.total && result.total > result.count + (options.offset || 0)) {
console.log(`\nUse --limit to see more, or --type to filter`);
}
}
// Handle regular query output
else {
const showingText = result.total ? `${result.count}/${result.total}` : `${result.count}`;
console.log(`\n=== Query Results (${showingText}) ===`);
if (result.count === 0) {
console.log('\nNo elements found matching your query.');
} else {
result.results.forEach((item, idx) => {
const num = (options.offset || 0) + idx + 1;
console.log(`\n[${num}]`);
if (!options.verbose) {
// Compact format
const textPreview = item.element.text ?
(item.element.text.length > 50 ? item.element.text.substring(0, 50) + '...' : item.element.text) : '';
console.log(` <${item.element.tag}> ${textPreview ? `"${textPreview}"` : '(no text)'}`);
console.log(` Position: (${item.element.position.x}, ${item.element.position.y})`);
console.log(` Selector: ${item.selector}`);
} else {
// Verbose format
console.log(` Tag: <${item.element.tag}>`);
if (item.element.text) {
const text = item.element.text.substring(0, 100);
console.log(` Text: "${text}${item.element.text.length > 100 ? '...' : ''}"`);
}
console.log(` Position: (${item.element.position.x}, ${item.element.position.y})`);
console.log(` 📍 Best Selector: ${item.selector}`);
if (item.alternatives.length > 0 && item.alternatives.length <= 3) {
console.log(` Alternatives:`);
item.alternatives.forEach(alt => console.log(` - ${alt}`));
}
}
});
if (result.total && result.total > result.count + (options.offset || 0)) {
console.log(`\nUse --limit and --offset for pagination, or --verbose for details`);
}
}
}
console.log('\nBrowser will stay open. Use "daemon-stop" to close it.');
} else {
console.error('Query failed:', response.error);
}
process.exit(response.success ? 0 : 1);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
// Get map status
program
.command('map-status')
.description('Check interaction map status (URL, element count, cache validity, timestamp)')
.action(async () => {
try {
const response = await executeViaDaemon('get-map-status', {});
if (response.success) {
const status = response.data as {
exists: boolean;
url: string | null;
timestamp: string | null;
elementCount: number;
cacheValid: boolean;
};
console.log(`\n=== Interaction Map Status ===`);
console.log(`Map exists: ${status.exists ? 'Yes' : 'No'}`);
if (status.exists) {
console.log(`URL: ${status.url || 'Unknown'}`);
console.log(`Timestamp: ${status.timestamp || 'Unknown'}`);
console.log(`Element count: ${status.elementCount}`);
console.log(`Cache valid: ${status.cacheValid ? 'Yes (< 10 minutes)' : 'No (expired or not cached)'}`);
}
console.log('\nBrowser will stay open. Use "daemon-stop" to close it.');
} else {
console.error('Status retrieval failed:', response.error);
}
process.exit(response.success ? 0 : 1);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
// Force regenerate map
program
.command('regen-map')
.description('Force rebuild interaction map (use when page content changes or map is stale)')
.action(async () => {
try {
console.log('Regenerating interaction map...');
const response = await executeViaDaemon('generate-map', { force: true });
if (response.success) {
const map = response.data as { url: string; timestamp: string; elementCount: number };
console.log(`✓ Map regenerated successfully`);
console.log(` URL: ${map.url}`);
console.log(` Timestamp: ${map.timestamp}`);
console.log(` Total elements: ${map.elementCount}`);
console.log('\nBrowser will stay open. Use "daemon-stop" to close it.');
} else {
console.error('Map regeneration failed:', response.error);
}
process.exit(response.success ? 0 : 1);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
}

View File

@@ -0,0 +1,34 @@
import { Command } from 'commander';
import { executeViaDaemon } from '../daemon-helper';
export function registerScrollCommands(program: Command) {
// Scroll command
program
.command('scroll')
.description('Scroll the page or a specific element to coordinates (x, y) or use a CSS selector to scroll an element into view')
.requiredOption('-x, --x <pixels>', 'Horizontal scroll position', parseInt)
.requiredOption('-y, --y <pixels>', 'Vertical scroll position', parseInt)
.option('-s, --selector <selector>', 'CSS selector to scroll (optional)')
.action(async (options) => {
try {
const response = await executeViaDaemon('scroll', {
x: options.x,
y: options.y,
selector: options.selector
});
if (response.success) {
const data = response.data as { success: boolean; position: { x: number; y: number } };
console.log('Scrolled to:', data.position);
console.log('Browser will stay open. Use "daemon-stop" to close it.');
} else {
console.error('Scroll failed:', response.error);
}
process.exit(response.success ? 0 : 1);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
}

View File

@@ -0,0 +1,71 @@
/**
* Selector helper utilities with automatic map regeneration fallback
*/
import { findSelector } from '../../cdp/map/query-map';
import { SELECTOR_RETRY_CONFIG } from '../../cdp/actions/helpers';
import { getOutputDir } from '../../cdp/config';
import { executeViaDaemon } from '../daemon-helper';
import * as path from 'path';
export interface SelectorQueryParams {
text: string;
index?: number;
type?: string;
viewportOnly?: boolean;
}
/**
* Find selector with automatic map regeneration fallback
*
* This function queries the interaction map for an element matching the given criteria.
* If the element is not found, it automatically regenerates the map and retries once.
*
* @param params - Selector query parameters (text, index, type, viewportOnly)
* @param elementType - Type of element being searched (for logging, e.g., "element", "input field")
* @returns Selector string or null if not found after retry
*/
export async function findSelectorWithRetry(
params: SelectorQueryParams,
elementType: string = 'element'
): Promise<string | null> {
const mapPath = path.join(getOutputDir(), SELECTOR_RETRY_CONFIG.MAP_FILENAME);
// First attempt
let selector = findSelector(mapPath, params);
// Fallback: regenerate map if element not found
if (!selector) {
console.log(`⚠️ ${elementType.charAt(0).toUpperCase() + elementType.slice(1)} not found in map, regenerating map and retrying...`);
try {
// Execute generate-map via daemon (force: true to regenerate)
const regenResponse = await executeViaDaemon('generate-map', { force: true }, { verbose: false });
if (!regenResponse.success) {
console.error(`✗ Failed to regenerate map: ${regenResponse.error}`);
return null;
}
console.log(`🔄 Map regenerated, retrying selector search...`);
// Allow file system to flush (especially important on Windows)
await new Promise(resolve => setTimeout(resolve, 300));
// Retry finding selector
selector = findSelector(mapPath, params);
if (!selector) {
console.error(`${elementType.charAt(0).toUpperCase() + elementType.slice(1)} still not found after map regeneration`);
return null;
}
console.log(`✓ Found ${elementType} after map regeneration: ${selector}`);
} catch (error) {
console.error(`✗ Error during map regeneration: ${error instanceof Error ? error.message : String(error)}`);
return null;
}
}
return selector;
}

View File

@@ -0,0 +1,222 @@
/**
* System maintenance commands
*/
import { Command } from 'commander';
import { DaemonManager } from '../../daemon/manager';
import { loadSharedConfig, saveSharedConfig } from '../../cdp/config';
import { getLocalTimestamp } from '../../utils/timestamp';
import { logger } from '../../utils/logger';
import * as fs from 'fs';
import * as path from 'path';
/**
* Show usage error and exit
*/
function showUsageAndExit(message: string, usage: string): never {
console.error(`❌ Error: ${message}`);
console.error(`Usage: ${usage}`);
process.exit(1);
}
/**
* Validate project root path
*/
function validateProjectRoot(projectRoot: string): string {
// Normalize path to prevent path traversal attacks
const normalized = path.normalize(projectRoot);
// Check if path exists
if (!fs.existsSync(normalized)) {
throw new Error(`Project root does not exist: ${normalized}`);
}
// Check if it's a directory
const stats = fs.statSync(normalized);
if (!stats.isDirectory()) {
throw new Error(`Project root is not a directory: ${normalized}`);
}
return normalized;
}
/**
* Parse and validate port number
*/
function parsePort(value: string): number {
const port = parseInt(value, 10);
if (isNaN(port)) {
throw new Error('Port must be a valid number');
}
return port;
}
export function registerSystemCommands(program: Command) {
// Reinstall command
program
.command('reinstall')
.description('Reinstall Browser Pilot scripts (removes .browser-pilot directory)')
.option('-y, --yes', 'Skip confirmation prompt')
.option('-q, --quiet', 'Suppress output')
.action(async (options) => {
try {
const projectRoot = validateProjectRoot(process.env.CLAUDE_PROJECT_DIR || process.cwd());
const browserPilotDir = path.join(projectRoot, '.browser-pilot');
// Check if directory exists
if (!fs.existsSync(browserPilotDir)) {
if (!options.quiet) {
console.log('✨ .browser-pilot directory not found. Nothing to reinstall.');
console.log('Run any command to initialize Browser Pilot.');
}
process.exit(0);
return;
}
// Confirmation prompt (skip if --yes flag provided)
if (!options.yes) {
console.log('⚠️ This will remove the .browser-pilot directory and stop the daemon.');
console.log('📁 Directory: ' + browserPilotDir);
console.log('');
console.log('Next command will trigger automatic reinstallation.');
console.log('');
console.log('Use --yes flag to skip this prompt.');
process.exit(1);
return;
}
// Stop daemon if running
const manager = new DaemonManager();
if (await manager.isRunning()) {
if (!options.quiet) {
console.log('🛑 Stopping daemon...');
}
try {
await manager.stop({ verbose: false, force: true });
if (!options.quiet) {
console.log('✓ Daemon stopped');
}
} catch (stopError) {
const errorMessage = stopError instanceof Error ? stopError.message : String(stopError);
console.error('⚠️ Failed to stop daemon:', errorMessage);
console.error('Continuing anyway, but manual cleanup may be needed.');
if (stopError instanceof Error && stopError.stack) {
logger.debug(`Stack trace: ${stopError.stack}`);
}
}
} else {
if (!options.quiet) {
console.log('✓ Daemon not running');
}
}
// Remove .browser-pilot directory
if (!options.quiet) {
console.log('🗑️ Removing .browser-pilot directory...');
}
fs.rmSync(browserPilotDir, { recursive: true, force: true });
if (!options.quiet) {
console.log('✨ Browser Pilot reinstalled successfully!');
console.log('');
console.log('Run any command to initialize Browser Pilot:');
console.log(' node .browser-pilot/bp navigate -u "https://example.com"');
console.log('');
console.log('Note: The .browser-pilot directory will be recreated automatically.');
}
process.exit(0);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error('❌ Error during reinstall:', errorMessage);
if (error instanceof Error && error.stack) {
logger.debug(`Stack trace: ${error.stack}`);
}
process.exit(1);
}
});
// Change port command
program
.command('change-port')
.description('Change Chrome DevTools Protocol port for current project')
.option('-p, --port <number>', 'New port number', parsePort)
.option('-q, --quiet', 'Suppress output')
.action(async (options) => {
try {
const projectRoot = validateProjectRoot(process.env.CLAUDE_PROJECT_DIR || process.cwd());
const projectName = path.basename(projectRoot);
if (!options.port) {
showUsageAndExit('Port number is required', 'change-port -p <port>');
}
const newPort = options.port;
// Validate port number
if (isNaN(newPort) || newPort < 1024 || newPort > 65535) {
console.error('❌ Error: Invalid port number');
console.error('Port must be between 1024 and 65535');
process.exit(1);
return;
}
// Load config
const config = loadSharedConfig();
const projectConfig = config.projects[projectName];
if (!projectConfig) {
console.error('❌ Error: Project configuration not found');
console.error('Run any Browser Pilot command to initialize the project first');
process.exit(1);
return;
}
const oldPort = projectConfig.port;
// Stop daemon if running
const manager = new DaemonManager();
if (await manager.isRunning()) {
if (!options.quiet) {
console.log('🛑 Stopping daemon on port ' + oldPort + '...');
}
try {
await manager.stop({ verbose: false, force: true });
if (!options.quiet) {
console.log('✓ Daemon stopped');
}
} catch (stopError) {
const errorMessage = stopError instanceof Error ? stopError.message : String(stopError);
console.error('⚠️ Failed to stop daemon:', errorMessage);
console.error('Continuing anyway, but daemon may still be running on old port.');
if (stopError instanceof Error && stopError.stack) {
logger.debug(`Stack trace: ${stopError.stack}`);
}
}
}
// Update port in config
projectConfig.port = newPort;
projectConfig.lastUsed = getLocalTimestamp();
saveSharedConfig(config);
if (!options.quiet) {
console.log('✅ Port changed successfully!');
console.log('');
console.log('Old port: ' + oldPort);
console.log('New port: ' + newPort);
console.log('');
console.log('The daemon will use the new port on next command.');
}
process.exit(0);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error('❌ Error changing port:', errorMessage);
if (error instanceof Error && error.stack) {
logger.debug(`Stack trace: ${error.stack}`);
}
process.exit(1);
}
});
}

View File

@@ -0,0 +1,118 @@
import { Command } from 'commander';
import { ChromeBrowser } from '../../cdp/browser';
import * as actions from '../../cdp/actions';
import { DaemonManager } from '../../daemon/manager';
export function registerTabsCommands(program: Command) {
// List tabs command
program
.command('tabs')
.description('List all open tabs with their index numbers, titles, and URLs')
.action(async () => {
const browser = new ChromeBrowser(false);
try {
await browser.connect();
const result = await actions.listTabs(browser);
const tabs = result.tabs as Array<{ index: number; title: string; url: string; targetId: string }>;
console.log(`Found ${result.count} tabs:`);
tabs.forEach((tab) => {
console.log(`[${tab.index}] ${tab.title} - ${tab.url}`);
});
process.exit(0);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
// New tab command
program
.command('new-tab')
.description('Open a new tab in the browser (optionally navigate to a specific URL with -u)')
.option('-u, --url <url>', 'URL to open', 'about:blank')
.action(async (options) => {
const browser = new ChromeBrowser(false);
try {
await browser.connect();
const result = await actions.newTab(browser, options.url);
console.log('New tab opened:', result.targetId);
process.exit(0);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
// Close tab command
program
.command('close-tab')
.description('Close a specific tab by its index number (use "tabs" command to see index numbers)')
.requiredOption('-i, --index <number>', 'Tab index to close', parseInt)
.action(async (options) => {
const browser = new ChromeBrowser(false);
try {
await browser.connect();
const result = await actions.closeTab(browser, undefined, options.index);
console.log(result.message);
process.exit(0);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
// Switch tab
program
.command('switch-tab')
.description('Switch to a different tab by its index number (use "tabs" command to see index numbers)')
.requiredOption('-i, --index <index>', 'Tab index', parseInt)
.action(async (options) => {
const browser = new ChromeBrowser(false);
try {
await browser.connect();
const result = await actions.switchTab(browser, options.index);
console.log(result.message);
process.exit(0);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
// Close browser command
program
.command('close')
.description('Close the browser completely and stop the daemon process')
.action(async () => {
const browser = new ChromeBrowser(false);
const daemonManager = new DaemonManager();
try {
// Close browser first
await browser.connect();
await browser.close();
console.log('✓ Browser closed');
// Then stop daemon
if (await daemonManager.isRunning()) {
await daemonManager.stop({ verbose: true });
console.log('✓ Daemon stopped');
}
process.exit(0);
} catch (error) {
// Try to stop daemon even if browser close failed
try {
if (await daemonManager.isRunning()) {
await daemonManager.stop({ verbose: true });
console.log('✓ Daemon stopped');
}
} catch (daemonError) {
console.error('Warning: Could not stop daemon:', daemonError);
}
console.error('Error:', error);
process.exit(1);
}
});
}

View File

@@ -0,0 +1,62 @@
import { Command } from 'commander';
import { ChromeBrowser } from '../../cdp/browser';
import * as actions from '../../cdp/actions';
export function registerWaitCommands(program: Command) {
// Wait for element command
program
.command('wait')
.description('Wait for a specific element to appear in the DOM using a CSS selector with optional timeout')
.requiredOption('-s, --selector <selector>', 'CSS selector to wait for')
.option('-t, --timeout <ms>', 'Timeout in milliseconds', parseInt, 30000)
.action(async (options) => {
const browser = new ChromeBrowser(false);
try {
await browser.connect();
const result = await actions.waitFor(browser, options.selector, options.timeout);
console.log('Element found:', result.selector);
process.exit(0);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
// Wait milliseconds
program
.command('sleep')
.description('Pause execution for a specified duration in milliseconds (useful for waiting between actions or for animations to complete)')
.requiredOption('-t, --time <ms>', 'Milliseconds to wait', parseInt)
.action(async (options) => {
const browser = new ChromeBrowser(false);
try {
await browser.connect();
const result = await actions.waitMilliseconds(browser, options.time);
console.log(`Waited ${result.waitedMs}ms`);
console.log('Browser remains open. Use "close" command to close it.');
process.exit(0);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
// Wait for network idle
program
.command('wait-idle')
.description('Wait for all network requests to complete and the page to become idle (useful after navigation or dynamic content loading)')
.option('-t, --timeout <ms>', 'Timeout in milliseconds', parseInt, 5000)
.action(async (options) => {
const browser = new ChromeBrowser(false);
try {
await browser.connect();
const result = await actions.waitForNetworkIdle(browser, options.timeout);
console.log('Network is idle:', result.state);
console.log('Browser remains open. Use "close" command to close it.');
process.exit(0);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
}

View File

@@ -0,0 +1,68 @@
/**
* Helper functions for daemon-based CLI commands
*/
import { IPCClient } from '../daemon/client';
import { DaemonManager } from '../daemon/manager';
import { IPCResponse } from '../daemon/protocol';
import { TIMING } from '../constants';
/**
* Execute command via daemon (auto-start if needed)
*/
export async function executeViaDaemon(
command: string,
params: Record<string, unknown> = {},
options: {
timeout?: number;
verbose?: boolean;
autoStart?: boolean;
} = {}
): Promise<IPCResponse> {
const { timeout = TIMING.WAIT_FOR_NAVIGATION, verbose = true, autoStart = true } = options;
// Ensure daemon is running
if (autoStart) {
const manager = new DaemonManager();
// If navigate command and daemon not running, pass initial URL
const isRunning = await manager.isRunning();
const initialUrl = (command === 'navigate' && !isRunning && params.url)
? params.url as string
: undefined;
await manager.ensureRunning({ verbose, initialUrl });
}
// Send command to daemon
const client = new IPCClient();
try {
const response = await client.sendRequest(command, params, timeout);
return response;
} finally {
client.close();
}
}
/**
* Format and display command result
*/
export function displayResult(response: IPCResponse, verbose: boolean = true): void {
if (!verbose) return;
if (response.success) {
// Display success result based on data type
const data = response.data;
if (data && typeof data === 'object') {
// Pretty print objects
console.log(JSON.stringify(data, null, 2));
} else if (data !== undefined) {
// Print primitive values
console.log(data);
}
} else {
console.error('Error:', response.error);
}
}

View File

@@ -0,0 +1,208 @@
/**
* Browser Pilot Constants
* 모든 매직 넘버, URL, 타이밍 등을 중앙에서 관리
*/
/**
* Chrome DevTools Protocol 관련 상수
* @property DEFAULT_PORT - 기본 디버깅 포트 (9222)
* @property PORT_RANGE_MAX - 포트 검색 범위 (100)
* @property LOCALHOST - 로컬 호스트 주소
* @property WS_TIMEOUT - WebSocket 연결 타임아웃 (30초)
* @property NAVIGATION_TIMEOUT - 페이지 네비게이션 타임아웃 (30초)
* @property EVALUATION_TIMEOUT - 스크립트 실행 타임아웃 (10초)
*/
export const CDP = {
DEFAULT_PORT: 9222,
PORT_RANGE_MAX: 100,
LOCALHOST: '127.0.0.1',
WS_TIMEOUT: 30000, // 30 seconds
NAVIGATION_TIMEOUT: 30000, // 30 seconds
EVALUATION_TIMEOUT: 10000, // 10 seconds
} as const;
/**
* 파일 시스템 관련 상수
* @property OUTPUT_DIR - 출력 디렉토리 (.browser-pilot)
* @property SCREENSHOTS_DIR - 스크린샷 디렉토리 (screenshots)
* @property PDFS_DIR - PDF 디렉토리 (pdfs)
* @property INTERACTION_MAP_FILE - Interaction Map 파일명
* @property MAP_CACHE_FILE - Map 캐시 파일명
* @property DAEMON_PID_FILE - 데몬 PID 파일명
* @property DAEMON_SOCKET - 데몬 소켓 파일명
* @property GITIGNORE_CONTENT - .gitignore 기본 내용
*/
export const FS = {
OUTPUT_DIR: '.browser-pilot',
SCREENSHOTS_DIR: 'screenshots',
PDFS_DIR: 'pdfs',
INTERACTION_MAP_FILE: 'interaction-map.json',
MAP_CACHE_FILE: 'map-cache.json',
DAEMON_PID_FILE: 'daemon.pid',
DAEMON_SOCKET: 'daemon.sock',
GITIGNORE_CONTENT: `# Browser Pilot generated files
*
`,
} as const;
/**
* 타이밍 관련 상수 (모든 시간 단위는 밀리초)
* @property DEFAULT_WAIT_TIMEOUT - 기본 대기 타임아웃 (30초)
* @property NETWORK_IDLE_TIMEOUT - 네트워크 idle 체크 간격 (500ms)
* @property MAP_CACHE_TTL - Map 캐시 유효 기간 (10분)
* @property DAEMON_IDLE_TIMEOUT - 데몬 idle 타임아웃 (30분)
* @property DAEMON_PING_INTERVAL - 데몬 ping 간격 (5초)
* @property SCREENSHOT_DELAY - 스크린샷 딜레이 (100ms)
* @property HOOK_INPUT_TIMEOUT - Hook stdin 읽기 타임아웃 (100ms)
* @property ACTION_DELAY_SHORT - 짧은 액션 딜레이 (50ms)
* @property ACTION_DELAY_MEDIUM - 표준 액션 딜레이 (100ms)
* @property ACTION_DELAY_LONG - 긴 액션 딜레이 (500ms)
* @property ACTION_DELAY_NAVIGATION - 네비게이션/페이지 로드 딜레이 (1초)
* @property POLLING_INTERVAL_FAST - 빠른 폴링 간격 (100ms)
* @property POLLING_INTERVAL_STANDARD - 표준 폴링 간격 (500ms)
* @property POLLING_INTERVAL_SLOW - 느린 폴링 간격 (1초)
* @property WAIT_FOR_ELEMENT - 엘리먼트 대기 타임아웃 (5초)
* @property WAIT_FOR_NAVIGATION - 네비게이션 대기 타임아웃 (30초)
* @property WAIT_FOR_LOAD_STATE - 로드 상태 대기 타임아웃 (30초)
* @property RECENT_MESSAGE_WINDOW - 최근 에러/경고 감지 윈도우 (5초)
*/
export const TIMING = {
DEFAULT_WAIT_TIMEOUT: 30000, // 30 seconds
NETWORK_IDLE_TIMEOUT: 500, // 500ms
MAP_CACHE_TTL: 600000, // 10 minutes
DAEMON_IDLE_TIMEOUT: 1800000, // 30 minutes
DAEMON_PING_INTERVAL: 5000, // 5 seconds
SCREENSHOT_DELAY: 100, // 100ms
HOOK_INPUT_TIMEOUT: 100, // 100ms for reading stdin
// Action delays
ACTION_DELAY_SHORT: 50, // 50ms - very short delay
ACTION_DELAY_MEDIUM: 100, // 100ms - standard action delay
ACTION_DELAY_LONG: 500, // 500ms - longer action delay
ACTION_DELAY_NAVIGATION: 1000, // 1s - navigation/page load delay
// Polling intervals
POLLING_INTERVAL_FAST: 100, // 100ms - fast polling
POLLING_INTERVAL_STANDARD: 500, // 500ms - standard polling
POLLING_INTERVAL_SLOW: 1000, // 1s - slow polling
// Wait timeouts
WAIT_FOR_ELEMENT: 5000, // 5s - wait for element
WAIT_FOR_NAVIGATION: 30000, // 30s - wait for navigation
WAIT_FOR_LOAD_STATE: 30000, // 30s - wait for load state
// Message/Error detection windows
RECENT_MESSAGE_WINDOW: 5000, // 5s - recent error/warning detection window
} as const;
/**
* 시간 단위 변환 상수
* @property MS_PER_SECOND - 1초당 밀리초 (1000)
* @property MS_PER_MINUTE - 1분당 밀리초 (60000)
* @property MS_PER_HOUR - 1시간당 밀리초 (3600000)
*/
export const TIME_CONVERSION = {
MS_PER_SECOND: 1000,
MS_PER_MINUTE: 60000,
MS_PER_HOUR: 3600000,
} as const;
// HTTP Status
export const HTTP_STATUS = {
OK: 200,
CREATED: 201,
NO_CONTENT: 204,
BAD_REQUEST: 400,
UNAUTHORIZED: 401,
FORBIDDEN: 403,
NOT_FOUND: 404,
INTERNAL_ERROR: 500,
} as const;
// Screenshot
export const SCREENSHOT = {
DEFAULT_FORMAT: 'png' as const,
DEFAULT_QUALITY: 80,
FULL_PAGE: true,
} as const;
// PDF
export const PDF = {
DEFAULT_FORMAT: 'A4' as const,
DEFAULT_MARGIN: {
top: '1cm',
right: '1cm',
bottom: '1cm',
left: '1cm',
},
PRINT_BACKGROUND: true,
} as const;
// Interaction Map
export const INTERACTION_MAP = {
CACHE_TTL: 600000, // 10 minutes
MAX_ELEMENTS: 10000,
SELECTOR_PRIORITY: ['byId', 'byText', 'byCSS', 'byRole', 'byAriaLabel'] as const,
} as const;
// Daemon
export const DAEMON = {
IPC_TIMEOUT: 5000, // 5 seconds
MAX_RETRIES: 3,
RETRY_DELAY: 1000, // 1 second
IDLE_CHECK_INTERVAL: 60000, // 1 minute
} as const;
// Browser
export const BROWSER = {
USER_AGENT_OVERRIDE: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
DEFAULT_VIEWPORT: {
width: 1920,
height: 1080,
},
HEADLESS: false,
} as const;
/**
* 환경 변수 이름 상수
* @property CDP_DEBUG_PORT - Chrome 디버깅 포트 환경 변수명
* @property CLAUDE_PROJECT_DIR - Claude 프로젝트 디렉토리 환경 변수명
* @property CLAUDE_PLUGIN_ROOT - Claude 플러그인 루트 환경 변수명
*/
export const ENV = {
CDP_DEBUG_PORT: 'CDP_DEBUG_PORT',
CLAUDE_PROJECT_DIR: 'CLAUDE_PROJECT_DIR',
CLAUDE_PLUGIN_ROOT: 'CLAUDE_PLUGIN_ROOT',
} as const;
// Error Messages
export const ERROR_MESSAGES = {
PROJECT_ROOT_NOT_FOUND: '[browser-pilot] Could not determine project root',
ELEMENT_NOT_FOUND: 'Element not found',
TIMEOUT: 'Operation timed out',
NAVIGATION_FAILED: 'Navigation failed',
DAEMON_NOT_RUNNING: 'Daemon is not running',
DAEMON_START_FAILED: 'Failed to start daemon',
PORT_NOT_AVAILABLE: 'No available port found',
CONFIG_LOAD_FAILED: 'Failed to load configuration',
INVALID_SELECTOR: 'Invalid selector',
} as const;
// Success Messages
export const SUCCESS_MESSAGES = {
NAVIGATION_COMPLETE: 'Navigation complete',
ELEMENT_CLICKED: 'Element clicked',
FORM_FILLED: 'Form filled',
SCREENSHOT_SAVED: 'Screenshot saved',
PDF_GENERATED: 'PDF generated',
DAEMON_STARTED: 'Daemon started',
DAEMON_STOPPED: 'Daemon stopped',
} as const;
// Regex Patterns
export const PATTERNS = {
XPATH: /^\/\//,
CSS_ID: /^#[a-zA-Z0-9_-]+$/,
CSS_CLASS: /^\.[a-zA-Z0-9_-]+$/,
URL: /^https?:\/\//,
} as const;

View File

@@ -0,0 +1,212 @@
/**
* IPC Client for Browser Pilot Daemon
* Used by CLI commands to communicate with the daemon
*/
import { Socket, connect } from 'net';
import { join } from 'path';
import { existsSync } from 'fs';
import { randomUUID } from 'crypto';
import { getOutputDir } from '../cdp/config';
import {
IPCRequest,
IPCResponse,
IPCError,
IPCErrorCodes,
SOCKET_PATH_PREFIX,
DEFAULT_TIMEOUT,
getProjectSocketName
} from './protocol';
import { logger } from '../utils/logger';
export class IPCClient {
private socket: Socket | null = null;
private socketPath: string;
private pendingRequests: Map<string, {
resolve: (response: IPCResponse) => void;
reject: (error: Error) => void;
timeout: NodeJS.Timeout;
}> = new Map();
private buffer: string = '';
constructor() {
const outputDir = getOutputDir();
this.socketPath = this.getSocketPath(outputDir);
}
/**
* Get socket path (platform-specific, project-unique)
*/
private getSocketPath(outputDir: string): string {
if (process.platform === 'win32') {
// Windows: project-specific named pipe
const socketName = getProjectSocketName();
return `\\\\.\\pipe\\${socketName}`;
} else {
// Unix domain socket (already project-specific via outputDir)
return join(outputDir, `${SOCKET_PATH_PREFIX}.sock`);
}
}
/**
* Connect to daemon
*/
async connect(): Promise<void> {
if (this.socket && !this.socket.destroyed) {
return; // Already connected
}
// Check if socket file exists (Unix only)
if (process.platform !== 'win32' && !existsSync(this.socketPath)) {
throw new IPCError('Daemon not running (socket file not found)', IPCErrorCodes.DAEMON_NOT_RUNNING);
}
return new Promise((resolve, reject) => {
this.socket = connect(this.socketPath);
this.socket.on('connect', () => {
this.setupSocket();
resolve();
});
this.socket.on('error', (error) => {
reject(new IPCError(`Connection failed: ${error.message}`, IPCErrorCodes.CONNECTION_ERROR));
});
});
}
/**
* Setup socket event handlers
*/
private setupSocket(): void {
if (!this.socket) return;
this.socket.on('data', (data) => {
this.buffer += data.toString();
// Process complete JSON messages (delimited by newline)
const messages = this.buffer.split('\n');
this.buffer = messages.pop() || ''; // Keep incomplete message in buffer
for (const message of messages) {
if (!message.trim()) continue;
try {
const response: IPCResponse = JSON.parse(message);
this.handleResponse(response);
} catch (error) {
logger.error('Failed to parse response', error);
}
}
});
this.socket.on('error', (error) => {
logger.error('Socket error', error);
this.rejectAllPending(new IPCError(`Socket error: ${error.message}`, IPCErrorCodes.CONNECTION_ERROR));
});
this.socket.on('close', () => {
this.socket = null;
this.rejectAllPending(new IPCError('Connection closed', IPCErrorCodes.CONNECTION_ERROR));
});
}
/**
* Handle response from daemon
*/
private handleResponse(response: IPCResponse): void {
const pending = this.pendingRequests.get(response.id);
if (!pending) {
logger.warn(`Received response for unknown request: ${response.id}`);
return;
}
clearTimeout(pending.timeout);
this.pendingRequests.delete(response.id);
if (response.success) {
pending.resolve(response);
} else {
pending.reject(new IPCError(response.error || 'Command failed', IPCErrorCodes.COMMAND_FAILED));
}
}
/**
* Reject all pending requests
*/
private rejectAllPending(error: Error): void {
for (const [_id, pending] of this.pendingRequests.entries()) {
clearTimeout(pending.timeout);
pending.reject(error);
}
this.pendingRequests.clear();
}
/**
* Send request to daemon
*/
async sendRequest(command: string, params: Record<string, unknown> = {}, timeout: number = DEFAULT_TIMEOUT): Promise<IPCResponse> {
await this.connect();
if (!this.socket || this.socket.destroyed) {
throw new IPCError('Not connected to daemon', IPCErrorCodes.CONNECTION_ERROR);
}
const requestId = randomUUID();
const request: IPCRequest = {
id: requestId,
command,
params,
timeout
};
return new Promise((resolve, reject) => {
// Set up timeout
const timeoutHandle = setTimeout(() => {
this.pendingRequests.delete(requestId);
reject(new IPCError(`Request timeout after ${timeout}ms`, IPCErrorCodes.TIMEOUT));
}, timeout);
// Store pending request
this.pendingRequests.set(requestId, {
resolve,
reject,
timeout: timeoutHandle
});
// Send request
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
this.socket!.write(JSON.stringify(request) + '\n', (error) => {
if (error) {
clearTimeout(timeoutHandle);
this.pendingRequests.delete(requestId);
reject(new IPCError(`Failed to send request: ${error.message}`, IPCErrorCodes.CONNECTION_ERROR));
}
});
});
}
/**
* Close connection
*/
close(): void {
if (this.socket) {
this.socket.destroy();
this.socket = null;
}
this.rejectAllPending(new IPCError('Client closed', IPCErrorCodes.CONNECTION_ERROR));
}
/**
* Check if daemon is running
*/
static isDaemonRunning(): boolean {
const outputDir = getOutputDir();
const socketPath = process.platform === 'win32'
? `\\\\.\\pipe\\${SOCKET_PATH_PREFIX}`
: join(outputDir, `${SOCKET_PATH_PREFIX}.sock`);
return existsSync(socketPath);
}
}

View File

@@ -0,0 +1,83 @@
/**
* Capture command handlers for Browser Pilot Daemon
*/
import { HandlerContext } from './navigation-handlers';
import * as actions from '../../cdp/actions';
/**
* Handle screenshot command
*/
export async function handleScreenshot(
context: HandlerContext,
params: Record<string, unknown>
): Promise<unknown> {
const filename = params.filename as string | undefined;
const fullPage = params.fullPage !== false; // Default true
// Parse clip options if provided
let clip: actions.ClipOptions | undefined;
if (params.clipX !== undefined && params.clipY !== undefined &&
params.clipWidth !== undefined && params.clipHeight !== undefined) {
clip = {
x: params.clipX as number,
y: params.clipY as number,
width: params.clipWidth as number,
height: params.clipHeight as number,
scale: params.clipScale as number | undefined
};
}
return actions.screenshot(context.browser, filename || 'screenshot.png', fullPage, clip);
}
/**
* Handle set viewport size command
*/
export async function handleSetViewport(
context: HandlerContext,
params: Record<string, unknown>
): Promise<unknown> {
const width = params.width as number;
const height = params.height as number;
const deviceScaleFactor = (params.deviceScaleFactor as number) || 1;
const mobile = (params.mobile as boolean) || false;
if (!width || !height) {
throw new Error('Width and height are required for viewport');
}
return actions.setViewportSize(context.browser, width, height, deviceScaleFactor, mobile);
}
/**
* Handle get viewport command
*/
export async function handleGetViewport(
context: HandlerContext,
params: Record<string, unknown>
): Promise<unknown> {
return actions.getViewport(context.browser);
}
/**
* Handle get screen info command
*/
export async function handleGetScreenInfo(
context: HandlerContext,
params: Record<string, unknown>
): Promise<unknown> {
return actions.getScreenInfo(context.browser);
}
/**
* Handle PDF generation command
*/
export async function handlePdf(
context: HandlerContext,
params: Record<string, unknown>
): Promise<unknown> {
const filename = params.filename as string | undefined;
const landscape = params.landscape as boolean | undefined;
return actions.generatePdf(context.browser, filename || 'page.pdf', landscape || false);
}

View File

@@ -0,0 +1,56 @@
/**
* Data extraction command handlers for Browser Pilot Daemon
*/
import { HandlerContext } from './navigation-handlers';
import * as actions from '../../cdp/actions';
/**
* Handle extract command (text extraction)
*/
export async function handleExtract(
context: HandlerContext,
params: Record<string, unknown>
): Promise<unknown> {
const selector = params.selector as string | undefined;
// If selectors object provided, use extractData for multiple selectors
if (params.selectors && typeof params.selectors === 'object') {
return actions.extractData(context.browser, params.selectors as Record<string, string>);
}
// Otherwise use extractText for single selector
return actions.extractText(context.browser, selector);
}
/**
* Handle content command (get page HTML)
*/
export async function handleContent(
context: HandlerContext,
_params: Record<string, unknown>
): Promise<unknown> {
return actions.getContent(context.browser);
}
/**
* Handle find command (find element)
*/
export async function handleFind(
context: HandlerContext,
params: Record<string, unknown>
): Promise<unknown> {
const selector = params.selector as string;
return actions.findElement(context.browser, selector);
}
/**
* Handle JavaScript evaluation command
*/
export async function handleEval(
context: HandlerContext,
params: Record<string, unknown>
): Promise<unknown> {
const expression = params.expression as string;
return actions.evaluate(context.browser, expression);
}

View File

@@ -0,0 +1,55 @@
/**
* Unified exports for all Browser Pilot Daemon handlers
*/
// Export handler context type
export type { HandlerContext } from './navigation-handlers';
// Navigation handlers
export {
handleNavigate,
handleBack,
handleForward,
handleReload
} from './navigation-handlers';
// Interaction handlers
export {
handleClick,
handleFill,
handleHover,
handlePress,
handleType
} from './interaction-handlers';
// Capture handlers
export {
handleScreenshot,
handlePdf,
handleSetViewport,
handleGetViewport,
handleGetScreenInfo
} from './capture-handlers';
// Data handlers
export {
handleExtract,
handleContent,
handleFind,
handleEval
} from './data-handlers';
// Map handlers
export {
handleQueryMap,
handleGenerateMap,
handleGetMapStatus
} from './map-handlers';
// Utility handlers
export {
handleScroll,
handleWait,
handleConsole,
handleStatus
} from './utility-handlers';

View File

@@ -0,0 +1,293 @@
/**
* Interaction command handlers for Browser Pilot Daemon
*/
import { ChromeBrowser } from '../../cdp/browser';
import { HandlerContext } from './navigation-handlers';
import * as actions from '../../cdp/actions';
import { logger } from '../../utils/logger';
/**
* Page change tracker for monitoring action effects
*/
interface PageChangeTracker {
urlBefore: string;
urlAfter: string | null;
navigationDetected: boolean;
domChangeDetected: boolean;
networkActive: boolean;
}
/**
* Selector query parameters for Smart Mode
*/
interface SelectorQueryParams {
text: string;
index?: number;
type?: string;
tag?: string;
viewportOnly?: boolean;
}
/**
* Helper: Get current URL from browser
*/
async function getCurrentUrl(browser: ChromeBrowser): Promise<string> {
try {
const result = await browser.sendCommand<{ result: { value: string } }>(
'Runtime.evaluate',
{ expression: 'window.location.href', returnByValue: true }
);
return result.result?.value || 'unknown';
} catch {
return 'unknown';
}
}
/**
* Helper: Execute action with automatic state tracking
*/
async function executeActionWithTracking<T>(
browser: ChromeBrowser,
actionFn: () => Promise<T>
): Promise<{ result: T; tracker: PageChangeTracker }> {
// Capture state before action
const urlBefore = await getCurrentUrl(browser);
const pageChangeTracker: PageChangeTracker = {
urlBefore,
urlAfter: null,
navigationDetected: false,
domChangeDetected: false,
networkActive: false
};
try {
// Execute action
const result = await actionFn();
// Capture state after action
const urlAfter = await getCurrentUrl(browser);
pageChangeTracker.urlAfter = urlAfter;
pageChangeTracker.navigationDetected = urlBefore !== urlAfter;
return { result, tracker: pageChangeTracker };
} finally {
// Cleanup if needed
}
}
/**
* Helper: Find selector with 3-stage fallback logic
* Stage 1: Type-based search (with alias expansion)
* Stage 2: Tag-based search
* Stage 3: Map regeneration + retry (max 3 attempts)
*/
async function findSelectorWithRetry(
context: HandlerContext,
params: SelectorQueryParams
): Promise<string> {
const { findSelector } = await import('../../cdp/map/query-map');
const { SELECTOR_RETRY_CONFIG } = await import('../../cdp/actions/helpers');
const { getOutputDir } = await import('../../cdp/config');
const path = await import('path');
const mapPath = path.join(getOutputDir(), SELECTOR_RETRY_CONFIG.MAP_FILENAME);
logger.debug(`🔍 Smart Mode: querying map for text="${params.text}"`);
let foundSelector: string | null = null;
let attemptCount = 0;
const maxAttempts = 3;
const originalType = params.type;
while (!foundSelector && attemptCount < maxAttempts) {
attemptCount++;
// Stage 1: Try with type (with alias expansion)
if (params.type && !params.tag) {
logger.debug(`[Attempt ${attemptCount}] Type-based search: "${params.type}"`);
foundSelector = findSelector(mapPath, {
text: params.text,
index: params.index,
type: params.type,
viewportOnly: params.viewportOnly
});
if (foundSelector) {
logger.debug(`✓ Found selector with type search: ${foundSelector}`);
break;
}
// Stage 2: Fallback to tag-based search
if (originalType) {
const baseTag = originalType.split('-')[0];
logger.debug(`[Attempt ${attemptCount}] Type failed, trying tag: "${baseTag}"`);
foundSelector = findSelector(mapPath, {
text: params.text,
index: params.index,
tag: baseTag,
viewportOnly: params.viewportOnly
});
if (foundSelector) {
logger.debug(`✓ Found selector with tag search: ${foundSelector}`);
break;
}
}
} else {
// No type specified, just search
foundSelector = findSelector(mapPath, {
text: params.text,
index: params.index,
type: params.type,
tag: params.tag,
viewportOnly: params.viewportOnly
});
if (foundSelector) {
logger.debug(`✓ Found selector: ${foundSelector}`);
break;
}
}
// Stage 3: Regenerate map and retry
if (!foundSelector && context.mapManager && attemptCount < maxAttempts) {
logger.warn(`[Attempt ${attemptCount}] Element not found, regenerating map...`);
await context.mapManager.generateMap(context.browser, true);
logger.debug('🔄 Map regenerated, retrying...');
}
}
// Final check
if (!foundSelector) {
let errorMsg = `Element not found after ${attemptCount} attempt(s): "${params.text}"\n`;
errorMsg += '\n💡 Troubleshooting:\n';
errorMsg += `- Check text is exact: --text "${params.text}"\n`;
if (params.type) {
const baseTag = params.type.split('-')[0];
errorMsg += `- Try tag search: --tag ${baseTag}\n`;
}
errorMsg += `- List available elements: node .browser-pilot/bp query --list-texts\n`;
errorMsg += `- Remove filters: try searching without --type or --viewport-only\n`;
logger.error(`${errorMsg}`);
throw new Error(errorMsg);
}
return foundSelector;
}
/**
* Handle click command with smart mode support
*/
export async function handleClick(
context: HandlerContext,
params: Record<string, unknown>
): Promise<unknown> {
let selector = params.selector as string | undefined;
// Smart Mode: if text provided, query map
if (params.text && !selector) {
selector = await findSelectorWithRetry(context, {
text: params.text as string,
index: params.index as number | undefined,
type: params.type as string | undefined,
tag: params.tag as string | undefined,
viewportOnly: params.viewportOnly as boolean | undefined
});
}
if (!selector) {
throw new Error('No selector provided');
}
// Execute with tracking
const { result, tracker } = await executeActionWithTracking(
context.browser,
() => actions.click(context.browser, selector)
);
// Always regenerate map after click (DOM may have changed, URL may or may not change)
logger.debug(`🔄 Regenerating map after click (URL: ${tracker.urlBefore}${tracker.urlAfter})`);
if (context.mapManager) {
await context.mapManager.generateMapSerially(context.browser, false);
}
return result;
}
/**
* Handle fill command with smart mode support
*/
export async function handleFill(
context: HandlerContext,
params: Record<string, unknown>
): Promise<unknown> {
let selector = params.selector as string | undefined;
const value = params.value as string;
// Smart Mode: if text provided, query map
if (params.text && !selector) {
selector = await findSelectorWithRetry(context, {
text: params.text as string,
index: params.index as number | undefined,
type: params.type as string | undefined,
tag: params.tag as string | undefined,
viewportOnly: params.viewportOnly as boolean | undefined
});
}
if (!selector) {
throw new Error('No selector provided');
}
// Execute with tracking
const { result, tracker } = await executeActionWithTracking(
context.browser,
() => actions.fill(context.browser, selector, value)
);
// Always regenerate map after fill (DOM may have changed, URL may or may not change)
logger.debug(`🔄 Regenerating map after fill (URL: ${tracker.urlBefore}${tracker.urlAfter})`);
if (context.mapManager) {
await context.mapManager.generateMapSerially(context.browser, false);
}
return result;
}
/**
* Handle hover command
*/
export async function handleHover(
context: HandlerContext,
params: Record<string, unknown>
): Promise<unknown> {
const selector = params.selector as string;
return actions.hover(context.browser, selector);
}
/**
* Handle press (keyboard key) command
*/
export async function handlePress(
context: HandlerContext,
params: Record<string, unknown>
): Promise<unknown> {
const key = params.key as string;
return actions.pressKey(context.browser, key);
}
/**
* Handle type (text input) command
*/
export async function handleType(
context: HandlerContext,
params: Record<string, unknown>
): Promise<unknown> {
const text = params.text as string;
const delay = params.delay as number | undefined;
return actions.typeText(context.browser, text, delay);
}

View File

@@ -0,0 +1,227 @@
/**
* Interaction Map command handlers for Browser Pilot Daemon
*/
import { join } from 'path';
import { HandlerContext, saveLastUrl } from './navigation-handlers';
import { loadMap, queryMap, listTypes, listTexts } from '../../cdp/map/query-map';
import { SELECTOR_RETRY_CONFIG } from '../../cdp/actions/helpers';
import { logger } from '../../utils/logger';
import {
MapQueryParams,
MapQueryResult,
MapGenerateParams,
MapGenerateResult,
MapStatusResult
} from '../protocol';
/**
* Handle query-map command with 3-stage fallback logic
*/
export async function handleQueryMap(
context: HandlerContext,
params: Record<string, unknown>
): Promise<MapQueryResult> {
const queryParams = params as MapQueryParams;
// Load map
const mapPath = join(context.outputDir, SELECTOR_RETRY_CONFIG.MAP_FILENAME);
let currentMap = loadMap(mapPath);
// Handle listTypes request
if (queryParams.listTypes) {
const types = listTypes(currentMap);
return {
count: Object.keys(types).length,
results: [],
types,
total: currentMap.statistics.total
};
}
// Handle listTexts request
if (queryParams.listTexts) {
const texts = listTexts(currentMap, {
type: queryParams.type,
limit: queryParams.limit,
offset: queryParams.offset
});
return {
count: texts.length,
results: [],
texts,
total: Object.keys(currentMap.indexes.byText).length
};
}
// 3-stage fallback logic (max 3 attempts)
let results: ReturnType<typeof queryMap> = [];
let attemptCount = 0;
const maxAttempts = 3;
const originalType = queryParams.type;
while (results.length === 0 && attemptCount < maxAttempts) {
attemptCount++;
// Stage 1: Try with type (with alias expansion)
if (queryParams.type && !queryParams.tag) {
logger.debug(`[Attempt ${attemptCount}] Trying type-based search: "${queryParams.type}"`);
results = queryMap(currentMap, queryParams);
if (results.length > 0) {
logger.debug(`✓ Found ${results.length} element(s) with type search`);
break;
}
// Stage 2: Fallback to tag-based search
// Extract base tag from type (e.g., "input-search" → "input")
if (originalType) {
const baseTag = originalType.split('-')[0];
logger.debug(`[Attempt ${attemptCount}] Type search failed, trying tag-based search: "${baseTag}"`);
const tagParams = { ...queryParams, type: undefined, tag: baseTag };
results = queryMap(currentMap, tagParams);
if (results.length > 0) {
logger.debug(`✓ Found ${results.length} element(s) with tag search`);
break;
}
}
} else {
// No type specified, just query
results = queryMap(currentMap, queryParams);
if (results.length > 0) {
break;
}
}
// Stage 3: Regenerate map and retry
if (results.length === 0 && context.mapManager && attemptCount < maxAttempts) {
logger.warn(`[Attempt ${attemptCount}] No elements found, regenerating map and retrying...`);
await context.mapManager.generateMap(context.browser, true);
logger.debug('🔄 Map regenerated, reloading and retrying...');
// Wait for map to be ready before continuing
currentMap = loadMap(mapPath, true, 10000);
}
}
// Calculate total count only once at the end
const allResults = queryMap(currentMap, { ...queryParams, limit: 0 });
// Final check: no results found after all attempts
if (results.length === 0 && !queryParams.listTypes && !queryParams.listTexts) {
// Build detailed error message with edge case handling
let errorMsg = 'No elements found matching query criteria after ' + attemptCount + ' attempt(s).\n';
errorMsg += '\n💡 Troubleshooting tips:\n';
if (queryParams.text) {
errorMsg += `- Try searching without quotes: --text ${queryParams.text.replace(/"/g, '')}\n`;
errorMsg += `- Try partial text: --text "${queryParams.text.substring(0, Math.min(10, queryParams.text.length))}"\n`;
errorMsg += `- List all texts: node .browser-pilot/bp query --list-texts\n`;
}
if (queryParams.type) {
const baseTag = queryParams.type.split('-')[0];
errorMsg += `- Try tag-based search: --tag ${baseTag}\n`;
errorMsg += `- List available types: node .browser-pilot/bp query --list-types\n`;
errorMsg += `- Remove type filter and search by text only\n`;
}
if (queryParams.tag) {
errorMsg += `- Try type-based search: --type ${queryParams.tag}\n`;
errorMsg += `- List available types: node .browser-pilot/bp query --list-types\n`;
}
if (!queryParams.text && !queryParams.type && !queryParams.tag) {
errorMsg += `- Specify search criteria: --text, --type, or --tag\n`;
errorMsg += `- List all elements: node .browser-pilot/bp query --list-types\n`;
}
errorMsg += `- Force map regeneration: node .browser-pilot/bp regen-map\n`;
errorMsg += `- Check if element is in viewport: --viewport-only (or remove if used)\n`;
throw new Error(errorMsg);
}
// Return all results in MapQueryResult format
return {
count: results.length,
results: results.map(result => ({
selector: result.selector,
alternatives: result.alternatives,
element: {
tag: result.element.tag,
text: result.element.text,
position: result.element.position
}
})),
total: allResults.length
};
}
/**
* Handle generate-map command
*/
export async function handleGenerateMap(
context: HandlerContext,
params: Record<string, unknown>
): Promise<MapGenerateResult> {
if (!context.mapManager) {
throw new Error('MapManager not initialized');
}
const generateParams = params as MapGenerateParams;
const force = generateParams.force ?? false;
// Get current URL before generation
const urlResult = await context.browser.sendCommand<{ result: { value: string } }>('Runtime.evaluate', {
expression: 'window.location.href',
returnByValue: true
});
const currentUrl = urlResult.result?.value || 'unknown';
// Check if we can use cache
const cached = !force && context.mapManager.isCacheValid(currentUrl);
// Generate map
const map = await context.mapManager.generateMap(context.browser, force);
// Save last visited URL
if (currentUrl !== 'unknown') {
await saveLastUrl(context.outputDir, currentUrl);
}
return {
success: true,
url: map.url,
elementCount: map.statistics.total,
timestamp: map.timestamp,
cached
};
}
/**
* Handle get-map-status command
*/
export async function handleGetMapStatus(
context: HandlerContext,
_params: Record<string, unknown>
): Promise<MapStatusResult> {
if (!context.mapManager) {
throw new Error('MapManager not initialized');
}
// Get current URL
const urlResult = await context.browser.sendCommand<{ result: { value: string } }>('Runtime.evaluate', {
expression: 'window.location.href',
returnByValue: true
});
const currentUrl = urlResult.result?.value || 'unknown';
// Get map status
return context.mapManager.getMapStatus(currentUrl);
}

View File

@@ -0,0 +1,184 @@
/**
* Navigation command handlers for Browser Pilot Daemon
*/
import * as fs from 'fs';
import * as path from 'path';
import { ChromeBrowser } from '../../cdp/browser';
import { MapManager } from '../map-manager';
import * as actions from '../../cdp/actions';
import { logger } from '../../utils/logger';
/**
* Handler context containing dependencies
*/
export interface HandlerContext {
browser: ChromeBrowser;
mapManager?: MapManager;
outputDir: string;
}
/**
* Helper: Get current URL from browser
*/
async function getCurrentUrl(browser: ChromeBrowser): Promise<string> {
try {
const result = await browser.sendCommand<{ result: { value: string } }>(
'Runtime.evaluate',
{ expression: 'window.location.href', returnByValue: true }
);
return result.result?.value || 'unknown';
} catch {
return 'unknown';
}
}
/**
* Helper: Save last visited URL to file
*/
export async function saveLastUrl(outputDir: string, url: string): Promise<void> {
try {
const lastUrlPath = path.join(outputDir, 'last-url.txt');
await fs.promises.writeFile(lastUrlPath, url, 'utf-8');
logger.debug(`💾 Saved last URL: ${url}`);
} catch (error) {
logger.warn(`Failed to save last URL: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Helper: Load last visited URL from file
*/
export async function loadLastUrl(outputDir: string): Promise<string | null> {
try {
const lastUrlPath = path.join(outputDir, 'last-url.txt');
const url = await fs.promises.readFile(lastUrlPath, 'utf-8');
const trimmedUrl = url.trim();
logger.debug(`📂 Loaded last URL: ${trimmedUrl}`);
return trimmedUrl || null;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
// File not found is an expected case, no need to log a warning
return null;
}
logger.warn(`Failed to load last URL: ${error instanceof Error ? error.message : String(error)}`);
return null;
}
}
/**
* Helper: Wait for map to be ready for a specific URL
*/
async function waitForMapReady(
context: HandlerContext,
expectedUrl: string,
_timeout: number
): Promise<void> {
logger.debug(`⏳ Waiting for map generation (URL: ${expectedUrl})...`);
if (!context.mapManager) {
logger.warn('MapManager not available, skipping map generation');
return;
}
// Check if map exists and has correct URL
const mapStatus = await context.mapManager.getMapStatus(expectedUrl);
if (!mapStatus.exists || mapStatus.url !== expectedUrl) {
// Map doesn't exist or has wrong URL - generate new map
logger.debug(`🔨 Generating new map for: ${expectedUrl}`);
await context.mapManager.generateMapSerially(context.browser, false);
// Above await completes only when map generation is fully done
}
logger.debug(`✅ Map ready for: ${expectedUrl}`);
}
/**
* Handle navigate command
*/
export async function handleNavigate(
context: HandlerContext,
params: Record<string, unknown>
): Promise<unknown> {
const url = params.url as string;
const result = await actions.navigate(context.browser, url);
// Navigation always changes URL, wait for map
logger.info(`🔄 Navigating to: ${url}`);
await waitForMapReady(context, url, 10000);
// Save last visited URL
await saveLastUrl(context.outputDir, url);
return result;
}
/**
* Handle back command
*/
export async function handleBack(
context: HandlerContext,
_params: Record<string, unknown>
): Promise<unknown> {
const result = await actions.goBack(context.browser);
// Get new URL after navigation
const newUrl = await getCurrentUrl(context.browser);
logger.info(`🔄 Navigated back to: ${newUrl}`);
await waitForMapReady(context, newUrl, 10000);
// Save last visited URL
if (newUrl !== 'unknown') {
await saveLastUrl(context.outputDir, newUrl);
}
return result;
}
/**
* Handle forward command
*/
export async function handleForward(
context: HandlerContext,
_params: Record<string, unknown>
): Promise<unknown> {
const result = await actions.goForward(context.browser);
// Get new URL after navigation
const newUrl = await getCurrentUrl(context.browser);
logger.info(`🔄 Navigated forward to: ${newUrl}`);
await waitForMapReady(context, newUrl, 10000);
// Save last visited URL
if (newUrl !== 'unknown') {
await saveLastUrl(context.outputDir, newUrl);
}
return result;
}
/**
* Handle reload command
*/
export async function handleReload(
context: HandlerContext,
params: Record<string, unknown>
): Promise<unknown> {
const hard = params.hard as boolean | undefined;
// Get current URL before reload
const currentUrl = await getCurrentUrl(context.browser);
const result = await actions.reload(context.browser, hard || false);
// Reload stays on same URL, wait for map
logger.info(`🔄 Reloading page: ${currentUrl}`);
await waitForMapReady(context, currentUrl, 10000);
// Save last visited URL
if (currentUrl !== 'unknown') {
await saveLastUrl(context.outputDir, currentUrl);
}
return result;
}

View File

@@ -0,0 +1,79 @@
/**
* Utility command handlers for Browser Pilot Daemon
*/
import { HandlerContext } from './navigation-handlers';
import * as actions from '../../cdp/actions';
import { DaemonState } from '../protocol';
/**
* Handle scroll command
*/
export async function handleScroll(
context: HandlerContext,
params: Record<string, unknown>
): Promise<unknown> {
const x = params.x as number;
const y = params.y as number;
return actions.scroll(context.browser, { x, y });
}
/**
* Handle wait command
*/
export async function handleWait(
context: HandlerContext,
params: Record<string, unknown>
): Promise<unknown> {
const duration = params.duration as number | undefined;
if (duration) {
// Simple sleep implementation
await new Promise(resolve => setTimeout(resolve, duration));
return { success: true, duration };
} else {
return actions.waitForLoad(context.browser);
}
}
/**
* Handle console command
*/
export async function handleConsole(
context: HandlerContext,
params: Record<string, unknown>
): Promise<unknown> {
const errorsOnly = params.errorsOnly as boolean | undefined;
const result = await actions.getConsoleMessages(context.browser, errorsOnly);
if (params.clear) {
context.browser.clearConsoleMessages();
}
return result;
}
/**
* Handle status command
*/
export async function handleStatus(
context: HandlerContext,
_params: Record<string, unknown>,
startTime: number,
lastActivity: number
): Promise<DaemonState> {
const currentUrl = await context.browser.sendCommand<{ result: { value: string } }>('Runtime.evaluate', {
expression: 'window.location.href',
returnByValue: true
});
return {
connected: true,
currentUrl: currentUrl.result?.value || null,
targetId: null, // CDP client doesn't expose targetId directly
debugPort: context.browser.debugPort,
consoleMessageCount: context.browser.getConsoleMessages().length,
networkErrorCount: context.browser.getNetworkErrors().length,
uptime: Date.now() - startTime,
lastActivity: lastActivity
};
}

View File

@@ -0,0 +1,596 @@
/**
* Daemon Process Manager
* Handles starting, stopping, and checking status of the Browser Pilot Daemon
*/
import { spawn, ChildProcess, execSync } from 'child_process';
import { join, basename } from 'path';
import { existsSync, unlinkSync } from 'fs';
import { readFile } from 'fs/promises';
import * as net from 'net';
import { getOutputDir, loadSharedConfig, saveSharedConfig } from '../cdp/config';
import { IPCClient } from './client';
import {
PID_FILENAME,
SOCKET_PATH_PREFIX,
DaemonState,
MapQueryParams,
MapQueryResult,
MapGenerateParams,
MapGenerateResult,
MapStatusResult,
getProjectSocketName
} from './protocol';
import { logger } from '../utils/logger';
import { getLocalTimestamp } from '../utils/timestamp';
import { TIMING, DAEMON } from '../constants';
export class DaemonManager {
private outputDir: string;
private pidPath: string;
private socketPath: string;
private cachedPid: { pid: number | null; timestamp: number } | null = null;
private readonly PID_CACHE_TTL = 1000; // 1 second
constructor() {
this.outputDir = getOutputDir();
this.pidPath = join(this.outputDir, PID_FILENAME);
this.socketPath = this.getSocketPath();
}
/**
* Get socket path (platform-specific, project-unique)
*/
private getSocketPath(): string {
if (process.platform === 'win32') {
// Windows: project-specific named pipe
const socketName = getProjectSocketName();
return `\\\\.\\pipe\\${socketName}`;
} else {
// Unix domain socket (already project-specific via outputDir)
return join(this.outputDir, `${SOCKET_PATH_PREFIX}.sock`);
}
}
/**
* Start daemon process with retry and port fallback
*/
async start(options: { verbose?: boolean; initialUrl?: string } = {}): Promise<void> {
const { verbose = true, initialUrl } = options;
// Check if already running
if (await this.isRunning()) {
if (verbose) {
console.log('✓ Daemon is already running');
}
return;
}
if (verbose) {
console.log('🚀 Starting Browser Pilot Daemon...');
}
// Get path to server.js (compiled output)
const serverPath = join(__dirname, 'server.js');
if (!existsSync(serverPath)) {
throw new Error(`Daemon server not found at ${serverPath}. Did you run 'npm run build'?`);
}
// Try starting with retry logic
const maxRetries = 2;
let lastError: Error | null = null;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
// Prepare environment variables
const env = { ...process.env };
if (initialUrl) {
env.BP_INITIAL_URL = initialUrl;
if (verbose && attempt === 1) {
logger.info(`Setting initial URL: ${initialUrl}`);
}
}
// Spawn daemon as detached process
const daemon: ChildProcess = spawn(process.execPath, [serverPath], {
detached: true,
stdio: 'ignore', // Don't inherit stdio
cwd: process.cwd(),
env // Pass environment variables
});
// Detach the process so it continues running when parent exits
daemon.unref();
// Wait a bit for daemon to start
await this.waitForDaemon(DAEMON.IPC_TIMEOUT);
if (verbose) {
console.log('✓ Daemon started successfully');
}
return; // Success!
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
if (verbose) {
console.log(`⚠️ Attempt ${attempt}/${maxRetries} failed: ${lastError.message}`);
}
// Stop any partially started daemon
if (await this.isRunning()) {
if (verbose) {
console.log('🛑 Stopping partially started daemon...');
}
try {
await this.stop({ verbose: false, force: true });
} catch (stopError) {
const errorMessage = stopError instanceof Error ? stopError.message : String(stopError);
logger.warn(`Failed to stop partially started daemon: ${errorMessage}`);
// Continue to next retry
}
}
// On last retry, try changing port
if (attempt === maxRetries) {
if (verbose) {
console.log('🔄 Attempting automatic port change...');
}
try {
await this.changePortAutomatically(verbose);
// One more attempt with new port
if (verbose) {
console.log('🚀 Retrying with new port...');
}
const env = { ...process.env };
if (initialUrl) {
env.BP_INITIAL_URL = initialUrl;
}
const daemon: ChildProcess = spawn(process.execPath, [serverPath], {
detached: true,
stdio: 'ignore',
cwd: process.cwd(),
env
});
daemon.unref();
await this.waitForDaemon(DAEMON.IPC_TIMEOUT);
if (verbose) {
console.log('✓ Daemon started successfully with new port');
}
return; // Success with new port!
} catch (portChangeError) {
if (verbose) {
console.log(`⚠️ Port change also failed: ${(portChangeError as Error).message}`);
}
}
}
// Wait a bit before retrying
if (attempt < maxRetries) {
await new Promise(resolve => setTimeout(resolve, 1000));
}
}
}
// All retries failed
throw new Error(`Failed to start daemon after ${maxRetries} attempts. Last error: ${lastError?.message || 'Unknown'}`);
}
/**
* Change port automatically to find available port
*/
private async changePortAutomatically(verbose: boolean): Promise<void> {
const projectRoot = process.env.CLAUDE_PROJECT_DIR || process.cwd();
const projectName = basename(projectRoot);
const config = loadSharedConfig();
const projectConfig = config.projects[projectName];
if (!projectConfig) {
throw new Error('Project configuration not found');
}
const oldPort = projectConfig.port;
const newPort = await this.findAvailablePort(oldPort);
if (verbose) {
console.log(`📍 Changing port: ${oldPort}${newPort}`);
}
projectConfig.port = newPort;
projectConfig.lastUsed = getLocalTimestamp();
saveSharedConfig(config);
}
/**
* Find available port starting from base + 1
*/
private async findAvailablePort(basePort: number): Promise<number> {
const MAX_PORTS = 100;
const timeout = 10000; // 10 seconds total timeout
const startTime = Date.now();
for (let port = basePort + 1; port < basePort + MAX_PORTS; port++) {
if (Date.now() - startTime > timeout) {
throw new Error('Timeout while searching for available port');
}
if (await this.isPortAvailable(port)) {
return port;
}
}
throw new Error(`No available ports found in range ${basePort + 1}-${basePort + MAX_PORTS}`);
}
/**
* Check if port is available
*/
private async isPortAvailable(port: number): Promise<boolean> {
return new Promise((resolve) => {
const server = net.createServer();
let resolved = false;
const cleanup = () => {
if (!resolved) {
resolved = true;
try {
server.close();
} catch (error) {
// Ignore close errors
}
}
};
const timeout = setTimeout(() => {
cleanup();
resolve(false); // Timeout = not available
}, 2000);
server.once('error', () => {
clearTimeout(timeout);
cleanup();
resolve(false);
});
server.once('listening', () => {
clearTimeout(timeout);
cleanup();
resolve(true);
});
server.listen(port, '127.0.0.1');
});
}
/**
* Stop daemon process
*/
async stop(options: { verbose?: boolean; force?: boolean } = {}): Promise<void> {
const { verbose = true, force = false } = options;
if (!(await this.isRunning())) {
if (verbose) {
console.log('⚠️ Daemon is not running');
}
return;
}
if (verbose) {
console.log('🛑 Stopping Browser Pilot Daemon...');
}
try {
// Try graceful shutdown via IPC first
if (!force) {
const client = new IPCClient();
await client.sendRequest('shutdown', {}, DAEMON.IPC_TIMEOUT);
client.close();
// Wait for daemon to stop
await this.waitForStop(DAEMON.IPC_TIMEOUT);
if (verbose) {
console.log('✓ Daemon stopped gracefully');
}
return;
}
} catch (_error) {
if (verbose) {
logger.warn('Graceful shutdown failed, forcing...');
}
}
// Force kill if graceful shutdown failed
const pid = await this.getPid();
if (pid) {
try {
process.kill(pid, 'SIGTERM');
// Wait a bit
await new Promise(resolve => setTimeout(resolve, TIMING.POLLING_INTERVAL_SLOW));
// Check if still running
try {
process.kill(pid, 0);
// Still running, force kill
process.kill(pid, 'SIGKILL');
} catch (_error) {
// Process is gone, good
}
if (verbose) {
console.log('✓ Daemon stopped (forced)');
}
} catch (_error) {
// Process already gone
if (verbose) {
console.log('✓ Daemon stopped');
}
}
// Clean up PID file
if (existsSync(this.pidPath)) {
unlinkSync(this.pidPath);
}
// Clean up socket file (Unix only)
if (process.platform !== 'win32' && existsSync(this.socketPath)) {
unlinkSync(this.socketPath);
}
}
}
/**
* Restart daemon
*/
async restart(options: { verbose?: boolean } = {}): Promise<void> {
await this.stop(options);
await new Promise(resolve => setTimeout(resolve, TIMING.ACTION_DELAY_NAVIGATION)); // Wait a bit
await this.start(options);
}
/**
* Get daemon status
*/
async getStatus(options: { verbose?: boolean } = {}): Promise<DaemonState | null> {
const { verbose = true } = options;
if (!(await this.isRunning())) {
if (verbose) {
console.log('❌ Daemon is not running');
}
return null;
}
try {
const client = new IPCClient();
const response = await client.sendRequest('status', {}, DAEMON.IPC_TIMEOUT);
client.close();
const state = response.data as DaemonState;
if (verbose) {
console.log('\n📊 Daemon Status:');
console.log(` Connected: ${state.connected ? '✓' : '✗'}`);
console.log(` Current URL: ${state.currentUrl || 'N/A'}`);
console.log(` Debug Port: ${state.debugPort || 'N/A'}`);
console.log(` Console Messages: ${state.consoleMessageCount}`);
console.log(` Network Errors: ${state.networkErrorCount}`);
console.log(` Uptime: ${Math.floor(state.uptime / TIMING.ACTION_DELAY_NAVIGATION)}s`);
console.log(` Last Activity: ${new Date(state.lastActivity).toLocaleTimeString()}`);
}
return state;
} catch (error) {
if (verbose) {
logger.error('Failed to get daemon status', error);
}
return null;
}
}
/**
* Check if daemon is running
*/
async isRunning(): Promise<boolean> {
const pid = await this.getPid();
if (!pid) {
return false;
}
try {
// Signal 0 checks if process exists without killing it
process.kill(pid, 0);
return true;
} catch (_error) {
// Process doesn't exist, clean up stale PID file and invalidate cache
this.cachedPid = null;
if (existsSync(this.pidPath)) {
unlinkSync(this.pidPath);
}
return false;
}
}
/**
* Get daemon PID from PID file (with caching, async for non-blocking I/O)
*/
private async getPid(): Promise<number | null> {
// Use cached value if available and fresh
if (this.cachedPid && Date.now() - this.cachedPid.timestamp < this.PID_CACHE_TTL) {
return this.cachedPid.pid;
}
if (!existsSync(this.pidPath)) {
this.cachedPid = { pid: null, timestamp: Date.now() };
return null;
}
try {
const pidStr = await readFile(this.pidPath, 'utf-8');
const pid = parseInt(pidStr.trim(), 10);
const result = isNaN(pid) ? null : pid;
this.cachedPid = { pid: result, timestamp: Date.now() };
return result;
} catch (_error) {
this.cachedPid = { pid: null, timestamp: Date.now() };
return null;
}
}
/**
* Wait for daemon to start
*/
private async waitForDaemon(timeout: number): Promise<void> {
const startTime = Date.now();
while (Date.now() - startTime < timeout) {
if (await this.isRunning()) {
// Also check if socket is available
if (existsSync(this.socketPath) || process.platform === 'win32') {
return;
}
}
await new Promise(resolve => setTimeout(resolve, TIMING.POLLING_INTERVAL_FAST));
}
throw new Error('Daemon failed to start within timeout period');
}
/**
* Wait for daemon to stop
*/
private async waitForStop(timeout: number): Promise<void> {
const startTime = Date.now();
while (Date.now() - startTime < timeout) {
if (!(await this.isRunning())) {
return;
}
await new Promise(resolve => setTimeout(resolve, TIMING.POLLING_INTERVAL_FAST));
}
throw new Error('Daemon failed to stop within timeout period');
}
/**
* Ensure daemon is running (auto-start if needed)
*/
async ensureRunning(options: { verbose?: boolean; initialUrl?: string } = {}): Promise<void> {
if (!(await this.isRunning())) {
await this.start(options);
}
}
/**
* Query interaction map for elements
*/
async queryMap(params: MapQueryParams, options: { verbose?: boolean } = {}): Promise<MapQueryResult> {
const { verbose = true } = options;
await this.ensureRunning({ verbose: false });
try {
const client = new IPCClient();
const response = await client.sendRequest('query-map', params as Record<string, unknown>, TIMING.WAIT_FOR_LOAD_STATE);
client.close();
const result = response.data as MapQueryResult;
if (verbose) {
console.log('\n🔍 Map Query Result:');
console.log(` Total matches: ${result.count}`);
if (result.count > 0) {
const firstResult = result.results[0];
console.log(` Best Selector: ${firstResult.selector}`);
console.log(` Element: ${firstResult.element.tag} - "${firstResult.element.text || '(no text)'}"`);
console.log(` Position: (${firstResult.element.position.x}, ${firstResult.element.position.y})`);
if (firstResult.alternatives.length > 0) {
console.log(` Alternatives: ${firstResult.alternatives.length} available`);
}
}
}
return result;
} catch (error) {
if (verbose) {
logger.error('Map query failed', error);
}
throw error;
}
}
/**
* Generate interaction map for current page
*/
async generateMap(params: MapGenerateParams, options: { verbose?: boolean } = {}): Promise<MapGenerateResult> {
const { verbose = true } = options;
await this.ensureRunning({ verbose: false });
try {
const client = new IPCClient();
const response = await client.sendRequest('generate-map', params as Record<string, unknown>, TIMING.WAIT_FOR_LOAD_STATE + DAEMON.IPC_TIMEOUT);
client.close();
const result = response.data as MapGenerateResult;
if (verbose) {
console.log('\n🗺 Interaction Map Generated:');
console.log(` URL: ${result.url}`);
console.log(` Elements: ${result.elementCount}`);
console.log(` Timestamp: ${result.timestamp}`);
console.log(` Cached: ${result.cached ? '✓' : '✗'}`);
}
return result;
} catch (error) {
if (verbose) {
logger.error('Map generation failed', error);
}
throw error;
}
}
/**
* Get interaction map status
*/
async getMapStatus(options: { verbose?: boolean } = {}): Promise<MapStatusResult> {
const { verbose = true } = options;
await this.ensureRunning({ verbose: false });
try {
const client = new IPCClient();
const response = await client.sendRequest('get-map-status', {}, DAEMON.IPC_TIMEOUT);
client.close();
const result = response.data as MapStatusResult;
if (verbose) {
console.log('\n📊 Interaction Map Status:');
console.log(` Exists: ${result.exists ? '✓' : '✗'}`);
if (result.exists) {
console.log(` URL: ${result.url || 'N/A'}`);
console.log(` Elements: ${result.elementCount}`);
console.log(` Timestamp: ${result.timestamp || 'N/A'}`);
console.log(` Cache Valid: ${result.cacheValid ? '✓' : '✗ (expired)'}`);
}
}
return result;
} catch (error) {
if (verbose) {
logger.error('Failed to get map status', error);
}
throw error;
}
}
}

View File

@@ -0,0 +1,527 @@
/**
* Interaction Map Manager for Browser Pilot Daemon
* Handles automatic map generation, caching, and lifecycle management
*/
import { EventEmitter } from 'events';
import { ChromeBrowser } from '../cdp/browser';
import { getInteractionMapScript, InteractionElement } from '../cdp/map/generate-interaction-map';
import { InteractionMap } from '../cdp/map/query-map';
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
import { join } from 'path';
import { logger, getLocalTimestamp } from '../utils/logger';
import { FS, TIMING } from '../constants';
/**
* Map cache metadata for a single URL
*/
interface MapCacheEntry {
url: string;
timestamp: string;
elementCount: number;
mapFile: string;
}
/**
* Map cache file structure
*/
interface MapCacheFile {
version: string;
maps: MapCacheEntry[];
}
/**
* Map generation event data
*/
export interface MapGenerationEvent {
url: string;
timestamp: string;
elementCount: number;
}
/**
* Configuration constants for map management
*/
const MAP_CONFIG = {
CACHE_MAX_AGE_MS: TIMING.MAP_CACHE_TTL,
MAP_FILENAME: FS.INTERACTION_MAP_FILE,
CACHE_FILENAME: FS.MAP_CACHE_FILE,
MAP_FOLDER: FS.OUTPUT_DIR,
CACHE_VERSION: '1.0.0',
DEBOUNCE_MS: TIMING.NETWORK_IDLE_TIMEOUT,
MAP_GENERATION_DELAY_MS: TIMING.ACTION_DELAY_NAVIGATION
} as const;
export class MapManager extends EventEmitter {
private outputDir: string;
private mapPath: string;
private cachePath: string;
private currentCache: MapCacheFile | null = null;
private lastGenerationTime: number = 0;
private generationDebounceTimer: NodeJS.Timeout | null = null;
private isGenerating: boolean = false;
private currentGenerationPromise: Promise<InteractionMap> | null = null;
constructor(outputDir: string) {
super();
this.outputDir = outputDir;
this.mapPath = join(outputDir, MAP_CONFIG.MAP_FILENAME);
this.cachePath = join(outputDir, MAP_CONFIG.CACHE_FILENAME);
// Ensure output directory exists
if (!existsSync(outputDir)) {
mkdirSync(outputDir, { recursive: true });
}
// Load existing cache
this.currentCache = this.loadCache();
}
/**
* Generate interaction map for current page
*/
async generateMap(browser: ChromeBrowser, force: boolean = false): Promise<InteractionMap> {
this.isGenerating = true;
// Get current URL
const urlResult = await browser.sendCommand<{ result: { value: string } }>('Runtime.evaluate', {
expression: 'window.location.href',
returnByValue: true
});
const url = urlResult.result?.value || 'unknown';
// Emit generation start event
this.emit('generation-start', { url });
// Check if we should generate (unless forced)
if (!force && !this.shouldGenerateMapForUrl(url)) {
const cachedMap = this.loadMapFromFile();
if (cachedMap && cachedMap.url === url && cachedMap.ready === true) {
this.isGenerating = false;
this.emit('generation-complete', {
url: cachedMap.url,
timestamp: cachedMap.timestamp,
elementCount: cachedMap.statistics.total
});
return cachedMap;
}
}
// Write placeholder map with ready: false immediately
const placeholderMap: InteractionMap = {
url,
timestamp: getLocalTimestamp(),
ready: false,
viewport: { width: 0, height: 0 },
elements: {},
indexes: { byText: {}, byType: {}, inViewport: [] },
statistics: { total: 0, byType: {}, duplicates: 0 }
};
this.saveMapToFile(placeholderMap);
// Get viewport size
const viewportResult = await browser.sendCommand<{
layoutViewport: { clientWidth: number; clientHeight: number };
}>('Page.getLayoutMetrics', {});
const viewport = {
width: viewportResult.layoutViewport.clientWidth,
height: viewportResult.layoutViewport.clientHeight
};
// Execute script to find all interactive elements
const script = getInteractionMapScript();
interface RuntimeEvaluateResult {
result?: {
type?: string;
value?: unknown;
description?: string;
};
exceptionDetails?: {
exception?: {
description?: string;
};
text?: string;
};
}
const result = await browser.sendCommand<RuntimeEvaluateResult>('Runtime.evaluate', {
expression: script,
returnByValue: true
});
// Check for script execution errors
if (result.exceptionDetails) {
const errorMsg = result.exceptionDetails.exception?.description ||
result.exceptionDetails.text ||
'Unknown script error';
logger.error('Map generation script error:', errorMsg);
throw new Error(`Failed to extract interactive elements: ${errorMsg}`);
}
if (!result.result || !result.result.value) {
logger.error('Unexpected result structure:', JSON.stringify(result, null, 2));
throw new Error('Failed to extract interactive elements: No value returned');
}
const elementsArray = result.result.value as InteractionElement[];
// Generate statistics
const byType: Record<string, number> = {};
const textCounts: Record<string, number> = {};
elementsArray.forEach(el => {
byType[el.type] = (byType[el.type] || 0) + 1;
if (el.text) {
textCounts[el.text] = (textCounts[el.text] || 0) + 1;
}
});
const duplicates = Object.values(textCounts).filter(count => count > 1).length;
// Add indexed selectors for duplicates
elementsArray.forEach(el => {
if (el.text && textCounts[el.text] > 1 && el.selectors.byText) {
const sameTextElements = elementsArray.filter(e => e.text === el.text);
const index = sameTextElements.indexOf(el) + 1;
el.selectors.byText = `(${el.selectors.byText})[${index}]`;
}
});
// Convert array to key-value structure
const elements: Record<string, InteractionElement> = {};
const textIndex: Record<string, string[]> = {};
const typeIndex: Record<string, string[]> = {};
const inViewportIds: string[] = [];
elementsArray.forEach(el => {
// Add to elements map
elements[el.id] = el;
// Build text index
if (el.text) {
if (!textIndex[el.text]) {
textIndex[el.text] = [];
}
textIndex[el.text].push(el.id);
}
// Build type index
if (!typeIndex[el.type]) {
typeIndex[el.type] = [];
}
typeIndex[el.type].push(el.id);
// Build viewport index
if (el.visibility.inViewport) {
inViewportIds.push(el.id);
}
});
const timestamp = getLocalTimestamp();
// Build map object with all data collected
const map: InteractionMap = {
url,
timestamp,
ready: true, // All data collected, ready to write
viewport,
elements,
indexes: {
byText: textIndex,
byType: typeIndex,
inViewport: inViewportIds
},
statistics: {
total: elementsArray.length,
byType,
duplicates
}
};
try {
// Save complete map to file in one write
this.saveMapToFile(map);
// Update cache metadata
this.updateCacheEntry(url, timestamp, map.statistics.total);
// Update last generation time
this.lastGenerationTime = Date.now();
// Emit generation complete event
this.emit('generation-complete', {
url: map.url,
timestamp: map.timestamp,
elementCount: map.statistics.total
});
return map;
} catch (error) {
this.emit('generation-error', error);
throw error;
} finally {
this.isGenerating = false;
}
}
/**
* Generate map with lock to prevent concurrent executions
* Returns a promise that resolves when map generation is complete
*/
async generateMapSerially(browser: ChromeBrowser, force: boolean = false): Promise<void> {
// If already generating and not forced, return existing promise
if (this.currentGenerationPromise && !force) {
logger.debug('Map generation already in progress, waiting for completion...');
await this.currentGenerationPromise;
return;
}
// Clear existing debounce timer (legacy support)
if (this.generationDebounceTimer) {
logger.debug(`Canceling previous map generation timer`);
clearTimeout(this.generationDebounceTimer);
}
// Generate with lock to prevent concurrent execution
try {
logger.debug('Generating map with lock...');
this.currentGenerationPromise = this.generateMap(browser, force);
await this.currentGenerationPromise;
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.warn(`Map generation failed: ${errorMessage}`);
this.emit('generation-error', error);
throw error;
} finally {
this.currentGenerationPromise = null;
}
}
/**
* Check if map should be generated for a URL
*/
private shouldGenerateMapForUrl(url: string): boolean {
if (!this.currentCache) {
return true;
}
// Find cache entry for this URL
const entry = this.currentCache.maps.find(m => m.url === url);
if (!entry) {
return true;
}
// Check if cache is still valid
const cacheAge = Date.now() - new Date(entry.timestamp).getTime();
return cacheAge > MAP_CONFIG.CACHE_MAX_AGE_MS;
}
/**
* Check if cached map is valid for a URL
*/
isCacheValid(url: string): boolean {
return !this.shouldGenerateMapForUrl(url);
}
/**
* Get map status for a URL
*/
getMapStatus(url: string): {
exists: boolean;
url: string | null;
timestamp: string | null;
elementCount: number;
cacheValid: boolean;
} {
const mapExists = existsSync(this.mapPath);
if (!mapExists || !this.currentCache) {
return {
exists: false,
url: null,
timestamp: null,
elementCount: 0,
cacheValid: false
};
}
const entry = this.currentCache.maps.find(m => m.url === url);
if (!entry) {
return {
exists: mapExists,
url: null,
timestamp: null,
elementCount: 0,
cacheValid: false
};
}
return {
exists: true,
url: entry.url,
timestamp: entry.timestamp,
elementCount: entry.elementCount,
cacheValid: this.isCacheValid(url)
};
}
/**
* Load map cache from file
*/
private loadCache(): MapCacheFile {
if (!existsSync(this.cachePath)) {
return {
version: MAP_CONFIG.CACHE_VERSION,
maps: []
};
}
try {
const data = readFileSync(this.cachePath, 'utf-8');
const parsed = JSON.parse(data) as unknown;
// Type guard to validate cache structure
if (
typeof parsed === 'object' &&
parsed !== null &&
'version' in parsed &&
'maps' in parsed &&
Array.isArray((parsed as { maps: unknown }).maps)
) {
return parsed as MapCacheFile;
}
// Invalid structure, return empty cache
return {
version: MAP_CONFIG.CACHE_VERSION,
maps: []
};
} catch (_error: unknown) {
logger.warn('Failed to load map cache, starting fresh');
return {
version: MAP_CONFIG.CACHE_VERSION,
maps: []
};
}
}
/**
* Save map cache to file
*/
private saveCache(cache: MapCacheFile): void {
try {
writeFileSync(this.cachePath, JSON.stringify(cache, null, 2), 'utf-8');
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.warn(`Failed to save map cache: ${errorMessage}`);
}
}
/**
* Update cache entry for a URL
*/
private updateCacheEntry(url: string, timestamp: string, elementCount: number): void {
if (!this.currentCache) {
this.currentCache = this.loadCache();
}
// Remove old entry for this URL
this.currentCache.maps = this.currentCache.maps.filter(m => m.url !== url);
// Add new entry
this.currentCache.maps.push({
url,
timestamp,
elementCount,
mapFile: MAP_CONFIG.MAP_FILENAME
});
// Save updated cache
this.saveCache(this.currentCache);
}
/**
* Load map from file
*/
private loadMapFromFile(): InteractionMap | null {
if (!existsSync(this.mapPath)) {
return null;
}
try {
const data = readFileSync(this.mapPath, 'utf-8');
return JSON.parse(data) as InteractionMap;
} catch (_error: unknown) {
return null;
}
}
/**
* Save map to file
*/
private saveMapToFile(map: InteractionMap): void {
try {
writeFileSync(this.mapPath, JSON.stringify(map, null, 2), 'utf-8');
} catch (error: unknown) {
throw new Error(`Failed to save map file: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Wait for ongoing map generation to complete
* @param timeout Maximum wait time in milliseconds (default: 10000)
* @returns true if generation completed successfully, false if timeout
*/
async waitForGeneration(timeout: number = TIMING.WAIT_FOR_LOAD_STATE): Promise<boolean> {
if (!this.isGenerating && !this.currentGenerationPromise) {
return true; // Already ready
}
return new Promise((resolve) => {
const timer = setTimeout(() => {
logger.warn('Map generation timeout');
this.removeListener('generation-complete', onComplete);
this.removeListener('generation-error', onError);
resolve(false);
}, timeout);
const onComplete = () => {
clearTimeout(timer);
this.removeListener('generation-error', onError);
resolve(true);
};
const onError = () => {
clearTimeout(timer);
this.removeListener('generation-complete', onComplete);
resolve(false);
};
this.once('generation-complete', onComplete);
this.once('generation-error', onError);
});
}
/**
* Set ready flag in existing map file
* Called by action handlers to invalidate map before action execution
* @param ready Ready state to set (typically false to invalidate)
*/
setMapReady(ready: boolean): void {
try {
const map = this.loadMapFromFile();
if (!map) {
return; // No map to update
}
map.ready = ready;
this.saveMapToFile(map);
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.warn(`Failed to update map ready flag: ${errorMessage}`);
}
}
}

View File

@@ -0,0 +1,265 @@
/**
* IPC Protocol definitions for Browser Pilot Daemon
*/
// Type imports for protocol definitions (used by type definitions below)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import { ConsoleMessage, NetworkError, FormattedConsoleMessage } from '../cdp/browser';
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import { QueryOptions, QueryResult, InteractionMap, InteractionElement } from '../cdp/map/query-map';
/**
* IPC Request from CLI to Daemon
*/
export interface IPCRequest {
id: string;
command: string;
params: Record<string, unknown>;
timeout?: number;
}
/**
* IPC Response from Daemon to CLI
*/
export interface IPCResponse {
id: string;
success: boolean;
data?: unknown;
error?: string;
}
/**
* Daemon state information
*/
export interface DaemonState {
connected: boolean;
currentUrl: string | null;
targetId: string | null;
debugPort: number | null;
consoleMessageCount: number;
networkErrorCount: number;
uptime: number;
lastActivity: number;
}
/**
* Command-specific parameter interfaces
*/
export interface NavigateParams {
url: string;
waitForLoad?: boolean;
timeout?: number;
}
export interface ClickParams {
selector: string;
waitForSelector?: boolean;
timeout?: number;
}
export interface FillParams {
selector: string;
value: string;
clear?: boolean;
}
export interface ScrollParams {
x: number;
y: number;
}
export interface EvaluateParams {
expression: string;
returnByValue?: boolean;
}
export interface ScreenshotParams {
filename?: string;
fullPage?: boolean;
quality?: number;
}
export interface ConsoleParams {
errorsOnly?: boolean;
clear?: boolean;
}
export interface WaitParams {
selector?: string;
timeout?: number;
duration?: number;
}
/**
* Map-related parameter interfaces
*/
export interface MapQueryParams extends QueryOptions {
// Extends QueryOptions which already has: text, type, index, viewportOnly, id
}
export interface MapGenerateParams {
force?: boolean;
useCache?: boolean;
}
/**
* Command result interfaces
*/
export interface ConsoleResult {
messages: FormattedConsoleMessage[];
count: number;
errorCount: number;
warningCount: number;
logCount: number;
}
export interface NavigateResult {
url: string;
title?: string;
}
export interface ScreenshotResult {
path: string;
size: number;
}
/**
* Map-related result interfaces
*/
export interface MapQueryResultItem {
selector: string;
alternatives: string[];
element: {
tag: string;
text: string | undefined;
position: { x: number; y: number };
};
}
export interface MapQueryResult {
count: number;
results: MapQueryResultItem[];
// Optional fields for list operations
types?: Record<string, number>; // For listTypes
texts?: Array<{ text: string; type: string; count: number }>; // For listTexts
total?: number; // Total count before pagination
}
export interface MapStatusResult {
exists: boolean;
url: string | null;
timestamp: string | null;
elementCount: number;
cacheValid: boolean;
}
export interface MapGenerateResult {
success: boolean;
url: string;
elementCount: number;
timestamp: string;
cached: boolean;
}
/**
* Protocol constants
*/
export const SOCKET_PATH_PREFIX = 'daemon';
export const PID_FILENAME = 'daemon.pid';
export const STATE_FILENAME = 'daemon-state.json';
export const DEFAULT_TIMEOUT = 30000; // 30 seconds
export const IDLE_SHUTDOWN_TIMEOUT = 1800000; // 30 minutes
/**
* Get project-specific socket name
* Uses project folder name + path hash to create unique socket for each project
*/
export function getProjectSocketName(): string {
const { basename } = require('path');
const { findProjectRoot } = require('../cdp/utils');
const { createHash } = require('crypto');
const projectRoot = findProjectRoot();
const projectName = basename(projectRoot)
.replace(/[^a-zA-Z0-9_-]/g, '-') // Replace special chars with hyphen
.toLowerCase();
// Add hash of full path to prevent collision
const hash = createHash('sha256')
.update(projectRoot)
.digest('hex')
.substring(0, 8); // Use first 8 chars for brevity
return `${SOCKET_PATH_PREFIX}-${projectName}-${hash}`;
}
/**
* Protocol errors
*/
export class IPCError extends Error {
constructor(
message: string,
public code: string
) {
super(message);
this.name = 'IPCError';
}
}
export const IPCErrorCodes = {
TIMEOUT: 'TIMEOUT',
DAEMON_NOT_RUNNING: 'DAEMON_NOT_RUNNING',
DAEMON_ALREADY_RUNNING: 'DAEMON_ALREADY_RUNNING',
BROWSER_NOT_CONNECTED: 'BROWSER_NOT_CONNECTED',
COMMAND_FAILED: 'COMMAND_FAILED',
INVALID_REQUEST: 'INVALID_REQUEST',
CONNECTION_ERROR: 'CONNECTION_ERROR'
} as const;
/**
* Daemon command constants
*/
export const DAEMON_COMMANDS = {
// Navigation
NAVIGATE: 'navigate',
BACK: 'back',
FORWARD: 'forward',
RELOAD: 'reload',
// Interaction
CLICK: 'click',
FILL: 'fill',
HOVER: 'hover',
PRESS: 'press',
TYPE: 'type',
// Capture
SCREENSHOT: 'screenshot',
PDF: 'pdf',
// Data
EXTRACT: 'extract',
CONTENT: 'content',
FIND: 'find',
EVAL: 'eval',
// Console
CONSOLE: 'console',
// Wait
WAIT: 'wait',
WAIT_IDLE: 'wait-idle',
SLEEP: 'sleep',
// Scroll
SCROLL: 'scroll',
// Daemon management
DAEMON_STATUS: 'daemon-status',
DAEMON_STOP: 'daemon-stop',
// Map operations
QUERY_MAP: 'query-map',
GENERATE_MAP: 'generate-map',
GET_MAP_STATUS: 'get-map-status'
} as const;
export type DaemonCommand = typeof DAEMON_COMMANDS[keyof typeof DAEMON_COMMANDS];

View File

@@ -0,0 +1,880 @@
/**
* Browser Pilot Daemon Server
* Maintains persistent CDP connection and handles IPC requests from CLI
*/
import { createServer, Server, Socket } from 'net';
import { join, basename } from 'path';
import { existsSync, unlinkSync, writeFileSync, readFileSync } from 'fs';
import { ChromeBrowser } from '../cdp/browser';
import { getOutputDir, loadSharedConfig } from '../cdp/config';
import { RuntimeEvaluateResult } from '../cdp/actions/helpers';
import { waitForDomStable } from '../cdp/actions/wait';
import {
IPCRequest,
IPCResponse,
IPCError,
IPCErrorCodes,
SOCKET_PATH_PREFIX,
PID_FILENAME,
IDLE_SHUTDOWN_TIMEOUT,
getProjectSocketName
} from './protocol';
import { MapManager } from './map-manager';
import { logger } from '../utils/logger';
import { TIME_CONVERSION } from '../constants';
import * as handlers from './handlers';
import { loadLastUrl } from './handlers/navigation-handlers';
export class DaemonServer {
private server: Server | null = null;
private browser: ChromeBrowser | null = null;
private socketPath: string;
private pidPath: string;
private outputDir: string;
private idleTimeout: NodeJS.Timeout | null = null;
private lastActivity: number = Date.now();
private startTime: number = Date.now();
private isShuttingDown: boolean = false;
private shutdownPromise: Promise<void> | null = null;
private mapManager: MapManager | null = null;
private pendingNetworkRequests: Set<string> = new Set();
private mapGenerationInProgress: boolean = false;
private activeSockets: Set<Socket> = new Set();
private initialUrl: string | undefined;
private readonly MAX_MESSAGE_SIZE = 10 * 1024 * 1024; // 10MB
constructor() {
this.outputDir = getOutputDir();
this.socketPath = this.getSocketPath();
this.pidPath = join(this.outputDir, PID_FILENAME);
this.mapManager = new MapManager(this.outputDir);
}
/**
* Get socket path (platform-specific, project-unique)
*/
private getSocketPath(): string {
if (process.platform === 'win32') {
// Windows: project-specific named pipe
const socketName = getProjectSocketName();
return `\\\\.\\pipe\\${socketName}`;
} else {
// Unix domain socket (already project-specific via outputDir)
return join(this.outputDir, `${SOCKET_PATH_PREFIX}.sock`);
}
}
/**
* Start daemon server
*/
async start(): Promise<void> {
// Enable file logging for daemon
const logFile = join(this.outputDir, 'daemon.log');
logger.enableFileLogging(logFile);
logger.info('🚀 Browser Pilot Daemon starting...');
logger.info(`Log file: ${logFile}`);
// Store initial URL from environment
this.initialUrl = process.env.BP_INITIAL_URL;
// Check if already running
if (this.isAlreadyRunning()) {
throw new IPCError('Daemon already running', IPCErrorCodes.DAEMON_ALREADY_RUNNING);
}
// Clean up stale socket file (Unix only)
if (process.platform !== 'win32' && existsSync(this.socketPath)) {
unlinkSync(this.socketPath);
}
// Initialize browser connection
logger.info('Starting Browser Pilot Daemon...');
this.browser = new ChromeBrowser(false);
try {
// Try to connect to existing browser first
await this.browser.connect();
logger.info('Connected to existing Chrome instance');
} catch (_error) {
// If no browser running, launch new one
if (this.initialUrl) {
logger.info(`Launching new Chrome instance with initial URL: ${this.initialUrl}`);
await this.browser.launch(this.initialUrl);
this.initialUrl = undefined; // Clear after use
} else {
logger.info('Launching new Chrome instance...');
await this.browser.launch();
}
logger.info('Chrome launched successfully');
}
// Set up Page domain for navigation events
await this.setupPageDomain();
// Set up Network tracking for auto-wait
await this.setupNetworkTracking();
// Auto-restore last visited URL if enabled
await this.autoRestoreUrl();
// Create IPC server
this.server = createServer((socket) => this.handleConnection(socket));
// Start listening
this.server.listen(this.socketPath, () => {
logger.info(`IPC server listening on ${this.socketPath}`);
this.writePidFile();
this.startIdleTimer();
logger.info('Browser Pilot Daemon is ready');
});
// Handle server errors
this.server.on('error', (error) => {
logger.error('Server error', error);
// For EADDRINUSE, exit immediately to allow DaemonManager retry logic
if ('code' in error && error.code === 'EADDRINUSE') {
logger.error('Address already in use. Exiting for retry...');
process.exit(1);
}
this.shutdown();
});
// Setup graceful shutdown
// Use async wrapper to properly await shutdown completion
process.on('SIGINT', () => {
this.shutdown().catch((error) => {
logger.error('Error during SIGINT shutdown', error);
process.exit(1);
});
});
process.on('SIGTERM', () => {
this.shutdown().catch((error) => {
logger.error('Error during SIGTERM shutdown', error);
process.exit(1);
});
});
}
/**
* Auto-restore last visited URL if enabled
*/
private async autoRestoreUrl(): Promise<void> {
if (!this.browser) return;
try {
// Load shared config
const config = loadSharedConfig();
const projectRoot = process.cwd();
const projectName = basename(projectRoot);
const projectConfig = config.projects[projectName];
// Check if autoRestore is enabled (default: true)
const autoRestore = projectConfig?.autoRestore !== false;
if (!autoRestore) {
logger.debug('Auto-restore disabled, skipping URL restoration');
return;
}
// Load last visited URL
const lastUrl = await loadLastUrl(this.outputDir);
if (!lastUrl) {
logger.debug('No last URL found, skipping restoration');
return;
}
logger.info(`🔄 Auto-restoring last visited URL: ${lastUrl}`);
// Navigate to last URL
await this.browser.sendCommand('Page.navigate', { url: lastUrl });
logger.info('✅ URL restored successfully');
} catch (error) {
logger.warn(`Failed to auto-restore URL: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Setup Page domain for navigation events
*/
private async setupPageDomain(): Promise<void> {
if (!this.browser) return;
try {
await this.browser.sendCommand('Page.enable');
// Listen for frame navigation to auto-clear console
this.browser.client?.on('Page.frameNavigated', (params: { frame: { id: string; parentId?: string; url: string } }) => {
// Only process main frame navigation (no parent)
if (!params.frame.parentId) {
logger.info(`🔄 Main frame navigated to: ${params.frame.url}`);
if (this.browser) {
this.browser.clearConsoleMessages();
this.browser.clearNetworkErrors();
}
}
});
// Listen for page load complete to ensure stable DOM
this.browser.client?.on('Page.loadEventFired', async () => {
logger.info('📄 Page load complete');
await this.generateMapAfterStabilization();
});
// Listen for SPA navigation (History API usage)
this.browser.client?.on('Page.navigatedWithinDocument', async (params: {
frameId: string;
url: string;
navigationType: 'fragment' | 'historyApi' | 'other';
}) => {
// Ignore fragment navigation (same page anchor links)
if (params.navigationType === 'fragment') {
logger.debug(`🔗 Fragment navigation ignored: ${params.url}`);
return;
}
// SPA routing detected (History API: pushState/replaceState)
logger.info(`🔄 SPA navigation detected (${params.navigationType}): ${params.url}`);
// Clear console/network errors for new route
if (this.browser) {
this.browser.clearConsoleMessages();
this.browser.clearNetworkErrors();
}
// Generate map after DOM stabilization (skip loadEventFired for SPA)
await this.generateMapAfterStabilization(true);
});
logger.info('Page navigation listeners enabled (full page + SPA)');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.warn(`Could not enable Page domain: ${errorMessage}`);
}
}
/**
* Setup network request tracking
*/
private async setupNetworkTracking(): Promise<void> {
if (!this.browser) return;
try {
await this.browser.sendCommand('Network.enable');
this.browser.client?.on('Network.requestWillBeSent', (params: {
requestId: string;
type: string;
request: { url: string };
}) => {
logger.debug(`📡 Network request: ${params.type}${params.request?.url || 'unknown'}`);
if (params.type === 'XHR' || params.type === 'Fetch') {
this.pendingNetworkRequests.add(params.requestId);
logger.info(`📤 XHR/Fetch started: ${params.request?.url || 'unknown'} (${this.pendingNetworkRequests.size} pending)`);
}
});
this.browser.client?.on('Network.responseReceived', (params: {
requestId: string;
}) => {
if (this.pendingNetworkRequests.has(params.requestId)) {
this.pendingNetworkRequests.delete(params.requestId);
logger.info(`📥 XHR/Fetch completed (${this.pendingNetworkRequests.size} pending)`);
}
});
this.browser.client?.on('Network.loadingFailed', (params: {
requestId: string;
}) => {
if (this.pendingNetworkRequests.has(params.requestId)) {
this.pendingNetworkRequests.delete(params.requestId);
logger.info(`❌ XHR/Fetch failed (${this.pendingNetworkRequests.size} pending)`);
}
});
logger.info('Network tracking enabled');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.warn(`Could not enable Network tracking: ${errorMessage}`);
}
}
/**
* Generate map after DOM stabilization
* @param skipLoadEvent Skip waiting for Page.loadEventFired (for SPA navigation)
*/
private async generateMapAfterStabilization(skipLoadEvent: boolean = false): Promise<void> {
if (!this.mapManager || !this.browser) return;
// Prevent concurrent map generation
if (this.mapGenerationInProgress) {
logger.debug(`⏭️ Skipping map generation (already in progress)`);
return;
}
this.mapGenerationInProgress = true;
try {
logger.debug(`🔨 Map generation requested (skipLoadEvent: ${skipLoadEvent})`);
// Mark map as not ready while generating (for chain commands)
if (this.mapManager) {
this.mapManager.setMapReady(false);
logger.debug('📝 Map marked as not ready (generating...)');
}
// Wait for Page.loadEventFired only for full page loads
if (!skipLoadEvent) {
await new Promise<void>((resolve) => {
const onLoad = () => {
this.browser?.client?.off('Page.loadEventFired', onLoad);
logger.debug('✓ Page load event fired');
resolve();
};
// Add listener
this.browser?.client?.once('Page.loadEventFired', onLoad);
// Timeout fallback
setTimeout(() => {
this.browser?.client?.off('Page.loadEventFired', onLoad);
logger.warn('⚠️ Page load event timeout, continuing anyway');
resolve();
}, 5000);
});
} else {
logger.info('⏭️ Skipping Page.loadEventFired (SPA navigation)');
// Wait for React/Vue to start making network requests after SPA navigation
logger.info('⏳ Waiting for SPA to start network requests (100ms)...');
await new Promise(resolve => setTimeout(resolve, 100));
}
// Wait for network idle (all XHR/Fetch requests complete)
logger.info('⏳ Waiting for network idle...');
const networkIdleStart = Date.now();
const networkIdleTimeout = 10000; // 10s max wait
while (this.pendingNetworkRequests.size > 0) {
if (Date.now() - networkIdleStart > networkIdleTimeout) {
logger.warn(`⚠️ Network idle timeout (${this.pendingNetworkRequests.size} requests still pending)`);
break;
}
await new Promise(resolve => setTimeout(resolve, 100));
}
if (this.pendingNetworkRequests.size === 0) {
logger.info(`✓ Network idle (waited ${Date.now() - networkIdleStart}ms)`);
}
// Wait for browser to be idle (React/Vue rendering complete)
logger.info('⏳ Waiting for browser idle (rendering complete)...');
const idleScript = `
new Promise((resolve) => {
const startTime = Date.now();
if (typeof requestIdleCallback !== 'undefined') {
// Browser supports requestIdleCallback
const idleId = requestIdleCallback(() => {
resolve({ waited: Date.now() - startTime });
}, { timeout: 2000 });
// Safety timeout
setTimeout(() => {
cancelIdleCallback(idleId);
resolve({ waited: Date.now() - startTime, timeout: true });
}, 3000);
} else {
// Fallback for browsers without requestIdleCallback (Safari)
setTimeout(() => {
resolve({ waited: Date.now() - startTime, fallback: true });
}, 0);
}
})
`;
try {
const result = await this.browser.sendCommand<RuntimeEvaluateResult>('Runtime.evaluate', {
expression: idleScript,
awaitPromise: true,
returnByValue: true
});
const data = result.result?.value as { waited: number; timeout?: boolean; fallback?: boolean };
if (data.timeout) {
logger.info(`✓ Browser idle timeout (waited ${data.waited}ms)`);
} else if (data.fallback) {
logger.info(`✓ Browser idle fallback (waited ${data.waited}ms)`);
} else {
logger.info(`✓ Browser idle (waited ${data.waited}ms)`);
}
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.warn(`⚠️ Browser idle check failed: ${errorMessage}`);
}
// Wait for DOM to stabilize (100ms of no mutations)
await waitForDomStable(this.browser, 100, 10000, { verbose: false });
logger.info('✓ DOM stabilized');
// Check again for pending network requests (may have started during DOM stabilization)
if (this.pendingNetworkRequests.size > 0) {
logger.info(`⏳ Waiting for network requests triggered during DOM stabilization (${this.pendingNetworkRequests.size} pending)...`);
const postDomNetworkStart = Date.now();
const postDomNetworkTimeout = 10000;
while (this.pendingNetworkRequests.size > 0) {
if (Date.now() - postDomNetworkStart > postDomNetworkTimeout) {
logger.warn(`⚠️ Post-DOM network idle timeout (${this.pendingNetworkRequests.size} requests still pending)`);
break;
}
await new Promise(resolve => setTimeout(resolve, 100));
}
if (this.pendingNetworkRequests.size === 0) {
logger.info(`✓ Post-DOM network idle (waited ${Date.now() - postDomNetworkStart}ms)`);
}
}
logger.info('✓ Generating interaction map...');
// Generate map with debounce
await this.mapManager.generateMapSerially(this.browser, false).catch((error: unknown) => {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.warn(`⚠️ Auto map generation failed: ${errorMessage}`);
});
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.warn(`⚠️ DOM stabilization failed: ${errorMessage}`);
} finally {
// Release lock
this.mapGenerationInProgress = false;
}
}
/**
* Check if daemon is already running
*/
private isAlreadyRunning(): boolean {
if (!existsSync(this.pidPath)) {
return false;
}
try {
const pidStr = readFileSync(this.pidPath, 'utf-8');
const pid = parseInt(pidStr, 10);
// Check if process with this PID exists
process.kill(pid, 0); // Signal 0 checks existence without killing
return true;
} catch (_error) {
// Process doesn't exist, clean up stale PID file
unlinkSync(this.pidPath);
return false;
}
}
/**
* Write PID file
*/
private writePidFile(): void {
writeFileSync(this.pidPath, String(process.pid), 'utf-8');
}
/**
* Start idle timer for auto-shutdown
*/
private startIdleTimer(): void {
this.resetIdleTimer();
}
/**
* Reset idle timer
*/
private resetIdleTimer(): void {
if (this.idleTimeout) {
clearTimeout(this.idleTimeout);
}
this.idleTimeout = setTimeout(() => {
const idleTime = Date.now() - this.lastActivity;
const idleSeconds = Math.floor(idleTime / TIME_CONVERSION.MS_PER_SECOND);
logger.info(`⏱️ Idle for ${idleSeconds}s, shutting down...`);
this.shutdown();
}, IDLE_SHUTDOWN_TIMEOUT);
}
/**
* Handle client connection
*/
private handleConnection(socket: Socket): void {
logger.debug('🔗 Client connected');
// Track active socket
this.activeSockets.add(socket);
let buffer = '';
socket.on('data', async (data) => {
buffer += data.toString();
// Check buffer size to prevent memory exhaustion
if (buffer.length > this.MAX_MESSAGE_SIZE) {
logger.error('Message size exceeds limit, closing connection');
socket.destroy();
return;
}
// Process complete JSON messages (delimited by newline)
const messages = buffer.split('\n');
buffer = messages.pop() || ''; // Keep incomplete message in buffer
for (const message of messages) {
if (!message.trim()) continue;
try {
const request: IPCRequest = JSON.parse(message);
// Validate request structure
if (!request.id || !request.command) {
throw new Error('Invalid request structure: missing id or command');
}
const response = await this.handleRequest(request);
socket.write(JSON.stringify(response) + '\n');
} catch (error) {
const errorResponse: IPCResponse = {
id: 'unknown',
success: false,
error: error instanceof Error ? error.message : String(error)
};
socket.write(JSON.stringify(errorResponse) + '\n');
}
}
});
socket.on('end', () => {
logger.info('Client disconnected');
this.activeSockets.delete(socket);
});
socket.on('error', (error) => {
logger.error('Socket error', error);
this.activeSockets.delete(socket);
});
}
/**
* Handle IPC request
*/
private async handleRequest(request: IPCRequest): Promise<IPCResponse> {
this.lastActivity = Date.now();
this.resetIdleTimer();
logger.debug(`📨 Received command: ${request.command}`);
if (!this.browser) {
return {
id: request.id,
success: false,
error: 'Browser not connected'
};
}
try {
let result: unknown;
// Create handler context
const context: handlers.HandlerContext = {
browser: this.browser,
mapManager: this.mapManager || undefined,
outputDir: this.outputDir
};
switch (request.command) {
// Navigation commands
case 'navigate':
result = await handlers.handleNavigate(context, request.params);
break;
case 'back':
result = await handlers.handleBack(context, request.params);
break;
case 'forward':
result = await handlers.handleForward(context, request.params);
break;
case 'reload':
result = await handlers.handleReload(context, request.params);
break;
// Interaction commands
case 'click':
result = await handlers.handleClick(context, request.params);
break;
case 'fill':
result = await handlers.handleFill(context, request.params);
break;
case 'hover':
result = await handlers.handleHover(context, request.params);
break;
case 'press':
result = await handlers.handlePress(context, request.params);
break;
case 'type':
result = await handlers.handleType(context, request.params);
break;
// Capture commands
case 'screenshot':
result = await handlers.handleScreenshot(context, request.params);
break;
case 'pdf':
result = await handlers.handlePdf(context, request.params);
break;
case 'set-viewport':
result = await handlers.handleSetViewport(context, request.params);
break;
case 'get-viewport':
result = await handlers.handleGetViewport(context, request.params);
break;
case 'get-screen-info':
result = await handlers.handleGetScreenInfo(context, request.params);
break;
// Data commands
case 'extract':
result = await handlers.handleExtract(context, request.params);
break;
case 'content':
result = await handlers.handleContent(context, request.params);
break;
case 'find':
result = await handlers.handleFind(context, request.params);
break;
case 'eval':
result = await handlers.handleEval(context, request.params);
break;
// Map commands
case 'query-map':
result = await handlers.handleQueryMap(context, request.params);
break;
case 'generate-map':
result = await handlers.handleGenerateMap(context, request.params);
break;
case 'get-map-status':
result = await handlers.handleGetMapStatus(context, request.params);
break;
// Utility commands
case 'scroll':
result = await handlers.handleScroll(context, request.params);
break;
case 'wait':
result = await handlers.handleWait(context, request.params);
break;
case 'console':
result = await handlers.handleConsole(context, request.params);
break;
case 'status':
result = await handlers.handleStatus(context, request.params, this.startTime, this.lastActivity);
break;
// Daemon management
case 'shutdown':
setImmediate(() => this.shutdown());
result = { message: 'Daemon shutting down...' };
break;
default:
throw new IPCError(`Unknown command: ${request.command}`, IPCErrorCodes.INVALID_REQUEST);
}
return {
id: request.id,
success: true,
data: result
};
} catch (error) {
logger.error(`Command failed: ${request.command}`, error);
return {
id: request.id,
success: false,
error: error instanceof Error ? error.message : String(error)
};
}
}
/**
* Graceful shutdown
*/
async shutdown(): Promise<void> {
// Return existing shutdown promise if already shutting down
if (this.shutdownPromise) {
return this.shutdownPromise;
}
this.shutdownPromise = this._doShutdown();
return this.shutdownPromise;
}
/**
* Internal shutdown implementation
*/
private async _doShutdown(): Promise<void> {
if (this.isShuttingDown) return;
this.isShuttingDown = true;
logger.info('Shutting down Browser Pilot Daemon...');
// Stop idle timer
if (this.idleTimeout) {
clearTimeout(this.idleTimeout);
}
// Remove process signal listeners
process.removeAllListeners('SIGINT');
process.removeAllListeners('SIGTERM');
// Close browser first
if (this.browser) {
try {
await this.browser.close();
logger.info('Browser closed');
} catch (error) {
logger.error('Error closing browser', error);
}
}
// Force close all active socket connections
if (this.activeSockets.size > 0) {
logger.info(`Closing ${this.activeSockets.size} active socket connection(s)...`);
for (const socket of this.activeSockets) {
try {
socket.destroy();
} catch (error) {
logger.error('Error destroying socket', error);
}
}
this.activeSockets.clear();
logger.info('All socket connections closed');
}
// Close IPC server (wait for all connections to close with timeout)
if (this.server) {
const server = this.server;
await new Promise<void>((resolve) => {
const timeout = setTimeout(() => {
logger.warn('IPC server close timed out after 2 seconds. Continuing shutdown.');
resolve();
}, 2000);
server.close((err?: Error) => {
clearTimeout(timeout);
if (err) {
logger.error('Error closing IPC server', err);
} else {
logger.info('IPC server closed');
}
resolve();
});
});
}
// Clean up socket file (Unix only) - safe after server.close() completes
if (process.platform !== 'win32' && existsSync(this.socketPath)) {
try {
unlinkSync(this.socketPath);
logger.info('Socket file removed');
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
logger.warn(`Failed to remove socket file: ${errorMsg}`);
}
}
// Remove PID file
if (existsSync(this.pidPath)) {
unlinkSync(this.pidPath);
logger.info('PID file removed');
}
// Remove interaction map cache files
const mapPath = join(this.outputDir, 'interaction-map.json');
const mapCachePath = join(this.outputDir, 'map-cache.json');
if (existsSync(mapPath)) {
try {
unlinkSync(mapPath);
logger.info('Interaction map cache removed');
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
logger.warn(`Failed to remove interaction map: ${errorMsg}`);
}
}
if (existsSync(mapCachePath)) {
try {
unlinkSync(mapCachePath);
logger.info('Map cache metadata removed');
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
logger.warn(`Failed to remove map cache metadata: ${errorMsg}`);
}
}
// Remove shutdown request flag (if exists from SessionEnd)
// This flag is created by SessionEnd (cleanup-config.js) to track daemon shutdown
const shutdownFlagPath = join(this.outputDir, 'daemon-to-stop.pid');
if (existsSync(shutdownFlagPath)) {
try {
unlinkSync(shutdownFlagPath);
logger.info('Shutdown request flag removed');
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
logger.warn(`Failed to remove shutdown flag: ${errorMsg}`);
// Fallback: Mark as COMPLETED so next SessionStart knows shutdown succeeded
// Even if file can't be deleted (Windows file lock), marking it prevents force-kill attempt
try {
writeFileSync(shutdownFlagPath, `COMPLETED:${process.pid}`, 'utf-8');
logger.info('Marked shutdown flag as COMPLETED (deletion failed due to file lock)');
} catch (_writeError) {
logger.error('Failed to mark shutdown flag as COMPLETED');
}
}
}
logger.info('Daemon shutdown complete');
process.exit(0);
}
/**
* Get current browser instance (for testing)
*/
get currentBrowser(): ChromeBrowser | null {
return this.browser;
}
/**
* Expose client property for Page event listener
*/
get client() {
return this.browser?.client;
}
}
// Start daemon if run directly
if (require.main === module) {
const daemon = new DaemonServer();
daemon.start().catch((error) => {
logger.error('Failed to start daemon', error);
process.exit(1);
});
}

View File

@@ -0,0 +1,242 @@
/**
* Logger utility for CLI commands
* Provides consistent logging with verbosity control
*/
import { writeFileSync, appendFileSync } from 'fs';
import { dirname } from 'path';
export enum LogLevel {
ERROR = 0,
WARN = 1,
INFO = 2,
DEBUG = 3,
VERBOSE = 4
}
export interface LoggerOptions {
level?: LogLevel;
prefix?: string;
timestamp?: boolean;
logFile?: string;
}
/**
* Get default log level from environment variable
* Set BP_LOG_LEVEL=DEBUG to change log level
*/
function getDefaultLogLevel(): LogLevel {
const envLevel = process.env.BP_LOG_LEVEL?.toUpperCase();
switch (envLevel) {
case 'ERROR': return LogLevel.ERROR;
case 'WARN': return LogLevel.WARN;
case 'INFO': return LogLevel.INFO;
case 'DEBUG': return LogLevel.DEBUG;
case 'VERBOSE': return LogLevel.VERBOSE;
default: return LogLevel.INFO;
}
}
/**
* Format timestamp in local time with milliseconds
* Shared timestamp format for consistency across logger and interaction maps
* Example: 2025-11-05 13:45:23.123
*/
export function getLocalTimestamp(): string {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
const hours = String(now.getHours()).padStart(2, '0');
const minutes = String(now.getMinutes()).padStart(2, '0');
const seconds = String(now.getSeconds()).padStart(2, '0');
const milliseconds = String(now.getMilliseconds()).padStart(3, '0');
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}.${milliseconds}`;
}
class Logger {
private level: LogLevel;
private prefix: string;
private timestamp: boolean;
private logFile: string | null;
constructor(options: LoggerOptions = {}) {
this.level = options.level ?? getDefaultLogLevel();
this.prefix = options.prefix ?? '[browser-pilot]';
this.timestamp = options.timestamp ?? false;
this.logFile = options.logFile ?? null;
// Initialize log file if specified
if (this.logFile) {
this.initLogFile();
}
}
/**
* Initialize log file (create or clear)
*/
private initLogFile(): void {
if (!this.logFile) return;
try {
// Create directory if needed
const fs = require('fs');
const dir = dirname(this.logFile);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
// Create empty log file
writeFileSync(this.logFile, `=== Browser Pilot Daemon Log ===\nStarted: ${getLocalTimestamp()}\n\n`, 'utf-8');
} catch (error) {
console.error('Failed to initialize log file:', error);
}
}
/**
* Write log message to file
*/
private writeToFile(message: string): void {
if (!this.logFile) return;
try {
appendFileSync(this.logFile, message + '\n', 'utf-8');
} catch (_error) {
// Silently fail - don't break logging
}
}
/**
* Enable file logging
*/
enableFileLogging(filePath: string): void {
this.logFile = filePath;
this.timestamp = true; // Always enable timestamp for file logging
this.initLogFile();
}
/**
* Disable file logging
*/
disableFileLogging(): void {
this.logFile = null;
}
/**
* Enable timestamp in logs
*/
enableTimestamp(): void {
this.timestamp = true;
}
/**
* Disable timestamp in logs
*/
disableTimestamp(): void {
this.timestamp = false;
}
/**
* Format timestamp in local time
* Example: 2025-11-05 13:45:23
*/
private getTimestamp(): string {
return getLocalTimestamp();
}
private formatMessage(level: string, message: string): string {
const parts: string[] = [];
if (this.timestamp) {
parts.push(`[${this.getTimestamp()}]`);
}
if (this.prefix) {
parts.push(this.prefix);
}
parts.push(`[${level}]`, message);
return parts.join(' ');
}
error(message: string, error?: unknown): void {
if (this.level >= LogLevel.ERROR) {
const formattedMsg = this.formatMessage('ERROR', message);
console.error(formattedMsg);
this.writeToFile(formattedMsg);
if (error instanceof Error) {
const errorMsg = ' ' + error.message;
console.error(errorMsg);
this.writeToFile(errorMsg);
if (this.level >= LogLevel.VERBOSE && error.stack) {
const stackMsg = ' Stack: ' + error.stack;
console.error(stackMsg);
this.writeToFile(stackMsg);
}
} else if (error) {
const errorStr = ' ' + String(error);
console.error(errorStr);
this.writeToFile(errorStr);
}
}
}
warn(message: string): void {
if (this.level >= LogLevel.WARN) {
const formatted = this.formatMessage('WARN', message);
console.warn(formatted);
this.writeToFile(formatted);
}
}
info(message: string): void {
if (this.level >= LogLevel.INFO) {
const formatted = this.formatMessage('INFO', message);
console.log(formatted);
this.writeToFile(formatted);
}
}
debug(message: string): void {
if (this.level >= LogLevel.DEBUG) {
const formatted = this.formatMessage('DEBUG', message);
console.log(formatted);
this.writeToFile(formatted);
}
}
verbose(message: string): void {
if (this.level >= LogLevel.VERBOSE) {
const formatted = this.formatMessage('VERBOSE', message);
console.log(formatted);
this.writeToFile(formatted);
}
}
success(message: string): void {
if (this.level >= LogLevel.INFO) {
const formatted = this.formatMessage('✓', message);
console.log(formatted);
this.writeToFile(formatted);
}
}
setLevel(level: LogLevel): void {
this.level = level;
}
getLevel(): LogLevel {
return this.level;
}
}
// Default logger instance
export const logger = new Logger();
// Factory function for creating custom loggers
export function createLogger(options: LoggerOptions = {}): Logger {
return new Logger(options);
}

View File

@@ -0,0 +1,48 @@
/**
* Timestamp utilities for local time formatting
*/
/**
* Get ISO 8601 timestamp (recommended for logs)
* Format: 2025-11-08T23:17:47.123Z
* Example: 2025-11-08T23:17:47.123Z
*/
export function getISOTimestamp(): string {
return new Date().toISOString();
}
/**
* Get local timestamp string in format: YYYY-MM-DD HH:MM:SS
* Example: 2025-11-08 23:17:47
*/
export function getLocalTimestamp(): string {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
const hours = String(now.getHours()).padStart(2, '0');
const minutes = String(now.getMinutes()).padStart(2, '0');
const seconds = String(now.getSeconds()).padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
}
/**
* Get local timestamp with timezone information
* Format: YYYY-MM-DD HH:MM:SS (UTC+X)
* Example: 2025-11-08 23:17:47 (UTC+9)
*/
export function getLocalTimestampWithTZ(): string {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
const hours = String(now.getHours()).padStart(2, '0');
const minutes = String(now.getMinutes()).padStart(2, '0');
const seconds = String(now.getSeconds()).padStart(2, '0');
// Get timezone offset in hours
const offset = -now.getTimezoneOffset() / 60;
const offsetStr = offset >= 0 ? `+${offset}` : String(offset);
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds} (UTC${offsetStr})`;
}

View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2023",
"module": "commonjs",
"lib": ["ES2023"],
"types": ["node"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"moduleResolution": "node"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}