diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 4db2aee1..32e07ecc 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -24,6 +24,6 @@ ], "metadata": { "description": "Marketplace for the dev-browser skill", - "version": "0.2.6" + "version": "0.2.9" } } diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4bd1d66d..b733fe5a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -94,6 +94,8 @@ jobs: runs-on: ubuntu-latest permissions: contents: write + # Required for npm trusted publishing (OIDC); also enables provenance. + id-token: write steps: - uses: actions/checkout@v4 @@ -107,6 +109,8 @@ jobs: with: node-version: 22 registry-url: 'https://registry.npmjs.org' + # Trusted publishing requires npm >= 11.5.1; node 22 ships an older npm. + - run: npm install -g npm@latest - run: cd daemon && pnpm install && pnpm run bundle && pnpm run bundle:sandbox-client - run: | mkdir -p dist/bin dist/scripts dist/daemon/dist @@ -118,9 +122,8 @@ jobs: cp package.json dist/ cp README.md dist/ cp LICENSE dist/ 2>/dev/null || true + # No NODE_AUTH_TOKEN: auth comes from the OIDC trusted publisher config. - run: cd dist && npm publish - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - uses: softprops/action-gh-release@v2 with: files: artifacts/**/* diff --git a/CHANGELOG.md b/CHANGELOG.md index 99da1264..2c70f24b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,27 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +## [0.2.9] - 2026-07-14 + +- Added configurable per-browser idle cleanup with `--idle-timeout`, `DEV_BROWSER_IDLE_TIMEOUT_MS`, and `~/.dev-browser/config.json` support. Idle cleanup preserves persistent profiles, excludes externally connected Chrome, and safely rechecks activity under the per-browser lock before closing. + +## [0.2.8] - 2026-06-05 + +- Added the `page.cua.*` pixel/vision toolset: coordinate-based `click`, `doubleClick`, `drag`, `move`, `scroll`, `keypress`, and `type`, plus a JPEG `screenshot()` whose pixels map 1:1 onto click coordinates at any DPR. +- Added the `page.domCua.*` DOM-id toolset: `getVisibleDom()` snapshots visible interactive elements as `node_id=N` lines, with `click`, `doubleClick`, `scroll`, `type`, and `keypress` acting against the latest snapshot's ids. +- Fixed script error messages being dropped from CLI output; thrown errors now report their name and message alongside the stack. +- Documented the vision and DOM-id workflows in the `--help` LLM usage guide. +- Capped the daemon's per-connection request buffer so a local client can no longer exhaust daemon memory with an unterminated frame. +- Serialized `browser-stop` with the per-browser lock so a browser can no longer be torn down while another client's script is running. +- Hardened daemon cold start against duplicate daemons when concurrent CLI invocations race to spawn one. +- Defaulted `PW_CHROMIUM_ATTACH_TO_OTHER=1` so attaching over CDP to Chrome 147's built-in remote debugging no longer hangs. + +## [0.2.7] - 2026-04-09 + +- Updated documentation to recommend `domcontentloaded` for dev server navigation. + ## [0.2.6] - 2026-03-30 - Pinned Playwright version. diff --git a/README.md b/README.md index ff10c93e..8bac6dbc 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,10 @@ A browser automation tool that lets AI agents and developers control browsers wi - **Auto-connect** - Connect to your running Chrome or launch a fresh Chromium - **Full Playwright API** - goto, click, fill, locators, evaluate, screenshots, and more +## Demo + +https://github.com/user-attachments/assets/c6cf7fb9-b1dc-46ed-93b9-6e7240990c53 + ## CLI Installation ```bash @@ -96,7 +100,7 @@ Windows npm installs download the native `dev-browser-windows-x64.exe` release a When `dev-browser` runs inside WSL: -- daemon-managed launch mode still uses Playwright's bundled Chromium profile under `~/.dev-browser` +- daemon-managed launch mode uses a persistent profile under `~/.dev-browser`; the browser executable can be configured as described below - `--connect` can auto-discover Chrome or Brave instances started on the Windows side when remote debugging is enabled - if auto-discovery still misses your browser, point directly at the Windows profile root with `--profile-path "/mnt/c/Users//AppData/Local/Google/Chrome/User Data"` @@ -106,9 +110,58 @@ Example: dev-browser --connect --profile-path "/mnt/c/Users//AppData/Local/Google/Chrome/User Data" ``` +### Default browser executable + +To launch a custom Chromium build, such as native Linux `chromium-stealthcdp` +inside WSL, set its absolute executable path in `~/.dev-browser/config.json`: + +```json +{ + "executablePath": "/absolute/path/to/chromium-stealthcdp/chrome-linux/chrome" +} +``` + +This setting applies to both headed and headless daemon-managed browsers. +`dev-browser status` and `dev-browser browsers` report the configured executable +for launched browsers. Existing browser instances keep their executable until +closed; new launches read the current configuration. A missing or invalid custom +executable produces an error. Omit `executablePath` to use Playwright's bundled +Chromium. `--connect` continues to attach to the requested external browser. + ### Using with AI agents -After installing, just tell your agent to run `dev-browser --help` — the help output includes a full LLM usage guide with examples and API reference. No plugin or skill installation needed. +After installing, tell your agent to run `dev-browser --help` — the help output includes the current LLM usage guide and API reference. + +For agents that discover local skills, install or refresh the embedded skill explicitly: + +```bash +dev-browser install-skill --codex # ~/.codex/skills/dev-browser/SKILL.md +dev-browser install-skill --claude # ~/.claude/skills/dev-browser/SKILL.md +dev-browser install-skill --agents # ~/.agents/skills/dev-browser/SKILL.md +``` + +Flags may be combined. With an interactive terminal, `dev-browser install-skill` prompts for targets. In non-interactive environments it updates all three locations, including Codex, so an older copied skill does not survive a CLI upgrade. + +### Idle browser cleanup + +Daemon-launched named Chromium instances can be closed automatically after they have been idle for a configured duration: + +```bash +dev-browser --idle-timeout 5m < script.js +DEV_BROWSER_IDLE_TIMEOUT_MS=300000 dev-browser status +``` + +The flag accepts `30s`, `5m`, `1h`, or raw milliseconds. You can also set a user default in `~/.dev-browser/config.json`: + +```json +{ + "idleTimeout": "5m" +} +``` + +Precedence is `--idle-timeout`, then `DEV_BROWSER_IDLE_TIMEOUT_MS`, then `idleTimeout` in the user config, then disabled. Set any source to `0` to disable cleanup. The effective setting is sent to an already-running daemon and shown by `dev-browser status`. + +Cleanup is applied independently to each named browser. Activity is measured from both the start and completion of each request, so running requests are never reaped. Only Chromium instances launched by dev-browser are eligible; browsers attached with `--connect` are never closed by idle cleanup. Closing an idle browser does not delete its profile directory, cookies, or login state, and the next request relaunches it from the same persistent profile. `dev-browser stop` keeps its existing behavior of stopping the daemon and all managed browser connections.
Allowing dev-browser in Claude Code without permission prompts @@ -159,7 +212,7 @@ You can also allow related commands in the same list:
-Legacy plugin installation (Claude Code / Amp / Codex) +Legacy Claude Code plugin installation ### Claude Code @@ -170,26 +223,6 @@ You can also allow related commands in the same list: Restart Claude Code after installation. -### Amp / Codex - -Copy the skill to your skills directory: - -```bash -# For Amp: ~/.claude/skills | For Codex: ~/.codex/skills -SKILLS_DIR=~/.claude/skills # or ~/.codex/skills - -mkdir -p $SKILLS_DIR -git clone https://github.com/sawyerhood/dev-browser /tmp/dev-browser-skill -cp -r /tmp/dev-browser-skill/skills/dev-browser $SKILLS_DIR/dev-browser -rm -rf /tmp/dev-browser-skill -``` - -If you already have the `dev-browser` CLI installed locally, you can also install the bundled skill directly: - -```bash -dev-browser install-skill --codex -``` -
## Script API @@ -214,6 +247,11 @@ console.log/warn/error/info // Routed to CLI stdout/stderr Pages are full [Playwright Page objects](https://playwright.dev/docs/api/class-page) — `goto`, `click`, `fill`, `locator`, `evaluate`, `screenshot`, and everything else, including `page.snapshotForAI({ track?, depth?, timeout? })`, which returns `{ full, incremental? }` for AI-friendly page snapshots. +Every page also exposes two computer-use toolsets: + +- `page.cua.*` — pixel/vision tier: `screenshot()` saves a JPEG whose pixels map 1:1 onto CSS coordinates at any DPR and returns `{ path, width, height }`; `click`, `doubleClick`, `drag`, `move`, `scroll`, `keypress`, and `type` act at those coordinates. +- `page.domCua.*` — DOM-id tier: `getVisibleDom()` snapshots visible interactive elements as pseudo-HTML lines with `node_id=N`; `click`, `doubleClick`, and `scroll` act by node id (ids are only valid against the latest snapshot of the current document), plus `type` and `keypress` for the focused element. + ## Benchmarks | Method | Time | Cost | Turns | Success | diff --git a/RELEASING.md b/RELEASING.md index 3d0be698..e05f87eb 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -1,69 +1,110 @@ # Releasing dev-browser -## First Time Setup +## First-Time Setup -### 1. npm authentication -```bash -npm login -``` +npm publishing uses GitHub Actions trusted publishing (OIDC), so the release +workflow does not need an `NPM_TOKEN`. In the npm package settings for +`dev-browser`, configure a trusted publisher with: + +- Organization or user: `SawyerHood` +- Repository: `dev-browser` +- Workflow filename: `release.yml` -### 2. GitHub secrets -Go to **GitHub repo → Settings → Secrets and variables → Actions** and add: -- `NPM_TOKEN` — your npm access token (create at https://www.npmjs.com/settings/tokens) +The workflow needs `id-token: write`, which is already configured in +`.github/workflows/release.yml`. ## Publishing a New Version -### 1. Bump the version +### 1. Prepare the release + +Start from an up-to-date `main` branch with a clean working tree. Move the +relevant entries from `Unreleased` into a dated version section in +`CHANGELOG.md`, then bump the version: + ```bash -node scripts/sync-version.js 0.2.0 +npm version 0.2.9 --no-git-tag-version ``` -This updates both `package.json` and `cli/Cargo.toml`. -### 2. Commit +The npm lifecycle hook updates all version-bearing files: + +- `package.json` +- `package-lock.json` +- `cli/Cargo.toml` +- `cli/Cargo.lock` +- `.claude-plugin/marketplace.json` + +Confirm that they all contain the intended version before tagging. + +### 2. Build and validate + +The Rust binary embeds the generated daemon bundles, so regenerate both bundles +before building the CLI: + ```bash -git add -A && git commit -m "release: v0.2.0" +cd daemon +pnpm install +pnpm bundle +pnpm bundle:sandbox-client +npx tsc --noEmit +pnpm vitest run +cd ../cli +cargo build +cd .. ``` -### 3. Tag and push +Also confirm that the normal CI checks for `main` are green before publishing. + +### 3. Commit + ```bash -git tag v0.2.0 -git push && git push --tags +git add -A +git commit -m "release: v0.2.9" ``` -The GitHub Actions release workflow triggers automatically and: -1. Cross-compiles the Rust CLI for 6 platforms (macOS ARM64/x64, Linux x64/ARM64/musl, Windows x64) -2. Bundles the daemon and sandbox client -3. Creates a GitHub release with all binaries attached -4. Publishes to npm +### 4. Merge, tag, and push + +Merge the release commit to `main`, update the local branch, and create the tag +on the resulting `main` commit. The tag must exactly match the version in +`package.json`: -### 4. Verify ```bash -npm info dev-browser version # should show 0.2.0 -npm install -g dev-browser # test the install -dev-browser --help # verify it works +git switch main +git pull --ff-only origin main +git tag v0.2.9 +git push origin v0.2.9 ``` -## Quick Patch Release +Pushing any `v*` tag triggers the GitHub Actions release workflow. Do not push +the tag until the release commit is merged and CI is green: the workflow does +not independently verify that the tag and package versions match. + +### 5. Monitor and verify + +Wait for the `Release` workflow to finish, then verify both distribution +channels: -Same flow, just use a patch version: ```bash -node scripts/sync-version.js 0.1.1 -git add -A && git commit -m "release: v0.1.1" -git tag v0.1.1 -git push && git push --tags +gh run list --workflow release.yml --limit 1 +npm info dev-browser version +npm install -g dev-browser +dev-browser --version +dev-browser --help ``` +If publishing fails after npm accepts the version, do not reuse that version; +fix the release workflow and publish a new patch version. + ## What the CI Does See `.github/workflows/release.yml`. On tag push (`v*`): | Step | What happens | |------|-------------| -| **Build** | Cross-compiles Rust CLI for each platform target | -| **Bundle** | Runs `pnpm run bundle` and `pnpm run bundle:sandbox-client` in `daemon/` | +| **Bundle** | Runs `pnpm bundle` and `pnpm bundle:sandbox-client` in `daemon/` | +| **Build** | Cross-compiles the Rust CLI for each platform target, embedding the generated daemon bundles | | **Assemble** | Copies bin wrapper, postinstall, daemon bundles, README, LICENSE into publish dir | -| **Publish npm** | `npm publish` from the assembled directory | -| **GitHub Release** | Creates a release with platform binaries attached | +| **Publish npm** | Uses OIDC trusted publishing to run `npm publish` from the assembled directory | +| **GitHub Release** | Creates a release with generated notes and the platform binaries attached | ## Platform Binaries diff --git a/cli/Cargo.lock b/cli/Cargo.lock index 2e0e6ba2..ab07bb8d 100644 --- a/cli/Cargo.lock +++ b/cli/Cargo.lock @@ -130,7 +130,7 @@ dependencies = [ [[package]] name = "dev-browser" -version = "0.2.6" +version = "0.2.9" dependencies = [ "clap", "dialoguer", diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 31fb589c..23f6ad52 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dev-browser" -version = "0.2.6" +version = "0.2.9" edition = "2021" [dependencies] diff --git a/cli/llm-guide.txt b/cli/llm-guide.txt index 53f1d445..f9ceb21f 100644 --- a/cli/llm-guide.txt +++ b/cli/llm-guide.txt @@ -1,6 +1,6 @@ LLM USAGE GUIDE: - Write small, focused scripts. Each script should do ONE thing: navigate, click, fill, or check. - End each script by logging the state you need for the next decision. + Make each script one decision-sized step. Batch tightly coupled inspect/act/verify work when the target is already known. + End each script by logging only the state needed for the next decision. Use descriptive page names like "login", "checkout", or "results" instead of "page1". Named pages from browser.getPage("name") persist between script runs, so you usually do not need to re-navigate. Inside page.evaluate(...), write plain JavaScript only - no TypeScript syntax in the browser context. @@ -27,19 +27,72 @@ LLM USAGE GUIDE: AI snapshots for element discovery: dev-browser <<'EOF' const page = await browser.getPage("main"); - const result = await page.snapshotForAI(); + const result = await page.snapshotForAI({ track: "main", timeout: 5000 }); console.log(result.full); // Returns { full: string, incremental?: string }. // Optional args: { track?: string, depth?: number, timeout?: number }. - // Read result.full to identify the right element. - // Then interact with it using Playwright: - // await page.getByRole("button", { name: "Continue" }).click(); - // Re-run page.snapshotForAI({ track: "main" }) after the page changes. + // Read result.full and copy the target's ref, such as e12 or f2e5. + // In the next decision-sized script: + // await page.getByRef("e12").click({ timeout: 5000 }); + // console.log((await page.snapshotForAI({ track: "main", timeout: 5000 })).incremental); EOF Choosing your approach: - Unknown pages: use page.snapshotForAI() first to discover the page, then interact based on what you find. - Known pages/selectors: skip the snapshot and use direct Playwright selectors like page.click(), page.fill(), or page.locator() for faster, more reliable automation. + Unknown pages: snapshotForAI({ track, timeout: 5000 }), act with getByRef(ref), then take a tracked snapshot to verify the change. + Known pages/selectors: skip the snapshot and use direct Playwright selectors with short explicit action timeouts. Use getByRole(...) as a semantic fallback when no stable direct selector is known. + Switch to page.domCua node ids when a stable locator cannot be built; use page.cua coordinates when visual structure is clearer than the DOM. + After acting, collect the cheapest state check; don't take both a snapshot and a screenshot by default. + + Vision workflow (page.cua): + Coordinate-based control across two scripts on a named page. + Script 1 - look: take a screenshot, then read the saved image to pick coordinates. + dev-browser <<'EOF' + const page = await browser.getPage("checkout"); + const shot = await page.cua.screenshot(); + console.log(JSON.stringify(shot)); + // {"path":"/Users/you/.dev-browser/tmp/cua-page_abc123.jpeg","width":1280,"height":720} + EOF + Script 2 - act: click at the coordinates measured on the image. + dev-browser <<'EOF' + const page = await browser.getPage("checkout"); + await page.cua.click({ x: 412, y: 233 }); + console.log(page.url()); + EOF + Pixel coordinates measured on the saved image map 1:1 onto page.cua coordinates (any display, any DPR). + Always use a named page so coordinates stay valid between scripts. + This holds for viewport and clip screenshots only — never derive click coordinates from a fullPage capture; scroll, then re-screenshot. + Also available: cua.doubleClick({x, y}), cua.drag({path: [{x, y}, ...]}), cua.move({x, y}), + cua.scroll({x, y, scrollX, scrollY}) (positive scrollY scrolls content down), + cua.keypress({keys: ["ctrl", "a"]}), cua.type({text}). + cua.click, cua.doubleClick, domCua.click, and domCua.doubleClick do not wait for navigation by default. + For a known destination, pair the action with page.waitForURL(...): + await Promise.all([ + page.waitForURL("**/confirmation", { timeout: 5000 }), + page.cua.click({ x: 412, y: 233 }), + ]); + For an unknown click destination, pass waitForNavigation: true to settle a main-frame navigation. + + DOM-id workflow (page.domCua): + Snapshot the visible interactive elements, then act on them by node id. + dev-browser <<'EOF' + const page = await browser.getPage("checkout"); + console.log(await page.domCua.getVisibleDom()); + // + // + // Example link + EOF + dev-browser <<'EOF' + const page = await browser.getPage("checkout"); + await page.domCua.click({ nodeId: 2 }); + console.log(page.url()); + EOF + Ids are only valid against the latest snapshot of the current document. + A "DOM node N is stale or missing — re-run getVisibleDom()" error means the id predates the latest snapshot or the document changed; re-run getVisibleDom() and use the fresh ids. + Re-snapshot after every navigation - ids from the old document never act on the new one. + The snapshot only includes elements visible in the viewport; scroll and re-snapshot to see more. + A truncation marker line appears when the snapshot budget is hit. + Also available: domCua.doubleClick({nodeId}), domCua.scroll({scrollX, scrollY, nodeId?}), + domCua.type({text}) and domCua.keypress({keys}) (both act on the focused element - click first). Screenshots for visual state: dev-browser <<'EOF' @@ -52,8 +105,8 @@ LLM USAGE GUIDE: Waiting patterns: dev-browser <<'EOF' const page = await browser.getPage("search-results"); - await page.waitForSelector(".results"); - await page.waitForURL("**/success"); + await page.waitForSelector(".results", { timeout: 5000 }); + await page.waitForURL("**/success", { timeout: 5000 }); console.log(JSON.stringify({ url: page.url(), title: await page.title(), @@ -86,6 +139,7 @@ LLM USAGE GUIDE: page.url() Get the current URL page.snapshotForAI(options) Get an AI-optimized snapshot; returns { full, incremental? } Options: { track?: string, depth?: number, timeout?: number } + page.getByRef(ref) Target an eN or iframe fNeN ref from snapshotForAI() page.getByRole(role, { name }) Target elements discovered from the snapshot page.textContent(selector) Get the text content of an element page.innerHTML(selector) Get the inner HTML of an element @@ -96,6 +150,13 @@ LLM USAGE GUIDE: page.waitForSelector(selector) Wait for an element to appear page.waitForURL(url) Wait for navigation to a URL page.screenshot() Capture a screenshot buffer; save it with saveScreenshot(...) + page.cua.screenshot(options) Save a JPEG for the vision workflow; returns { path, width, height } + Options: { name?: string, fullPage?: boolean, clip? } + page.cua.click({ x, y, waitForNavigation? }) + Click at viewport coordinates; navigation wait defaults false + page.domCua.getVisibleDom() Snapshot visible interactive elements as node_id=N lines + page.domCua.click({ nodeId, waitForNavigation? }) + Click a current node id; navigation wait defaults false page.$$eval(selector, fn) Run a function on all matching elements page.$eval(selector, fn) Run a function on the first matching element page.evaluate(fn) Run JavaScript in the page context (plain JS only) @@ -125,6 +186,7 @@ LLM USAGE GUIDE: - Prefer page.snapshotForAI() for structure; use screenshots when visual layout or styling matters. - Keep page names stable across scripts so you can resume work after failures. - Each --browser name maps to a separate daemon-managed browser instance. + - For unattended work, --idle-timeout 5m closes each idle daemon-launched browser while preserving its profile; it never closes --connect browsers. Use 0 to disable. - Use --connect to attach to an existing browser; omit the URL to auto-discover Chrome with debugging enabled. - Use short timeouts (--timeout 10) so scripts fail fast instead of hanging on missing elements. - Add --headless for unattended automation; omit it when you want to watch the browser window. diff --git a/cli/src/config.rs b/cli/src/config.rs new file mode 100644 index 00000000..fba3aa8e --- /dev/null +++ b/cli/src/config.rs @@ -0,0 +1,190 @@ +use serde::Deserialize; +use std::env; +use std::error::Error; +use std::fs; +use std::io; +use std::path::Path; + +const MAX_SAFE_TIMEOUT_MS: u64 = 9_007_199_254_740_991; + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum IdleTimeoutValue { + String(String), + Milliseconds(u64), +} + +#[derive(Debug, Default, Deserialize)] +#[serde(default, rename_all = "camelCase")] +struct UserConfig { + idle_timeout: Option, +} + +pub fn parse_idle_timeout(value: &str) -> Result { + let value = value.trim(); + if value.is_empty() { + return Err("idle timeout cannot be empty".to_string()); + } + + let (number, multiplier) = match value.as_bytes().last().copied() { + Some(b's') => (&value[..value.len() - 1], 1_000_u64), + Some(b'm') => (&value[..value.len() - 1], 60_000_u64), + Some(b'h') => (&value[..value.len() - 1], 3_600_000_u64), + Some(last) if last.is_ascii_alphabetic() => { + return Err("invalid unit (use s, m, h, or raw milliseconds)".to_string()); + } + _ => (value, 1_u64), + }; + + if number.is_empty() { + return Err("idle timeout is missing a number".to_string()); + } + + let amount = number + .parse::() + .map_err(|_| "idle timeout must be a non-negative integer".to_string())?; + let milliseconds = amount + .checked_mul(multiplier) + .ok_or_else(|| "idle timeout is too large".to_string())?; + + if milliseconds > MAX_SAFE_TIMEOUT_MS { + return Err("idle timeout is too large".to_string()); + } + + Ok(milliseconds) +} + +pub fn effective_idle_timeout_ms(cli_value: Option) -> Result> { + let config_path = dirs::home_dir().map(|home| home.join(".dev-browser").join("config.json")); + resolve_idle_timeout( + cli_value, + env::var("DEV_BROWSER_IDLE_TIMEOUT_MS").ok().as_deref(), + config_path.as_deref(), + ) +} + +fn resolve_idle_timeout( + cli_value: Option, + environment_value: Option<&str>, + config_path: Option<&Path>, +) -> Result> { + if let Some(milliseconds) = cli_value { + return Ok(milliseconds); + } + + if let Some(value) = environment_value { + return parse_idle_timeout(value) + .map_err(|error| format!("Invalid DEV_BROWSER_IDLE_TIMEOUT_MS: {error}").into()); + } + + let Some(config_path) = config_path else { + return Ok(0); + }; + + let contents = match fs::read_to_string(config_path) { + Ok(contents) => contents, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(0), + Err(error) => return Err(error.into()), + }; + let config: UserConfig = serde_json::from_str(&contents) + .map_err(|error| format!("Invalid user config at {}: {error}", config_path.display()))?; + + match config.idle_timeout { + Some(IdleTimeoutValue::String(value)) => parse_idle_timeout(&value).map_err(|error| { + format!("Invalid idleTimeout in {}: {error}", config_path.display()).into() + }), + Some(IdleTimeoutValue::Milliseconds(milliseconds)) => { + if milliseconds > MAX_SAFE_TIMEOUT_MS { + Err(format!( + "Invalid idleTimeout in {}: idle timeout is too large", + config_path.display() + ) + .into()) + } else { + Ok(milliseconds) + } + } + None => Ok(0), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn temp_config(contents: &str) -> PathBuf { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let dir = env::temp_dir().join(format!( + "dev-browser-config-{}-{unique}", + std::process::id() + )); + fs::create_dir_all(&dir).unwrap(); + let path = dir.join("config.json"); + fs::write(&path, contents).unwrap(); + path + } + + #[test] + fn parses_human_friendly_and_raw_timeouts() { + assert_eq!(parse_idle_timeout("30s").unwrap(), 30_000); + assert_eq!(parse_idle_timeout("5m").unwrap(), 300_000); + assert_eq!(parse_idle_timeout("1h").unwrap(), 3_600_000); + assert_eq!(parse_idle_timeout("2500").unwrap(), 2_500); + assert_eq!(parse_idle_timeout("0").unwrap(), 0); + } + + #[test] + fn rejects_invalid_timeouts() { + assert!(parse_idle_timeout("").is_err()); + assert!(parse_idle_timeout("1d").is_err()); + assert!(parse_idle_timeout("1.5m").is_err()); + assert!(parse_idle_timeout("-1").is_err()); + } + + #[test] + fn resolves_cli_then_environment_then_config_then_disabled() { + let config_path = temp_config(r#"{"idleTimeout":"1h"}"#); + + assert_eq!( + resolve_idle_timeout(Some(30_000), Some("5m"), Some(&config_path)).unwrap(), + 30_000 + ); + assert_eq!( + resolve_idle_timeout(Some(0), Some("5m"), Some(&config_path)).unwrap(), + 0 + ); + assert_eq!( + resolve_idle_timeout(None, Some("5m"), Some(&config_path)).unwrap(), + 300_000 + ); + assert_eq!( + resolve_idle_timeout(None, None, Some(&config_path)).unwrap(), + 3_600_000 + ); + assert_eq!(resolve_idle_timeout(None, None, None).unwrap(), 0); + + fs::remove_dir_all(config_path.parent().unwrap()).unwrap(); + } + + #[test] + fn accepts_numeric_config_and_zero_disables_cleanup() { + let numeric_path = temp_config(r#"{"idleTimeout":45000}"#); + assert_eq!( + resolve_idle_timeout(None, None, Some(&numeric_path)).unwrap(), + 45_000 + ); + fs::remove_dir_all(numeric_path.parent().unwrap()).unwrap(); + + let zero_path = temp_config(r#"{"idleTimeout":"0s"}"#); + assert_eq!( + resolve_idle_timeout(None, None, Some(&zero_path)).unwrap(), + 0 + ); + fs::remove_dir_all(zero_path.parent().unwrap()).unwrap(); + } +} diff --git a/cli/src/daemon.rs b/cli/src/daemon.rs index 037f978e..5c50c061 100644 --- a/cli/src/daemon.rs +++ b/cli/src/daemon.rs @@ -37,6 +37,14 @@ pub fn ensure_daemon() -> Result<(), Box> { return Ok(()); } + // Hold an exclusive lock while spawning so concurrent CLI invocations on a + // cold start cannot each spawn a daemon and race on the socket path. The + // lock is released when the file handle drops. + let _spawn_lock = acquire_spawn_lock()?; + if is_daemon_running() { + return Ok(()); + } + let command = find_daemon_command()?; if command.requires_runtime_install && !embedded_runtime_installed(&command.current_dir) { return Err( @@ -87,6 +95,30 @@ pub fn is_daemon_running() -> bool { connect_to_daemon().is_ok() } +fn acquire_spawn_lock() -> Result> { + let base_dir = daemon_base_dir()?; + fs::create_dir_all(&base_dir)?; + let lock_file = fs::OpenOptions::new() + .create(true) + .truncate(false) + .write(true) + .open(base_dir.join("daemon-spawn.lock"))?; + + // Use flock(2) rather than std::fs::File::lock so the CLI keeps a low MSRV + // (File::lock was only stabilized in Rust 1.89). The advisory lock releases + // when the returned handle drops. Best-effort on non-Unix: the daemon-side + // bind also serializes concurrent starts. + #[cfg(unix)] + { + use std::os::unix::io::AsRawFd; + if unsafe { libc::flock(lock_file.as_raw_fd(), libc::LOCK_EX) } != 0 { + return Err(io::Error::last_os_error().into()); + } + } + + Ok(lock_file) +} + pub fn current_daemon_pid() -> Option { daemon_pid() } @@ -237,7 +269,12 @@ fn sync_text_file(path: &Path, contents: &str) -> Result<(), Box> { }; if needs_update { - fs::write(path, contents)?; + // Write to a per-process temp file and rename into place so a + // concurrent daemon spawn reading this file never observes a partial + // (truncated) write. + let tmp_path = path.with_extension(format!("tmp.{}", std::process::id())); + fs::write(&tmp_path, contents)?; + fs::rename(&tmp_path, path)?; } Ok(()) diff --git a/cli/src/main.rs b/cli/src/main.rs index 0a42a52b..776b1f53 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -1,8 +1,10 @@ +mod config; mod connection; mod daemon; mod skill; use clap::{CommandFactory, Parser, Subcommand}; +use config::{effective_idle_timeout_ms, parse_idle_timeout}; use connection::{connect_to_daemon, read_line, send_message}; use daemon::{ current_daemon_pid, ensure_daemon, install_daemon_runtime, is_daemon_running, @@ -170,6 +172,16 @@ struct Cli { )] timeout: u32, + #[arg( + long, + global = true, + value_name = "DURATION", + value_parser = parse_idle_timeout, + help = "Close idle daemon-launched browsers after a duration", + long_help = "Close each idle daemon-launched browser after the specified duration.\n\nAccepts human-friendly values such as 30s, 5m, and 1h, or raw milliseconds. The policy is applied per named browser, preserves browser profiles, and never closes externally connected Chrome. Use 0 to disable cleanup.\n\nPrecedence: --idle-timeout, DEV_BROWSER_IDLE_TIMEOUT_MS, ~/.dev-browser/config.json idleTimeout, then disabled." + )] + idle_timeout: Option, + #[command(subcommand)] command: Option, } @@ -195,7 +207,7 @@ enum Command { Install, #[command( about = "Install the dev-browser skill into agent skill directories", - long_about = "Install the embedded dev-browser skill into agent skill directories.\n\nBy default, launches an interactive multi-select prompt for the supported install targets when a TTY is available.\n\nIn non-interactive environments, installs to all supported skill directories.\n\nUse `--claude`, `--agents`, and/or `--codex` to skip prompting and install to specific targets." + long_about = "Install the embedded dev-browser skill into agent skill directories.\n\nBy default, launches an interactive multi-select prompt for the supported install targets when a TTY is available.\n\nIn non-interactive environments, installs to all supported skill directories, including Codex, so upgrades replace stale skill copies.\n\nUse `--claude`, `--agents`, and/or `--codex` to skip prompting and install to specific targets." )] InstallSkill { #[arg( @@ -221,7 +233,7 @@ enum Command { Browsers, #[command( about = "Show daemon status", - long_about = "Show daemon status.\n\nPrints daemon process details, socket path, uptime, and the current set of managed browsers." + long_about = "Show daemon status.\n\nPrints daemon process details, socket path, uptime, the effective idle timeout, and useful per-browser idle information." )] Status, #[command( @@ -238,6 +250,12 @@ struct BrowserSummary { kind: String, status: String, pages: Vec, + #[serde(default, rename = "idleForMs")] + idle_for_ms: Option, + #[serde(default, rename = "idleRemainingMs")] + idle_remaining_ms: Option, + #[serde(default, rename = "activeRequests")] + active_requests: usize, } #[derive(Debug, Deserialize)] @@ -249,6 +267,9 @@ struct StatusSummary { browser_count: usize, #[serde(rename = "socketPath")] socket_path: String, + #[serde(rename = "idleTimeoutMs")] + #[serde(default)] + idle_timeout_ms: u64, browsers: Vec, } @@ -280,11 +301,13 @@ fn run() -> Result> { run_script(&cli, script) } Some(Command::Browsers) => { + let idle_timeout_ms = effective_idle_timeout_ms(cli.idle_timeout)?; ensure_daemon()?; send_request( json!({ "id": request_id("browsers"), "type": "browsers", + "idleTimeoutMs": idle_timeout_ms, }), ResultMode::Browsers, ) @@ -302,11 +325,13 @@ fn run() -> Result> { Ok(0) } Some(Command::Status) => { + let idle_timeout_ms = effective_idle_timeout_ms(cli.idle_timeout)?; ensure_daemon()?; send_request( json!({ "id": request_id("status"), "type": "status", + "idleTimeoutMs": idle_timeout_ms, }), ResultMode::Status, ) @@ -351,6 +376,7 @@ fn run() -> Result> { } fn run_script(cli: &Cli, script: String) -> Result> { + let idle_timeout_ms = effective_idle_timeout_ms(cli.idle_timeout)?; ensure_daemon()?; if let Some(connect) = &cli.connect { @@ -376,6 +402,7 @@ fn run_script(cli: &Cli, script: String) -> Result> { "browser": cli.browser, "script": script, "timeoutMs": timeout_ms, + "idleTimeoutMs": idle_timeout_ms, }); if cli.headless { @@ -529,13 +556,45 @@ fn print_status(data: &Value) -> Result<(), Box> { println!("PID: {}", status.pid); println!("Uptime: {}", format_duration_ms(status.uptime_ms)); println!("Browsers: {}", status.browser_count); + println!( + "Idle timeout: {}", + if status.idle_timeout_ms == 0 { + "disabled".to_string() + } else { + format_duration_ms(status.idle_timeout_ms) + } + ); println!("Socket: {}", status.socket_path); if !status.browsers.is_empty() { let managed = status .browsers .iter() - .map(|browser| format!("{} ({}, {})", browser.name, browser.kind, browser.status)) + .map(|browser| { + let idle = if browser.kind == "connected" { + "idle cleanup exempt".to_string() + } else if browser.active_requests > 0 { + format!("{} active request(s)", browser.active_requests) + } else if status.idle_timeout_ms == 0 { + match browser.idle_for_ms { + Some(idle_for_ms) => format!("idle {}", format_duration_ms(idle_for_ms)), + None => "idle time unavailable".to_string(), + } + } else { + match (browser.idle_for_ms, browser.idle_remaining_ms) { + (Some(idle_for_ms), Some(remaining_ms)) => format!( + "idle {}, closes in {}", + format_duration_ms(idle_for_ms), + format_duration_ms(remaining_ms) + ), + _ => "idle time unavailable".to_string(), + } + }; + format!( + "{} ({}, {}, {})", + browser.name, browser.kind, browser.status, idle + ) + }) .collect::>() .join(", "); println!("Managed: {managed}"); @@ -576,3 +635,25 @@ fn format_duration_ms(duration_ms: u64) -> String { let seconds = total_seconds % 60; format!("{minutes}m {seconds}s") } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn idle_timeout_is_global_and_accepts_human_friendly_values() { + let before = + Cli::try_parse_from(["dev-browser", "--idle-timeout", "5m", "status"]).unwrap(); + assert_eq!(before.idle_timeout, Some(300_000)); + + let after = + Cli::try_parse_from(["dev-browser", "status", "--idle-timeout", "30s"]).unwrap(); + assert_eq!(after.idle_timeout, Some(30_000)); + } + + #[test] + fn idle_timeout_zero_is_accepted() { + let cli = Cli::try_parse_from(["dev-browser", "--idle-timeout", "0", "status"]).unwrap(); + assert_eq!(cli.idle_timeout, Some(0)); + } +} diff --git a/cli/src/skill.rs b/cli/src/skill.rs index 466c3b42..61cc564f 100644 --- a/cli/src/skill.rs +++ b/cli/src/skill.rs @@ -240,32 +240,38 @@ mod tests { #[test] fn explicit_claude_flag_skips_prompt() { - let selection = resolve_install_target_selection(true, false, true); + let selection = resolve_install_target_selection(true, false, false, true); assert_selected(selection, &[0]); } #[test] fn explicit_agents_flag_skips_prompt() { - let selection = resolve_install_target_selection(false, true, true); + let selection = resolve_install_target_selection(false, true, false, true); assert_selected(selection, &[1]); } #[test] - fn explicit_flags_can_select_both_targets() { - let selection = resolve_install_target_selection(true, true, false); - assert_selected(selection, &[0, 1]); + fn explicit_codex_flag_skips_prompt() { + let selection = resolve_install_target_selection(false, false, true, true); + assert_selected(selection, &[2]); + } + + #[test] + fn explicit_flags_can_select_all_targets() { + let selection = resolve_install_target_selection(true, true, true, false); + assert_selected(selection, &[0, 1, 2]); } #[test] fn interactive_terminal_without_flags_prompts() { - let selection = resolve_install_target_selection(false, false, true); + let selection = resolve_install_target_selection(false, false, false, true); assert!(matches!(selection, InstallTargetSelection::Prompt)); } #[test] - fn non_interactive_without_flags_defaults_to_both_targets() { - let selection = resolve_install_target_selection(false, false, false); - assert_selected(selection, &[0, 1]); + fn non_interactive_without_flags_defaults_to_all_targets() { + let selection = resolve_install_target_selection(false, false, false, false); + assert_selected(selection, &[0, 1, 2]); } fn assert_selected(selection: InstallTargetSelection, expected: &[usize]) { diff --git a/daemon/scripts/bundle-sandbox-client.ts b/daemon/scripts/bundle-sandbox-client.ts index 30a0c48a..43b937ad 100644 --- a/daemon/scripts/bundle-sandbox-client.ts +++ b/daemon/scripts/bundle-sandbox-client.ts @@ -9,6 +9,10 @@ const outfile = resolve(daemonDir, "dist/sandbox-client.js"); await mkdir(dirname(outfile), { recursive: true }); +// WARNING: src/sandbox/forked-client/src/client/domCuaInjected.ts exports +// functions that are serialized with String(fn) and re-evaluated inside the +// page. Do not add flags that rewrite function bodies (minify, keepNames) — +// they would corrupt the serialized source. await build({ entryPoints: [entryPoint], bundle: true, diff --git a/daemon/src/browser-manager-title-timeout.test.ts b/daemon/src/browser-manager-title-timeout.test.ts index 8d7b1760..52c6f78a 100644 --- a/daemon/src/browser-manager-title-timeout.test.ts +++ b/daemon/src/browser-manager-title-timeout.test.ts @@ -10,9 +10,10 @@ type BrowserManagerInternals = { getPageTargetId: (context: BrowserContext, page: Page) => Promise; }; -function createMockEntry(page: Page): BrowserEntry { +function createMockEntry(pages: Page | Page[]): BrowserEntry { + const pageList = Array.isArray(pages) ? pages : [pages]; const context = { - pages: () => [page], + pages: () => pageList, } as unknown as BrowserContext; const browser = { @@ -66,6 +67,49 @@ describe("BrowserManager listPages title handling", () => { ]); }); + it("starts every title lookup concurrently and uses one timeout window", async () => { + vi.useFakeTimers(); + + const started: number[] = []; + const pages = [0, 1, 2].map( + (index) => + ({ + isClosed: () => false, + on: () => undefined, + title: () => { + started.push(index); + return new Promise(() => {}); + }, + url: () => `chrome://page-${index}`, + }) as unknown as Page + ); + + const manager = new BrowserManager("/tmp/dev-browser-concurrent-title-timeout"); + const internals = manager as unknown as BrowserManagerInternals; + internals.browsers.set(browserName, createMockEntry(pages)); + vi.spyOn(internals, "getPageTargetId").mockImplementation(async (_context, page) => { + return `target-${pages.indexOf(page)}`; + }); + + let settled = false; + const pagesPromise = manager.listPages(browserName).finally(() => { + settled = true; + }); + await vi.advanceTimersByTimeAsync(0); + + expect(started).toEqual([0, 1, 2]); + + await vi.advanceTimersByTimeAsync(1_499); + expect(settled).toBe(false); + + await vi.advanceTimersByTimeAsync(1); + await expect(pagesPromise).resolves.toEqual([ + { id: "target-0", name: null, title: "", url: "chrome://page-0" }, + { id: "target-1", name: null, title: "", url: "chrome://page-1" }, + { id: "target-2", name: null, title: "", url: "chrome://page-2" }, + ]); + }); + it("still surfaces page.title errors when the page remains open", async () => { const page = { isClosed: () => false, diff --git a/daemon/src/browser-manager.ts b/daemon/src/browser-manager.ts index c5414e8c..5b4d07b6 100644 --- a/daemon/src/browser-manager.ts +++ b/daemon/src/browser-manager.ts @@ -11,6 +11,7 @@ export interface BrowserEntry { context: BrowserContext; pages: Map; profileDir?: string; + executablePath?: string; endpoint?: string; headless: boolean; ignoreHTTPSErrors: boolean; @@ -21,6 +22,7 @@ interface BrowserSummary { type: BrowserEntry["type"]; status: "running" | "connected" | "disconnected"; pages: string[]; + executablePath?: string; } interface BrowserPageSummary { @@ -42,6 +44,11 @@ type BrowserManagerDependencies = { readFile: typeof readFile; }; +interface BrowserOperationOptions { + deadline?: number; + signal?: AbortSignal; +} + type DebuggerWebSocketLookupResult = | { status: "ok"; @@ -95,7 +102,9 @@ export class BrowserManager { } private static detectWsl(): boolean { - return process.platform === "linux" && !!(process.env.WSL_DISTRO_NAME || process.env.WSL_INTEROP); + return ( + process.platform === "linux" && !!(process.env.WSL_DISTRO_NAME || process.env.WSL_INTEROP) + ); } async ensureBrowser( @@ -103,9 +112,13 @@ export class BrowserManager { options: { headless?: boolean; ignoreHTTPSErrors?: boolean; + deadline?: number; + signal?: AbortSignal; } = {} ): Promise { + this.throwIfOperationAborted(options); await this.ensureBaseDir(); + this.throwIfOperationAborted(options); const existing = this.browsers.get(name); const requestedHeadless = options.headless ?? existing?.headless ?? false; const requestedIgnoreHTTPSErrors = @@ -126,17 +139,19 @@ export class BrowserManager { await this.stopBrowser(name); } - return this.launchBrowser(name, requestedHeadless, requestedIgnoreHTTPSErrors); + return this.launchBrowser(name, requestedHeadless, requestedIgnoreHTTPSErrors, options); } async autoConnect( name: string, - options: { + options: BrowserOperationOptions & { port?: number; profilePath?: string; } = {} ): Promise { + this.throwIfOperationAborted(options); await this.ensureBaseDir(); + this.throwIfOperationAborted(options); const existing = this.browsers.get(name); if (existing?.type === "connected" && existing.browser.isConnected()) { @@ -158,20 +173,23 @@ export class BrowserManager { attemptedEndpoints.add(endpoint); try { - return await this.openConnectedBrowser(name, endpoint); + return await this.openConnectedBrowser(name, endpoint, options); } catch (error) { + this.throwIfOperationAborted(options); lastError = error; return null; } }; const devToolsEndpoint = await this.readDevToolsActivePort(undefined, options.profilePath); + this.throwIfOperationAborted(options); const devToolsBrowser = await tryEndpoint(devToolsEndpoint); if (devToolsBrowser) { return devToolsBrowser; } for (const endpoint of await this.discoverAgentBrowserEndpoints()) { + this.throwIfOperationAborted(options); const connectedBrowser = await tryEndpoint(endpoint); if (connectedBrowser) { return connectedBrowser; @@ -180,7 +198,9 @@ export class BrowserManager { const candidatePorts = options.port !== undefined ? [options.port] : DISCOVERY_PORTS; for (const port of candidatePorts) { + this.throwIfOperationAborted(options); const endpoint = await this.probePort(port); + this.throwIfOperationAborted(options); const connectedBrowser = await tryEndpoint(endpoint); if (connectedBrowser) { return connectedBrowser; @@ -193,7 +213,7 @@ export class BrowserManager { async connectBrowser( name: string, endpoint: string, - options: { + options: BrowserOperationOptions & { port?: number; profilePath?: string; } = {} @@ -202,8 +222,11 @@ export class BrowserManager { return this.autoConnect(name, options); } + this.throwIfOperationAborted(options); await this.ensureBaseDir(); + this.throwIfOperationAborted(options); const resolvedEndpoint = await this.resolveEndpoint(endpoint, options); + this.throwIfOperationAborted(options); const existing = this.browsers.get(name); if (existing) { @@ -219,7 +242,7 @@ export class BrowserManager { await this.stopBrowser(name); } - return this.openConnectedBrowser(name, resolvedEndpoint); + return this.openConnectedBrowser(name, resolvedEndpoint, options); } getBrowser(name: string): BrowserEntry | undefined { @@ -266,34 +289,36 @@ export class BrowserManager { this.pruneClosedPages(entry); const namesByPage = this.getNamedPagesByPage(entry); - const summaries: BrowserPageSummary[] = []; - - for (const { context, page } of this.getContextPages(entry)) { - const id = await this.getPageTargetId(context, page); - if (!id) { - continue; - } - - let title = ""; - try { - title = await this.getPageTitle(page); - } catch (error) { - if (page.isClosed()) { - continue; + const summaries = await Promise.all( + this.getContextPages(entry).map( + async ({ context, page }): Promise => { + const id = await this.getPageTargetId(context, page); + if (!id) { + return null; + } + + let title = ""; + try { + title = await this.getPageTitle(page); + } catch (error) { + if (page.isClosed()) { + return null; + } + + throw error; + } + + return { + id, + url: page.url(), + title, + name: namesByPage.get(page) ?? null, + }; } + ) + ); - throw error; - } - - summaries.push({ - id, - url: page.url(), - title, - name: namesByPage.get(page) ?? null, - }); - } - - return summaries; + return summaries.filter((summary): summary is BrowserPageSummary => summary !== null); } async closePage(browserName: string, pageName: string): Promise { @@ -331,6 +356,7 @@ export class BrowserManager { type: entry.type, status, pages: this.listNamedPages(entry), + ...(entry.executablePath ? { executablePath: entry.executablePath } : {}), }; }) .sort((left, right) => left.name.localeCompare(right.name)); @@ -381,21 +407,36 @@ export class BrowserManager { private async launchBrowser( name: string, headless: boolean, - ignoreHTTPSErrors: boolean + ignoreHTTPSErrors: boolean, + operation: BrowserOperationOptions = {} ): Promise { const profileDir = path.join(this.baseDir, name, "chromium-profile"); await this.dependencies.mkdir(profileDir, { recursive: true }); + const executablePath = await this.configuredExecutablePath(); + const timeout = this.remainingOperationTimeout(operation); const context = await this.dependencies.launchPersistentContext(profileDir, { + ...(executablePath === undefined ? {} : { executablePath }), headless, viewport: headless ? undefined : null, ignoreHTTPSErrors, handleSIGINT: false, handleSIGTERM: false, handleSIGHUP: false, + ...(timeout === undefined ? {} : { timeout }), }); const browser = context.browser(); + try { + this.throwIfOperationAborted(operation); + } catch (error) { + await context.close().catch(() => undefined); + if (browser?.isConnected()) { + await browser.close().catch(() => undefined); + } + throw error; + } + if (!browser) { await context.close(); throw new Error(`Playwright did not expose a browser handle for "${name}"`); @@ -408,6 +449,7 @@ export class BrowserManager { context, pages: new Map(), profileDir, + ...(executablePath === undefined ? {} : { executablePath }), headless, ignoreHTTPSErrors, }; @@ -417,8 +459,54 @@ export class BrowserManager { return entry; } - private async openConnectedBrowser(name: string, endpoint: string): Promise { - const browser = await this.dependencies.connectOverCDP(endpoint); + private async configuredExecutablePath(): Promise { + const configPath = path.join(this.dependencies.homedir(), ".dev-browser", "config.json"); + let contents: string; + try { + contents = await this.dependencies.readFile(configPath, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return undefined; + } + throw error; + } + + let config: unknown; + try { + config = JSON.parse(contents); + } catch { + throw new Error(`Invalid JSON in ${configPath}`); + } + if (!config || typeof config !== "object" || Array.isArray(config)) { + throw new Error(`Invalid user config at ${configPath}: expected an object`); + } + const executablePath = (config as { executablePath?: unknown }).executablePath; + if (executablePath === undefined) { + return undefined; + } + const platformPath = this.dependencies.platform === "win32" ? path.win32 : path.posix; + if (typeof executablePath !== "string" || !platformPath.isAbsolute(executablePath)) { + throw new Error(`Invalid executablePath in ${configPath}: expected an absolute path`); + } + return executablePath; + } + + private async openConnectedBrowser( + name: string, + endpoint: string, + operation: BrowserOperationOptions = {} + ): Promise { + const timeout = this.remainingOperationTimeout(operation); + const browser = + timeout === undefined + ? await this.dependencies.connectOverCDP(endpoint) + : await this.dependencies.connectOverCDP(endpoint, { timeout }); + try { + this.throwIfOperationAborted(operation); + } catch (error) { + await browser.close().catch(() => undefined); + throw error; + } const contexts = browser.contexts(); // Enumerate existing tabs for connected browsers, but leave them unnamed so getPage(name) @@ -460,6 +548,25 @@ export class BrowserManager { }); } + private throwIfOperationAborted(options: BrowserOperationOptions): void { + if (options.signal?.aborted) { + throw options.signal.reason instanceof Error + ? options.signal.reason + : new Error(String(options.signal.reason)); + } + if (options.deadline !== undefined && Date.now() >= options.deadline) { + throw new Error("Browser setup deadline exceeded"); + } + } + + private remainingOperationTimeout(options: BrowserOperationOptions): number | undefined { + this.throwIfOperationAborted(options); + if (options.deadline === undefined) { + return undefined; + } + return Math.max(1, options.deadline - Date.now()); + } + private async closeLaunchedBrowser(entry: BrowserEntry): Promise { const contexts = this.getBrowserContexts(entry); await Promise.allSettled(contexts.map(async (context) => context.close())); @@ -581,7 +688,9 @@ export class BrowserManager { path.join(homeDir, ".config", "google-chrome-beta", "DevToolsActivePort"), path.join(homeDir, ".config", "google-chrome-unstable", "DevToolsActivePort"), path.join(homeDir, ".config", "BraveSoftware", "Brave-Browser", "DevToolsActivePort"), - ...(this.dependencies.isWsl ? await this.getWslWindowsDevToolsActivePortCandidates() : []), + ...(this.dependencies.isWsl + ? await this.getWslWindowsDevToolsActivePortCandidates() + : []), ]); case "win32": return this.dedupePaths([ @@ -667,7 +776,15 @@ export class BrowserManager { const userDir = path.join(windowsUsersRoot, entry.name); candidates.push( - path.join(userDir, "AppData", "Local", "Google", "Chrome", "User Data", "DevToolsActivePort"), + path.join( + userDir, + "AppData", + "Local", + "Google", + "Chrome", + "User Data", + "DevToolsActivePort" + ), path.join( userDir, "AppData", @@ -756,8 +873,9 @@ export class BrowserManager { ): Promise { let token: string; try { - token = (await this.dependencies.readFile(path.join(socketDir, `${session}.token`), "utf8")) - .trim(); + token = ( + await this.dependencies.readFile(path.join(socketDir, `${session}.token`), "utf8") + ).trim(); } catch (error) { if (isIgnorableFileError(error)) { return null; @@ -773,7 +891,10 @@ export class BrowserManager { if (this.dependencies.platform === "win32") { let portContents: string; try { - portContents = await this.dependencies.readFile(path.join(socketDir, `${session}.port`), "utf8"); + portContents = await this.dependencies.readFile( + path.join(socketDir, `${session}.port`), + "utf8" + ); } catch (error) { if (isIgnorableFileError(error)) { return null; diff --git a/daemon/src/daemon.ts b/daemon/src/daemon.ts index a3e74718..cf35fe4a 100644 --- a/daemon/src/daemon.ts +++ b/daemon/src/daemon.ts @@ -3,6 +3,9 @@ import { chmod, mkdir, unlink, writeFile } from "node:fs/promises"; import net from "node:net"; import path from "node:path"; import { BrowserManager } from "./browser-manager.js"; +import { executeRequest } from "./execute-request.js"; +import { formatError } from "./format-error.js"; +import { IdleBrowserReaper } from "./idle-browser-reaper.js"; import { createKeyedLock, createMutex } from "./lock.js"; import { getBrowsersDir, @@ -24,6 +27,10 @@ const SOCKET_CLOSE_TIMEOUT_MS = 500; const UNIX_DEV_BROWSER_DIR_MODE = 0o700; const UNIX_DAEMON_SOCKET_MODE = 0o600; const UNIX_DAEMON_PID_MODE = 0o600; +// Bounds the in-memory request buffer. The socket decodes to UTF-8 strings, so +// this is measured in JavaScript string length (UTF-16 code units), which is +// what caps the JS string we actually retain. +const MAX_FRAME_CHARS = 10 * 1024 * 1024; const EMBEDDED_PACKAGE_JSON = JSON.stringify({ name: "dev-browser-runtime", private: true, @@ -35,25 +42,29 @@ const EMBEDDED_PACKAGE_JSON = JSON.stringify({ }, }); +// Chrome 147's built-in remote debugging does not emit Target.attachedToTarget +// for some target types, which hangs connectOverCDP unless Playwright is +// allowed to attach to "other" targets. Respect an explicit user override. +// See https://github.com/SawyerHood/dev-browser/issues/103 and +// https://github.com/microsoft/playwright/issues/40027. +if (process.env.PW_CHROMIUM_ATTACH_TO_OTHER === undefined) { + process.env.PW_CHROMIUM_ATTACH_TO_OTHER = "1"; +} + const manager = new BrowserManager(BROWSERS_DIR); const startedAt = Date.now(); const withBrowserLock = createKeyedLock(); const withInstallLock = createMutex(); const clients = new Set(); +const idleReaper = new IdleBrowserReaper({ + listBrowsers: () => manager.listBrowsers(), + stopBrowser: (name) => manager.stopBrowser(name), + withBrowserLock, +}); let server: net.Server | null = null; let shuttingDown: Promise | null = null; - -function formatError(error: unknown): string { - if (error instanceof Error) { - if (error.name === "ScriptTimeoutError") { - return error.message; - } - return error.stack ?? error.message; - } - - return String(error); -} +let ownsEndpoint = false; async function writeMessage(socket: net.Socket, message: Response): Promise { if (socket.destroyed) { @@ -138,68 +149,56 @@ function createMessageQueue(socket: net.Socket) { } async function handleExecute(socket: net.Socket, request: ExecuteRequest): Promise { - await withBrowserLock(request.browser, async () => { - if (request.connect === "auto") { - await manager.autoConnect(request.browser, { - port: request.connectPort, - profilePath: request.connectProfilePath, - }); - } else if (request.connect) { - await manager.connectBrowser(request.browser, request.connect, { - port: request.connectPort, - profilePath: request.connectProfilePath, - }); - } else { - await manager.ensureBrowser(request.browser, { - headless: request.headless, - ignoreHTTPSErrors: request.ignoreHTTPSErrors, - }); - } - - const output = createMessageQueue(socket); - const timeoutMs = request.timeoutMs ?? DEFAULT_SCRIPT_TIMEOUT_MS; - - try { - await runScript( - request.script, - manager, - request.browser, - { - onStdout: (data) => { - void output.push({ - id: request.id, - type: "stdout", - data, - }); - }, - onStderr: (data) => { - void output.push({ - id: request.id, - type: "stderr", - data, + idleReaper.requestStarted(request.browser); + try { + await executeRequest( + request, + request.timeoutMs ?? DEFAULT_SCRIPT_TIMEOUT_MS, + { + isOpen: () => !socket.destroyed && socket.writable && !socket.writableEnded, + onDisconnect: (listener) => { + const onDisconnect = () => listener(); + socket.once("close", onDisconnect); + socket.once("error", onDisconnect); + return () => { + socket.off("close", onDisconnect); + socket.off("error", onDisconnect); + }; + }, + send: (message) => writeMessage(socket, message), + }, + { + withBrowserLock, + prepareBrowser: async (currentRequest, context) => { + const operation = { + deadline: context.deadline, + signal: context.signal, + port: currentRequest.connectPort, + profilePath: currentRequest.connectProfilePath, + }; + if (currentRequest.connect === "auto") { + await manager.autoConnect(currentRequest.browser, operation); + } else if (currentRequest.connect) { + await manager.connectBrowser(currentRequest.browser, currentRequest.connect, operation); + } else { + await manager.ensureBrowser(currentRequest.browser, { + headless: currentRequest.headless, + ignoreHTTPSErrors: currentRequest.ignoreHTTPSErrors, + ...operation, }); - }, + } }, - { - timeout: timeoutMs, - } - ); - - await output.drain(); - await writeMessage(socket, { - id: request.id, - type: "complete", - success: true, - }); - } catch (error) { - await output.drain().catch(() => undefined); - await writeMessage(socket, { - id: request.id, - type: "error", - message: formatError(error), - }); - } - }); + runScript: async (currentRequest, output, context) => { + await runScript(currentRequest.script, manager, currentRequest.browser, output, { + signal: context.signal, + timeout: Math.max(1, context.deadline - Date.now()), + }); + }, + } + ); + } finally { + idleReaper.requestFinished(request.browser); + } } async function handleInstall(socket: net.Socket, request: { id: string }): Promise { @@ -302,6 +301,10 @@ async function handleRequest(socket: net.Socket, line: string): Promise { const { request } = parsed; + if (request.idleTimeoutMs !== undefined) { + idleReaper.configure(request.idleTimeoutMs); + } + if (shuttingDown && request.type !== "stop") { await writeMessage(socket, { id: request.id, @@ -317,10 +320,11 @@ async function handleRequest(socket: net.Socket, line: string): Promise { return; case "browsers": + const browsers = manager.listBrowsers(); await writeMessage(socket, { id: request.id, type: "result", - data: manager.listBrowsers(), + data: browsers.map((browser) => ({ ...browser, ...idleReaper.idleInfo(browser) })), }); await writeMessage(socket, { id: request.id, @@ -330,7 +334,8 @@ async function handleRequest(socket: net.Socket, line: string): Promise { return; case "browser-stop": - await manager.stopBrowser(request.browser); + await withBrowserLock(request.browser, () => manager.stopBrowser(request.browser)); + idleReaper.browserStopped(request.browser); await writeMessage(socket, { id: request.id, type: "result", @@ -344,6 +349,7 @@ async function handleRequest(socket: net.Socket, line: string): Promise { return; case "status": + const statusBrowsers = manager.listBrowsers(); await writeMessage(socket, { id: request.id, type: "result", @@ -351,8 +357,12 @@ async function handleRequest(socket: net.Socket, line: string): Promise { pid: process.pid, uptimeMs: Date.now() - startedAt, browserCount: manager.browserCount(), - browsers: manager.listBrowsers(), + browsers: statusBrowsers.map((browser) => ({ + ...browser, + ...idleReaper.idleInfo(browser), + })), socketPath: SOCKET_PATH, + idleTimeoutMs: idleReaper.idleTimeoutMs, }, }); await writeMessage(socket, { @@ -393,11 +403,18 @@ async function shutdown(exitCode = 0): Promise { const serverClosed = serverToClose ? closeServerInstance(serverToClose) : Promise.resolve(); await manager.stopAll(); + idleReaper.dispose(); await Promise.allSettled(Array.from(clients, (socket) => closeClientSocket(socket))); await serverClosed; - const cleanup = [unlinkIfExists(PID_PATH)]; - if (requiresDaemonEndpointCleanup()) { - cleanup.push(unlinkIfExists(SOCKET_PATH)); + // Only remove the pid file and socket path if this process successfully + // bound them; otherwise a daemon that lost the startup race would delete + // the live daemon's endpoint. + const cleanup: Promise[] = []; + if (ownsEndpoint) { + cleanup.push(unlinkIfExists(PID_PATH)); + if (requiresDaemonEndpointCleanup()) { + cleanup.push(unlinkIfExists(SOCKET_PATH)); + } } await Promise.allSettled(cleanup); @@ -409,6 +426,54 @@ async function shutdown(exitCode = 0): Promise { return shuttingDown; } +async function isEndpointActive(endpoint: string): Promise { + return await new Promise((resolve) => { + const probe = net.connect(endpoint); + const finish = (active: boolean) => { + probe.destroy(); + resolve(active); + }; + probe.once("connect", () => finish(true)); + probe.once("error", () => finish(false)); + }); +} + +function listenOnEndpoint(target: net.Server): Promise { + return new Promise((resolve, reject) => { + const onError = (error: Error) => reject(error); + target.once("error", onError); + target.listen(SOCKET_PATH, () => { + target.off("error", onError); + resolve(); + }); + }); +} + +async function bindEndpoint(target: net.Server): Promise { + try { + await listenOnEndpoint(target); + return; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + // Binding is the atomic claim. Only fall back to replacing the path when + // the bind actually failed because the path already exists. + if (code !== "EADDRINUSE" || !requiresDaemonEndpointCleanup()) { + throw error; + } + } + + // The path exists. If a live daemon answers, defer to it; unlinking a bound + // Unix socket would not stop it and would only split clients between daemons. + if (await isEndpointActive(SOCKET_PATH)) { + process.stderr.write("daemon already running\n"); + process.exit(0); + } + + // Stale socket file from a crashed daemon — remove it and claim the path. + await unlinkIfExists(SOCKET_PATH); + await listenOnEndpoint(target); +} + async function start(): Promise { await mkdir(BASE_DIR, { recursive: true, @@ -416,13 +481,6 @@ async function start(): Promise { }); await chmodIfSupported(BASE_DIR, UNIX_DEV_BROWSER_DIR_MODE); await ensureDevBrowserTempDir(); - if (requiresDaemonEndpointCleanup()) { - await unlinkIfExists(SOCKET_PATH); - } - await writeFile(PID_PATH, `${process.pid}\n`, { - mode: UNIX_DAEMON_PID_MODE, - }); - await chmodIfSupported(PID_PATH, UNIX_DAEMON_PID_MODE); server = net.createServer((socket) => { if (shuttingDown) { @@ -441,6 +499,24 @@ async function start(): Promise { const lines = buffer.split("\n"); buffer = lines.pop() ?? ""; + if (buffer.length > MAX_FRAME_CHARS || lines.some((line) => line.length > MAX_FRAME_CHARS)) { + // Pause synchronously so the unparsed remainder of an oversized frame + // cannot be reinterpreted as fresh requests while the error response + // drains (the write callback may be deferred under backpressure). + socket.pause(); + buffer = ""; + void writeMessage(socket, { + id: "unknown", + type: "error", + message: `Request exceeds the maximum frame size of ${MAX_FRAME_CHARS} characters`, + }) + .catch(() => undefined) + .finally(() => { + socket.destroy(); + }); + return; + } + for (const rawLine of lines) { const line = rawLine.trim(); if (!line) { @@ -471,19 +547,19 @@ async function start(): Promise { }); }); + await bindEndpoint(server); + + // Only attach the runtime error handler after a successful bind so a bind + // failure handled by bindEndpoint cannot also trip a shutdown. server.on("error", (error) => { console.error("Daemon server error:", error); void shutdown(1); }); - await new Promise((resolve, reject) => { - server?.once("error", reject); - server?.listen(SOCKET_PATH, () => { - server?.off("error", reject); - resolve(); - }); - }); + ownsEndpoint = true; await chmodIfSupported(SOCKET_PATH, UNIX_DAEMON_SOCKET_MODE); + await writeFile(PID_PATH, `${process.pid}\n`, { mode: UNIX_DAEMON_PID_MODE }); + await chmodIfSupported(PID_PATH, UNIX_DAEMON_PID_MODE); process.stderr.write("daemon ready\n"); } diff --git a/daemon/src/execute-request.test.ts b/daemon/src/execute-request.test.ts new file mode 100644 index 00000000..deaa92d2 --- /dev/null +++ b/daemon/src/execute-request.test.ts @@ -0,0 +1,254 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { executeRequest, type ExecuteRequestTransport } from "./execute-request.js"; +import { createKeyedLock } from "./lock.js"; +import type { ExecuteRequest, Response } from "./protocol.js"; + +function deferred() { + let resolve!: (value: T | PromiseLike) => void; + const promise = new Promise((innerResolve) => { + resolve = innerResolve; + }); + return { promise, resolve }; +} + +class FakeTransport implements ExecuteRequestTransport { + readonly messages: Response[] = []; + readonly listeners = new Set<() => void>(); + open = true; + + isOpen(): boolean { + return this.open; + } + + onDisconnect(listener: () => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + async send(message: Response): Promise { + this.messages.push(message); + } + + disconnect(): void { + this.open = false; + for (const listener of [...this.listeners]) { + listener(); + } + } +} + +function request(id: string, browser = "shared"): ExecuteRequest { + return { + id, + type: "execute", + browser, + script: "", + }; +} + +describe("executeRequest", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("expires in the browser queue and never starts later", async () => { + vi.useFakeTimers(); + const withBrowserLock = createKeyedLock(); + const releaseFirst = deferred(); + const firstStarted = deferred(); + const first = withBrowserLock("shared", async () => { + firstStarted.resolve(); + await releaseFirst.promise; + }); + await firstStarted.promise; + + const transport = new FakeTransport(); + const prepareBrowser = vi.fn(async () => undefined); + const execution = executeRequest(request("queued"), 50, transport, { + withBrowserLock, + prepareBrowser, + runScript: vi.fn(async () => undefined), + }); + + await vi.advanceTimersByTimeAsync(50); + await execution; + + expect(prepareBrowser).not.toHaveBeenCalled(); + expect(transport.messages).toEqual([ + { + id: "queued", + type: "error", + message: "Script timed out after 50ms and was terminated.", + }, + ]); + expect(transport.listeners.size).toBe(0); + expect(vi.getTimerCount()).toBe(0); + + releaseFirst.resolve(); + await first; + await vi.advanceTimersByTimeAsync(0); + expect(prepareBrowser).not.toHaveBeenCalled(); + }); + + it("holds the browser lock through disconnect cancellation before the next request", async () => { + const withBrowserLock = createKeyedLock(); + const firstTransport = new FakeTransport(); + const secondTransport = new FakeTransport(); + const firstRunning = deferred(); + const stopStarted = deferred(); + const allowStop = deferred(); + const events: string[] = []; + + const runScript = async ( + current: ExecuteRequest, + _output: unknown, + { signal }: { signal: AbortSignal } + ) => { + if (current.id !== "first") { + events.push("second:run"); + return; + } + + events.push("first:run"); + firstRunning.resolve(); + await new Promise((_resolve, reject) => { + signal.addEventListener( + "abort", + () => { + events.push("first:stop-start"); + stopStarted.resolve(); + void allowStop.promise.then(() => { + events.push("first:stop-end"); + reject(signal.reason); + }); + }, + { once: true } + ); + }); + }; + + const dependencies = { + withBrowserLock, + prepareBrowser: async (current: ExecuteRequest) => { + events.push(`${current.id}:prepare`); + }, + runScript, + }; + + const first = executeRequest(request("first"), 10_000, firstTransport, dependencies); + await firstRunning.promise; + firstTransport.disconnect(); + await stopStarted.promise; + + const second = executeRequest(request("second"), 10_000, secondTransport, dependencies); + await Promise.resolve(); + expect(events).not.toContain("second:prepare"); + + allowStop.resolve(); + await first; + await second; + + expect(firstTransport.messages).toEqual([]); + expect(secondTransport.messages).toEqual([{ id: "second", type: "complete", success: true }]); + expect(events).toEqual([ + "first:prepare", + "first:run", + "first:stop-start", + "first:stop-end", + "second:prepare", + "second:run", + ]); + expect(firstTransport.listeners.size).toBe(0); + expect(secondTransport.listeners.size).toBe(0); + }); + + it("suppresses output and duplicate terminal frames after the deadline", async () => { + vi.useFakeTimers(); + const transport = new FakeTransport(); + const withBrowserLock = createKeyedLock(); + + const execution = executeRequest(request("running"), 25, transport, { + withBrowserLock, + prepareBrowser: async () => undefined, + runScript: async (_request, output, { signal }) => { + output.onStdout("before\n"); + await new Promise((_resolve, reject) => { + signal.addEventListener( + "abort", + () => { + output.onStdout("late\n"); + output.onStderr("later\n"); + reject(new Error("late inner failure")); + }, + { once: true } + ); + }); + }, + }); + + await vi.advanceTimersByTimeAsync(25); + await execution; + + expect(transport.messages).toEqual([ + { id: "running", type: "stdout", data: "before\n" }, + { + id: "running", + type: "error", + message: "Script timed out after 25ms and was terminated.", + }, + ]); + expect(transport.messages.filter((message) => message.type === "error")).toHaveLength(1); + expect(transport.messages.some((message) => message.type === "complete")).toBe(false); + expect(transport.listeners.size).toBe(0); + expect(vi.getTimerCount()).toBe(0); + }); + + it("preserves an inner error that wins before the hard deadline", async () => { + vi.useFakeTimers(); + const transport = new FakeTransport(); + + await executeRequest(request("inner-error"), 1_000, transport, { + withBrowserLock: createKeyedLock(), + prepareBrowser: async () => undefined, + runScript: async () => { + throw new Error("locator click failed: target closed"); + }, + }); + + expect(transport.messages).toHaveLength(1); + expect(transport.messages[0]).toEqual( + expect.objectContaining({ + id: "inner-error", + type: "error", + message: expect.stringContaining("locator click failed: target closed"), + }) + ); + expect(transport.listeners.size).toBe(0); + expect(vi.getTimerCount()).toBe(0); + }); + + it("returns the deadline error instead of success when the timer callback is delayed", async () => { + vi.useFakeTimers(); + vi.setSystemTime(10_000); + const transport = new FakeTransport(); + + await executeRequest(request("boundary"), 100, transport, { + withBrowserLock: createKeyedLock(), + prepareBrowser: async () => undefined, + runScript: async () => { + vi.setSystemTime(10_100); + }, + }); + + expect(transport.messages).toEqual([ + { + id: "boundary", + type: "error", + message: "Script timed out after 100ms and was terminated.", + }, + ]); + expect(transport.listeners.size).toBe(0); + expect(vi.getTimerCount()).toBe(0); + }); +}); diff --git a/daemon/src/execute-request.ts b/daemon/src/execute-request.ts new file mode 100644 index 00000000..8f589c82 --- /dev/null +++ b/daemon/src/execute-request.ts @@ -0,0 +1,225 @@ +import { formatError } from "./format-error.js"; +import type { ExecuteRequest, Response } from "./protocol.js"; + +export interface ExecuteRequestTransport { + isOpen(): boolean; + onDisconnect(listener: () => void): () => void; + send(message: Response): Promise; +} + +interface ScriptOutput { + onStdout(data: string): void; + onStderr(data: string): void; +} + +export interface ExecuteRequestDependencies { + prepareBrowser( + request: ExecuteRequest, + context: { deadline: number; signal: AbortSignal } + ): Promise; + runScript( + request: ExecuteRequest, + output: ScriptOutput, + context: { deadline: number; signal: AbortSignal } + ): Promise; + withBrowserLock( + browser: string, + action: () => Promise, + options: { signal: AbortSignal } + ): Promise; +} + +class RequestTimeoutError extends Error { + constructor(timeoutMs: number) { + super(`Script timed out after ${formatTimeoutDuration(timeoutMs)} and was terminated.`); + this.name = "ScriptTimeoutError"; + } +} + +class RequestDisconnectedError extends Error { + constructor() { + super("Client disconnected"); + this.name = "RequestDisconnectedError"; + } +} + +function formatTimeoutDuration(timeoutMs: number): string { + if (timeoutMs % 1_000 === 0) { + return `${timeoutMs / 1_000}s`; + } + + return `${timeoutMs}ms`; +} + +function abortReason(signal: AbortSignal): Error { + return signal.reason instanceof Error ? signal.reason : new Error(String(signal.reason)); +} + +class RequestSession { + readonly deadline: number; + readonly signal: AbortSignal; + + readonly #controller = new AbortController(); + readonly #detachDisconnect: () => void; + readonly #request: ExecuteRequest; + readonly #timeout: ReturnType; + readonly #timeoutMs: number; + readonly #transport: ExecuteRequestTransport; + + #active = true; + #messages = Promise.resolve(); + #terminal = Promise.resolve(); + + constructor(request: ExecuteRequest, timeoutMs: number, transport: ExecuteRequestTransport) { + this.#request = request; + this.#timeoutMs = timeoutMs; + this.#transport = transport; + this.deadline = Date.now() + timeoutMs; + this.signal = this.#controller.signal; + this.#detachDisconnect = transport.onDisconnect(() => { + this.#disconnect(); + }); + this.#timeout = setTimeout(() => { + this.#finish( + { + id: request.id, + type: "error", + message: new RequestTimeoutError(timeoutMs).message, + }, + new RequestTimeoutError(timeoutMs) + ); + }, timeoutMs); + this.#timeout.unref?.(); + } + + stream(type: "stdout" | "stderr", data: string): void { + if (!this.#active) { + return; + } + + this.#messages = this.#messages + .then(async () => { + if (this.#transport.isOpen()) { + await this.#transport.send({ id: this.#request.id, type, data }); + } + }) + .catch(() => undefined); + } + + async complete(): Promise { + this.#finish({ id: this.#request.id, type: "complete", success: true }); + await this.#terminal; + } + + async fail(error: unknown): Promise { + this.#finish({ id: this.#request.id, type: "error", message: formatError(error) }); + await this.#terminal; + } + + throwIfAborted(): void { + if (this.signal.aborted) { + throw abortReason(this.signal); + } + } + + throwIfAbortedOrExpired(): void { + this.throwIfAborted(); + if (Date.now() < this.deadline) { + return; + } + + const error = new RequestTimeoutError(this.#timeoutMs); + this.#finish( + { + id: this.#request.id, + type: "error", + message: error.message, + }, + error + ); + throw error; + } + + async dispose(): Promise { + clearTimeout(this.#timeout); + this.#detachDisconnect(); + await this.#terminal; + } + + #disconnect(): void { + if (!this.#active) { + return; + } + + this.#active = false; + clearTimeout(this.#timeout); + this.#controller.abort(new RequestDisconnectedError()); + } + + #finish(message: Response, abortError?: Error): void { + if (!this.#active) { + return; + } + + this.#active = false; + clearTimeout(this.#timeout); + if (abortError) { + this.#controller.abort(abortError); + } + this.#terminal = this.#messages + .then(async () => { + if (this.#transport.isOpen()) { + await this.#transport.send(message); + } + }) + .catch(() => undefined); + } +} + +export async function executeRequest( + request: ExecuteRequest, + timeoutMs: number, + transport: ExecuteRequestTransport, + dependencies: ExecuteRequestDependencies +): Promise { + const session = new RequestSession(request, timeoutMs, transport); + + try { + await dependencies.withBrowserLock( + request.browser, + async () => { + session.throwIfAbortedOrExpired(); + await dependencies.prepareBrowser(request, { + deadline: session.deadline, + signal: session.signal, + }); + session.throwIfAbortedOrExpired(); + await dependencies.runScript( + request, + { + onStdout: (data) => session.stream("stdout", data), + onStderr: (data) => session.stream("stderr", data), + }, + { + deadline: session.deadline, + signal: session.signal, + } + ); + session.throwIfAbortedOrExpired(); + }, + { signal: session.signal } + ); + + session.throwIfAbortedOrExpired(); + await session.complete(); + } catch (error) { + try { + session.throwIfAbortedOrExpired(); + } catch { + // A timeout or disconnect already owns the terminal outcome. + } + await session.fail(error); + } finally { + await session.dispose(); + } +} diff --git a/daemon/src/format-error.test.ts b/daemon/src/format-error.test.ts new file mode 100644 index 00000000..602ac665 --- /dev/null +++ b/daemon/src/format-error.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; + +import { formatError } from "./format-error.js"; + +describe("formatError", () => { + it("composes a name/message header when the stack has none", () => { + const error = new Error("QuickJS promise rejected: boom message"); + error.stack = " at (user-script.js:2:15)"; + + const formatted = formatError(error); + + expect(formatted).toContain("Error: QuickJS promise rejected: boom message"); + expect(formatted).toContain("at (user-script.js:2:15)"); + }); + + it("does not duplicate the header when the stack already has one", () => { + const error = new Error("native failure"); + + const formatted = formatError(error); + + expect(formatted).toBe(error.stack); + expect(formatted.indexOf("Error: native failure")).toBe( + formatted.lastIndexOf("Error: native failure") + ); + }); + + it("falls back to the header when there is no stack", () => { + const error = new Error("no stack here"); + error.stack = undefined; + + expect(formatError(error)).toBe("Error: no stack here"); + }); + + it("returns only the message for script timeouts", () => { + const error = new Error("Script timed out after 30000ms"); + error.name = "ScriptTimeoutError"; + + expect(formatError(error)).toBe("Script timed out after 30000ms"); + }); + + it("stringifies non-error values", () => { + expect(formatError("plain failure")).toBe("plain failure"); + }); +}); diff --git a/daemon/src/format-error.ts b/daemon/src/format-error.ts new file mode 100644 index 00000000..90957094 --- /dev/null +++ b/daemon/src/format-error.ts @@ -0,0 +1,14 @@ +export function formatError(error: unknown): string { + if (error instanceof Error) { + if (error.name === "ScriptTimeoutError") { + return error.message; + } + const header = `${error.name}: ${error.message}`; + if (!error.stack) { + return header; + } + return error.stack.startsWith(header) ? error.stack : `${header}\n${error.stack}`; + } + + return String(error); +} diff --git a/daemon/src/idle-browser-reaper.test.ts b/daemon/src/idle-browser-reaper.test.ts new file mode 100644 index 00000000..98535c3b --- /dev/null +++ b/daemon/src/idle-browser-reaper.test.ts @@ -0,0 +1,175 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { IdleBrowserReaper, type IdleBrowserSummary } from "./idle-browser-reaper.js"; +import { createKeyedLock } from "./lock.js"; + +function deferred() { + let resolve!: () => void; + const promise = new Promise((innerResolve) => { + resolve = innerResolve; + }); + return { promise, resolve }; +} + +function createHarness(initialBrowsers: IdleBrowserSummary[]) { + const browsers = new Map(initialBrowsers.map((browser) => [browser.name, browser])); + const stopped: string[] = []; + const withBrowserLock = createKeyedLock(); + const reaper = new IdleBrowserReaper({ + listBrowsers: () => [...browsers.values()], + stopBrowser: async (name) => { + stopped.push(name); + browsers.delete(name); + }, + withBrowserLock, + }); + return { browsers, reaper, stopped, withBrowserLock }; +} + +describe("IdleBrowserReaper", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("fires at the pinned idle deadline despite recurring background work", async () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const { reaper, stopped } = createHarness([{ name: "managed", type: "launched" }]); + reaper.configure(1_000); + reaper.requestStarted("managed"); + reaper.requestFinished("managed"); + + let backgroundTicks = 0; + const backgroundWork = setInterval(() => { + backgroundTicks += 1; + }, 100); + + await vi.advanceTimersByTimeAsync(999); + expect(backgroundTicks).toBeGreaterThan(0); + expect(stopped).toEqual([]); + + await vi.advanceTimersByTimeAsync(1); + expect(stopped).toEqual(["managed"]); + + clearInterval(backgroundWork); + reaper.dispose(); + }); + + it("does not close an active request and starts its idle window at completion", async () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const { reaper, stopped } = createHarness([{ name: "active", type: "launched" }]); + reaper.configure(100); + reaper.requestStarted("active"); + + await vi.advanceTimersByTimeAsync(500); + expect(stopped).toEqual([]); + expect(reaper.idleInfo({ name: "active", type: "launched" }).activeRequests).toBe(1); + + reaper.requestFinished("active"); + await vi.advanceTimersByTimeAsync(99); + expect(stopped).toEqual([]); + await vi.advanceTimersByTimeAsync(1); + expect(stopped).toEqual(["active"]); + reaper.dispose(); + }); + + it("tracks idle deadlines independently for each named browser", async () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const { reaper, stopped } = createHarness([ + { name: "first", type: "launched" }, + { name: "second", type: "launched" }, + ]); + reaper.configure(100); + reaper.requestStarted("first"); + reaper.requestFinished("first"); + reaper.requestStarted("second"); + reaper.requestFinished("second"); + + await vi.advanceTimersByTimeAsync(50); + reaper.requestStarted("second"); + reaper.requestFinished("second"); + + await vi.advanceTimersByTimeAsync(50); + expect(stopped).toEqual(["first"]); + await vi.advanceTimersByTimeAsync(50); + expect(stopped).toEqual(["first", "second"]); + reaper.dispose(); + }); + + it("acquires the browser lock and rechecks activity before closing", async () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const { reaper, stopped, withBrowserLock } = createHarness([ + { name: "racing", type: "launched" }, + ]); + reaper.configure(100); + reaper.requestStarted("racing"); + reaper.requestFinished("racing"); + + const releaseLock = deferred(); + const lockAcquired = deferred(); + const heldLock = withBrowserLock("racing", async () => { + lockAcquired.resolve(); + await releaseLock.promise; + }); + await lockAcquired.promise; + + await vi.advanceTimersByTimeAsync(100); + reaper.requestStarted("racing"); + releaseLock.resolve(); + await heldLock; + await vi.advanceTimersByTimeAsync(0); + expect(stopped).toEqual([]); + + reaper.requestFinished("racing"); + await vi.advanceTimersByTimeAsync(100); + expect(stopped).toEqual(["racing"]); + reaper.dispose(); + }); + + it("never closes externally connected Chrome", async () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const { reaper, stopped } = createHarness([{ name: "external", type: "connected" }]); + reaper.configure(100); + reaper.requestStarted("external"); + reaper.requestFinished("external"); + + await vi.advanceTimersByTimeAsync(1_000); + expect(stopped).toEqual([]); + reaper.dispose(); + }); + + it("disables cleanup when configured to zero", async () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const { reaper, stopped } = createHarness([{ name: "disabled", type: "launched" }]); + reaper.configure(100); + reaper.requestStarted("disabled"); + reaper.requestFinished("disabled"); + reaper.configure(0); + + await vi.advanceTimersByTimeAsync(1_000); + expect(stopped).toEqual([]); + reaper.dispose(); + }); + + it("applies timeout changes without restarting the daemon", async () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const { reaper, stopped } = createHarness([{ name: "managed", type: "launched" }]); + reaper.configure(1_000); + reaper.requestStarted("managed"); + reaper.requestFinished("managed"); + await vi.advanceTimersByTimeAsync(200); + + reaper.configure(300); + await vi.advanceTimersByTimeAsync(99); + expect(stopped).toEqual([]); + await vi.advanceTimersByTimeAsync(1); + expect(stopped).toEqual(["managed"]); + reaper.dispose(); + }); +}); diff --git a/daemon/src/idle-browser-reaper.ts b/daemon/src/idle-browser-reaper.ts new file mode 100644 index 00000000..f93d53ea --- /dev/null +++ b/daemon/src/idle-browser-reaper.ts @@ -0,0 +1,198 @@ +export interface IdleBrowserSummary { + name: string; + type: "launched" | "connected"; +} + +export interface BrowserIdleInfo { + activeRequests: number; + idleForMs?: number; + idleRemainingMs?: number; +} + +interface ActivityState { + activeRequests: number; + lastActivityAt: number; +} + +interface IdleBrowserReaperDependencies { + listBrowsers(): IdleBrowserSummary[]; + stopBrowser(name: string): Promise; + withBrowserLock(name: string, action: () => Promise): Promise; + now?: () => number; +} + +const MAX_TIMER_DELAY_MS = 2_147_483_647; + +export class IdleBrowserReaper { + readonly #activity = new Map(); + readonly #dependencies: IdleBrowserReaperDependencies; + readonly #now: () => number; + + #idleTimeoutMs = 0; + #timer: ReturnType | null = null; + + constructor(dependencies: IdleBrowserReaperDependencies) { + this.#dependencies = dependencies; + this.#now = dependencies.now ?? Date.now; + } + + configure(idleTimeoutMs: number): void { + if (idleTimeoutMs === this.#idleTimeoutMs) { + return; + } + + this.#idleTimeoutMs = idleTimeoutMs; + this.#scheduleNextDeadline(); + } + + get idleTimeoutMs(): number { + return this.#idleTimeoutMs; + } + + requestStarted(browserName: string): void { + const state = this.#getOrCreateActivity(browserName); + state.activeRequests += 1; + state.lastActivityAt = this.#now(); + this.#scheduleNextDeadline(); + } + + requestFinished(browserName: string): void { + const state = this.#getOrCreateActivity(browserName); + state.activeRequests = Math.max(0, state.activeRequests - 1); + state.lastActivityAt = this.#now(); + this.#scheduleNextDeadline(); + } + + browserStopped(browserName: string): void { + this.#activity.delete(browserName); + this.#scheduleNextDeadline(); + } + + idleInfo(browser: IdleBrowserSummary): BrowserIdleInfo { + const state = this.#activity.get(browser.name); + if (!state) { + return { activeRequests: 0 }; + } + + const now = this.#now(); + const idleForMs = Math.max(0, now - state.lastActivityAt); + const info: BrowserIdleInfo = { + activeRequests: state.activeRequests, + idleForMs, + }; + + if (browser.type === "launched" && this.#idleTimeoutMs > 0 && state.activeRequests === 0) { + info.idleRemainingMs = Math.max(0, this.#idleTimeoutMs - idleForMs); + } + + return info; + } + + dispose(): void { + if (this.#timer) { + clearTimeout(this.#timer); + this.#timer = null; + } + } + + #getOrCreateActivity(browserName: string): ActivityState { + let state = this.#activity.get(browserName); + if (!state) { + state = { activeRequests: 0, lastActivityAt: this.#now() }; + this.#activity.set(browserName, state); + } + return state; + } + + #scheduleNextDeadline(): void { + if (this.#timer) { + clearTimeout(this.#timer); + this.#timer = null; + } + + if (this.#idleTimeoutMs === 0) { + return; + } + + const now = this.#now(); + let earliestDeadline: number | undefined; + + for (const browser of this.#dependencies.listBrowsers()) { + if (browser.type !== "launched") { + continue; + } + + const state = this.#getOrCreateActivity(browser.name); + if (state.activeRequests > 0) { + continue; + } + + const deadline = state.lastActivityAt + this.#idleTimeoutMs; + earliestDeadline = + earliestDeadline === undefined ? deadline : Math.min(earliestDeadline, deadline); + } + + if (earliestDeadline === undefined) { + return; + } + + const delay = Math.min(MAX_TIMER_DELAY_MS, Math.max(0, earliestDeadline - now)); + this.#timer = setTimeout(() => { + this.#timer = null; + void this.#reapDueBrowsers().finally(() => this.#scheduleNextDeadline()); + }, delay); + this.#timer.unref?.(); + } + + async #reapDueBrowsers(): Promise { + if (this.#idleTimeoutMs === 0) { + return; + } + + const now = this.#now(); + const candidates = this.#dependencies + .listBrowsers() + .filter((browser) => { + const state = this.#activity.get(browser.name); + return ( + browser.type === "launched" && + state !== undefined && + state.activeRequests === 0 && + now - state.lastActivityAt >= this.#idleTimeoutMs + ); + }) + .map((browser) => browser.name); + + await Promise.allSettled( + candidates.map(async (browserName) => { + await this.#dependencies.withBrowserLock(browserName, async () => { + const browser = this.#dependencies + .listBrowsers() + .find((candidate) => candidate.name === browserName); + const state = this.#activity.get(browserName); + + // Recheck after acquiring the same lock used by scripts and explicit stops. + // A request that started or completed while the reaper waited gets a fresh deadline. + if ( + !browser || + browser.type !== "launched" || + !state || + state.activeRequests > 0 || + this.#idleTimeoutMs === 0 || + this.#now() - state.lastActivityAt < this.#idleTimeoutMs + ) { + return; + } + + try { + await this.#dependencies.stopBrowser(browserName); + this.#activity.delete(browserName); + } catch { + // Avoid a tight retry loop if a close fails unexpectedly. + state.lastActivityAt = this.#now(); + } + }); + }) + ); + } +} diff --git a/daemon/src/lock.ts b/daemon/src/lock.ts index df893738..301465a8 100644 --- a/daemon/src/lock.ts +++ b/daemon/src/lock.ts @@ -1,26 +1,61 @@ type AsyncAction = () => Promise; +function abortReason(signal: AbortSignal): Error { + return signal.reason instanceof Error ? signal.reason : new Error(String(signal.reason)); +} + +async function waitForTurn(previous: Promise, signal: AbortSignal): Promise { + if (signal.aborted) { + throw abortReason(signal); + } + + await new Promise((resolve, reject) => { + const onAbort = () => { + signal.removeEventListener("abort", onAbort); + reject(abortReason(signal)); + }; + + signal.addEventListener("abort", onAbort, { once: true }); + previous.then(() => { + signal.removeEventListener("abort", onAbort); + resolve(); + }); + }); +} + export function createKeyedLock() { const locks = new Map>(); - return async function withLock(key: K, action: AsyncAction): Promise { - const previous = locks.get(key) ?? Promise.resolve(); + return async function withLock( + key: K, + action: AsyncAction, + options: { signal?: AbortSignal } = {} + ): Promise { + const previous = (locks.get(key) ?? Promise.resolve()).catch(() => undefined); let release!: () => void; const current = new Promise((resolve) => { release = resolve; }); - const tail = previous.catch(() => undefined).then(() => current); + const tail = previous.then(() => current); locks.set(key, tail); - await previous.catch(() => undefined); - try { + if (options.signal) { + await waitForTurn(previous, options.signal); + } else { + await previous; + } + if (options.signal?.aborted) { + throw abortReason(options.signal); + } return await action(); } finally { release(); - if (locks.get(key) === tail) { - locks.delete(key); - } + void tail.then(() => { + if (locks.get(key) === tail) { + locks.delete(key); + } + }); } }; } diff --git a/daemon/src/protocol.test.ts b/daemon/src/protocol.test.ts new file mode 100644 index 00000000..4b4bf2f3 --- /dev/null +++ b/daemon/src/protocol.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; + +import { parseRequest } from "./protocol.js"; + +describe("idle timeout protocol configuration", () => { + it("accepts a non-negative safe integer on requests", () => { + expect( + parseRequest( + JSON.stringify({ + id: "status-1", + type: "status", + idleTimeoutMs: 300_000, + }) + ) + ).toEqual({ + success: true, + request: { + id: "status-1", + type: "status", + idleTimeoutMs: 300_000, + }, + }); + + expect( + parseRequest(JSON.stringify({ id: "status-2", type: "status", idleTimeoutMs: 0 })) + ).toEqual({ + success: true, + request: { id: "status-2", type: "status", idleTimeoutMs: 0 }, + }); + }); + + it("rejects negative or unsafe timeout values", () => { + expect( + parseRequest(JSON.stringify({ id: "negative", type: "status", idleTimeoutMs: -1 })).success + ).toBe(false); + expect( + parseRequest( + JSON.stringify({ + id: "unsafe", + type: "status", + idleTimeoutMs: Number.MAX_SAFE_INTEGER + 1, + }) + ).success + ).toBe(false); + }); +}); diff --git a/daemon/src/protocol.ts b/daemon/src/protocol.ts index 07e7a703..9003307e 100644 --- a/daemon/src/protocol.ts +++ b/daemon/src/protocol.ts @@ -2,6 +2,7 @@ import { z } from "zod"; const RequestBaseSchema = z.object({ id: z.string().min(1), + idleTimeoutMs: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER).optional(), }); const ExecuteRequestSchema = RequestBaseSchema.extend({ diff --git a/daemon/src/sandbox/__tests__/auto-connect.test.ts b/daemon/src/sandbox/__tests__/auto-connect.test.ts index 56022523..0025c34c 100644 --- a/daemon/src/sandbox/__tests__/auto-connect.test.ts +++ b/daemon/src/sandbox/__tests__/auto-connect.test.ts @@ -169,7 +169,7 @@ function createManager( options.fetch ?? (vi.fn(async () => { throw new Error("unexpected fetch"); - }) as typeof globalThis.fetch); + }) as unknown as typeof globalThis.fetch); const readFile = options.readFile ?? (vi.fn(async (filePath: string) => { @@ -177,9 +177,7 @@ function createManager( }) as ReturnType); const launchPersistentContext = options.launchPersistentContext ?? (vi.fn() as ReturnType); - const readdir = - options.readdir ?? - (vi.fn(async () => []) as ReturnType); + const readdir = options.readdir ?? (vi.fn(async () => []) as ReturnType); const manager = new BrowserManager(path.join("/tmp", "dev-browser-auto-connect-tests"), { connectOverCDP: connectOverCDP as never, @@ -212,6 +210,94 @@ afterEach(() => { }); describe("BrowserManager auto-connect", () => { + it.each([false, true])( + "launches the configured executable with headless=%s", + async (headless) => { + const context = new MockContext(); + context.setBrowser(new MockBrowser([context])); + const launchPersistentContext = vi.fn(async () => context); + const executablePath = "/opt/chromium-stealthcdp/chrome"; + const readFile = vi.fn(async (filePath: string) => { + expect(filePath).toBe(path.join("/Users/tester", ".dev-browser", "config.json")); + return JSON.stringify({ executablePath, idleTimeout: "5m" }); + }); + const { manager } = createManager({ + platform: "linux", + isWsl: true, + readFile, + launchPersistentContext, + }); + await manager.ensureBrowser("stealth", { headless }); + expect(launchPersistentContext).toHaveBeenCalledWith( + expect.stringContaining(path.join("stealth", "chromium-profile")), + expect.objectContaining({ executablePath, headless }) + ); + expect(manager.listBrowsers()).toEqual([ + expect.objectContaining({ name: "stealth", executablePath }), + ]); + await manager.stopAll(); + } + ); + + it.each(["{}", '{"idleTimeout":"5m"}'])( + "keeps bundled Chromium for config %s", + async (contents) => { + const context = new MockContext(); + context.setBrowser(new MockBrowser([context])); + const launchPersistentContext = vi.fn(async () => context); + const { manager } = createManager({ + readFile: vi.fn(async () => contents), + launchPersistentContext, + }); + await manager.ensureBrowser("bundled"); + expect(launchPersistentContext).toHaveBeenCalledWith( + expect.any(String), + expect.not.objectContaining({ executablePath: expect.anything() }) + ); + await manager.stopAll(); + } + ); + + it.each([ + "invalid-json", + "null", + "[]", + '{"executablePath":""}', + '{"executablePath":"relative/chrome"}', + '{"executablePath":42}', + ])("rejects invalid browser configuration without launching: %s", async (contents) => { + const { manager, launchPersistentContext } = createManager({ + readFile: vi.fn(async () => contents), + }); + await expect(manager.ensureBrowser("invalid")).rejects.toThrow("Invalid"); + expect(launchPersistentContext).not.toHaveBeenCalled(); + }); + + it("does not silently use bundled Chromium when the configured executable fails", async () => { + const launchPersistentContext = vi.fn(async () => { + throw new Error("executable does not exist"); + }); + const { manager } = createManager({ + readFile: vi.fn(async () => '{"executablePath":"/missing/chrome"}'), + launchPersistentContext, + }); + await expect(manager.ensureBrowser("missing")).rejects.toThrow("executable does not exist"); + expect(launchPersistentContext).toHaveBeenCalledTimes(1); + }); + + it("does not read the launch executable configuration when attaching over CDP", async () => { + const readFile = vi.fn(async () => { + throw new Error("must not read launch config"); + }); + const { manager } = createManager({ + readFile, + connectOverCDP: vi.fn(async () => new MockBrowser([new MockContext()])), + }); + await manager.connectBrowser("external", "ws://127.0.0.1:9333/devtools/browser/external"); + expect(readFile).not.toHaveBeenCalled(); + await manager.stopAll(); + }); + it("passes ignoreHTTPSErrors to launched browsers and only relaunches when it changes", async () => { const launchPersistentContext = vi.fn(async () => { const context = new MockContext(); @@ -289,6 +375,29 @@ describe("BrowserManager auto-connect", () => { expect(relaunchedEntry.ignoreHTTPSErrors).toBe(true); }); + it("closes a persistent context that returns after its request is aborted", async () => { + const controller = new AbortController(); + const context = new MockContext(); + const browser = new MockBrowser([context]); + context.setBrowser(browser); + const launchPersistentContext = vi.fn(async () => { + controller.abort(new Error("launch request disconnected")); + return context; + }); + const { manager } = createManager({ launchPersistentContext }); + + await expect( + manager.ensureBrowser("late-launch", { + headless: true, + signal: controller.signal, + }) + ).rejects.toThrow("launch request disconnected"); + + expect(context.closeCalls).toBe(1); + expect(browser.closeCalls).toBe(1); + expect(manager.getBrowser("late-launch")).toBeUndefined(); + }); + it("parses DevToolsActivePort and returns the browser websocket endpoint", async () => { const homeDir = "/Users/tester"; const devToolsPath = path.join( @@ -345,7 +454,7 @@ describe("BrowserManager auto-connect", () => { }); it("checks a custom profile path for DevToolsActivePort before default locations", async () => { - const customProfilePath = "/tmp/custom-chrome-profile"; + const customProfilePath = path.resolve("/tmp/custom-chrome-profile"); const devToolsPath = path.join(customProfilePath, "DevToolsActivePort"); const readFile = vi.fn(async (filePath: string) => { if (filePath === devToolsPath) { @@ -413,7 +522,7 @@ describe("BrowserManager auto-connect", () => { throw createEnoentError(filePath); }); - const fetch = vi.fn() as typeof globalThis.fetch; + const fetch = vi.fn() as unknown as typeof globalThis.fetch; const { manager } = createManager({ fetch, homedir: () => homeDir, readFile }); await expect(getInternals(manager).discoverChrome()).resolves.toBe(websocketUrl); @@ -489,7 +598,7 @@ describe("BrowserManager auto-connect", () => { }, } ); - }) as typeof globalThis.fetch; + }) as unknown as typeof globalThis.fetch; const { manager } = createManager({ connectOverCDP, fetch, @@ -527,6 +636,25 @@ describe("BrowserManager auto-connect", () => { ]); }); + it("closes a CDP browser connection that returns after its request is aborted", async () => { + const controller = new AbortController(); + const browser = new MockBrowser([new MockContext()]); + const connectOverCDP = vi.fn(async () => { + controller.abort(new Error("connect request disconnected")); + return browser; + }); + const { manager } = createManager({ connectOverCDP }); + + await expect( + manager.connectBrowser("late-connect", "ws://127.0.0.1:9222/devtools/browser/late", { + signal: controller.signal, + }) + ).rejects.toThrow("connect request disconnected"); + + expect(browser.closeCalls).toBe(1); + expect(manager.getBrowser("late-connect")).toBeUndefined(); + }); + it("getBrowser returns connected entries without relaunching them", async () => { const browser = new MockBrowser([new MockContext()]); const connectOverCDP = vi.fn(async () => browser); @@ -568,7 +696,7 @@ describe("BrowserManager auto-connect", () => { const fetch = vi.fn(async (input: RequestInfo | URL) => { expect(String(input)).toBe("http://127.0.0.1:9222/json/version"); return new Response("not found", { status: 404 }); - }) as typeof globalThis.fetch; + }) as unknown as typeof globalThis.fetch; const readFile = vi.fn(async (filePath: string) => { if (filePath === devToolsPath) { return "9222\n/devtools/browser/from-active-port\n"; @@ -601,7 +729,7 @@ describe("BrowserManager auto-connect", () => { }); const fetch = vi.fn( async () => new Response("not found", { status: 404 }) - ) as typeof globalThis.fetch; + ) as unknown as typeof globalThis.fetch; const { manager } = createManager({ fetch, homedir: () => homeDir, @@ -662,7 +790,7 @@ describe("BrowserManager auto-connect", () => { } throw new Error("connection refused"); - }) as typeof globalThis.fetch; + }) as unknown as typeof globalThis.fetch; const readFile = vi.fn(async (filePath: string) => { throw createEnoentError(filePath); }); @@ -728,6 +856,56 @@ describe("BrowserManager auto-connect", () => { expect(connectOverCDP).toHaveBeenCalledWith("ws://127.0.0.1:9333/devtools/browser/custom-port"); }); + it("preserves cancellation and deadlines when connecting through a custom profile", async () => { + const controller = new AbortController(); + const browser = new MockBrowser([new MockContext()]); + const endpoint = "ws://127.0.0.1:9333/devtools/browser/custom-profile"; + const connectOverCDP = vi.fn(async (_endpoint: string, options?: { timeout: number }) => { + expect(options?.timeout).toBeGreaterThan(0); + expect(options?.timeout).toBeLessThanOrEqual(5000); + controller.abort(new Error("custom profile request disconnected")); + return browser; + }); + const readFile = vi.fn(async (filePath: string) => { + if (filePath === path.join(path.resolve("/custom/profile"), "DevToolsActivePort")) { + return "9333\n/devtools/browser/custom-profile\n"; + } + throw createEnoentError(filePath); + }); + const { manager, fetch } = createManager({ connectOverCDP, readFile }); + await expect( + manager.connectBrowser("custom", "auto", { + profilePath: "/custom/profile", + port: 9333, + deadline: Date.now() + 5000, + signal: controller.signal, + }) + ).rejects.toThrow("custom profile request disconnected"); + expect(connectOverCDP).toHaveBeenCalledWith(endpoint, { timeout: expect.any(Number) }); + expect(browser.closeCalls).toBe(1); + expect(manager.getBrowser("custom")).toBeUndefined(); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("does not attach after a custom port probe is cancelled", async () => { + const controller = new AbortController(); + const fetch = vi.fn(async () => { + controller.abort(new Error("custom port request disconnected")); + return new Response( + JSON.stringify({ + webSocketDebuggerUrl: "ws://127.0.0.1:9333/devtools/browser/custom-port", + }), + { status: 200 } + ); + }); + const { manager, connectOverCDP } = createManager({ fetch: fetch as typeof globalThis.fetch }); + await expect( + manager.autoConnect("custom-port", { port: 9333, signal: controller.signal }) + ).rejects.toThrow("custom port request disconnected"); + expect(fetch).toHaveBeenCalledTimes(1); + expect(connectOverCDP).not.toHaveBeenCalled(); + }); + it("autoConnect discovers Windows Chrome profiles when running under WSL", async () => { const browser = new MockBrowser([new MockContext()]); const connectOverCDP = vi.fn(async () => browser); @@ -740,9 +918,7 @@ describe("BrowserManager auto-connect", () => { const readFile = vi.fn(async (filePath: string) => { if ( filePath === - path.join( - "/mnt/c/Users/ecoch/AppData/Local/Google/Chrome/User Data/DevToolsActivePort" - ) + path.join("/mnt/c/Users/ecoch/AppData/Local/Google/Chrome/User Data/DevToolsActivePort") ) { return "9222\n/devtools/browser/wsl-discovered\n"; } @@ -759,11 +935,13 @@ describe("BrowserManager auto-connect", () => { await manager.autoConnect("wsl-browser"); - expect(readdir).toHaveBeenCalledWith("/mnt/c/Users", { + expect(readdir).toHaveBeenCalledWith(path.join("/mnt", "c", "Users"), { encoding: "utf8", withFileTypes: true, }); - expect(connectOverCDP).toHaveBeenCalledWith("ws://127.0.0.1:9222/devtools/browser/wsl-discovered"); + expect(connectOverCDP).toHaveBeenCalledWith( + "ws://127.0.0.1:9222/devtools/browser/wsl-discovered" + ); }); it("autoConnect falls back from DevToolsActivePort to port probing when the direct websocket is stale", async () => { @@ -810,7 +988,7 @@ describe("BrowserManager auto-connect", () => { } throw new Error("connection refused"); - }) as typeof globalThis.fetch; + }) as unknown as typeof globalThis.fetch; const readFile = vi.fn(async (filePath: string) => { if (filePath === devToolsPath) { return "9222\n/devtools/browser/from-active-port\n"; @@ -836,7 +1014,7 @@ describe("BrowserManager auto-connect", () => { ]); }); - it("autoConnect discovers agent-browser managed sessions via the local daemon socket", async () => { + it("autoConnect discovers agent-browser managed sessions via the native daemon transport", async () => { const socketDir = await mkdtemp(path.join(os.tmpdir(), "dev-browser-agent-browser-")); const sessionName = "managed-session"; const socketPath = path.join(socketDir, `${sessionName}.sock`); @@ -877,12 +1055,20 @@ describe("BrowserManager auto-connect", () => { await new Promise((resolve, reject) => { server.once("error", reject); - server.listen(socketPath, () => { - server.off("error", reject); - resolve(); - }); + server.listen( + process.platform === "win32" ? { host: "127.0.0.1", port: 0 } : socketPath, + () => { + server.off("error", reject); + resolve(); + } + ); }); + if (process.platform === "win32") { + const address = server.address() as net.AddressInfo; + await writeFile(path.join(socketDir, `${sessionName}.port`), `${address.port}\n`); + } + const connectOverCDP = vi.fn(async (endpoint: string) => { expect(endpoint).toBe(cdpUrl); const context = new MockContext(); @@ -897,6 +1083,7 @@ describe("BrowserManager auto-connect", () => { try { const { manager } = createManager({ connectOverCDP, + platform: process.platform, readFile: vi.fn(async (filePath: string) => readFileFromFs(filePath, "utf8")), }); @@ -924,7 +1111,7 @@ describe("BrowserManager auto-connect", () => { }); const fetch = vi.fn(async () => { throw new Error("connection refused"); - }) as typeof globalThis.fetch; + }) as unknown as typeof globalThis.fetch; const { manager } = createManager({ fetch, readFile, @@ -941,7 +1128,7 @@ describe("BrowserManager auto-connect", () => { }); const fetch = vi.fn(async () => { throw new Error("connection refused"); - }) as typeof globalThis.fetch; + }) as unknown as typeof globalThis.fetch; const { manager } = createManager({ fetch, platform: "win32", diff --git a/daemon/src/sandbox/__tests__/cua.test.ts b/daemon/src/sandbox/__tests__/cua.test.ts new file mode 100644 index 00000000..08f14d8e --- /dev/null +++ b/daemon/src/sandbox/__tests__/cua.test.ts @@ -0,0 +1,939 @@ +import { once } from "node:events"; +import { mkdtemp, readFile, rm, stat } from "node:fs/promises"; +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import type { AddressInfo } from "node:net"; +import os from "node:os"; +import path from "node:path"; + +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { BrowserManager } from "../../browser-manager.js"; +import { DEV_BROWSER_TMP_DIR } from "../../temp-files.js"; +import { removeDirectoryWithRetries } from "../../test-cleanup.js"; +import { QuickJSSandbox } from "../quickjs-sandbox.js"; +import { ensureSandboxClientBundle } from "./bundle-test-helpers.js"; + +const SANDBOX_TIMEOUT_MS = 60_000; + +const CUA_TEST_PAGE_HTML = String.raw` + + + CUA Test Page + + + +
+ +
+ + +`; + +interface CapturedOutput { + stdout: string[]; + stderr: string[]; +} + +interface JsonSandboxHarness { + dispose: () => Promise; + runJson: (script: string) => Promise; +} + +interface NavigationServer { + baseUrl: string; + close: () => Promise; +} + +interface RecordedClick { + x: number; + y: number; + button: number; + detail: number; + shiftKey: boolean; + altKey: boolean; +} + +interface RecordedMouseEvent { + type: string; + x: number; + y: number; + button: number; +} + +interface RecordedKeyEvent { + key: string; + code: string; + shiftKey: boolean; + ctrlKey: boolean; + metaKey: boolean; +} + +interface ScreenshotResult { + path: string; + width: number; + height: number; +} + +function createOutput(): CapturedOutput & { + sink: { + onStdout: (data: string) => void; + onStderr: (data: string) => void; + }; +} { + const stdout: string[] = []; + const stderr: string[] = []; + + return { + stdout, + stderr, + sink: { + onStdout: (data) => { + stdout.push(data); + }, + onStderr: (data) => { + stderr.push(data); + }, + }, + }; +} + +function clearOutput(output: CapturedOutput): void { + output.stdout.length = 0; + output.stderr.length = 0; +} + +function outputLines(output: CapturedOutput): string[] { + return output.stdout.map((line) => line.trim()).filter((line) => line.length > 0); +} + +function parseLastJsonLine(output: CapturedOutput): T { + const lines = outputLines(output); + expect(lines.length).toBeGreaterThan(0); + return JSON.parse(lines.at(-1)!) as T; +} + +function withCuaPage(pageName: string, body: string): string { + return ` + const page = await browser.getPage(${JSON.stringify(pageName)}); + await page.setContent(${JSON.stringify(CUA_TEST_PAGE_HTML)}, { waitUntil: "load" }); + ${body} + `; +} + +async function createSandboxHarness( + manager: BrowserManager, + browserName: string +): Promise { + await manager.ensureBrowser(browserName, { + headless: true, + }); + + const output = createOutput(); + const sandbox = new QuickJSSandbox({ + manager, + browserName, + onStdout: output.sink.onStdout, + onStderr: output.sink.onStderr, + timeoutMs: SANDBOX_TIMEOUT_MS, + }); + + await sandbox.initialize(); + + return { + dispose: async () => { + await sandbox.dispose(); + }, + runJson: async (script: string): Promise => { + clearOutput(output); + await sandbox.executeScript(`(async () => {\n${script}\n})()`); + expect(output.stderr).toEqual([]); + return parseLastJsonLine(output); + }, + }; +} + +function readJpegDimensions(data: Buffer): { width: number; height: number } { + expect(data[0]).toBe(0xff); + expect(data[1]).toBe(0xd8); + expect(data[2]).toBe(0xff); + + let offset = 2; + while (offset + 4 <= data.length) { + if (data[offset] !== 0xff) { + throw new Error("Invalid JPEG segment"); + } + const marker = data[offset + 1]; + if (marker === undefined) { + break; + } + if (marker === 0xff) { + offset += 1; + continue; + } + if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd9)) { + offset += 2; + continue; + } + if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) { + return { + height: data.readUInt16BE(offset + 5), + width: data.readUInt16BE(offset + 7), + }; + } + offset += 2 + data.readUInt16BE(offset + 2); + } + + throw new Error("No JPEG SOF marker found"); +} + +function navigationPageHtml(pathname: string): string { + switch (pathname) { + case "/cua/first": + return ` + + First Page + + + +`; + case "/cua/second": + return ` + + Second Page +

Second

+`; + case "/cua/iframe-host": + return ` + + Iframe Host + + + + +`; + case "/cua/frame-a": + return ` + + Frame A + frame a +`; + case "/cua/frame-b": + return ` + + Frame B + frame b +`; + default: + return ""; + } +} + +function handleNavigationRequest(request: IncomingMessage, response: ServerResponse): void { + const url = new URL(request.url ?? "/", "http://127.0.0.1"); + const html = navigationPageHtml(url.pathname); + + if (!html) { + response.writeHead(404, { + "content-type": "text/plain; charset=utf-8", + }); + response.end("not found"); + return; + } + + response.writeHead(200, { + "content-type": "text/html; charset=utf-8", + "cache-control": "no-store", + }); + response.end(html); +} + +async function createNavigationServer(): Promise { + const server = createServer(handleNavigationRequest); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Navigation test server did not expose a TCP address"); + } + + const { port } = address as AddressInfo; + + return { + baseUrl: `http://127.0.0.1:${port}`, + close: async () => { + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + }, + }; +} + +describe.sequential("QuickJS page.cua toolset", () => { + let browserRootDir = ""; + let manager: BrowserManager; + const screenshotCleanup = new Set(); + + beforeAll(async () => { + await ensureSandboxClientBundle(); + + browserRootDir = await mkdtemp(path.join(os.tmpdir(), "dev-browser-cua-")); + manager = new BrowserManager(path.join(browserRootDir, "browsers")); + }, 180_000); + + afterAll(async () => { + await manager.stopAll(); + await removeDirectoryWithRetries(browserRootDir); + for (const filePath of screenshotCleanup) { + await rm(filePath, { + force: true, + }); + } + }, 180_000); + + describe.sequential("pointer and keyboard actions", () => { + const browserName = "cua-input"; + let harness: JsonSandboxHarness; + + beforeAll(async () => { + harness = await createSandboxHarness(manager, browserName); + }, 180_000); + + afterAll(async () => { + await harness.dispose(); + await manager.stopBrowser(browserName); + }, 180_000); + + it("clicks at exact coordinates with the left button by default", async () => { + const result = await harness.runJson<{ clicks: RecordedClick[]; elapsed: number }>( + withCuaPage( + "cua-click", + ` + await page.cua.click({ x: 350, y: 250, waitForNavigation: false }); + console.log(JSON.stringify({ clicks: await page.evaluate(() => window.clicks) })); + ` + ) + ); + + expect(result.clicks).toEqual([ + { + x: 350, + y: 250, + button: 0, + detail: 1, + shiftKey: false, + altKey: false, + }, + ]); + }, 15_000); + + it("supports middle and right buttons and rejects unsupported buttons", async () => { + const result = await harness.runJson<{ + downs: RecordedMouseEvent[]; + buttonError: string | null; + }>( + withCuaPage( + "cua-buttons", + ` + await page.cua.click({ x: 350, y: 250, button: "right", waitForNavigation: false }); + await page.cua.click({ x: 350, y: 250, button: "middle", waitForNavigation: false }); + const downs = await page.evaluate(() => { + return window.mouseEvents.filter((event) => event.type === "mousedown"); + }); + let buttonError = null; + try { + await page.cua.click({ x: 350, y: 250, button: "back" }); + } catch (error) { + buttonError = String((error && error.message) || error); + } + console.log(JSON.stringify({ downs, buttonError })); + ` + ) + ); + + expect(result.downs.map((event) => event.button)).toEqual([2, 1]); + expect(result.buttonError).toContain('Unsupported mouse button "back"'); + expect(result.buttonError).toContain('"left", "middle", or "right"'); + }, 15_000); + + it("doubleClick clicks twice at the same point", async () => { + const result = await harness.runJson<{ clicks: RecordedClick[]; elapsed: number }>( + withCuaPage( + "cua-double-click", + ` + const start = Date.now(); + await page.cua.doubleClick({ x: 350, y: 250 }); + const elapsed = Date.now() - start; + console.log(JSON.stringify({ elapsed, clicks: await page.evaluate(() => window.clicks) })); + ` + ) + ); + + expect(result.elapsed).toBeLessThan(900); + expect(result.clicks).toHaveLength(2); + expect(result.clicks.map((click) => click.detail)).toEqual([1, 2]); + for (const click of result.clicks) { + expect(click.x).toBe(350); + expect(click.y).toBe(250); + } + }, 15_000); + + it("holds modifiers during clicks and releases them afterwards", async () => { + const result = await harness.runJson<{ clicks: RecordedClick[] }>( + withCuaPage( + "cua-modifiers", + ` + await page.cua.click({ x: 350, y: 250, modifiers: ["shift"], waitForNavigation: false }); + await page.cua.click({ x: 350, y: 250, waitForNavigation: false }); + console.log(JSON.stringify({ clicks: await page.evaluate(() => window.clicks) })); + ` + ) + ); + + expect(result.clicks).toHaveLength(2); + expect(result.clicks[0]!.shiftKey).toBe(true); + expect(result.clicks[1]!.shiftKey).toBe(false); + }, 15_000); + + it("releases already-pressed modifiers when a later key in the sequence is invalid", async () => { + const result = await harness.runJson<{ + clickError: string | null; + keypressError: string | null; + clicks: RecordedClick[]; + keyEvents: RecordedKeyEvent[]; + }>( + withCuaPage( + "cua-modifier-release", + ` + let clickError = null; + try { + await page.cua.click({ + x: 350, + y: 250, + modifiers: ["shift", "bogus"], + waitForNavigation: false, + }); + } catch (error) { + clickError = String((error && error.message) || error); + } + let keypressError = null; + try { + await page.cua.keypress({ keys: ["ctrl", "bogus", "c"] }); + } catch (error) { + keypressError = String((error && error.message) || error); + } + await page.cua.click({ x: 350, y: 250, waitForNavigation: false }); + await page.evaluate(() => { + window.keyEvents = []; + }); + await page.cua.keypress({ keys: ["a"] }); + console.log(JSON.stringify({ + clickError, + keypressError, + clicks: await page.evaluate(() => window.clicks), + keyEvents: await page.evaluate(() => window.keyEvents), + })); + ` + ) + ); + + expect(result.clickError).toContain("bogus"); + expect(result.keypressError).toContain("bogus"); + expect(result.clicks).toHaveLength(1); + expect(result.clicks[0]!.shiftKey).toBe(false); + expect(result.keyEvents).toHaveLength(1); + expect(result.keyEvents[0]!.shiftKey).toBe(false); + expect(result.keyEvents[0]!.ctrlKey).toBe(false); + expect(result.keyEvents[0]!.metaKey).toBe(false); + }, 15_000); + + it("moves the pointer", async () => { + const result = await harness.runJson<{ moves: RecordedMouseEvent[] }>( + withCuaPage( + "cua-move", + ` + await page.cua.move({ x: 123, y: 217 }); + const moves = await page.evaluate(() => { + return window.mouseEvents.filter((event) => event.type === "mousemove"); + }); + console.log(JSON.stringify({ moves })); + ` + ) + ); + + const lastMove = result.moves.at(-1); + expect(lastMove).toMatchObject({ x: 123, y: 217 }); + }, 15_000); + + it("drags along a path with pressed moves", async () => { + const result = await harness.runJson<{ events: RecordedMouseEvent[] }>( + withCuaPage( + "cua-drag", + ` + await page.cua.drag({ + path: [ + { x: 310, y: 210 }, + { x: 360, y: 260 }, + { x: 390, y: 290 }, + ], + }); + console.log(JSON.stringify({ events: await page.evaluate(() => window.mouseEvents) })); + ` + ) + ); + + const downs = result.events.filter((event) => event.type === "mousedown"); + const ups = result.events.filter((event) => event.type === "mouseup"); + expect(downs).toEqual([{ type: "mousedown", x: 310, y: 210, button: 0 }]); + expect(ups).toEqual([{ type: "mouseup", x: 390, y: 290, button: 0 }]); + + const downIndex = result.events.findIndex((event) => event.type === "mousedown"); + const upIndex = result.events.findIndex((event) => event.type === "mouseup"); + expect(downIndex).toBeLessThan(upIndex); + + const pressedMoves = result.events + .slice(downIndex + 1, upIndex) + .filter((event) => event.type === "mousemove"); + expect(pressedMoves.length).toBeGreaterThan(2); + expect(pressedMoves.some((event) => event.x === 360 && event.y === 260)).toBe(true); + expect(pressedMoves.at(-1)).toMatchObject({ x: 390, y: 290 }); + }, 15_000); + + it("scrolls delta-direct on both axes", async () => { + const result = await harness.runJson<{ + afterDown: { x: number; y: number }; + afterRight: { x: number; y: number }; + afterUp: { x: number; y: number }; + }>( + withCuaPage( + "cua-scroll", + ` + const readScroll = () => page.evaluate(() => ({ x: window.scrollX, y: window.scrollY })); + await page.cua.scroll({ x: 400, y: 300, scrollX: 0, scrollY: 400 }); + await page.waitForFunction(() => window.scrollY === 400, { timeout: 5000 }); + const afterDown = await readScroll(); + await page.cua.scroll({ x: 400, y: 300, scrollX: 250, scrollY: 0 }); + await page.waitForFunction(() => window.scrollX === 250, { timeout: 5000 }); + const afterRight = await readScroll(); + await page.cua.scroll({ x: 400, y: 300, scrollX: 0, scrollY: -150 }); + await page.waitForFunction(() => window.scrollY === 250, { timeout: 5000 }); + const afterUp = await readScroll(); + console.log(JSON.stringify({ afterDown, afterRight, afterUp })); + ` + ) + ); + + expect(result.afterDown).toEqual({ x: 0, y: 400 }); + expect(result.afterRight).toEqual({ x: 250, y: 400 }); + expect(result.afterUp).toEqual({ x: 250, y: 250 }); + }, 15_000); + + it("normalizes key aliases in keypress", async () => { + const result = await harness.runJson>( + withCuaPage( + "cua-key-aliases", + ` + const record = async (keys) => { + await page.evaluate(() => { + window.keyEvents = []; + }); + await page.cua.keypress({ keys }); + return await page.evaluate(() => window.keyEvents); + }; + console.log(JSON.stringify({ + esc: await record(["esc"]), + left: await record(["left"]), + pageup: await record(["pageup"]), + del: await record(["del"]), + ret: await record(["return"]), + space: await record(["space"]), + })); + ` + ) + ); + + expect(result.esc).toHaveLength(1); + expect(result.esc![0]!.key).toBe("Escape"); + expect(result.left![0]!.key).toBe("ArrowLeft"); + expect(result.pageup![0]!.key).toBe("PageUp"); + expect(result.del![0]!.key).toBe("Delete"); + expect(result.ret![0]!.key).toBe("Enter"); + expect(result.space![0]!.code).toBe("Space"); + }, 15_000); + + it("applies chord rewrites: ctrl+a selects all, ctrl+y becomes redo", async () => { + const result = await harness.runJson<{ + selection: { start: number; end: number }; + selectAllKeys: RecordedKeyEvent[]; + redoKeys: RecordedKeyEvent[]; + }>( + withCuaPage( + "cua-chords", + ` + await page.fill("#field", "hello world"); + await page.focus("#field"); + await page.evaluate(() => { + window.keyEvents = []; + }); + await page.cua.keypress({ keys: ["ctrl", "a"] }); + const selection = await page.evaluate(() => { + const field = document.getElementById("field"); + return { start: field.selectionStart, end: field.selectionEnd }; + }); + const selectAllKeys = await page.evaluate(() => window.keyEvents); + await page.evaluate(() => { + window.keyEvents = []; + }); + await page.cua.keypress({ keys: ["ctrl", "y"] }); + const redoKeys = await page.evaluate(() => window.keyEvents); + console.log(JSON.stringify({ selection, selectAllKeys, redoKeys })); + ` + ) + ); + + expect(result.selection).toEqual({ start: 0, end: 11 }); + const selectAllLast = result.selectAllKeys.at(-1)!; + expect(selectAllLast.key.toLowerCase()).toBe("a"); + expect(selectAllLast.ctrlKey || selectAllLast.metaKey).toBe(true); + + expect(result.redoKeys).toHaveLength(3); + const redoLast = result.redoKeys.at(-1)!; + expect(redoLast.key.toLowerCase()).toBe("z"); + expect(redoLast.shiftKey).toBe(true); + expect(redoLast.ctrlKey || redoLast.metaKey).toBe(true); + }, 15_000); + + it("types text with real keystrokes", async () => { + const result = await harness.runJson<{ value: string }>( + withCuaPage( + "cua-type", + ` + await page.focus("#field"); + await page.cua.type({ text: "hello world" }); + console.log(JSON.stringify({ value: await page.inputValue("#field") })); + ` + ) + ); + + expect(result.value).toBe("hello world"); + }, 15_000); + }); + + describe.sequential("screenshots", () => { + const browserName = "cua-screenshots"; + let harness: JsonSandboxHarness; + + beforeAll(async () => { + harness = await createSandboxHarness(manager, browserName); + }, 180_000); + + afterAll(async () => { + await harness.dispose(); + await manager.stopBrowser(browserName); + }, 180_000); + + it("returns path and css-pixel viewport dimensions", async () => { + const result = await harness.runJson<{ + shot: ScreenshotResult; + dims: [number, number]; + }>( + withCuaPage( + "cua-shot-viewport", + ` + const shot = await page.cua.screenshot(); + const dims = await page.evaluate(() => [innerWidth, innerHeight]); + console.log(JSON.stringify({ shot, dims })); + ` + ) + ); + screenshotCleanup.add(result.shot.path); + + expect(path.isAbsolute(result.shot.path)).toBe(true); + expect(result.shot.path.startsWith(`${path.resolve(DEV_BROWSER_TMP_DIR)}${path.sep}`)).toBe( + true + ); + expect(path.basename(result.shot.path)).toMatch(/^cua-page.*\.jpeg$/); + expect(result.shot.width).toBe(result.dims[0]); + expect(result.shot.height).toBe(result.dims[1]); + + expect((await stat(result.shot.path)).size).toBeGreaterThan(0); + const data = await readFile(result.shot.path); + expect(readJpegDimensions(data)).toEqual({ + width: result.shot.width, + height: result.shot.height, + }); + }, 15_000); + + it("pins clip coordinate semantics as viewport-relative", async () => { + const result = await harness.runJson<{ shot: ScreenshotResult }>( + withCuaPage( + "cua-shot-clip", + ` + await page.evaluate(() => window.scrollTo(0, 150)); + await page.waitForFunction(() => window.scrollY === 150, { timeout: 5000 }); + const shot = await page.cua.screenshot({ + name: "cua-clip-test", + clip: { x: 300, y: 50, width: 100, height: 100 }, + }); + console.log(JSON.stringify({ shot })); + ` + ) + ); + screenshotCleanup.add(result.shot.path); + + expect(path.basename(result.shot.path)).toBe("cua-clip-test.jpeg"); + expect(result.shot.width).toBe(100); + expect(result.shot.height).toBe(100); + + const data = await readFile(result.shot.path); + expect(readJpegDimensions(data)).toEqual({ width: 100, height: 100 }); + + const decoded = await harness.runJson<{ + pixel: { width: number; height: number; r: number; g: number; b: number }; + }>(` + const page = await browser.getPage("cua-shot-clip"); + const pixel = await page.evaluate(async (encoded) => { + const image = new Image(); + image.src = "data:image/jpeg;base64," + encoded; + await image.decode(); + const canvas = document.createElement("canvas"); + canvas.width = image.naturalWidth; + canvas.height = image.naturalHeight; + const context = canvas.getContext("2d"); + context.drawImage(image, 0, 0); + const data = context.getImageData(50, 50, 1, 1).data; + return { + width: image.naturalWidth, + height: image.naturalHeight, + r: data[0], + g: data[1], + b: data[2], + }; + }, ${JSON.stringify(data.toString("base64"))}); + console.log(JSON.stringify({ pixel })); + `); + + expect(decoded.pixel.width).toBe(100); + expect(decoded.pixel.height).toBe(100); + expect(decoded.pixel.r).toBeGreaterThan(200); + expect(decoded.pixel.g).toBeLessThan(80); + expect(decoded.pixel.b).toBeLessThan(80); + }, 30_000); + + it("supports fullPage screenshots with document dimensions", async () => { + const result = await harness.runJson<{ + shot: ScreenshotResult; + docDims: [number, number]; + viewport: [number, number]; + }>( + withCuaPage( + "cua-shot-fullpage", + ` + const shot = await page.cua.screenshot({ name: "cua-fullpage-test", fullPage: true }); + const docDims = await page.evaluate(() => [ + document.documentElement.scrollWidth, + document.documentElement.scrollHeight, + ]); + const viewport = await page.evaluate(() => [innerWidth, innerHeight]); + console.log(JSON.stringify({ shot, docDims, viewport })); + ` + ) + ); + screenshotCleanup.add(result.shot.path); + + expect(path.basename(result.shot.path)).toBe("cua-fullpage-test.jpeg"); + expect(result.shot.width).toBe(result.docDims[0]); + expect(result.shot.height).toBe(result.docDims[1]); + expect(result.shot.height).toBeGreaterThan(result.viewport[1]); + + const data = await readFile(result.shot.path); + expect(readJpegDimensions(data)).toEqual({ + width: result.shot.width, + height: result.shot.height, + }); + }, 30_000); + + it("downscales device-pixel screenshots back to css pixels", async () => { + const result = await harness.runJson<{ + shot: ScreenshotResult; + dims: [number, number]; + }>( + withCuaPage( + "cua-shot-retina", + ` + const dims = await page.evaluate(() => [innerWidth, innerHeight]); + const oversized = await page.screenshot({ + type: "jpeg", + quality: 80, + clip: { x: 0, y: 0, width: dims[0] * 2, height: dims[1] * 2 }, + }); + page.screenshot = async () => oversized; + const shot = await page.cua.screenshot({ name: "cua-retina-test" }); + console.log(JSON.stringify({ shot, dims })); + ` + ) + ); + screenshotCleanup.add(result.shot.path); + + expect(result.shot.width).toBe(result.dims[0]); + expect(result.shot.height).toBe(result.dims[1]); + + const data = await readFile(result.shot.path); + expect(readJpegDimensions(data)).toEqual({ + width: result.shot.width, + height: result.shot.height, + }); + }, 30_000); + }); + + describe.sequential("navigation waiting", () => { + const browserName = "cua-navigation"; + let harness: JsonSandboxHarness; + let navigationServer: NavigationServer; + + beforeAll(async () => { + harness = await createSandboxHarness(manager, browserName); + navigationServer = await createNavigationServer(); + }, 180_000); + + afterAll(async () => { + await harness.dispose(); + await navigationServer.close(); + await manager.stopBrowser(browserName); + }, 180_000); + + it("does not pay the navigation grace delay by default", async () => { + const firstUrl = `${navigationServer.baseUrl}/cua/first`; + const result = await harness.runJson<{ elapsed: number; url: string }>(` + const page = await browser.getPage("cua-nav-default"); + await page.goto(${JSON.stringify(firstUrl)}, { waitUntil: "load" }); + const box = await page.locator("#nav").boundingBox(); + const start = Date.now(); + await page.cua.click({ x: box.x + box.width / 2, y: box.y + box.height / 2 }); + const elapsed = Date.now() - start; + await page.waitForURL("**/cua/second"); + console.log(JSON.stringify({ elapsed, url: page.url() })); + `); + + expect(result.elapsed).toBeLessThan(900); + expect(result.url).toBe(`${navigationServer.baseUrl}/cua/second`); + }, 30_000); + + it("settles main-frame navigation when explicitly requested", async () => { + const firstUrl = `${navigationServer.baseUrl}/cua/first`; + const result = await harness.runJson<{ title: string; url: string }>(` + const page = await browser.getPage("cua-nav-explicit"); + await page.goto(${JSON.stringify(firstUrl)}, { waitUntil: "load" }); + const box = await page.locator("#nav").boundingBox(); + await page.cua.click({ + x: box.x + box.width / 2, + y: box.y + box.height / 2, + waitForNavigation: true, + }); + console.log(JSON.stringify({ title: await page.title(), url: page.url() })); + `); + + expect(result.url).toBe(`${navigationServer.baseUrl}/cua/second`); + expect(result.title).toBe("Second Page"); + }, 30_000); + + it("ignores child-frame navigations via the main-frame predicate", async () => { + const hostUrl = `${navigationServer.baseUrl}/cua/iframe-host`; + const result = await harness.runJson<{ + elapsed: number; + url: string; + frameUrls: string[]; + }>(` + const page = await browser.getPage("cua-nav-iframe"); + await page.goto(${JSON.stringify(hostUrl)}, { waitUntil: "load" }); + const box = await page.locator("#swap").boundingBox(); + const start = Date.now(); + await page.cua.click({ + x: box.x + box.width / 2, + y: box.y + box.height / 2, + waitForNavigation: true, + }); + const elapsed = Date.now() - start; + console.log(JSON.stringify({ + elapsed, + url: page.url(), + frameUrls: page.frames().map((frame) => frame.url()), + })); + `); + + expect(result.elapsed).toBeGreaterThanOrEqual(900); + expect(result.elapsed).toBeLessThan(5000); + expect(result.url).toBe(hostUrl); + expect(result.frameUrls).toContain(`${navigationServer.baseUrl}/cua/frame-b`); + }, 30_000); + }); +}); diff --git a/daemon/src/sandbox/__tests__/dom-cua.test.ts b/daemon/src/sandbox/__tests__/dom-cua.test.ts new file mode 100644 index 00000000..6453d9c5 --- /dev/null +++ b/daemon/src/sandbox/__tests__/dom-cua.test.ts @@ -0,0 +1,973 @@ +import { once } from "node:events"; +import { mkdtemp } from "node:fs/promises"; +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import type { AddressInfo } from "node:net"; +import os from "node:os"; +import path from "node:path"; +import vm from "node:vm"; + +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { BrowserManager } from "../../browser-manager.js"; +import { removeDirectoryWithRetries } from "../../test-cleanup.js"; +import { domCuaRegister, domCuaWalker } from "../forked-client/src/client/domCuaInjected.js"; +import { QuickJSSandbox } from "../quickjs-sandbox.js"; +import { runScript } from "../script-runner-quickjs.js"; +import { ensureSandboxClientBundle } from "./bundle-test-helpers.js"; + +const SANDBOX_TIMEOUT_MS = 60_000; + +const DOM_TEST_PAGE_HTML = ` + + + DomCua Test Page + + + + + Example link + +
x
+ + + + + + + + + + +
plain text
+ +
+ +`; + +const HIDDEN_ACT_PAGE_HTML = ` + + + + + +`; + +const TYPE_ACT_PAGE_HTML = ` + + + + +`; + +const SCROLL_ACT_PAGE_HTML = ` + + +
+
+ Pane link +
+
+
+ +`; + +const BUDGET_PAGE_HTML = ` + + + ${Array.from( + { length: 250 }, + (_, i) => + `L${i}` + ).join("\n ")} + +`; + +const ID_HELPERS = ` + const idFor = (snapshot, needle) => { + const line = snapshot.split("\\n").find((entry) => entry.includes(needle)); + if (!line) throw new Error("no snapshot line contains " + needle); + return Number(line.match(/node_id=(\\d+)/)[1]); + }; + const allIds = (snapshot) => + Array.from(snapshot.matchAll(/node_id=(\\d+)/g)).map((match) => Number(match[1])); +`; + +interface CapturedOutput { + stdout: string[]; + stderr: string[]; +} + +interface JsonSandboxHarness { + dispose: () => Promise; + runJson: (script: string) => Promise; +} + +interface DomServer { + baseUrl: string; + close: () => Promise; +} + +function createOutput(): CapturedOutput & { + sink: { + onStdout: (data: string) => void; + onStderr: (data: string) => void; + }; +} { + const stdout: string[] = []; + const stderr: string[] = []; + + return { + stdout, + stderr, + sink: { + onStdout: (data) => { + stdout.push(data); + }, + onStderr: (data) => { + stderr.push(data); + }, + }, + }; +} + +function clearOutput(output: CapturedOutput): void { + output.stdout.length = 0; + output.stderr.length = 0; +} + +function parseLastJsonLine(output: CapturedOutput): T { + const lines = output.stdout.map((line) => line.trim()).filter((line) => line.length > 0); + expect(lines.length).toBeGreaterThan(0); + return JSON.parse(lines.at(-1)!) as T; +} + +async function createSandboxHarness( + manager: BrowserManager, + browserName: string +): Promise { + await manager.ensureBrowser(browserName, { + headless: true, + }); + + const output = createOutput(); + const sandbox = new QuickJSSandbox({ + manager, + browserName, + onStdout: output.sink.onStdout, + onStderr: output.sink.onStderr, + timeoutMs: SANDBOX_TIMEOUT_MS, + }); + + await sandbox.initialize(); + + return { + dispose: async () => { + await sandbox.dispose(); + }, + runJson: async (script: string): Promise => { + clearOutput(output); + await sandbox.executeScript(`(async () => {\n${script}\n})()`); + expect(output.stderr).toEqual([]); + return parseLastJsonLine(output); + }, + }; +} + +const RECORDER_SCRIPT = ``; + +function manyLinksHtml(count: number, prefix: string): string { + const links = Array.from( + { length: count }, + (_, i) => + `${prefix.toUpperCase()}${i}` + ).join(""); + return `${links}`; +} + +function domPageHtml(pathname: string): string { + switch (pathname) { + case "/dom/first": + return ` + + Dom First + + ${RECORDER_SCRIPT} + + + + +`; + case "/dom/second": + return ` + + Dom Second + + ${RECORDER_SCRIPT} + + + + + + +`; + case "/dom/iframe-host": + return ` + + Iframe Host + + + + +`; + case "/dom/iframe-content": + return ` + + + + + +`; + case "/dom/named-frame-host": + return ` + + Named Frame Host + + + +`; + case "/dom/frame-one": + return ` + + + + +`; + case "/dom/frame-two": + return ` + + + + + +`; + case "/dom/frame-budget-host": + return ` + + + Host link + + +`; + case "/dom/many-links": + return manyLinksHtml(60, "m"); + default: + return ""; + } +} + +function handleDomRequest(request: IncomingMessage, response: ServerResponse): void { + const url = new URL(request.url ?? "/", "http://127.0.0.1"); + const html = domPageHtml(url.pathname); + + if (!html) { + response.writeHead(404, { + "content-type": "text/plain; charset=utf-8", + }); + response.end("not found"); + return; + } + + response.writeHead(200, { + "content-type": "text/html; charset=utf-8", + "cache-control": "no-store", + }); + response.end(html); +} + +async function createDomServer(): Promise { + const server = createServer(handleDomRequest); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Dom test server did not expose a TCP address"); + } + + const { port } = address as AddressInfo; + + return { + baseUrl: `http://127.0.0.1:${port}`, + close: async () => { + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + }, + }; +} + +interface StubText { + nodeType: 3; + nodeValue: string; +} + +interface StubElement { + nodeType: 1; + tagName: string; + childNodes: Array; + children: StubElement[]; + shadowRoot: { childNodes: Array; children: StubElement[] } | null; + getAttribute: (name: string) => string | null; + hasAttribute: (name: string) => boolean; + getClientRects: () => Array<{ + left: number; + top: number; + right: number; + bottom: number; + width: number; + height: number; + }>; +} + +function stubText(value: string): StubText { + return { nodeType: 3, nodeValue: value }; +} + +function stubElement( + tag: string, + attrs: Record = {}, + children: Array = [], + shadowChildren?: Array +): StubElement { + return { + nodeType: 1, + tagName: tag.toUpperCase(), + childNodes: children, + children: children.filter((child): child is StubElement => child.nodeType === 1), + shadowRoot: shadowChildren + ? { + childNodes: shadowChildren, + children: shadowChildren.filter((child): child is StubElement => child.nodeType === 1), + } + : null, + getAttribute: (name) => + Object.prototype.hasOwnProperty.call(attrs, name) ? attrs[name]! : null, + hasAttribute: (name) => Object.prototype.hasOwnProperty.call(attrs, name), + getClientRects: () => [{ left: 10, top: 10, right: 110, bottom: 40, width: 100, height: 30 }], + }; +} + +function createIsolatedRealm(root: StubElement): vm.Context { + return vm.createContext({ + document: { body: root, documentElement: root }, + getComputedStyle: () => ({ + visibility: "visible", + display: "block", + pointerEvents: "auto", + opacity: "1", + }), + innerWidth: 1280, + innerHeight: 720, + }); +} + +describe.sequential("QuickJS page.domCua toolset", () => { + let browserRootDir = ""; + let manager: BrowserManager; + let server: DomServer; + let crossOriginServer: DomServer; + + beforeAll(async () => { + await ensureSandboxClientBundle(); + + browserRootDir = await mkdtemp(path.join(os.tmpdir(), "dev-browser-dom-cua-")); + manager = new BrowserManager(path.join(browserRootDir, "browsers")); + server = await createDomServer(); + crossOriginServer = await createDomServer(); + }, 180_000); + + afterAll(async () => { + await manager.stopAll(); + await server.close(); + await crossOriginServer.close(); + await removeDirectoryWithRetries(browserRootDir); + }, 180_000); + + describe.sequential("snapshots", () => { + const browserName = "dom-cua-snapshots"; + let harness: JsonSandboxHarness; + + beforeAll(async () => { + harness = await createSandboxHarness(manager, browserName); + }, 180_000); + + afterAll(async () => { + await harness.dispose(); + await manager.stopBrowser(browserName); + }, 180_000); + + it("renders interactive elements as pseudo-HTML lines with node ids", async () => { + const { snapshot } = await harness.runJson<{ snapshot: string }>(` + const page = await browser.getPage("dom-cua-format"); + await page.setContent(${JSON.stringify(DOM_TEST_PAGE_HTML)}, { waitUntil: "load" }); + console.log(JSON.stringify({ snapshot: await page.domCua.getVisibleDom() })); + `); + + expect(snapshot).toMatch(//); + expect(snapshot).toMatch(/' + ); + }); + const second = await page.domCua.getVisibleDom(); + console.log(JSON.stringify({ + firstIds: allIds(first), + submitFirst: idFor(first, ">Submit<"), + submitSecond: idFor(second, ">Submit<"), + freshId: idFor(second, ">Fresh<"), + })); + `); + + expect(result.submitSecond).toBe(result.submitFirst); + expect(result.firstIds).not.toContain(result.freshId); + expect(result.freshId).toBeGreaterThan(Math.max(...result.firstIds)); + }, 30_000); + + it("appends a truncation marker when the element budget trips", async () => { + const { snapshot } = await harness.runJson<{ snapshot: string }>(` + const page = await browser.getPage("dom-cua-budget"); + await page.setContent(${JSON.stringify(BUDGET_PAGE_HTML)}, { waitUntil: "load" }); + console.log(JSON.stringify({ snapshot: await page.domCua.getVisibleDom() })); + `); + + const lines = snapshot.split("\n"); + expect(lines.filter((line) => line.startsWith(" { + const hostUrl = `${server.baseUrl}/dom/frame-budget-host`; + const { snapshot } = await harness.runJson<{ snapshot: string }>(` + const page = await browser.getPage("dom-cua-frame-budget"); + await page.goto(${JSON.stringify(hostUrl)}, { waitUntil: "load" }); + console.log(JSON.stringify({ snapshot: await page.domCua.getVisibleDom() })); + `); + + const frameLines = snapshot.split("\n").filter((line) => line.includes('href="#m')); + expect(frameLines.length).toBe(50); + expect(snapshot).toContain(">Host link<"); + expect(snapshot).toContain("output truncated"); + }, 30_000); + }); + + describe.sequential("acting by node id", () => { + const browserName = "dom-cua-act"; + let harness: JsonSandboxHarness; + + beforeAll(async () => { + harness = await createSandboxHarness(manager, browserName); + }, 180_000); + + afterAll(async () => { + await harness.dispose(); + await manager.stopBrowser(browserName); + }, 180_000); + + it("clicks the element that owns a node id", async () => { + const firstUrl = `${server.baseUrl}/dom/first`; + const result = await harness.runJson<{ + clicks: Array<{ target: string; x: number; y: number }>; + elapsed: number; + }>(` + ${ID_HELPERS} + const page = await browser.getPage("dom-cua-act-click"); + await page.goto(${JSON.stringify(firstUrl)}, { waitUntil: "load" }); + const snapshot = await page.domCua.getVisibleDom(); + const start = Date.now(); + await page.domCua.click({ nodeId: idFor(snapshot, ">Two<") }); + const elapsed = Date.now() - start; + console.log(JSON.stringify({ elapsed, clicks: await page.evaluate(() => window.clicks) })); + `); + + expect(result.elapsed).toBeLessThan(900); + expect(result.clicks).toEqual([{ target: "two", x: 80, y: 86 }]); + }, 30_000); + + it("accepts a numeric-string nodeId as regexed from the snapshot text", async () => { + const firstUrl = `${server.baseUrl}/dom/first`; + const result = await harness.runJson<{ + clicks: Array<{ target: string; x: number; y: number }>; + }>(` + ${ID_HELPERS} + const page = await browser.getPage("dom-cua-act-string-id"); + await page.goto(${JSON.stringify(firstUrl)}, { waitUntil: "load" }); + const snapshot = await page.domCua.getVisibleDom(); + await page.domCua.click({ nodeId: String(idFor(snapshot, ">Two<")), waitForNavigation: false }); + console.log(JSON.stringify({ clicks: await page.evaluate(() => window.clicks) })); + `); + + expect(result.clicks).toEqual([{ target: "two", x: 80, y: 86 }]); + }, 30_000); + + it("clicks elements inside an iframe at frame-offset coordinates", async () => { + const hostUrl = `${server.baseUrl}/dom/iframe-host`; + const result = await harness.runJson<{ + snapshot: string; + frameClicks: Array<{ target: string; x: number; y: number }>; + }>(` + ${ID_HELPERS} + const page = await browser.getPage("dom-cua-act-iframe"); + await page.goto(${JSON.stringify(hostUrl)}, { waitUntil: "load" }); + const snapshot = await page.domCua.getVisibleDom(); + await page.domCua.click({ nodeId: idFor(snapshot, ">Inner button<"), waitForNavigation: false }); + const frame = page.frames().find((candidate) => candidate.url().includes("/dom/iframe-content")); + const frameClicks = await frame.evaluate(() => window.frameClicks); + console.log(JSON.stringify({ snapshot, frameClicks })); + `); + + expect(result.snapshot).toContain(">Outer<"); + expect(result.snapshot).toContain(">Inner button<"); + expect(result.frameClicks).toEqual([{ target: "inner", x: 60, y: 25 }]); + }, 30_000); + + it("doubleClick clicks twice at the node center", async () => { + const firstUrl = `${server.baseUrl}/dom/first`; + const result = await harness.runJson<{ + clicks: Array<{ target: string; x: number; y: number }>; + elapsed: number; + }>(` + ${ID_HELPERS} + const page = await browser.getPage("dom-cua-act-double"); + await page.goto(${JSON.stringify(firstUrl)}, { waitUntil: "load" }); + const snapshot = await page.domCua.getVisibleDom(); + const start = Date.now(); + await page.domCua.doubleClick({ nodeId: idFor(snapshot, ">One<") }); + const elapsed = Date.now() - start; + console.log(JSON.stringify({ elapsed, clicks: await page.evaluate(() => window.clicks) })); + `); + + expect(result.elapsed).toBeLessThan(900); + expect(result.clicks).toHaveLength(2); + for (const click of result.clicks) { + expect(click).toEqual({ target: "one", x: 80, y: 36 }); + } + }, 30_000); + + it("scrolls at the node center when nodeId is given, viewport center otherwise", async () => { + const result = await harness.runJson<{ + paneScroll: number; + windowScrollAfterPane: number; + windowScrollAfterViewport: number; + }>(` + ${ID_HELPERS} + const page = await browser.getPage("dom-cua-act-scroll"); + await page.setContent(${JSON.stringify(SCROLL_ACT_PAGE_HTML)}, { waitUntil: "load" }); + const snapshot = await page.domCua.getVisibleDom(); + await page.domCua.scroll({ scrollY: 200, nodeId: idFor(snapshot, ">Pane link<") }); + await page.waitForFunction(() => document.getElementById("pane").scrollTop === 200, { timeout: 5000 }); + const paneScroll = await page.evaluate(() => document.getElementById("pane").scrollTop); + const windowScrollAfterPane = await page.evaluate(() => window.scrollY); + await page.domCua.scroll({ scrollY: 300 }); + await page.waitForFunction(() => window.scrollY === 300, { timeout: 5000 }); + const windowScrollAfterViewport = await page.evaluate(() => window.scrollY); + console.log(JSON.stringify({ paneScroll, windowScrollAfterPane, windowScrollAfterViewport })); + `); + + expect(result.paneScroll).toBe(200); + expect(result.windowScrollAfterPane).toBe(0); + expect(result.windowScrollAfterViewport).toBe(300); + }, 30_000); + + it("types into the element focused by a click by id", async () => { + const result = await harness.runJson<{ value: string }>(` + ${ID_HELPERS} + const page = await browser.getPage("dom-cua-act-type"); + await page.setContent(${JSON.stringify(TYPE_ACT_PAGE_HTML)}, { waitUntil: "load" }); + const snapshot = await page.domCua.getVisibleDom(); + await page.domCua.click({ nodeId: idFor(snapshot, " { + const result = await harness.runJson<{ + error: string | null; + elapsed: number; + clicks: string[]; + }>(` + ${ID_HELPERS} + const page = await browser.getPage("dom-cua-act-hidden"); + await page.setContent(${JSON.stringify(HIDDEN_ACT_PAGE_HTML)}, { waitUntil: "load" }); + const snapshot = await page.domCua.getVisibleDom(); + const nodeId = idFor(snapshot, ">Target<"); + await page.evaluate(() => { + document.getElementById("target").style.display = "none"; + }); + const start = Date.now(); + let error = null; + try { + await page.domCua.click({ nodeId }); + } catch (caught) { + error = String((caught && caught.message) || caught); + } + const elapsed = Date.now() - start; + console.log(JSON.stringify({ error, elapsed, clicks: await page.evaluate(() => window.clicks) })); + `); + + expect(result.error).toContain("stale or missing — re-run getVisibleDom()"); + expect(result.elapsed).toBeLessThan(10_000); + expect(result.clicks).toEqual([]); + }, 30_000); + }); + + describe.sequential("navigation and staleness", () => { + const browserName = "dom-cua-stale"; + let harness: JsonSandboxHarness; + + beforeAll(async () => { + harness = await createSandboxHarness(manager, browserName); + }, 180_000); + + afterAll(async () => { + await harness.dispose(); + await manager.stopBrowser(browserName); + }, 180_000); + + it("fails fast on ids from before a reload", async () => { + const firstUrl = `${server.baseUrl}/dom/first`; + const result = await harness.runJson<{ error: string | null; elapsed: number }>(` + ${ID_HELPERS} + const page = await browser.getPage("dom-cua-reload"); + await page.goto(${JSON.stringify(firstUrl)}, { waitUntil: "load" }); + const snapshot = await page.domCua.getVisibleDom(); + const nodeId = idFor(snapshot, ">Three<"); + await page.reload({ waitUntil: "load" }); + const start = Date.now(); + let error = null; + try { + await page.domCua.click({ nodeId }); + } catch (caught) { + error = String((caught && caught.message) || caught); + } + console.log(JSON.stringify({ error, elapsed: Date.now() - start })); + `); + + expect(result.error).toContain("stale or missing — re-run getVisibleDom()"); + expect(result.elapsed).toBeLessThan(3000); + }, 30_000); + + it("never reuses pre-navigation ids: acting on one errors instead of clicking", async () => { + const firstUrl = `${server.baseUrl}/dom/first`; + const secondUrl = `${server.baseUrl}/dom/second`; + const result = await harness.runJson<{ + preNavIds: number[]; + postNavIds: number[]; + error: string | null; + clicks: Array<{ target: string }>; + }>(` + ${ID_HELPERS} + const page = await browser.getPage("dom-cua-id-reuse"); + await page.goto(${JSON.stringify(firstUrl)}, { waitUntil: "load" }); + const preNavIds = allIds(await page.domCua.getVisibleDom()); + await page.goto(${JSON.stringify(secondUrl)}, { waitUntil: "load" }); + const postNavIds = allIds(await page.domCua.getVisibleDom()); + let error = null; + try { + await page.domCua.click({ nodeId: preNavIds[0] }); + } catch (caught) { + error = String((caught && caught.message) || caught); + } + const clicks = await page.evaluate(() => window.clicks); + console.log(JSON.stringify({ preNavIds, postNavIds, error, clicks })); + `); + + expect(result.preNavIds).toHaveLength(3); + expect(result.postNavIds).toHaveLength(5); + expect(Math.min(...result.postNavIds)).toBeGreaterThan(Math.max(...result.preNavIds)); + expect(result.error).toContain("stale or missing — re-run getVisibleDom()"); + expect(result.clicks).toEqual([]); + }, 30_000); + + it("never reuses pre-navigation ids across cross-origin navigations", async () => { + const firstUrl = `${server.baseUrl}/dom/first`; + const secondUrl = `${crossOriginServer.baseUrl}/dom/second`; + const result = await harness.runJson<{ + preNavIds: number[]; + postNavIds: number[]; + error: string | null; + clicks: Array<{ target: string }>; + }>(` + ${ID_HELPERS} + const page = await browser.getPage("dom-cua-cross-origin"); + await page.goto(${JSON.stringify(firstUrl)}, { waitUntil: "load" }); + const preNavIds = allIds(await page.domCua.getVisibleDom()); + await page.goto(${JSON.stringify(secondUrl)}, { waitUntil: "load" }); + const postNavIds = allIds(await page.domCua.getVisibleDom()); + let error = null; + try { + await page.domCua.click({ nodeId: preNavIds[0] }); + } catch (caught) { + error = String((caught && caught.message) || caught); + } + const clicks = await page.evaluate(() => window.clicks); + console.log(JSON.stringify({ preNavIds, postNavIds, error, clicks })); + `); + + expect(result.preNavIds).toHaveLength(3); + expect(result.postNavIds).toHaveLength(5); + expect(result.postNavIds.filter((id) => result.preNavIds.includes(id))).toEqual([]); + expect(result.error).toContain("stale or missing — re-run getVisibleDom()"); + expect(result.clicks).toEqual([]); + }, 30_000); + + it("assigns fresh ids after a child-frame navigation and stales the old ones", async () => { + const hostUrl = `${server.baseUrl}/dom/named-frame-host`; + const frameTwoUrl = `${server.baseUrl}/dom/frame-two`; + const result = await harness.runJson<{ + oldId: number; + newId: number; + error: string | null; + frameClicks: string[]; + }>(` + ${ID_HELPERS} + const page = await browser.getPage("dom-cua-frame-nav"); + await page.goto(${JSON.stringify(hostUrl)}, { waitUntil: "load" }); + const first = await page.domCua.getVisibleDom(); + const oldId = idFor(first, ">FrameOne<"); + const frame = page.frames().find((candidate) => candidate.name() === "child"); + await frame.goto(${JSON.stringify(frameTwoUrl)}, { waitUntil: "load" }); + const second = await page.domCua.getVisibleDom(); + const newId = idFor(second, ">FrameTwo<"); + let error = null; + try { + await page.domCua.click({ nodeId: oldId, waitForNavigation: false }); + } catch (caught) { + error = String((caught && caught.message) || caught); + } + const frameClicks = await frame.evaluate(() => window.frameClicks); + console.log(JSON.stringify({ oldId, newId, error, frameClicks })); + `); + + expect(result.newId).toBeGreaterThan(result.oldId); + expect(result.error).toContain("stale or missing — re-run getVisibleDom()"); + expect(result.frameClicks).toEqual([]); + }, 30_000); + }); + + describe.sequential("cross-invocation", () => { + const browserName = "dom-cua-cross"; + + beforeAll(async () => { + await manager.ensureBrowser(browserName, { + headless: true, + }); + }, 180_000); + + afterAll(async () => { + await manager.stopBrowser(browserName); + }, 180_000); + + it("acts on ids from a snapshot taken in a previous invocation", async () => { + const firstUrl = `${server.baseUrl}/dom/first`; + + const snapshotOutput = createOutput(); + await runScript( + ` + ${ID_HELPERS} + const page = await browser.getPage("dom-cua-cross-page"); + await page.goto(${JSON.stringify(firstUrl)}, { waitUntil: "load" }); + const snapshot = await page.domCua.getVisibleDom(); + console.log(JSON.stringify({ nodeId: idFor(snapshot, ">Two<") })); + `, + manager, + browserName, + snapshotOutput.sink + ); + expect(snapshotOutput.stderr).toEqual([]); + const { nodeId } = parseLastJsonLine<{ nodeId: number }>(snapshotOutput); + expect(nodeId).toBeGreaterThan(0); + + const clickOutput = createOutput(); + await runScript( + ` + const page = await browser.getPage("dom-cua-cross-page"); + await page.domCua.click({ nodeId: ${nodeId}, waitForNavigation: false }); + console.log(JSON.stringify({ clicks: await page.evaluate(() => window.clicks) })); + `, + manager, + browserName, + clickOutput.sink + ); + expect(clickOutput.stderr).toEqual([]); + const { clicks } = parseLastJsonLine<{ + clicks: Array<{ target: string; x: number; y: number }>; + }>(clickOutput); + expect(clicks).toEqual([{ target: "two", x: 80, y: 86 }]); + }, 60_000); + }); + + describe("walker self-containment", () => { + it("runs the serialized walker in an isolated realm against a DOM stub", () => { + const input = stubElement("input", { type: "text", placeholder: "name here" }); + const button = stubElement("button", {}, [stubText("Submit")]); + const link = stubElement("a", { href: "https://example.com" }, [stubText("Example link")]); + const shadowed = stubElement("button", {}, [], [stubText("Shadow label")]); + const plain = stubElement("div", {}, [stubText("ignore me")]); + const root = stubElement("body", {}, [input, button, link, shadowed, plain]); + const realm = createIsolatedRealm(root); + + const walkerInRealm = vm.runInContext(`(${String(domCuaWalker)})`, realm); + const result = walkerInRealm({ maxElements: 50 }); + + expect(result.blocked).toBe(false); + expect(result.truncated).toBe(false); + expect(typeof result.docToken).toBe("string"); + expect(result.entries.map((entry: { line: string }) => entry.line)).toEqual([ + '', + "", + 'Example link', + "", + ]); + + const again = walkerInRealm({ maxElements: 50 }); + expect(again.entries.map((entry: { ref: number }) => entry.ref)).toEqual([1, 2, 3, 4]); + expect(again.docToken).toBe(result.docToken); + + const capped = walkerInRealm({ maxElements: 2 }); + expect(capped.truncated).toBe(true); + expect(capped.entries).toHaveLength(2); + }); + + it("runs the serialized register function in an isolated realm", () => { + const realm = createIsolatedRealm(stubElement("body")); + const registerInRealm = vm.runInContext(`(${String(domCuaRegister)})`, realm); + + const first = registerInRealm({ + frames: [{ key: "main", docToken: "doc-1", refs: [1, 2, 3] }], + }); + expect(first.blocked).toBe(false); + expect(first.ids[0]).toHaveLength(3); + expect(first.ids[0][0]).toBeGreaterThanOrEqual(1_000_000); + + const second = registerInRealm({ + frames: [{ key: "main", docToken: "doc-1", refs: [2, 3, 9] }], + }); + expect(second.blocked).toBe(false); + expect(second.ids[0][0]).toBe(first.ids[0][1]); + expect(second.ids[0][1]).toBe(first.ids[0][2]); + expect(second.ids[0][2]).toBeGreaterThan(first.ids[0][2]); + + const replaced = registerInRealm({ + frames: [{ key: "main", docToken: "doc-2", refs: [1, 2, 3] }], + }); + expect(replaced.blocked).toBe(false); + for (const id of replaced.ids[0]) { + expect(id).toBeGreaterThan(second.ids[0][2]); + } + }); + + it("starts at a high base when sessionStorage works but holds no counter", () => { + const storage = new Map(); + const realm = vm.createContext({ + sessionStorage: { + getItem: (key: string) => (storage.has(key) ? storage.get(key)! : null), + setItem: (key: string, value: string) => { + storage.set(key, String(value)); + }, + }, + }); + const registerInRealm = vm.runInContext(`(${String(domCuaRegister)})`, realm); + + const result = registerInRealm({ + frames: [{ key: "main", docToken: "doc-1", refs: [1, 2] }], + }); + expect(result.blocked).toBe(false); + expect(Math.min(...result.ids[0])).toBeGreaterThanOrEqual(1_000_000); + expect(Number(storage.get("__devBrowserDomCuaNextPublicId"))).toBeGreaterThan( + Math.max(...result.ids[0]) + ); + }); + }); +}); diff --git a/daemon/src/sandbox/__tests__/playwright-api.test.ts b/daemon/src/sandbox/__tests__/playwright-api.test.ts index 40dc7d85..8ba92212 100644 --- a/daemon/src/sandbox/__tests__/playwright-api.test.ts +++ b/daemon/src/sandbox/__tests__/playwright-api.test.ts @@ -739,6 +739,70 @@ describe.sequential("QuickJS Playwright Page API coverage", () => { expect(result.full).toContain('heading "Hello World"'); expect(result.full).toContain('button "Submit"'); }); + + it("uses refs from snapshotForAI() as injection-safe locators", async () => { + const result = await harness.runJson<{ + clicked: string; + invalidRefError: string; + ref: string; + }>( + withTestPage( + "snapshot-ref", + ` + const snapshot = await page.snapshotForAI({ timeout: 5000 }); + const submitLine = snapshot.full + .split("\\n") + .find((line) => line.includes('button "Submit"')); + const ref = submitLine?.match(/ref=((?:f\\d+)?e\\d+)/)?.[1]; + if (!ref) throw new Error("Submit snapshot ref not found"); + + await page.getByRef(ref).click({ timeout: 5000 }); + + let invalidRefError = ""; + try { + page.getByRef('e1 >> button'); + } catch (error) { + invalidRefError = error.message; + } + + console.log(JSON.stringify({ + clicked: await page.locator("#result").textContent(), + invalidRefError, + ref, + })); + ` + ) + ); + + expect(result.ref).toMatch(/^(?:f\d+)?e\d+$/); + expect(result.clicked).toBe("clicked::red"); + expect(result.invalidRefError).toContain("Invalid snapshot ref"); + }); + + it("uses iframe refs from snapshotForAI()", async () => { + const result = await harness.runJson<{ clicked: string; ref: string }>(` + const page = await browser.getPage("snapshot-iframe-ref"); + await page.setContent( + '', + { waitUntil: "load" }, + ); + const snapshot = await page.snapshotForAI({ timeout: 5000 }); + const insideLine = snapshot.full + .split("\\n") + .find((line) => line.includes('button "Inside"')); + const ref = insideLine?.match(/ref=(f\\d+e\\d+)/)?.[1]; + if (!ref) throw new Error("Iframe snapshot ref not found"); + await page.getByRef(ref).click({ timeout: 5000 }); + const child = page.frames().find((frame) => frame !== page.mainFrame()); + console.log(JSON.stringify({ + clicked: await child.locator("#inside").getAttribute("data-clicked"), + ref, + })); + `); + + expect(result.ref).toMatch(/^f\d+e\d+$/); + expect(result.clicked).toBe("1"); + }); }); describe.sequential("screenshots and input devices", () => { diff --git a/daemon/src/sandbox/__tests__/sandbox-integration.test.ts b/daemon/src/sandbox/__tests__/sandbox-integration.test.ts index 2dc301e1..923ce8bc 100644 --- a/daemon/src/sandbox/__tests__/sandbox-integration.test.ts +++ b/daemon/src/sandbox/__tests__/sandbox-integration.test.ts @@ -5,6 +5,7 @@ import path from "node:path"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { BrowserManager } from "../../browser-manager.js"; +import { formatError } from "../../format-error.js"; import { removeDirectoryWithRetries } from "../../test-cleanup.js"; import { runScript } from "../script-runner-quickjs.js"; import { ensureSandboxClientBundle } from "./bundle-test-helpers.js"; @@ -132,6 +133,25 @@ describe.sequential("QuickJS sandbox integration", () => { ).rejects.toThrow("boom"); }); + it("surfaces thrown error messages in formatted errors", async () => { + const output = createOutput(); + + const error = await runScript( + ` + throw new Error("boom message"); + `, + manager, + "default", + output.sink + ).then( + () => null, + (caught: unknown) => caught + ); + + expect(error).toBeInstanceOf(Error); + expect(formatError(error)).toContain("boom message"); + }); + it("enforces CPU timeouts", async () => { const output = createOutput(); @@ -168,6 +188,66 @@ describe.sequential("QuickJS sandbox integration", () => { ).rejects.toThrow(/timed out|terminated|interrupted/i); }, 120_000); + it("stops pending Playwright operations on abort before reusing the browser", async () => { + const controller = new AbortController(); + let announceReady!: () => void; + const ready = new Promise((resolve) => { + announceReady = resolve; + }); + const firstOutput = createOutput(); + const onStdout = firstOutput.sink.onStdout; + firstOutput.sink.onStdout = (data) => { + onStdout(data); + if (data.includes("waiting")) announceReady(); + }; + + const blocked = runScript( + ` + const page = await browser.getPage("abort-reuse"); + await page.setContent(""); + console.log("waiting"); + await page.locator("#never").click({ timeout: 60000 }); + console.log("late"); + `, + manager, + "default", + firstOutput.sink, + { signal: controller.signal, timeout: 60_000 } + ); + const blockedOutcome = blocked.then( + () => null, + (error: unknown) => error + ); + + await ready; + const abortStarted = Date.now(); + controller.abort(new Error("client disconnected")); + const blockedError = await Promise.race([ + blockedOutcome, + new Promise((_, reject) => + setTimeout(() => reject(new Error("sandbox abort did not settle")), 2_000) + ), + ]); + expect(blockedError).toBeInstanceOf(Error); + expect((blockedError as Error).message).toContain("client disconnected"); + expect(Date.now() - abortStarted).toBeLessThan(2_000); + expect(firstOutput.stdout.join("")).not.toContain("late"); + + const nextOutput = createOutput(); + await runScript( + ` + const page = await browser.getPage("abort-reuse"); + await page.locator("#ok").click({ timeout: 5000 }); + console.log(await page.locator("#ok").textContent()); + `, + manager, + "default", + nextOutput.sink, + { timeout: 10_000 } + ); + expect(nextOutput.stdout.join("")).toContain("Ready"); + }, 30_000); + it("routes console output to stdout", async () => { const output = createOutput(); @@ -183,4 +263,24 @@ describe.sequential("QuickJS sandbox integration", () => { expect(output.stdout.join("")).toContain("sandbox 42 { ok: true }"); expect(output.stderr.join("")).toBe(""); }); + + it("supports Buffer.isBuffer", async () => { + const output = createOutput(); + + await runScript( + ` + console.log( + Buffer.isBuffer(Buffer.from([1, 2, 3])), + Buffer.isBuffer(new Uint8Array(3)), + Buffer.isBuffer("nope") + ); + `, + manager, + "default", + output.sink + ); + + expect(output.stdout.join("")).toContain("true false false"); + expect(output.stderr.join("")).toBe(""); + }); }); diff --git a/daemon/src/sandbox/__tests__/sandbox-security.test.ts b/daemon/src/sandbox/__tests__/sandbox-security.test.ts index 67fb9f22..1806c84b 100644 --- a/daemon/src/sandbox/__tests__/sandbox-security.test.ts +++ b/daemon/src/sandbox/__tests__/sandbox-security.test.ts @@ -170,6 +170,35 @@ describe.sequential("QuickJS sandbox security", () => { expect(payload.browserHasNullPrototype).toBe(true); }, 120_000); + it("exposes the cua and domCua namespaces on pages", async () => { + const output = await runSandboxScript(` + const page = await browser.newPage(); + console.log( + JSON.stringify({ + cuaClick: typeof page.cua.click, + cuaScreenshot: typeof page.cua.screenshot, + domCuaGetVisibleDom: typeof page.domCua.getVisibleDom, + domCuaClick: typeof page.domCua.click, + }), + ); + `); + + expect(output.stderr).toEqual([]); + expect(output.stdout).toHaveLength(1); + + const reportLine = output.stdout[0]; + if (reportLine === undefined) { + throw new Error("Sandbox namespace report was not captured"); + } + + expect(JSON.parse(reportLine)).toEqual({ + cuaClick: "function", + cuaScreenshot: "function", + domCuaGetVisibleDom: "function", + domCuaClick: "function", + }); + }, 120_000); + it("captures console output without leaking to host stdout", async () => { const output = createOutput(); const stdoutSpy = vi.spyOn(process.stdout, "write"); diff --git a/daemon/src/sandbox/forked-client/src/client/cua.ts b/daemon/src/sandbox/forked-client/src/client/cua.ts new file mode 100644 index 00000000..e73920f4 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/cua.ts @@ -0,0 +1,238 @@ +// @ts-nocheck +import { normalizeKeys } from "./cuaKeys"; +import type { Page } from "./page"; + +const SUPPORTED_BUTTONS = ["left", "middle", "right"]; + +function assertButton(button: string): void { + if (!SUPPORTED_BUTTONS.includes(button)) { + throw new Error( + `Unsupported mouse button "${button}" — must be one of "left", "middle", or "right"` + ); + } +} + +function jpegDimensions(buffer: Buffer): { width: number; height: number } | null { + if (buffer.length < 4 || buffer[0] !== 0xff || buffer[1] !== 0xd8) return null; + let offset = 2; + while (offset + 9 < buffer.length) { + if (buffer[offset] !== 0xff) { + offset++; + continue; + } + const marker = buffer[offset + 1]; + if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) + return { + height: (buffer[offset + 5] << 8) | buffer[offset + 6], + width: (buffer[offset + 7] << 8) | buffer[offset + 8], + }; + offset += 2 + ((buffer[offset + 2] << 8) | buffer[offset + 3]); + } + return null; +} + +export class Cua { + #page: Page; + + constructor(page: Page) { + this.#page = page; + } + + /** Click at viewport coordinates. Opt into navigation settling when needed. */ + async click({ + x, + y, + button = "left", + clickCount = 1, + modifiers = [], + waitForNavigation = false, + }: { + x: number; + y: number; + button?: "left" | "middle" | "right"; + clickCount?: number; + modifiers?: string[]; + waitForNavigation?: boolean; + }): Promise { + assertButton(button); + const act = () => + this.#withModifiers(modifiers, () => this.#page.mouse.click(x, y, { button, clickCount })); + if (waitForNavigation) await this.#actAndSettle(act); + else await act(); + } + + async doubleClick({ + x, + y, + modifiers = [], + waitForNavigation = false, + }: { + x: number; + y: number; + modifiers?: string[]; + waitForNavigation?: boolean; + }): Promise { + await this.click({ x, y, clickCount: 2, modifiers, waitForNavigation }); + } + + async drag({ + path, + modifiers = [], + }: { + path: Array<{ x: number; y: number }>; + modifiers?: string[]; + }): Promise { + if (!Array.isArray(path) || path.length === 0) + throw new Error("cua.drag requires a non-empty path of {x, y} points"); + await this.#withModifiers(modifiers, async () => { + await this.#page.mouse.move(path[0].x, path[0].y); + await this.#page.mouse.down(); + try { + for (const point of path.slice(1)) + await this.#page.mouse.move(point.x, point.y, { steps: 10 }); + } finally { + await this.#page.mouse.up(); + } + }); + } + + async move({ x, y }: { x: number; y: number }): Promise { + await this.#page.mouse.move(x, y); + } + + async scroll({ + x, + y, + scrollX = 0, + scrollY = 0, + modifiers = [], + }: { + x: number; + y: number; + scrollX?: number; + scrollY?: number; + modifiers?: string[]; + }): Promise { + await this.#withModifiers(modifiers, async () => { + await this.#page.mouse.move(x, y); + await this.#page.mouse.wheel(scrollX, scrollY); + }); + } + + async keypress({ keys }: { keys: string[] }): Promise { + const normalized = normalizeKeys(keys); + if (normalized.length === 0) return; + const held = normalized.slice(0, -1); + const pressed: string[] = []; + try { + for (const key of held) { + await this.#page.keyboard.down(key); + pressed.push(key); + } + await this.#page.keyboard.press(normalized[normalized.length - 1]); + } finally { + for (const key of pressed.reverse()) await this.#page.keyboard.up(key); + } + } + + async type({ text }: { text: string }): Promise { + await this.#page.keyboard.type(text); + } + + /** + * Save a JPEG screenshot whose pixels map 1:1 onto cua coordinates + * (CSS pixels at any DPR). Never derive click coordinates from a + * `fullPage` image — scroll, then take a viewport screenshot instead. + */ + async screenshot({ + name, + fullPage, + clip, + }: { + name?: string; + fullPage?: boolean; + clip?: { x: number; y: number; width: number; height: number }; + } = {}): Promise<{ path: string; width: number; height: number }> { + let buffer = await this.#page.screenshot({ + type: "jpeg", + quality: 80, + scale: "css", + fullPage, + clip, + }); + let width: number; + let height: number; + if (clip) { + width = clip.width; + height = clip.height; + } else if (fullPage) { + [width, height] = await this.#page.evaluate(() => [ + document.documentElement.scrollWidth, + document.documentElement.scrollHeight, + ]); + } else { + [width, height] = await this.#page.evaluate(() => [innerWidth, innerHeight]); + } + width = Math.round(width); + height = Math.round(height); + // Playwright ignores scale:"css" on viewport:null pages (headed and + // connected Chrome), returning device-pixel images that break the 1:1 + // coordinate contract — downscale in-page when the dims disagree. + const actual = jpegDimensions(buffer); + if (actual && (Math.abs(actual.width - width) > 1 || Math.abs(actual.height - height) > 1)) + buffer = await this.#downscaleToCssPixels(buffer, width, height); + const save = globalThis.saveScreenshot; + if (typeof save !== "function") + throw new Error("saveScreenshot() is not available in the QuickJS sandbox"); + const path = await save(buffer, (name ?? `cua-${this.#page._guid}`) + ".jpeg"); + return { path, width, height }; + } + + async #downscaleToCssPixels(buffer: Buffer, width: number, height: number): Promise { + const base64 = await this.#page.evaluate( + async ({ data, width, height }) => { + const raw = atob(data); + const bytes = new Uint8Array(raw.length); + for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i); + const bitmap = await createImageBitmap(new Blob([bytes], { type: "image/jpeg" })); + const canvas = new OffscreenCanvas(width, height); + canvas.getContext("2d").drawImage(bitmap, 0, 0, width, height); + bitmap.close(); + const blob = await canvas.convertToBlob({ type: "image/jpeg", quality: 0.8 }); + const out = new Uint8Array(await blob.arrayBuffer()); + let binary = ""; + for (let i = 0; i < out.length; i += 0x8000) + binary += String.fromCharCode.apply(null, out.subarray(i, i + 0x8000)); + return btoa(binary); + }, + { data: buffer.toString("base64"), width, height } + ); + return Buffer.from(base64, "base64"); + } + + async #withModifiers(modifiers: string[], act: () => Promise): Promise { + const keys = normalizeKeys(modifiers ?? []); + const pressed: string[] = []; + try { + for (const key of keys) { + await this.#page.keyboard.down(key); + pressed.push(key); + } + await act(); + } finally { + for (const key of pressed.reverse()) await this.#page.keyboard.up(key); + } + } + + async #actAndSettle(act: () => Promise): Promise { + const nav = this.#page + .waitForEvent("framenavigated", { + predicate: (frame) => frame === this.#page.mainFrame(), + timeout: 1000, + }) + .catch(() => null); + await act(); + if (await nav) + await this.#page.waitForLoadState("domcontentloaded", { timeout: 10_000 }).catch(() => {}); + } +} diff --git a/daemon/src/sandbox/forked-client/src/client/cuaKeys.ts b/daemon/src/sandbox/forked-client/src/client/cuaKeys.ts new file mode 100644 index 00000000..43806f3c --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/cuaKeys.ts @@ -0,0 +1,66 @@ +// @ts-nocheck +const KEY_ALIASES: Record = { + alt: "Alt", + option: "Alt", + arrowdown: "ArrowDown", + arrowleft: "ArrowLeft", + arrowright: "ArrowRight", + arrowup: "ArrowUp", + down: "ArrowDown", + left: "ArrowLeft", + right: "ArrowRight", + up: "ArrowUp", + backspace: "Backspace", + capslock: "CapsLock", + cmd: "Meta", + command: "Meta", + meta: "Meta", + super: "Meta", + win: "Meta", + ctrl: "ControlOrMeta", + control: "ControlOrMeta", + del: "Delete", + delete: "Delete", + end: "End", + enter: "Enter", + return: "Enter", + esc: "Escape", + escape: "Escape", + home: "Home", + insert: "Insert", + pagedown: "PageDown", + pgdn: "PageDown", + pageup: "PageUp", + pgup: "PageUp", + shift: "Shift", + space: "Space", + spacebar: "Space", + tab: "Tab", +}; + +const CHORD_REWRITES: Record = { + "ctrl+a": ["ControlOrMeta", "a"], + "ctrl+c": ["ControlOrMeta", "c"], + "ctrl+l": ["ControlOrMeta", "l"], + "ctrl+n": ["ControlOrMeta", "n"], + "ctrl+v": ["ControlOrMeta", "v"], + "ctrl+x": ["ControlOrMeta", "x"], + "ctrl+y": ["ControlOrMeta", "Shift", "z"], + "ctrl+z": ["ControlOrMeta", "z"], +}; + +function normalizeKey(key: string): string { + const lowered = String(key).trim().toLowerCase(); + const alias = KEY_ALIASES[lowered]; + if (alias) return alias; + if (/^f\d{1,2}$/.test(lowered)) return "F" + lowered.slice(1); + if (lowered.length === 1) return lowered; + return key; +} + +export function normalizeKeys(keys: string[]): string[] { + const lowered = keys.map((key) => String(key).trim().toLowerCase()); + const chord = CHORD_REWRITES[lowered.join("+")]; + if (chord) return chord.slice(); + return keys.map(normalizeKey); +} diff --git a/daemon/src/sandbox/forked-client/src/client/domCua.ts b/daemon/src/sandbox/forked-client/src/client/domCua.ts new file mode 100644 index 00000000..c1c9fd00 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/domCua.ts @@ -0,0 +1,199 @@ +// @ts-nocheck +import { domCuaRegister, domCuaWalker } from "./domCuaInjected"; +import { TimeoutError } from "./errors"; +import type { Frame } from "./frame"; +import type { Page } from "./page"; + +const MAIN_FRAME_ELEMENT_BUDGET = 200; +const CHILD_FRAME_ELEMENT_BUDGET = 50; +const MAX_LINES = 200; +const MAX_CHARS = 20_000; +const FRAME_TRUNCATION_MARKER = ""; +const SNAPSHOT_TRUNCATION_MARKER = ""; + +function frameKey(frame: Frame): string { + const name = frame.name(); + if (name) return name; + const indexPath: number[] = []; + let current = frame; + for (let parent = current.parentFrame(); parent; parent = current.parentFrame()) { + indexPath.unshift(parent.childFrames().indexOf(current)); + current = parent; + } + return `${frame.url()}@${indexPath.join(".")}`; +} + +function staleNodeError(nodeId: number): Error { + return new Error(`DOM node ${nodeId} is stale or missing — re-run getVisibleDom()`); +} + +function blockedStateError(): Error { + return new Error("this page blocks domCua state — domCua cannot track elements here"); +} + +export class DomCua { + #page: Page; + + constructor(page: Page) { + this.#page = page; + } + + /** + * Snapshot the visible interactive elements of every frame as pseudo-HTML + * lines with `node_id=N` attributes. Ids are only valid against the latest + * snapshot of the current document — re-run after any navigation. + */ + async getVisibleDom(): Promise { + const mainFrame = this.#page.mainFrame(); + const frames = [mainFrame, ...this.#page.frames().filter((frame) => frame !== mainFrame)]; + const snapshots: Array<{ + key: string; + docToken: string; + entries: Array<{ ref: number; line: string }>; + truncated: boolean; + }> = []; + for (const frame of frames) { + const isMain = frame === mainFrame; + let result; + try { + result = await frame.evaluate(domCuaWalker, { + maxElements: isMain ? MAIN_FRAME_ELEMENT_BUDGET : CHILD_FRAME_ELEMENT_BUDGET, + }); + } catch (error) { + if (isMain) throw error; + continue; + } + if (result.blocked) { + if (isMain) throw blockedStateError(); + continue; + } + snapshots.push({ + key: frameKey(frame), + docToken: result.docToken, + entries: result.entries, + truncated: result.truncated, + }); + } + + const registration = await mainFrame.evaluate(domCuaRegister, { + frames: snapshots.map((snapshot) => ({ + key: snapshot.key, + docToken: snapshot.docToken, + refs: snapshot.entries.map((entry) => entry.ref), + })), + }); + if (registration.blocked) throw blockedStateError(); + + const lines: string[] = []; + let chars = 0; + let budgetExceeded = false; + for (let i = 0; i < snapshots.length && !budgetExceeded; i++) { + const snapshot = snapshots[i]; + const ids = registration.ids[i]; + for (let j = 0; j < snapshot.entries.length; j++) { + const line = snapshot.entries[j].line.replace(/node_id=\d+/, `node_id=${ids[j]}`); + if (lines.length >= MAX_LINES || chars + line.length > MAX_CHARS) { + budgetExceeded = true; + break; + } + lines.push(line); + chars += line.length + 1; + } + if (!budgetExceeded && snapshot.truncated) lines.push(FRAME_TRUNCATION_MARKER); + } + if (budgetExceeded) lines.push(SNAPSHOT_TRUNCATION_MARKER); + return lines.join("\n"); + } + + async click({ + nodeId, + button = "left", + modifiers = [], + waitForNavigation = false, + }: { + nodeId: number | string; + button?: "left" | "middle" | "right"; + modifiers?: string[]; + waitForNavigation?: boolean; + }): Promise { + const { x, y } = await this.#resolveNodeCenter(nodeId); + await this.#page.cua.click({ x, y, button, modifiers, waitForNavigation }); + } + + async doubleClick({ + nodeId, + waitForNavigation = false, + }: { + nodeId: number | string; + waitForNavigation?: boolean; + }): Promise { + const { x, y } = await this.#resolveNodeCenter(nodeId); + await this.#page.cua.click({ x, y, clickCount: 2, waitForNavigation }); + } + + async scroll({ + scrollX = 0, + scrollY = 0, + nodeId, + }: { + scrollX?: number; + scrollY?: number; + nodeId?: number | string; + }): Promise { + let x: number; + let y: number; + if (nodeId !== undefined) { + ({ x, y } = await this.#resolveNodeCenter(nodeId)); + } else { + const [width, height] = await this.#page.evaluate(() => [innerWidth, innerHeight]); + x = width / 2; + y = height / 2; + } + await this.#page.cua.scroll({ x, y, scrollX, scrollY }); + } + + async type({ text }: { text: string }): Promise { + await this.#page.cua.type({ text }); + } + + async keypress({ keys }: { keys: string[] }): Promise { + await this.#page.cua.keypress({ keys }); + } + + async #resolveNodeCenter(nodeId: number | string): Promise<{ x: number; y: number }> { + if (typeof nodeId === "string" && /^\d+$/.test(nodeId)) nodeId = Number(nodeId); + if (typeof nodeId !== "number") + throw new Error("domCua requires a numeric nodeId from getVisibleDom()"); + const target = await this.#page + .mainFrame() + .evaluate( + (id) => globalThis.__devBrowserDomCua?.actionableByPublicId?.get(id) ?? null, + nodeId + ); + if (!target) throw staleNodeError(nodeId); + const frame = this.#page.frames().find((candidate) => frameKey(candidate) === target.frameKey); + if (!frame) throw staleNodeError(nodeId); + const handle = await frame.evaluateHandle( + (ref) => globalThis.__devBrowserDomCua?.refToElement?.get(ref) ?? null, + target.ref + ); + const element = handle.asElement(); + if (!element) { + await handle.dispose(); + throw staleNodeError(nodeId); + } + try { + try { + await element.scrollIntoViewIfNeeded({ timeout: 3000 }); + } catch (error) { + if (error instanceof TimeoutError) throw staleNodeError(nodeId); + throw error; + } + const box = await element.boundingBox(); + if (!box) throw staleNodeError(nodeId); + return { x: box.x + box.width / 2, y: box.y + box.height / 2 }; + } finally { + await element.dispose(); + } + } +} diff --git a/daemon/src/sandbox/forked-client/src/client/domCuaInjected.ts b/daemon/src/sandbox/forked-client/src/client/domCuaInjected.ts new file mode 100644 index 00000000..63b3f041 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/domCuaInjected.ts @@ -0,0 +1,308 @@ +// @ts-nocheck +// WARNING: every export in this file is serialized with String(fn) and +// re-evaluated inside the page (frame.evaluate ships source text over the +// wire). Each export must stay ONE truly self-contained function expression — +// no references to module-level helpers, constants, or imports. A closure +// would bundle and stringify fine but explode as a ReferenceError in-page at +// runtime. Bundler flags that rewrite function bodies (minify, keepNames) +// would also corrupt the serialized source; see +// daemon/scripts/bundle-sandbox-client.ts. + +export const domCuaWalker = function (options) { + const maxElements = options && typeof options.maxElements === "number" ? options.maxElements : 50; + + const INTERACTIVE_TAGS = { + a: 1, + button: 1, + details: 1, + input: 1, + option: 1, + select: 1, + summary: 1, + textarea: 1, + }; + const INTERACTIVE_ROLES = { + button: 1, + checkbox: 1, + combobox: 1, + link: 1, + menuitem: 1, + option: 1, + radio: 1, + slider: 1, + spinbutton: 1, + switch: 1, + tab: 1, + textbox: 1, + }; + const SKIPPED_TAGS = { script: 1, style: 1, template: 1, noscript: 1 }; + const TEXT_ATTRIBUTES = [ + "aria-disabled", + "aria-label", + "contenteditable", + "href", + "name", + "placeholder", + "role", + "title", + "type", + "value", + ]; + const BOOLEAN_ATTRIBUTES = [ + ["checked", "checked"], + ["disabled", "disabled"], + ["multiple", "multiple"], + ["readonly", "readOnly"], + ["required", "required"], + ["selected", "selected"], + ]; + + let state = globalThis.__devBrowserDomCua; + if (!state || typeof state !== "object") { + state = {}; + globalThis.__devBrowserDomCua = state; + if (globalThis.__devBrowserDomCua !== state) + return { blocked: true, entries: [], truncated: false }; + } + if (!(state.elementToRef instanceof WeakMap)) state.elementToRef = new WeakMap(); + if (typeof state.nextRef !== "number") state.nextRef = 1; + if (typeof state.docToken !== "string") state.docToken = Date.now() + "-" + Math.random(); + const refToElement = new Map(); + state.refToElement = refToElement; + if (state.refToElement !== refToElement || !(state.elementToRef instanceof WeakMap)) + return { blocked: true, entries: [], truncated: false }; + + const viewport = + typeof visualViewport !== "undefined" && visualViewport + ? { + left: visualViewport.offsetLeft, + top: visualViewport.offsetTop, + width: visualViewport.width, + height: visualViewport.height, + } + : { left: 0, top: 0, width: innerWidth, height: innerHeight }; + + function collapseWhitespace(text) { + return text.replace(/\s+/g, " ").trim(); + } + + function escapeHtml(text) { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + } + + function isStyleVisible(element) { + const style = getComputedStyle(element); + return ( + style.visibility === "visible" && + style.display !== "none" && + style.pointerEvents !== "none" && + parseFloat(style.opacity) > 0.01 + ); + } + + function isTextVisible(element) { + const style = getComputedStyle(element); + return ( + style.visibility === "visible" && style.display !== "none" && parseFloat(style.opacity) > 0.01 + ); + } + + function intersectsViewport(element) { + const rects = element.getClientRects(); + for (let i = 0; i < rects.length; i++) { + const rect = rects[i]; + if ( + rect.width > 0 && + rect.height > 0 && + rect.right > viewport.left && + rect.left < viewport.left + viewport.width && + rect.bottom > viewport.top && + rect.top < viewport.top + viewport.height + ) + return true; + } + return false; + } + + function visibleText(root) { + let out = ""; + const nodes = root.childNodes || []; + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i]; + if (node.nodeType === 3) { + out += node.nodeValue || ""; + continue; + } + if (node.nodeType !== 1) continue; + if (SKIPPED_TAGS[node.tagName.toLowerCase()] === 1) continue; + if (node.getAttribute("aria-hidden") === "true" || node.hasAttribute("hidden")) continue; + if (!isTextVisible(node)) continue; + if (node.shadowRoot) out += visibleText(node.shadowRoot) + " "; + out += visibleText(node); + } + return out; + } + + function isInteractive(element) { + const tag = element.tagName.toLowerCase(); + if (INTERACTIVE_TAGS[tag] === 1) return true; + if ( + element.hasAttribute("contenteditable") && + element.getAttribute("contenteditable") !== "false" + ) + return true; + if (element.hasAttribute("href")) return true; + if (element.hasAttribute("onclick")) return true; + const role = element.getAttribute("role"); + if (role && INTERACTIVE_ROLES[role.toLowerCase()] === 1) return true; + const tabIndex = element.getAttribute("tabindex"); + if (tabIndex !== null && parseInt(tabIndex, 10) >= 0) return true; + return false; + } + + function renderLine(element, ref) { + const tag = element.tagName.toLowerCase(); + const parts = ["<" + tag + " node_id=" + ref]; + for (let i = 0; i < TEXT_ATTRIBUTES.length; i++) { + const name = TEXT_ATTRIBUTES[i]; + const value = + name === "value" && typeof element.value === "string" + ? element.value + : element.getAttribute(name); + if (value === null || value === undefined || value === "") continue; + parts.push(name + '="' + escapeHtml(collapseWhitespace(String(value))) + '"'); + } + for (let j = 0; j < BOOLEAN_ATTRIBUTES.length; j++) { + const attrName = BOOLEAN_ATTRIBUTES[j][0]; + const propName = BOOLEAN_ATTRIBUTES[j][1]; + const enabled = + propName in element ? element[propName] === true : element.hasAttribute(attrName); + if (enabled) parts.push(attrName + '="true"'); + } + let text = visibleText(element); + if (element.shadowRoot) text = visibleText(element.shadowRoot) + " " + text; + text = collapseWhitespace(text); + if (text.length > 160) text = text.slice(0, 160); + const opener = parts.join(" "); + if (!text) return opener + " />"; + return opener + ">" + escapeHtml(text) + ""; + } + + const entries = []; + let truncated = false; + + function visit(node) { + if (truncated) return; + if (node.nodeType !== 1) return; + const tag = node.tagName.toLowerCase(); + if (SKIPPED_TAGS[tag] === 1) return; + if (node.getAttribute("aria-hidden") === "true" || node.hasAttribute("hidden")) return; + const hiddenInput = + tag === "input" && (node.getAttribute("type") || "").toLowerCase() === "hidden"; + if (!hiddenInput && isInteractive(node) && isStyleVisible(node) && intersectsViewport(node)) { + if (entries.length >= maxElements) { + truncated = true; + return; + } + let ref = state.elementToRef.get(node); + if (ref === undefined) { + ref = state.nextRef++; + state.elementToRef.set(node, ref); + } + refToElement.set(ref, node); + entries.push({ ref, line: renderLine(node, ref) }); + } + if (node.shadowRoot) { + const shadowChildren = node.shadowRoot.children; + for (let i = 0; i < shadowChildren.length; i++) { + visit(shadowChildren[i]); + if (truncated) return; + } + } + const children = node.children; + for (let j = 0; j < children.length; j++) { + visit(children[j]); + if (truncated) return; + } + } + + const root = document.body || document.documentElement; + if (root) visit(root); + return { blocked: false, entries, truncated, docToken: state.docToken }; +}; + +export const domCuaRegister = function (data) { + const STORAGE_KEY = "__devBrowserDomCuaNextPublicId"; + + let state = globalThis.__devBrowserDomCua; + if (!state || typeof state !== "object") { + state = {}; + globalThis.__devBrowserDomCua = state; + if (globalThis.__devBrowserDomCua !== state) return { blocked: true, ids: [] }; + } + + let next = state.nextPublicId; + if (typeof next !== "number" || !isFinite(next)) { + let stored = null; + try { + stored = sessionStorage.getItem(STORAGE_KEY); + } catch (error) { + stored = null; + } + const parsed = stored === null ? NaN : parseInt(stored, 10); + next = parsed >= 1 ? parsed : 1_000_000 + Math.floor(Math.random() * 2_000_000_000); + } + + if (!(state.publicIdByFrameKey instanceof Map)) state.publicIdByFrameKey = new Map(); + const sticky = state.publicIdByFrameKey; + let total = 0; + sticky.forEach((frameMap) => { + total += frameMap.size; + }); + if (total > 5000) { + sticky.clear(); + next += 1_000_000; + } + + const actionable = new Map(); + const ids = []; + for (let i = 0; i < data.frames.length; i++) { + const frame = data.frames[i]; + const stickyKey = frame.key + "::" + frame.docToken; + let frameMap = sticky.get(stickyKey); + if (!frameMap) { + frameMap = new Map(); + sticky.set(stickyKey, frameMap); + } + const frameIds = []; + for (let j = 0; j < frame.refs.length; j++) { + const ref = frame.refs[j]; + let id = frameMap.get(ref); + if (id === undefined) { + id = next++; + frameMap.set(ref, id); + } + actionable.set(id, { frameKey: frame.key, ref }); + frameIds.push(id); + } + ids.push(frameIds); + } + state.actionableByPublicId = actionable; + state.nextPublicId = next; + try { + sessionStorage.setItem(STORAGE_KEY, String(next)); + } catch (error) { + // sessionStorage may be blocked; the random high base covers the next document + } + if ( + globalThis.__devBrowserDomCua !== state || + state.actionableByPublicId !== actionable || + state.publicIdByFrameKey !== sticky + ) + return { blocked: true, ids: [] }; + return { blocked: false, ids }; +}; diff --git a/daemon/src/sandbox/forked-client/src/client/page.ts b/daemon/src/sandbox/forked-client/src/client/page.ts index f66ef97f..a742b69a 100644 --- a/daemon/src/sandbox/forked-client/src/client/page.ts +++ b/daemon/src/sandbox/forked-client/src/client/page.ts @@ -20,7 +20,9 @@ import { Artifact } from "./artifact"; import { ChannelOwner } from "./channelOwner"; import { evaluationScript } from "./clientHelper"; import { Coverage } from "./coverage"; +import { Cua } from "./cua"; import { DisposableObject, DisposableStub } from "./disposable"; +import { DomCua } from "./domCua"; import { Download } from "./download"; import { ElementHandle, determineScreenshotType } from "./elementHandle"; import { TargetClosedError, isTargetClosedError, parseError, serializeError } from "./errors"; @@ -113,6 +115,8 @@ export class Page extends ChannelOwner implements api.Page _webSocketRoutes: WebSocketRouteHandler[] = []; readonly coverage: Coverage; + readonly cua: Cua; + readonly domCua: DomCua; readonly keyboard: Keyboard; readonly mouse: Mouse; readonly request: APIRequestContext; @@ -155,6 +159,8 @@ export class Page extends ChannelOwner implements api.Page this._browserContext._timeoutSettings ); + this.cua = new Cua(this); + this.domCua = new DomCua(this); this.keyboard = new Keyboard(this); this.mouse = new Mouse(this); this.request = this._browserContext.request; @@ -911,6 +917,12 @@ export class Page extends ChannelOwner implements api.Page return this.mainFrame().locator(selector, options); } + getByRef(ref: string): Locator { + if (!/^(?:f\d+)?e\d+$/.test(ref)) + throw new Error(`Invalid snapshot ref "${ref}" — expected e or fe`); + return this.locator("aria-ref=" + ref); + } + getByTestId(testId: string | RegExp): Locator { return this.mainFrame().getByTestId(testId); } diff --git a/daemon/src/sandbox/host-bridge.ts b/daemon/src/sandbox/host-bridge.ts index 34907da0..24ff5be5 100644 --- a/daemon/src/sandbox/host-bridge.ts +++ b/daemon/src/sandbox/host-bridge.ts @@ -55,6 +55,21 @@ export class HostBridge { await this.dispatcherConnection.dispatch(JSON.parse(json) as Record); } + async stopPendingOperations(error: Error): Promise { + const dispatchers = this.dispatcherConnection._dispatcherByGuid; + if (!dispatchers) { + await this.rootDispatcher.stopPendingOperations(error); + return; + } + + const controllers = new Set( + [...new Set(dispatchers.values())].flatMap((dispatcher) => [ + ...(dispatcher._activeProgressControllers ?? []), + ]) + ); + await Promise.all([...controllers].map((controller) => controller.abort(error))); + } + async dispose(): Promise { if (this.disposed) { return; @@ -63,10 +78,22 @@ export class HostBridge { this.disposed = true; this.dispatcherConnection.onmessage = () => {}; + let cleanupError: unknown; + try { + await this.stopPendingOperations(new Error("Sandbox bridge disposed")); + } catch (error) { + cleanupError = error; + } try { await this.playwrightDispatcher?.cleanup(); + } catch (error) { + cleanupError ??= error; } finally { this.rootDispatcher._dispose(); } + + if (cleanupError) { + throw cleanupError; + } } } diff --git a/daemon/src/sandbox/playwright-internals.ts b/daemon/src/sandbox/playwright-internals.ts index 3d7ce96f..ce8425b0 100644 --- a/daemon/src/sandbox/playwright-internals.ts +++ b/daemon/src/sandbox/playwright-internals.ts @@ -23,12 +23,19 @@ export interface ClientConnectionLike { } export interface DispatcherConnectionLike { + _dispatcherByGuid?: Map; onmessage: (message: WireMessage) => void; dispatch(message: WireMessage): Promise; } export interface RootDispatcherLike { + _activeProgressControllers?: Set; _dispose(): void; + stopPendingOperations(error: Error): Promise; +} + +export interface ProgressControllerLike { + abort(error: Error): Promise; } export interface PlaywrightDispatcherLike { diff --git a/daemon/src/sandbox/quickjs-sandbox.ts b/daemon/src/sandbox/quickjs-sandbox.ts index b6097574..113f55b8 100644 --- a/daemon/src/sandbox/quickjs-sandbox.ts +++ b/daemon/src/sandbox/quickjs-sandbox.ts @@ -179,10 +179,13 @@ interface QuickJSSandboxOptions { export class QuickJSSandbox { readonly #options: QuickJSSandboxOptions; readonly #anonymousPages = new Set(); + readonly #abortWakeup: Promise; readonly #pendingHostOperations = new Set>(); readonly #transportInbox: string[] = []; #asyncError?: Error; + #abortError?: Error; + #resolveAbortWakeup!: () => void; #host?: QuickJSHost; #hostBridge?: HostBridge; #flushPromise?: Promise; @@ -191,16 +194,21 @@ export class QuickJSSandbox { constructor(options: QuickJSSandboxOptions) { this.#options = options; + this.#abortWakeup = new Promise((resolve) => { + this.#resolveAbortWakeup = resolve; + }); } async initialize(): Promise { this.#assertAlive(); + this.#throwIfAborted(); if (this.#initialized) { return; } try { await ensureDevBrowserTempDir(); + this.#throwIfAborted(); this.#host = await QuickJSHost.create({ memoryLimitBytes: this.#options.memoryLimitBytes ?? DEFAULT_MEMORY_LIMIT_BYTES, @@ -222,6 +230,7 @@ export class QuickJSSandbox { this.#handleTransportSend(message); }, }); + this.#throwIfAborted(); this.#host.executeScriptSync( ` @@ -301,6 +310,10 @@ export class QuickJSSandbox { super(value); } + static isBuffer(value) { + return value instanceof Buffer; + } + static from(value, encodingOrOffset, length) { if (typeof value === "string") { if (encodingOrOffset !== undefined && encodingOrOffset !== "base64") { @@ -350,6 +363,7 @@ export class QuickJSSandbox { ); const bundleCode = await getSandboxClientBundleCode(); + this.#throwIfAborted(); const bundleFactorySource = JSON.stringify(`${bundleCode}\nreturn __PlaywrightClient;`); this.#host.executeScriptSync( ` @@ -376,6 +390,7 @@ export class QuickJSSandbox { sharedBrowser: true, denyLaunch: true, }); + this.#throwIfAborted(); await this.#host.executeScript( ` @@ -527,8 +542,10 @@ export class QuickJSSandbox { filename: "sandbox-init.js", } ); + this.#throwIfAborted(); await this.#flushTransportQueue(); + this.#throwIfAborted(); this.#throwIfAsyncError(); this.#initialized = true; } catch (error) { @@ -539,6 +556,7 @@ export class QuickJSSandbox { async executeScript(script: string): Promise { this.#assertInitialized(); + this.#throwIfAborted(); let executionError: unknown; try { @@ -550,6 +568,7 @@ export class QuickJSSandbox { filename: "user-script.js", } ); + this.#throwIfAborted(); await this.#flushTransportQueue(); this.#throwIfAsyncError(); @@ -573,6 +592,12 @@ export class QuickJSSandbox { return; } + try { + await this.stopPendingOperations(new Error("QuickJS sandbox disposed")); + } catch { + // Best effort cleanup during sandbox teardown. + } + this.#disposed = true; await this.#cleanupAnonymousPages({ @@ -594,6 +619,18 @@ export class QuickJSSandbox { } } + async abort(error: Error): Promise { + if (!this.#abortError) { + this.#abortError = error; + this.#resolveAbortWakeup(); + } + await this.stopPendingOperations(this.#abortError); + } + + async stopPendingOperations(error: Error): Promise { + await this.#hostBridge?.stopPendingOperations(error); + } + #routeConsole(level: QuickJSConsoleLevel, args: unknown[]): void { const line = `${formatArgs(args)}\n`; if (level === "warn" || level === "error") { @@ -623,17 +660,21 @@ export class QuickJSSandbox { } async #drainAsyncOps(): Promise { + this.#throwIfAborted(); this.#throwIfAsyncError(); await this.#flushTransportQueue(); + this.#throwIfAborted(); this.#throwIfAsyncError(); if (this.#pendingHostOperations.size === 0) { return; } - await Promise.race(this.#pendingHostOperations); + await Promise.race([Promise.race(this.#pendingHostOperations), this.#abortWakeup]); + this.#throwIfAborted(); this.#throwIfAsyncError(); await this.#flushTransportQueue(); + this.#throwIfAborted(); this.#throwIfAsyncError(); } @@ -736,6 +777,12 @@ export class QuickJSSandbox { } } + #throwIfAborted(): void { + if (this.#abortError) { + throw this.#abortError; + } + } + #assertAlive(): void { if (this.#disposed) { throw new Error("QuickJS sandbox has been disposed"); diff --git a/daemon/src/sandbox/script-runner-quickjs.test.ts b/daemon/src/sandbox/script-runner-quickjs.test.ts new file mode 100644 index 00000000..f16f8aa4 --- /dev/null +++ b/daemon/src/sandbox/script-runner-quickjs.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it, vi } from "vitest"; + +import { finalizeSandbox } from "./script-runner-quickjs.js"; + +describe("finalizeSandbox", () => { + it("always disposes the sandbox when pending-operation cancellation rejects", async () => { + const dispose = vi.fn(async () => undefined); + const abortError = new Error("stopPendingOperations failed"); + + await expect(finalizeSandbox({ dispose }, Promise.reject(abortError))).rejects.toBe(abortError); + + expect(dispose).toHaveBeenCalledOnce(); + }); +}); diff --git a/daemon/src/sandbox/script-runner-quickjs.ts b/daemon/src/sandbox/script-runner-quickjs.ts index 8d5140e6..ff5da7c7 100644 --- a/daemon/src/sandbox/script-runner-quickjs.ts +++ b/daemon/src/sandbox/script-runner-quickjs.ts @@ -6,12 +6,37 @@ interface ScriptOutput { onStderr: (data: string) => void; } +export async function finalizeSandbox( + sandbox: Pick, + abortPromise: Promise +): Promise { + let finalizationError: unknown; + try { + await abortPromise; + } catch (error) { + finalizationError = error; + } + + try { + await sandbox.dispose(); + } catch (error) { + finalizationError ??= error; + } + + if (finalizationError instanceof Error) { + throw finalizationError; + } + if (finalizationError !== undefined) { + throw new Error(String(finalizationError)); + } +} + export async function runScript( script: string, manager: BrowserManager, browserName: string, output: ScriptOutput, - options: { timeout?: number; memoryLimitBytes?: number } = {} + options: { timeout?: number; memoryLimitBytes?: number; signal?: AbortSignal } = {} ): Promise { const sandbox = new QuickJSSandbox({ manager, @@ -22,10 +47,25 @@ export async function runScript( timeoutMs: options.timeout, }); + let abortPromise = Promise.resolve(); + const onAbort = () => { + const reason = + options.signal?.reason instanceof Error + ? options.signal.reason + : new Error(String(options.signal?.reason ?? "Script aborted")); + abortPromise = sandbox.abort(reason); + }; + options.signal?.addEventListener("abort", onAbort, { once: true }); + try { + if (options.signal?.aborted) { + onAbort(); + throw options.signal.reason; + } await sandbox.initialize(); await sandbox.executeScript(`(async () => {\n${script}\n})()`); } finally { - await sandbox.dispose(); + options.signal?.removeEventListener("abort", onAbort); + await finalizeSandbox(sandbox, abortPromise); } } diff --git a/docs/maintenance-v0.2.9.md b/docs/maintenance-v0.2.9.md new file mode 100644 index 00000000..29f0f5ba --- /dev/null +++ b/docs/maintenance-v0.2.9.md @@ -0,0 +1,46 @@ +# v0.2.9 fork maintenance merge + +Date: 2026-09-21 + +## Scope and plan + +Merge upstream tag `v0.2.9` (`edd6f44`) into fork `main` at `e3a7174`, +preserving published ancestry. Upstream `main` at `a25e767` contains the +incompatible 1.0 rewrite and is outside this maintenance merge. + +1. Merge the release tag without rebasing the four published fork commits. +2. Resolve overlaps while retaining WSL and agent-browser discovery, custom + CDP port/profile flags, Codex skill installation, and Unix permissions. +3. Rebuild both embedded bundles, run daemon and Rust checks, and inspect the + resulting fork delta against the release tag. +4. Commit the verified merge locally. Publication and installed-runtime + replacement are separate operations. + +## Resolution decisions + +- Keep upstream request execution and idle reaping. Pass custom discovery + hints together with its deadline and cancellation signal. +- Keep upstream atomic endpoint binding. Apply the fork's socket and PID + permissions after binding, without restoring the old unconditional unlink. +- Keep the local skill guidance and add upstream idle-cleanup documentation. + Use upstream's current Codex installer guidance instead of the obsolete + clone-and-copy README section. +- Retain the installer step that regenerates the sandbox-client bundle. +- Upstream already includes Codex installer support; its updated Rust tests + cover the shared behavior. + +## Validation results + +- `daemon`: frozen pnpm install, `npx tsc --noEmit`, both bundle commands, + and `pnpm format:check` passed. +- `daemon`: `pnpm vitest run` passed, 21 files and 161 tests. This includes + existing WSL, custom-port, agent-browser, idle-reaper, sandbox, CUA, and + request-execution coverage. Two added regression tests cover cancellation + during custom-port probing and deadline/cancellation with custom profiles. +- `cli`: `cargo fmt -- --check`, `cargo build`, and `cargo test` passed; + all 12 Rust tests passed. +- Built CLI help includes custom port/profile flags, idle timeout, and CUA APIs. +- `git diff --check` passed. Native Windows execution and attachment to a + real Windows Chrome session were not exercised; WSL discovery uses fixtures. + +The pre-existing untracked `.codex` entry is excluded from the merge. diff --git a/docs/validation/wsl-stealth-browser-smoke.json b/docs/validation/wsl-stealth-browser-smoke.json new file mode 100644 index 00000000..5e814d14 --- /dev/null +++ b/docs/validation/wsl-stealth-browser-smoke.json @@ -0,0 +1,25 @@ +{ + "success": true, + "receipts": [ + { + "mode": "headless", + "executablePath": "/home/ecochran76/workspace.local/chromium/artifacts/chromium-stealthcdp/150.0.7835.0+stealthcdp.3676a7503929/chrome-linux/chrome", + "version": "150.0.7835.0", + "title": "stealth-ok", + "webdriver": false, + "snapshot": { + "full": "- generic [ref=e1]:\n - textbox \"Name\" [ref=e2]: stealth-ok\n - button \"Apply\" [active] [ref=e3]" + } + }, + { + "mode": "headed", + "executablePath": "/home/ecochran76/workspace.local/chromium/artifacts/chromium-stealthcdp/150.0.7835.0+stealthcdp.3676a7503929/chrome-linux/chrome", + "version": "150.0.7835.0", + "title": "stealth-ok", + "webdriver": false, + "snapshot": { + "full": "- generic [ref=e1]:\n - textbox \"Name\" [ref=e2]: stealth-ok\n - button \"Apply\" [active] [ref=e3]" + } + } + ] +} \ No newline at end of file diff --git a/docs/wsl-browser-default.md b/docs/wsl-browser-default.md new file mode 100644 index 00000000..c94eeb64 --- /dev/null +++ b/docs/wsl-browser-default.md @@ -0,0 +1,57 @@ +# WSL browser default + +The local WSL configuration uses native Linux chromium-stealthcdp through +`executablePath` in `~/.dev-browser/config.json`: + +```text +/home/ecochran76/workspace.local/chromium/artifacts/chromium-stealthcdp/150.0.7835.0+stealthcdp.3676a7503929/chrome-linux/chrome +``` + +The executable SHA-256 matches the promoted artifact manifest: +`aebeac48273efa3a2767763cf0694cfa8f1be52c91b7fbafff0d4698a993ffce`. +The shared Chromium `current` alias still points to its Windows artifact. + +## Behavior + +New daemon-managed browsers read the configured absolute executable path. +Headed and headless launches use the same build and keep dev-browser's own +persistent profiles. Existing browser instances retain their original binary. +CDP attachment is unaffected. Invalid configuration or launch failure is an +error, without silently substituting bundled Chromium. Removing the setting +restores the bundled-browser default. + +## Validation + +- TypeScript, both embedded bundle builds, daemon formatting, Rust formatting, + Rust build, and all 12 Rust tests passed. +- All 173 daemon tests passed, including configured executable selection, + default fallback when unset, invalid settings, launch errors, CDP isolation, + and browser-status reporting. +- A real QuickJS/Playwright smoke passed in both headed (Xvfb) and headless + modes using temporary profiles. It checked the CDP-reported executable, + browser version, textbox fill, a normal locator click, resulting title, + ARIA snapshot, and `navigator.webdriver === false`. See + [the smoke receipt](validation/wsl-stealth-browser-smoke.json). +- The promoted 153.0.8003.0 build was not selected: normal locator clicks + timed out while waiting for element stability, reproduced without QuickJS + using the pinned Playwright 1.58.2, in headed and headless modes. Bringing + the page to the front and using navigation instead of setContent did not + resolve it. The 150 build passed the equivalent test. + +## Installed activation + +The previous CLI and the config-absence marker were saved under +`~/.dev-browser/backups/wsl-stealth-default-20260922T015934Z/`. +Activation completed after explicit approval to close the 10 previous sessions. +The installed CLI launched a fresh named browser without an executable override; +daemon status reported the configured Linux artifact, and the script passed a +real textbox fill, ordinary locator click, title check (`stealth-default-ok`), +ARIA snapshot, and `navigator.webdriver === false` check. Its user agent reported +HeadlessChrome/150.0.0.0. Installed daemon and sandbox bundle bytes match the +rebuilt repository bundles. + +The process census found an extra startup daemon and a verification process +tree that did not finish graceful shutdown. Those exact processes were removed; +the final daemon is PID 77326 with zero browsers, ready to launch the configured +default. The old PID 63226 and the temporary daemon/browser processes are gone. +The activation changed no Chromium artifact aliases. diff --git a/package-lock.json b/package-lock.json index 5a56976a..f34bee94 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "prettier": "^3.7.4", "typescript": "^5" }, - "version": "0.2.6" + "version": "0.2.9" }, "node_modules/ansi-escapes": { "version": "7.2.0", @@ -475,5 +475,5 @@ } } }, - "version": "0.2.6" + "version": "0.2.9" } diff --git a/package.json b/package.json index fd510733..8fccd1ac 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dev-browser", - "version": "0.2.6", + "version": "0.2.9", "description": "CLI for controlling browsers with sandboxed JavaScript scripts", "type": "module", "bin": { diff --git a/skills/dev-browser/SKILL.md b/skills/dev-browser/SKILL.md index 30dfe91d..1da5ab67 100644 --- a/skills/dev-browser/SKILL.md +++ b/skills/dev-browser/SKILL.md @@ -93,3 +93,5 @@ EOF - Use persistent named pages to avoid re-navigation across turns - Use `--connect` only when the user wants to work inside an existing Chrome session - For command details and API reference, run `dev-browser --help` + +Named daemon-launched browsers persist by default. For unattended work, `--idle-timeout 5m` closes each launched browser after inactivity while preserving its profile and login state. The setting never closes Chrome attached with `--connect`; use `--idle-timeout 0` to disable configured cleanup.