diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 45021147..c054cb94 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,12 +10,20 @@ "source": "./", "description": "Browser automation skill with persistent page state for developers and AI agents", "strict": false, - "keywords": ["browser", "playwright", "automation", "testing", "debugging"], - "skills": ["./skills/dev-browser"] + "keywords": [ + "browser", + "playwright", + "automation", + "testing", + "debugging" + ], + "skills": [ + "./skills/dev-browser" + ] } ], "metadata": { "description": "Marketplace for the dev-browser skill", - "version": "1.0.1" + "version": "0.2.8" } } diff --git a/.cursor/rules/use-bun-instead-of-node-vite-npm-pnpm.mdc b/.cursor/rules/use-bun-instead-of-node-vite-npm-pnpm.mdc deleted file mode 100644 index b8100b77..00000000 --- a/.cursor/rules/use-bun-instead-of-node-vite-npm-pnpm.mdc +++ /dev/null @@ -1,111 +0,0 @@ ---- -description: Use Bun instead of Node.js, npm, pnpm, or vite. -globs: "*.ts, *.tsx, *.html, *.css, *.js, *.jsx, package.json" -alwaysApply: false ---- - -Default to using Bun instead of Node.js. - -- Use `bun ` instead of `node ` or `ts-node ` -- Use `bun test` instead of `jest` or `vitest` -- Use `bun build ` instead of `webpack` or `esbuild` -- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install` -- Use `bun run - - -``` - -With the following `frontend.tsx`: - -```tsx#frontend.tsx -import React from "react"; - -// import .css files directly and it works -import './index.css'; - -import { createRoot } from "react-dom/client"; - -const root = createRoot(document.body); - -export default function Frontend() { - return

Hello, world!

; -} - -root.render(); -``` - -Then, run index.ts - -```sh -bun --hot ./index.ts -``` - -For more information, read the Bun API docs in `node_modules/bun-types/docs/**.md`. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 94621dcf..f8b91d0e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,65 +11,80 @@ concurrency: cancel-in-progress: true jobs: - typecheck: - name: TypeScript + build-daemon: + name: Build Daemon Bundles runs-on: ubuntu-latest steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 with: - node-version: "20" - - - name: Install dependencies - working-directory: skills/dev-browser - run: npm install - - - name: TypeScript check - working-directory: skills/dev-browser - run: npx tsc --noEmit - - test: - name: Tests - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 + version: latest + - uses: actions/setup-node@v4 with: - node-version: "20" - - - name: Install dependencies - working-directory: skills/dev-browser - run: npm install - - - name: Install Playwright browsers - working-directory: skills/dev-browser - run: npx playwright install --with-deps chromium - - - name: Run tests - working-directory: skills/dev-browser - run: npm test + node-version: 22 + - run: cd daemon && pnpm install + - run: cd daemon && pnpm run bundle + - run: cd daemon && pnpm run bundle:sandbox-client + - uses: actions/upload-artifact@v4 + with: + name: daemon-bundles + path: | + daemon/dist/daemon.bundle.mjs + daemon/dist/sandbox-client.js format: - name: Formatting + name: Format Checks runs-on: ubuntu-latest steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: latest + - uses: actions/setup-node@v4 with: - node-version: "20" - cache: "npm" + node-version: 22 + - run: cd daemon && pnpm install + - run: cd daemon && pnpm run format:check + - uses: dtolnay/rust-toolchain@stable + - run: cd cli && cargo fmt -- --check - - name: Install dependencies - run: npm ci + daemon-tests: + name: Daemon (TypeScript + Tests) + needs: build-daemon + strategy: + matrix: + os: [ubuntu-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: latest + - uses: actions/setup-node@v4 + with: + node-version: 22 + - uses: actions/download-artifact@v4 + with: + name: daemon-bundles + path: daemon/dist/ + - run: cd daemon && pnpm install + - run: cd daemon && pnpm exec tsc --noEmit + - run: cd daemon && pnpm exec playwright install chromium + - run: cd daemon && pnpm vitest run - - name: Check formatting - run: npm run format:check + cli: + name: CLI (Rust) + needs: build-daemon + strategy: + matrix: + os: [ubuntu-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + with: + name: daemon-bundles + path: daemon/dist/ + - uses: dtolnay/rust-toolchain@stable + - run: cd cli && cargo build + - run: cd cli && cargo check diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..b733fe5a --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,130 @@ +name: Release + +on: + push: + tags: + - 'v*' + +jobs: + build-daemon: + name: Build Daemon Bundles + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: latest + - uses: actions/setup-node@v4 + with: + node-version: 22 + - run: cd daemon && pnpm install + - run: cd daemon && pnpm run bundle + - run: cd daemon && pnpm run bundle:sandbox-client + - uses: actions/upload-artifact@v4 + with: + name: daemon-bundles + path: | + daemon/dist/daemon.bundle.mjs + daemon/dist/sandbox-client.js + + build-binaries: + needs: build-daemon + strategy: + matrix: + include: + - target: aarch64-apple-darwin + os: macos-latest + binary: dev-browser-darwin-arm64 + source_binary: dev-browser + - target: x86_64-apple-darwin + os: macos-latest + binary: dev-browser-darwin-x64 + source_binary: dev-browser + - target: x86_64-unknown-linux-gnu + os: ubuntu-latest + binary: dev-browser-linux-x64 + source_binary: dev-browser + - target: aarch64-unknown-linux-gnu + os: ubuntu-latest + binary: dev-browser-linux-arm64 + cross: true + source_binary: dev-browser + - target: x86_64-unknown-linux-musl + os: ubuntu-latest + binary: dev-browser-linux-musl-x64 + cross: true + source_binary: dev-browser + - target: x86_64-pc-windows-msvc + os: windows-latest + binary: dev-browser-windows-x64.exe + source_binary: dev-browser.exe + + runs-on: ${{ matrix.os }} + + steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + with: + name: daemon-bundles + path: daemon/dist/ + - uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + - name: Install cross (if needed) + if: matrix.cross + run: cargo install cross + - name: Build + shell: bash + run: | + if [ "${{ matrix.cross }}" = "true" ]; then + cross build --release --target ${{ matrix.target }} --manifest-path cli/Cargo.toml + else + cargo build --release --target ${{ matrix.target }} --manifest-path cli/Cargo.toml + fi + - name: Rename binary + shell: bash + run: cp cli/target/${{ matrix.target }}/release/${{ matrix.source_binary }} ${{ matrix.binary }} + - uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.binary }} + path: ${{ matrix.binary }} + + publish: + needs: build-binaries + runs-on: ubuntu-latest + permissions: + contents: write + # Required for npm trusted publishing (OIDC); also enables provenance. + id-token: write + + steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + with: + path: artifacts/ + - uses: pnpm/action-setup@v4 + with: + version: latest + - uses: actions/setup-node@v4 + 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 + cp bin/dev-browser.js dist/bin/ + cp scripts/postinstall.js dist/scripts/ + cp scripts/sync-version.js dist/scripts/ 2>/dev/null || true + cp daemon/dist/daemon.bundle.mjs dist/daemon/dist/ + cp daemon/dist/sandbox-client.js dist/daemon/dist/ + 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 + - uses: softprops/action-gh-release@v2 + with: + files: artifacts/**/* + generate_release_notes: true diff --git a/.gitignore b/.gitignore index 1fd9cd38..344130cd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,44 +1,42 @@ -# Dependencies -node_modules/ - -# Build output -dist/ -build/ -# Environment files +_scratch/ +!bin/dev-browser.js +.dev-browser/ +.DS_Store .env -.env.local .env.*.local - -# IDE +.env.local .idea/ +.output/ .vscode/ -*.swp +.wxt/ +*.log *.swo - -# OS -.DS_Store -Thumbs.db - +*.swp +*.tsbuildinfo +# Browser profiles +# Build output +# Dependencies +# Environment files +# IDE # Logs -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* - +# OS +# Temporary files # Test coverage -coverage/ - # TypeScript cache -*.tsbuildinfo - -# Temporary files -tmp/ -temp/ - -# Browser profiles -profiles/ - # WXT generated files -.wxt/ -.output/ +bin/dev-browser-* +build/ +**/target/ +coverage/ +daemon/dist/* +daemon/node_modules/ +dist/ +node_modules/ +npm-debug.log* +profiles/ +temp/ +Thumbs.db +tmp/ +yarn-debug.log* +yarn-error.log* diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..51608893 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,28 @@ +# CLAUDE.md + +This repository ships `dev-browser`: a Rust CLI plus a Node.js daemon for browser automation with a QuickJS sandbox. Use this file as the repo-specific guide when making code changes. + +## Tooling + +- Use Node.js tooling for `daemon/` and Cargo for `cli/`. Do not use Bun. +- The daemon package uses `pnpm`. +- The repo root contains packaging glue (`bin/`, `scripts/`, `README.md`), but most runtime behavior lives in `cli/` and `daemon/`. + +## Validation + +Run these before finishing changes that touch runtime code: + +```bash +cd daemon && npx tsc --noEmit +cd daemon && pnpm vitest run +cd cli && cargo build +``` + +If you change daemon runtime code that is embedded into the Rust binary, rebuild the bundles first: + +```bash +cd daemon && pnpm bundle +cd daemon && pnpm bundle:sandbox-client +``` + +`cli/src/daemon.rs` embeds `daemon/dist/daemon.bundle.mjs` and `daemon/dist/sandbox-client.js` via `include_str!`, so `cargo build` only sees the latest daemon changes after those bundles are regenerated. diff --git a/CHANGELOG.md b/CHANGELOG.md index e9439aa7..870522d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,13 +1,54 @@ # Changelog -## [1.0.1] - 2025-12-10 +All notable changes to this project will be documented in this file. -### Added +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). -- Support for headless mode +## [Unreleased] -## [1.0.0] - 2025-12-10 +## [0.2.8] - 2026-06-05 -### Added +- 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. -- Initial release +## [0.2.7] - 2026-04-09 + +- Updated documentation to recommend `domcontentloaded` for dev server navigation. + +## [0.2.6] - 2026-03-30 + +- Pinned Playwright version. + +## [0.2.5] - 2026-03-30 + +- Updated Windows documentation with PowerShell examples. +- Use null viewport for headed mode. + +## [0.2.4] - 2026-03-26 + +- Added `--ignore-https-errors` flag for self-signed certificates. + +## [0.2.3] - 2026-03-25 + +- Added Windows x64 compatibility. + +## [0.2.1] - 2026-03-19 + +- Added an interactive `install-skill` TUI command to install the skill into `~/.claude/skills/` and `~/.agents/skills/`. +- Added a `--timeout` flag for script execution with a 30-second default. +- Documented `page.snapshotForAI()` for LLM-friendly page inspection. +- Expanded the `--help` LLM usage guide with approach guidance, screenshots, waiting patterns, and error recovery. +- Simplified the README, added a Windows-not-supported note, and attributed Do Browser. +- Aligned marketplace versioning with `package.json` and added auto-sync support. +- Added `rustfmt` and Prettier plus CI format checks. + +## [0.2.0] - 2026-03-19 + +Initial CLI release. diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index f4923274..00000000 --- a/CLAUDE.md +++ /dev/null @@ -1,102 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Build and Development Commands - -Always use Node.js/npm instead of Bun. - -```bash -# Install dependencies (from skills/dev-browser/ directory) -cd skills/dev-browser && npm install - -# Start the dev-browser server -cd skills/dev-browser && npm run start-server - -# Run dev mode with watch -cd skills/dev-browser && npm run dev - -# Run tests (uses vitest) -cd skills/dev-browser && npm test - -# Run TypeScript check -cd skills/dev-browser && npx tsc --noEmit -``` - -## Important: Before Completing Code Changes - -**Always run these checks before considering a task complete:** - -1. **TypeScript check**: `npx tsc --noEmit` - Ensure no type errors -2. **Tests**: `npm test` - Ensure all tests pass - -Common TypeScript issues in this codebase: - -- Use `import type { ... }` for type-only imports (required by `verbatimModuleSyntax`) -- Browser globals (`document`, `window`) in `page.evaluate()` callbacks need `declare const document: any;` since DOM lib is not included - -## Project Architecture - -### Overview - -This is a browser automation tool designed for developers and AI agents. It solves the problem of maintaining browser state across multiple script executions - unlike Playwright scripts that start fresh each time, dev-browser keeps pages alive and reusable. - -### Structure - -All source code lives in `skills/dev-browser/`: - -- `src/index.ts` - Server: launches persistent Chromium context, exposes HTTP API for page management -- `src/client.ts` - Client: connects to server, retrieves pages by name via CDP -- `src/types.ts` - Shared TypeScript types for API requests/responses -- `src/dom/` - DOM tree extraction utilities for LLM-friendly page inspection -- `scripts/start-server.ts` - Entry point to start the server -- `tmp/` - Directory for temporary automation scripts - -### Path Aliases - -The project uses `@/` as a path alias to `./src/`. This is configured in both `package.json` (via `imports`) and `tsconfig.json` (via `paths`). - -```typescript -// Import from src/client.ts -import { connect } from "@/client.js"; - -// Import from src/index.ts -import { serve } from "@/index.js"; -``` - -### How It Works - -1. **Server** (`serve()` in `src/index.ts`): - - Launches Chromium with `launchPersistentContext` (preserves cookies, localStorage) - - Exposes HTTP API on port 9222 for page management - - Exposes CDP WebSocket endpoint on port 9223 - - Pages are registered by name and persist until explicitly closed - -2. **Client** (`connect()` in `src/client.ts`): - - Connects to server's HTTP API - - Uses CDP `targetId` to reliably find pages across reconnections - - Returns standard Playwright `Page` objects for automation - -3. **Key API Endpoints**: - - `GET /` - Returns CDP WebSocket endpoint - - `GET /pages` - Lists all named pages - - `POST /pages` - Gets or creates a page by name (body: `{ name: string }`) - - `DELETE /pages/:name` - Closes a page - -### Usage Pattern - -```typescript -import { connect } from "@/client.js"; - -const client = await connect("http://localhost:9222"); -const page = await client.page("my-page"); // Gets existing or creates new -await page.goto("https://example.com"); -// Page persists for future scripts -await client.disconnect(); // Disconnects CDP but page stays alive on server -``` - -## Node.js Guidelines - -- Use `npx tsx` for running TypeScript files -- Use `dotenv` or similar if you need to load `.env` files -- Use `node:fs` for file system operations diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 00000000..47dc3e3d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/README.md b/README.md index a5998263..b493ce7f 100644 --- a/README.md +++ b/README.md @@ -2,91 +2,184 @@ Dev Browser - Browser automation for Claude Code

-A browser automation plugin for [Claude Code](https://docs.anthropic.com/en/docs/claude-code) that lets Claude control your browser to test and verify your work as you develop. +Brought to you by [Do Browser](https://dobrowser.io). + +A browser automation tool that lets AI agents and developers control browsers with sandboxed JavaScript scripts. **Key features:** +- **Sandboxed execution** - Scripts run in a QuickJS WASM sandbox with no host access - **Persistent pages** - Navigate once, interact across multiple scripts -- **Flexible execution** - Full scripts when possible, step-by-step when exploring -- **LLM-friendly DOM snapshots** - Structured page inspection optimized for AI +- **Auto-connect** - Connect to your running Chrome or launch a fresh Chromium +- **Full Playwright API** - goto, click, fill, locators, evaluate, screenshots, and more -## Prerequisites +## Demo -- [Claude Code](https://docs.anthropic.com/en/docs/claude-code) CLI installed -- [Node.js](https://nodejs.org) (v18 or later) with npm +https://github.com/user-attachments/assets/c6cf7fb9-b1dc-46ed-93b9-6e7240990c53 -## Installation +## CLI Installation -### Claude Code +```bash +npm install -g dev-browser +dev-browser install # installs Playwright + Chromium +``` +### Quick start + +```bash +# Launch a headless browser and run a script +dev-browser --headless <<'EOF' +const page = await browser.getPage("main"); +await page.goto("https://example.com", { waitUntil: "domcontentloaded" }); +console.log(await page.title()); +EOF + +# Connect to your running Chrome (enable at chrome://inspect/#remote-debugging) +dev-browser --connect <<'EOF' +const tabs = await browser.listPages(); +console.log(JSON.stringify(tabs, null, 2)); +EOF ``` -/plugin marketplace add sawyerhood/dev-browser -/plugin install dev-browser@sawyerhood/dev-browser + +### PowerShell (Windows) + +```powershell +@" +const page = await browser.getPage("main"); +await page.goto("https://example.com", { waitUntil: "domcontentloaded" }); +console.log(await page.title()); +"@ | dev-browser ``` -Restart Claude Code after installation. +With `--connect`: -### Amp / Codex +```powershell +@" +const page = await browser.getPage("main"); +console.log(await page.title()); +"@ | dev-browser --connect +``` -Copy the skill to your skills directory: +### Windows notes -```bash -# For Amp: ~/.claude/skills | For Codex: ~/.codex/skills -SKILLS_DIR=~/.claude/skills # or ~/.codex/skills +PowerShell install: -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 +```powershell +npm install -g dev-browser +dev-browser install ``` -**Amp only:** Start the server manually before use: +To attach to a running Chrome instance on Windows: + +```powershell +chrome.exe --remote-debugging-port=9222 +dev-browser --connect +``` + +Windows npm installs download the native `dev-browser-windows-x64.exe` release asset during `postinstall`, and the generated npm shims invoke that executable directly. + +### Using with AI agents + +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 -cd ~/.claude/skills/dev-browser && npm install && npm run start-server +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 ``` -### Chrome Extension (Optional) +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. -The Chrome extension allows Dev Browser to control your existing Chrome browser instead of launching a separate Chromium instance. This gives you access to your logged-in sessions, bookmarks, and extensions. +
+Allowing dev-browser in Claude Code without permission prompts -**Installation:** +By default, Claude Code asks for approval each time it runs a bash command. You can pre-approve `dev-browser` so it runs without permission checks by adding it to the `allow` list in your settings. -1. Download `extension.zip` from the [latest release](https://github.com/sawyerhood/dev-browser/releases/latest) -2. Unzip the file to a permanent location (e.g., `~/.dev-browser-extension`) -3. Open Chrome and go to `chrome://extensions` -4. Enable "Developer mode" (toggle in top right) -5. Click "Load unpacked" and select the unzipped extension folder +**Per-project** — add to `.claude/settings.json` in your project root: -**Using the extension:** +```json +{ + "permissions": { + "allow": [ + "Bash(dev-browser *)" + ] + } +} +``` -1. Click the Dev Browser extension icon in Chrome's toolbar -2. Toggle it to "Active" - this enables browser control -3. Ask Claude to connect to your browser (e.g., "connect to my Chrome" or "use the extension") +**Per-user (global)** — add to `~/.claude/settings.json`: -When active, Claude can control your existing Chrome tabs with all your logged-in sessions, cookies, and extensions intact. +```json +{ + "permissions": { + "allow": [ + "Bash(dev-browser *)" + ] + } +} +``` -## Permissions +The pattern `Bash(dev-browser *)` matches any command starting with `dev-browser ` followed by arguments (e.g. `dev-browser --headless`, `dev-browser --connect`). This is safe because dev-browser scripts run in a sandboxed QuickJS WASM environment with no host filesystem or network access. -To skip permission prompts, add to `~/.claude/settings.json`: +You can also allow related commands in the same list: ```json { "permissions": { - "allow": ["Skill(dev-browser:dev-browser)", "Bash(npx tsx:*)"] + "allow": [ + "Bash(dev-browser *)", + "Bash(npx dev-browser *)" + ] } } ``` -Or run with `claude --dangerously-skip-permissions` (skips all prompts). +> **Tip:** If you've already been prompted and clicked "Always allow", Claude Code adds the specific command pattern automatically. The settings file approach lets you pre-approve it before the first run. -## Usage +
-Just ask Claude to interact with your browser: +
+Legacy Claude Code plugin installation -> "Open localhost:3000 and verify the signup flow works" +### Claude Code -> "Go to the settings page and figure out why the save button isn't working" +``` +/plugin marketplace add sawyerhood/dev-browser +/plugin install dev-browser@sawyerhood/dev-browser +``` + +Restart Claude Code after installation. + +
+ +## Script API + +Scripts run in a sandboxed QuickJS runtime (not Node.js). Available globals: + +```javascript +// Browser control +browser.getPage(nameOrId) // Get/create named page, or connect to tab by targetId +browser.newPage() // Create anonymous page (cleaned up after script) +browser.listPages() // List all tabs: [{id, url, title, name}] +browser.closePage(name) // Close a named page + +// File I/O (restricted to ~/.dev-browser/tmp/) +await saveScreenshot(buf, name) // Save screenshot buffer, returns path +await writeFile(name, data) // Write file, returns path +await readFile(name) // Read file, returns content + +// Output +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 @@ -99,14 +192,6 @@ Just ask Claude to interact with your browser: _See [dev-browser-eval](https://github.com/SawyerHood/dev-browser-eval) for methodology._ -### How It's Different - -| Approach | How It Works | Tradeoff | -| ---------------------------------------------------------------- | ------------------------------------------------- | ------------------------------------------------------ | -| [Playwright MCP](https://github.com/microsoft/playwright-mcp) | Observe-think-act loop with individual tool calls | Simple but slow; each action is a separate round-trip | -| [Playwright Skill](https://github.com/lackeyjb/playwright-skill) | Full scripts that run end-to-end | Fast but fragile; scripts start fresh every time | -| **Dev Browser** | Stateful server + agentic script execution | Best of both: persistent state with flexible execution | - ## License MIT diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 00000000..3d0be698 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,97 @@ +# Releasing dev-browser + +## First Time Setup + +### 1. npm authentication +```bash +npm login +``` + +### 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) + +## Publishing a New Version + +### 1. Bump the version +```bash +node scripts/sync-version.js 0.2.0 +``` +This updates both `package.json` and `cli/Cargo.toml`. + +### 2. Commit +```bash +git add -A && git commit -m "release: v0.2.0" +``` + +### 3. Tag and push +```bash +git tag v0.2.0 +git push && git push --tags +``` + +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. 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 +``` + +## Quick Patch Release + +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 +``` + +## 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/` | +| **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 | + +## Platform Binaries + +| Platform | Rust Target | Binary Name | +|----------|------------|-------------| +| macOS Apple Silicon | `aarch64-apple-darwin` | `dev-browser-darwin-arm64` | +| macOS Intel | `x86_64-apple-darwin` | `dev-browser-darwin-x64` | +| Linux x64 | `x86_64-unknown-linux-gnu` | `dev-browser-linux-x64` | +| Linux ARM64 | `aarch64-unknown-linux-gnu` | `dev-browser-linux-arm64` | +| Linux x64 (musl) | `x86_64-unknown-linux-musl` | `dev-browser-linux-musl-x64` | +| Windows x64 | `x86_64-pc-windows-msvc` | `dev-browser-windows-x64.exe` | + +## How Users Install + +```bash +# Global install (postinstall downloads binary, patches shims for zero Node startup) +npm install -g dev-browser +dev-browser install # installs Playwright + Chromium + +# Or one-off via npx (uses Node wrapper, slightly slower startup) +npx dev-browser --help +``` + +Windows PowerShell: + +```powershell +npm install -g dev-browser +dev-browser install +chrome.exe --remote-debugging-port=9222 +dev-browser --connect +``` diff --git a/bin/dev-browser.js b/bin/dev-browser.js new file mode 100755 index 00000000..58530a7e --- /dev/null +++ b/bin/dev-browser.js @@ -0,0 +1,150 @@ +#!/usr/bin/env node + +import { execSync, spawn } from 'child_process'; +import { accessSync, chmodSync, constants, existsSync, readFileSync } from 'fs'; +import { arch, platform } from 'os'; +import { dirname, join } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const packageJson = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8')); +const version = packageJson.version; +const repoSlug = 'SawyerHood/dev-browser'; +const supportedTargets = Object.freeze({ + 'darwin-arm64': 'dev-browser-darwin-arm64', + 'darwin-x64': 'dev-browser-darwin-x64', + 'linux-arm64': 'dev-browser-linux-arm64', + 'linux-musl-x64': 'dev-browser-linux-musl-x64', + 'linux-x64': 'dev-browser-linux-x64', + 'win32-x64': 'dev-browser-windows-x64.exe', +}); + +function isMusl() { + if (platform() !== 'linux') { + return false; + } + + try { + const report = process.report?.getReport?.(); + if (report?.header?.glibcVersionRuntime) { + return false; + } + } catch { + // Fall through to the ldd probe. + } + + try { + const output = execSync('ldd --version 2>&1', { encoding: 'utf8' }); + return output.toLowerCase().includes('musl'); + } catch { + return existsSync('/lib/ld-musl-x86_64.so.1') || existsSync('/lib/ld-musl-aarch64.so.1'); + } +} + +function getTargetKey() { + const currentPlatform = platform(); + const currentArch = arch(); + + if (currentPlatform === 'darwin') { + if (currentArch === 'arm64' || currentArch === 'aarch64') { + return 'darwin-arm64'; + } + + if (currentArch === 'x64' || currentArch === 'x86_64') { + return 'darwin-x64'; + } + + return null; + } + + if (currentPlatform === 'linux') { + if (currentArch === 'x64' || currentArch === 'x86_64') { + return isMusl() ? 'linux-musl-x64' : 'linux-x64'; + } + + if (currentArch === 'arm64' || currentArch === 'aarch64') { + return isMusl() ? null : 'linux-arm64'; + } + } + + if (currentPlatform === 'win32') { + if (currentArch === 'x64' || currentArch === 'x86_64') { + return 'win32-x64'; + } + } + + return null; +} + +function getBinaryName() { + const targetKey = getTargetKey(); + return targetKey ? supportedTargets[targetKey] : null; +} + +function getSupportedPlatformsText() { + return Object.keys(supportedTargets).join(', '); +} + +function ensureExecutable(binaryPath) { + if (platform() === 'win32') { + return; + } + + try { + accessSync(binaryPath, constants.X_OK); + } catch { + chmodSync(binaryPath, 0o755); + } +} + +function main() { + const binaryName = getBinaryName(); + + if (!binaryName) { + console.error(`Error: Unsupported platform: ${platform()}-${arch()}`); + console.error(`Supported platforms: ${getSupportedPlatformsText()}`); + process.exit(1); + } + + const binaryPath = join(__dirname, binaryName); + const releaseAssetUrl = `https://github.com/${repoSlug}/releases/download/v${version}/${binaryName}`; + + if (!existsSync(binaryPath)) { + console.error(`Error: Native binary not found for ${platform()}-${arch()}`); + console.error(`Expected: ${binaryPath}`); + console.error(''); + console.error('The postinstall step downloads this binary from GitHub releases.'); + console.error('Reinstall the package to retry the download, or verify this release includes'); + console.error(`the asset "${binaryName}" for your platform.`); + console.error(`Expected release asset URL: ${releaseAssetUrl}`); + process.exit(1); + } + + try { + ensureExecutable(binaryPath); + } catch (error) { + console.error(`Error: Cannot make the native binary executable: ${error.message}`); + process.exit(1); + } + + const child = spawn(binaryPath, process.argv.slice(2), { + stdio: 'inherit', + windowsHide: false, + }); + + child.on('error', (error) => { + console.error(`Error executing native binary: ${error.message}`); + process.exit(1); + }); + + child.on('exit', (code, signal) => { + if (signal) { + process.kill(process.pid, signal); + return; + } + + process.exit(code ?? 1); + }); +} + +main(); diff --git a/bun.lock b/bun.lock deleted file mode 100644 index 04dcd799..00000000 --- a/bun.lock +++ /dev/null @@ -1,101 +0,0 @@ -{ - "lockfileVersion": 1, - "configVersion": 1, - "workspaces": { - "": { - "name": "browser-skill", - "devDependencies": { - "@types/bun": "latest", - "husky": "^9.1.7", - "lint-staged": "^16.2.7", - "prettier": "^3.7.4", - "typescript": "^5", - }, - }, - }, - "packages": { - "@types/bun": ["@types/bun@1.3.3", "", { "dependencies": { "bun-types": "1.3.3" } }, "sha512-ogrKbJ2X5N0kWLLFKeytG0eHDleBYtngtlbu9cyBKFtNL3cnpDZkNdQj8flVf6WTZUX5ulI9AY1oa7ljhSrp+g=="], - - "@types/node": ["@types/node@24.10.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="], - - "ansi-escapes": ["ansi-escapes@7.2.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-g6LhBsl+GBPRWGWsBtutpzBYuIIdBkLEvad5C/va/74Db018+5TZiyA26cZJAr3Rft5lprVqOIPxf5Vid6tqAw=="], - - "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], - - "ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], - - "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], - - "bun-types": ["bun-types@1.3.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-z3Xwlg7j2l9JY27x5Qn3Wlyos8YAp0kKRlrePAOjgjMGS5IG6E7Jnlx736vH9UVI4wUICwwhC9anYL++XeOgTQ=="], - - "cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="], - - "cli-truncate": ["cli-truncate@5.1.1", "", { "dependencies": { "slice-ansi": "^7.1.0", "string-width": "^8.0.0" } }, "sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A=="], - - "colorette": ["colorette@2.0.20", "", {}, "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w=="], - - "commander": ["commander@14.0.2", "", {}, "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ=="], - - "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], - - "environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="], - - "eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="], - - "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], - - "get-east-asian-width": ["get-east-asian-width@1.4.0", "", {}, "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q=="], - - "husky": ["husky@9.1.7", "", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="], - - "is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], - - "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], - - "lint-staged": ["lint-staged@16.2.7", "", { "dependencies": { "commander": "^14.0.2", "listr2": "^9.0.5", "micromatch": "^4.0.8", "nano-spawn": "^2.0.0", "pidtree": "^0.6.0", "string-argv": "^0.3.2", "yaml": "^2.8.1" }, "bin": { "lint-staged": "bin/lint-staged.js" } }, "sha512-lDIj4RnYmK7/kXMya+qJsmkRFkGolciXjrsZ6PC25GdTfWOAWetR0ZbsNXRAj1EHHImRSalc+whZFg56F5DVow=="], - - "listr2": ["listr2@9.0.5", "", { "dependencies": { "cli-truncate": "^5.0.0", "colorette": "^2.0.20", "eventemitter3": "^5.0.1", "log-update": "^6.1.0", "rfdc": "^1.4.1", "wrap-ansi": "^9.0.0" } }, "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g=="], - - "log-update": ["log-update@6.1.0", "", { "dependencies": { "ansi-escapes": "^7.0.0", "cli-cursor": "^5.0.0", "slice-ansi": "^7.1.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w=="], - - "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], - - "mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="], - - "nano-spawn": ["nano-spawn@2.0.0", "", {}, "sha512-tacvGzUY5o2D8CBh2rrwxyNojUsZNU2zjNTzKQrkgGJQTbGAfArVWXSKMBokBeeg6C7OLRGUEyoFlYbfeWQIqw=="], - - "onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], - - "picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], - - "pidtree": ["pidtree@0.6.0", "", { "bin": { "pidtree": "bin/pidtree.js" } }, "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g=="], - - "prettier": ["prettier@3.7.4", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA=="], - - "restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="], - - "rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="], - - "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], - - "slice-ansi": ["slice-ansi@7.1.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w=="], - - "string-argv": ["string-argv@0.3.2", "", {}, "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q=="], - - "string-width": ["string-width@8.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.0", "strip-ansi": "^7.1.0" } }, "sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg=="], - - "strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], - - "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], - - "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - - "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], - - "wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], - - "yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="], - - "wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], - } -} diff --git a/cli/Cargo.lock b/cli/Cargo.lock new file mode 100644 index 00000000..d14e6862 --- /dev/null +++ b/cli/Cargo.lock @@ -0,0 +1,871 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clap" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "console" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" +dependencies = [ + "encode_unicode", + "libc", + "unicode-width", + "windows-sys 0.61.2", +] + +[[package]] +name = "dev-browser" +version = "0.2.8" +dependencies = [ + "clap", + "dialoguer", + "dirs", + "interprocess", + "libc", + "serde", + "serde_json", +] + +[[package]] +name = "dialoguer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25f104b501bf2364e78d0d3974cbc774f738f5865306ed128e1e0d7499c0ad96" +dependencies = [ + "console", + "shell-words", + "tempfile", + "zeroize", +] + +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + +[[package]] +name = "doctest-file" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2db04e74f0a9a93103b50e90b96024c9b2bdca8bce6a632ec71b88736d3d359" + +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "interprocess" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6be5e5c847dbdb44564bd85294740d031f4f8aeb3464e5375ef7141f7538db69" +dependencies = [ + "doctest-file", + "libc", + "recvmsg", + "widestring", + "windows-sys 0.52.0", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.183" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" + +[[package]] +name = "libredox" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" +dependencies = [ + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "recvmsg" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3edd4d5d42c92f0a659926464d4cce56b562761267ecf0f469d85b7de384175" + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/cli/Cargo.toml b/cli/Cargo.toml new file mode 100644 index 00000000..12845e17 --- /dev/null +++ b/cli/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "dev-browser" +version = "0.2.8" +edition = "2021" + +[dependencies] +clap = { version = "4", features = ["derive"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +dirs = "5" +libc = "0.2" +dialoguer = "0.12" +interprocess = "2.2.3" + +[profile.release] +opt-level = 3 +lto = true +codegen-units = 1 +strip = true diff --git a/cli/llm-guide.txt b/cli/llm-guide.txt new file mode 100644 index 00000000..fa0ef428 --- /dev/null +++ b/cli/llm-guide.txt @@ -0,0 +1,191 @@ +LLM USAGE GUIDE: + 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. + On Windows/PowerShell, use here-strings to pipe multiline scripts: + @" + const page = await browser.getPage("main"); + console.log(await page.title()); + "@ | dev-browser --connect + + Quick inspection: + dev-browser --connect <<'EOF' + const tabs = await browser.listPages(); + console.log(JSON.stringify(tabs, null, 2)); + EOF + + dev-browser --connect <<'EOF' + const page = await browser.getPage("TARGET_ID_HERE"); + console.log(JSON.stringify({ + url: page.url(), + title: await page.title(), + }, null, 2)); + EOF + + AI snapshots for element discovery: + dev-browser <<'EOF' + const page = await browser.getPage("main"); + 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 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: 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' + const page = await browser.getPage("main"); + const buf = await page.screenshot(); + const path = await saveScreenshot(buf, "debug.png"); + console.log(path); + EOF + + Waiting patterns: + dev-browser <<'EOF' + const page = await browser.getPage("search-results"); + await page.waitForSelector(".results", { timeout: 5000 }); + await page.waitForURL("**/success", { timeout: 5000 }); + console.log(JSON.stringify({ + url: page.url(), + title: await page.title(), + }, null, 2)); + EOF + + Dev server navigation: + For local dev servers (Next.js, Vite, etc.), prefer: + await page.goto(url, { waitUntil: "domcontentloaded" }); + The default "load" wait can hang on HMR, streaming, or other long-lived dev-server connections. + Use "load" only when you specifically need every subresource to finish loading. + + Error recovery: + If a script fails, the page usually stays where it stopped. + Reconnect to the same page name, take a screenshot, and log the URL/title: + dev-browser <<'EOF' + const page = await browser.getPage("checkout"); + const path = await saveScreenshot(await page.screenshot(), "debug.png"); + console.log(JSON.stringify({ + screenshot: path, + url: page.url(), + title: await page.title(), + }, null, 2)); + EOF + + Common Playwright Page methods: + page.goto(url, { waitUntil: "domcontentloaded" }) + Navigate to a URL; prefer this on dev servers + page.title() Get the current page title + 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 + page.fill(selector, value) Fill an input field + page.click(selector) Click an element + page.type(selector, text) Type text character by character + page.press(selector, key) Press a key such as Enter or Tab + 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) + page.locator(selector) Create a locator for chained actions + + Connecting to a running Chrome instance: + Auto-discover Chrome with debugging enabled: + dev-browser --connect <<'EOF' + const page = await browser.getPage("main"); + console.log(await page.title()); + EOF + + Connect to a specific CDP endpoint: + dev-browser --connect http://localhost:9222 <<'EOF' + const page = await browser.getPage("main"); + console.log(await page.title()); + EOF + + To launch Chrome with debugging enabled: + chrome.exe --remote-debugging-port=9222 + google-chrome --remote-debugging-port=9222 + + Or visit chrome://inspect/#remote-debugging to configure. + + Tips: + - Use console.log(JSON.stringify(...)) for structured output. + - 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. + - 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/rustfmt.toml b/cli/rustfmt.toml new file mode 100644 index 00000000..e7461061 --- /dev/null +++ b/cli/rustfmt.toml @@ -0,0 +1,2 @@ +max_width = 100 +newline_style = "Unix" diff --git a/cli/src/connection.rs b/cli/src/connection.rs new file mode 100644 index 00000000..dc5dac5c --- /dev/null +++ b/cli/src/connection.rs @@ -0,0 +1,103 @@ +use std::io::{self, BufRead, Write}; + +#[cfg(unix)] +use std::os::unix::net::UnixStream; +#[cfg(unix)] +use std::path::PathBuf; +#[cfg(unix)] +use std::time::Duration; + +#[cfg(windows)] +use interprocess::local_socket::{prelude::*, GenericNamespaced, Stream}; + +#[cfg(unix)] +pub type DaemonStream = UnixStream; +#[cfg(windows)] +pub type DaemonStream = Stream; + +#[cfg(unix)] +pub fn socket_path() -> io::Result { + dirs::home_dir() + .map(|path| path.join(".dev-browser").join("daemon.sock")) + .ok_or_else(|| { + io::Error::new( + io::ErrorKind::NotFound, + "Could not determine home directory", + ) + }) +} + +#[cfg(windows)] +fn sanitize_pipe_segment(value: &str) -> String { + let sanitized = value + .chars() + .map(|character| match character { + 'a'..='z' | 'A'..='Z' | '0'..='9' | '.' | '_' | '-' => character, + _ => '-', + }) + .collect::() + .trim_matches('-') + .to_ascii_lowercase(); + + if sanitized.is_empty() { + "user".to_string() + } else { + sanitized + } +} + +#[cfg(windows)] +fn daemon_pipe_name() -> String { + let user = std::env::var("USERNAME") + .or_else(|_| std::env::var("USER")) + .ok() + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| { + dirs::home_dir() + .and_then(|path| { + path.file_name() + .map(|name| name.to_string_lossy().into_owned()) + }) + .unwrap_or_else(|| "user".to_string()) + }); + + format!("dev-browser-daemon-{}", sanitize_pipe_segment(&user)) +} + +#[cfg(unix)] +pub fn connect_to_daemon() -> io::Result { + let stream = DaemonStream::connect(socket_path()?)?; + stream.set_write_timeout(Some(Duration::from_secs(5)))?; + Ok(stream) +} + +#[cfg(windows)] +pub fn connect_to_daemon() -> io::Result { + let name = daemon_pipe_name() + .to_ns_name::() + .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error.to_string()))?; + + DaemonStream::connect(name) +} + +pub fn send_message(stream: &mut W, msg: &serde_json::Value) -> io::Result<()> { + let json = serde_json::to_string(msg) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; + stream.write_all(json.as_bytes())?; + stream.write_all(b"\n")?; + stream.flush() +} + +pub fn read_line(reader: &mut R) -> io::Result { + let mut line = String::new(); + let bytes_read = reader.read_line(&mut line)?; + + if bytes_read == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "Daemon connection closed unexpectedly", + )); + } + + Ok(line) +} diff --git a/cli/src/daemon.rs b/cli/src/daemon.rs new file mode 100644 index 00000000..5c50c061 --- /dev/null +++ b/cli/src/daemon.rs @@ -0,0 +1,324 @@ +use crate::connection::connect_to_daemon; +use std::env; +use std::error::Error; +use std::ffi::OsStr; +use std::fs; +use std::io; +#[cfg(unix)] +use std::os::unix::process::CommandExt; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::thread; +use std::time::{Duration, Instant}; + +const DEV_BROWSER_DIR: &str = ".dev-browser"; +const EMBEDDED_DAEMON: &str = include_str!("../../daemon/dist/daemon.bundle.mjs"); +const EMBEDDED_SANDBOX_CLIENT: &str = include_str!("../../daemon/dist/sandbox-client.js"); +const EMBEDDED_PACKAGE_JSON: &str = r#"{ + "name": "dev-browser-runtime", + "private": true, + "type": "module", + "dependencies": { + "playwright": "1.58.2", + "playwright-core": "1.58.2", + "quickjs-emscripten": "^0.32.0" + } +}"#; + +struct DaemonCommand { + program: String, + args: Vec, + current_dir: PathBuf, + requires_runtime_install: bool, +} + +pub fn ensure_daemon() -> Result<(), Box> { + if is_daemon_running() { + 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( + "Embedded daemon dependencies are missing. Run `dev-browser install` first.".into(), + ); + } + + spawn_daemon(&command)?; + + let deadline = Instant::now() + Duration::from_secs(5); + while Instant::now() < deadline { + thread::sleep(Duration::from_millis(100)); + if is_daemon_running() { + return Ok(()); + } + } + + Err("Daemon failed to start within 5 seconds".into()) +} + +pub fn ensure_daemon_extracted() -> Result> { + let base_dir = daemon_base_dir()?; + let daemon_path = base_dir.join("daemon.mjs"); + let package_json_path = base_dir.join("package.json"); + + fs::create_dir_all(&base_dir)?; + let sandbox_client_path = base_dir.join("sandbox-client.js"); + sync_text_file(&daemon_path, EMBEDDED_DAEMON)?; + sync_text_file(&sandbox_client_path, EMBEDDED_SANDBOX_CLIENT)?; + sync_text_file(&package_json_path, EMBEDDED_PACKAGE_JSON)?; + + Ok(daemon_path) +} + +pub fn install_daemon_runtime() -> Result<(), Box> { + let base_dir = daemon_base_dir()?; + ensure_daemon_extracted()?; + run_install_command(npm_command(), &["install"], &base_dir)?; + run_install_command( + npm_command(), + &["exec", "--", "playwright", "install", "chromium"], + &base_dir, + )?; + Ok(()) +} + +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() +} + +pub fn wait_for_daemon_exit(pid: i32, timeout: Duration) -> Result<(), Box> { + let deadline = Instant::now() + timeout; + while Instant::now() < deadline { + if daemon_has_exited(pid, connect_to_daemon().is_err()) { + return Ok(()); + } + + thread::sleep(Duration::from_millis(100)); + } + + Err(format!("Daemon failed to stop within {} seconds", timeout.as_secs()).into()) +} + +fn daemon_has_exited(_pid: i32, daemon_unreachable: bool) -> bool { + daemon_unreachable +} + +fn spawn_daemon(command: &DaemonCommand) -> io::Result<()> { + let mut process = Command::new(&command.program); + process.args(&command.args); + process.current_dir(&command.current_dir); + process.stdin(Stdio::null()); + process.stdout(Stdio::null()); + process.stderr(Stdio::null()); + + #[cfg(unix)] + unsafe { + process.pre_exec(|| { + if libc::setsid() == -1 { + return Err(io::Error::last_os_error()); + } + Ok(()) + }); + } + + let _child = process.spawn()?; + Ok(()) +} + +fn daemon_pid() -> Option { + let pid_path = dirs::home_dir()?.join(".dev-browser").join("daemon.pid"); + let pid = fs::read_to_string(pid_path).ok()?; + pid.trim().parse::().ok() +} + +fn find_daemon_command() -> Result> { + if let Some(entry) = env::var_os("DEV_BROWSER_DAEMON") { + return command_from_entry(PathBuf::from(entry)); + } + + let daemon_path = ensure_daemon_extracted()?; + Ok(DaemonCommand { + program: "node".to_string(), + args: vec![daemon_path.to_string_lossy().into_owned()], + current_dir: daemon_base_dir()?, + requires_runtime_install: true, + }) +} + +fn command_from_entry(entry: PathBuf) -> Result> { + let entry = fs::canonicalize(entry)?; + let current_dir = entry + .parent() + .ok_or("Daemon entrypoint has no parent directory")? + .to_path_buf(); + + match entry.extension().and_then(OsStr::to_str) { + Some("js") | Some("mjs") | Some("cjs") => Ok(DaemonCommand { + program: "node".to_string(), + args: vec![entry.to_string_lossy().into_owned()], + current_dir, + requires_runtime_install: false, + }), + Some("ts") | Some("mts") | Some("cts") => { + let tsx_cli = find_tsx_cli(&entry)?; + Ok(DaemonCommand { + program: "node".to_string(), + args: vec![ + tsx_cli.to_string_lossy().into_owned(), + entry.to_string_lossy().into_owned(), + ], + current_dir, + requires_runtime_install: false, + }) + } + _ => Ok(DaemonCommand { + program: entry.to_string_lossy().into_owned(), + args: Vec::new(), + current_dir, + requires_runtime_install: false, + }), + } +} + +fn find_tsx_cli(entry: &Path) -> Result> { + for candidate in entry.ancestors() { + let tsx_cli = candidate + .join("node_modules") + .join("tsx") + .join("dist") + .join("cli.mjs"); + if tsx_cli.is_file() { + return Ok(tsx_cli); + } + } + + Err("Could not locate the tsx runtime required to launch the TypeScript daemon.".into()) +} + +fn daemon_base_dir() -> Result> { + dirs::home_dir() + .map(|path| path.join(DEV_BROWSER_DIR)) + .ok_or_else(|| { + "Could not determine the home directory for the embedded daemon runtime.".into() + }) +} + +fn embedded_runtime_installed(base_dir: &Path) -> bool { + dependency_installed(base_dir, "playwright") + && dependency_installed(base_dir, "quickjs-emscripten") +} + +fn dependency_installed(base_dir: &Path, package_name: &str) -> bool { + base_dir + .join("node_modules") + .join(package_name) + .join("package.json") + .is_file() +} + +fn npm_command() -> &'static str { + if cfg!(target_os = "windows") { + "npm.cmd" + } else { + "npm" + } +} + +fn sync_text_file(path: &Path, contents: &str) -> Result<(), Box> { + let needs_update = match fs::read_to_string(path) { + Ok(existing) => existing != contents, + Err(error) if error.kind() == io::ErrorKind::NotFound => true, + Err(error) => return Err(error.into()), + }; + + if needs_update { + // 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(()) +} + +fn run_install_command( + program: &str, + args: &[&str], + current_dir: &Path, +) -> Result<(), Box> { + let status = Command::new(program) + .args(args) + .current_dir(current_dir) + .stdin(Stdio::inherit()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .status() + .map_err(|error| -> Box { + match error.kind() { + io::ErrorKind::NotFound => format!( + "Could not find `{program}` in PATH while setting up the embedded daemon runtime in {}. Install Node.js/npm and run `dev-browser install` again.", + current_dir.display() + ) + .into(), + _ => format!( + "Failed to run `{program} {}` in {}: {error}", + args.join(" "), + current_dir.display() + ) + .into(), + } + })?; + + if status.success() { + return Ok(()); + } + + let reason = match status.code() { + Some(code) => format!( + "`{program} {}` failed with exit code {code}", + args.join(" ") + ), + None => format!("`{program} {}` terminated by signal", args.join(" ")), + }; + + Err(reason.into()) +} diff --git a/cli/src/main.rs b/cli/src/main.rs new file mode 100644 index 00000000..6eee603f --- /dev/null +++ b/cli/src/main.rs @@ -0,0 +1,530 @@ +mod connection; +mod daemon; +mod skill; + +use clap::{CommandFactory, Parser, Subcommand}; +use connection::{connect_to_daemon, read_line, send_message}; +use daemon::{ + current_daemon_pid, ensure_daemon, install_daemon_runtime, is_daemon_running, + wait_for_daemon_exit, +}; +use serde::Deserialize; +use serde_json::{json, Value}; +use skill::install_skill; +use std::error::Error; +use std::fs; +use std::io::{self, BufRead, BufReader, IsTerminal, Read, Write}; +use std::process; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +const CLI_LONG_ABOUT: &str = r###"Dev Browser is a CLI for controlling local or external browsers with JavaScript scripts. +Scripts run in a sandboxed QuickJS runtime (not Node.js). Top-level `await` is +available, along with a preconnected `browser` global and standard `console` output. +A background daemon starts automatically when needed and manages browser instances, +named pages, and CDP connections. + +SANDBOX ENVIRONMENT: + Scripts execute inside a QuickJS WASM sandbox with no arbitrary access to the host system. + This is NOT Node.js — the following are NOT available: + - require() / import() No module loading + - process No process access + - fs / path / os No direct filesystem access + - fetch / WebSocket No direct network access + - __dirname / __filename No path globals + + Available globals: + browser Pre-connected browser handle (see API below) + console log, warn, error, info (routed to CLI output) + setTimeout / clearTimeout Basic timers + saveScreenshot(buf, name) Save a screenshot buffer (async, must be awaited) + writeFile(name, data) Write a file to temp dir (async, must be awaited) + readFile(name) Read a file from temp dir (async, must be awaited) + + Memory and CPU limits are enforced. Infinite loops will be interrupted. + +Primary invocation styles: + dev-browser <<'EOF' + const page = await browser.getPage("main"); + await page.goto("https://example.com"); + console.log(await page.title()); + EOF + + dev-browser run script.js + dev-browser --browser my-project < script.js + dev-browser --connect http://localhost:9222 <<'EOF' + const page = await browser.getPage("main"); + await page.goto("https://example.com"); + EOF + dev-browser --connect <<'EOF' + const page = await browser.getPage("main"); + console.log(await page.title()); + EOF + +Script API available inside every script: + browser.getPage(nameOrId) Get a page by name (creates if new) or connect to an existing + tab by its targetId from listPages(). + browser.newPage() Create an anonymous page. Anonymous pages are cleaned up after the script exits. + browser.listPages() List all tabs: named pages and existing browser tabs. + Returns [{id, url, title, name}]. + browser.closePage(name) Close and remove a named page. + await saveScreenshot(buf: Buffer, name: string): Promise + Save a screenshot buffer to ~/.dev-browser/tmp/. + Returns the full path to the saved file. + Example: const path = await saveScreenshot(await page.screenshot(), "home.png"); + + await writeFile(name: string, data: string): Promise + Write data to ~/.dev-browser/tmp/. + Returns the full path to the written file. + Example: const path = await writeFile("results.json", JSON.stringify(data)); + + await readFile(name: string): Promise + Read a file from ~/.dev-browser/tmp/. + Returns the file content as a string. + Example: const data = JSON.parse(await readFile("results.json")); + + console.log/info(...) Write output to stdout. + console.warn/error(...) Write output to stderr. + + All file I/O functions are async and must be awaited. + All paths are restricted to ~/.dev-browser/tmp/ — no filesystem escape. + +Pages returned by `browser.getPage()` and `browser.newPage()` are full Playwright +Page objects — you get the same API (goto, click, fill, locator, evaluate, etc.): + https://playwright.dev/docs/api/class-page"###; + +const CLI_AFTER_LONG_HELP: &str = include_str!("../llm-guide.txt"); + +const DEFAULT_SCRIPT_TIMEOUT_SECS: u32 = 30; + +#[derive(Parser)] +#[command(name = "dev-browser")] +#[command(about = "Control browsers with JavaScript automation scripts")] +#[command(long_about = CLI_LONG_ABOUT)] +#[command(after_long_help = CLI_AFTER_LONG_HELP)] +struct Cli { + #[arg( + long, + default_value = "default", + value_name = "NAME", + help = "Use a named daemon-managed browser instance", + long_help = "Select the named browser instance to run against.\n\nThe daemon keeps separate browser state for each name. Named pages created with `browser.getPage(\"name\")` persist within that browser between script runs.\n\nDefaults to `default`." + )] + browser: String, + + #[arg( + long, + num_args = 0..=1, + default_missing_value = "auto", + value_name = "URL", + help = "Connect to a running Chrome instance", + long_help = "Connect to a running Chrome instance.\n\nWithout a URL: auto-discovers Chrome with debugging enabled.\nWorks with Chrome's built-in remote debugging\n(chrome://inspect/#remote-debugging) and classic\n--remote-debugging-port mode.\n\nWith a URL: connects to the specified CDP endpoint.\nAccepts HTTP or WebSocket CDP endpoints such as `http://localhost:9222` or `ws://host:9222/devtools/browser/...`.\n\nTo launch Chrome with debugging, use a command such as:\n chrome.exe --remote-debugging-port=9222\n google-chrome --remote-debugging-port=9222\n\nOr visit chrome://inspect/#remote-debugging to configure." + )] + connect: Option, + + #[arg( + long, + help = "Launch daemon-managed Chromium without a visible window", + long_help = "Launch or relaunch daemon-managed Chromium in headless mode.\n\nThis only affects daemon-launched browsers. It has no effect when `--connect` attaches to an already-running external browser." + )] + headless: bool, + + #[arg( + long, + help = "Ignore HTTPS certificate errors for daemon-managed Chromium", + long_help = "Launch or relaunch daemon-managed Chromium with HTTPS certificate errors ignored.\n\nThis is useful for self-signed certificates in local or staging environments. The setting applies per managed browser session until the daemon restarts or the setting changes and triggers a relaunch.\n\nThis only affects daemon-launched browsers. It has no effect when `--connect` attaches to an already-running external browser." + )] + ignore_https_errors: bool, + + #[arg( + long, + default_value_t = DEFAULT_SCRIPT_TIMEOUT_SECS, + value_name = "SECONDS", + value_parser = clap::value_parser!(u32).range(1..), + help = "Maximum script execution time in seconds", + long_help = "Maximum script execution time in seconds.\n\nIf the script exceeds this limit, the daemon terminates it and returns an error.\n\nDefaults to 30 seconds." + )] + timeout: u32, + + #[command(subcommand)] + command: Option, +} + +#[derive(Subcommand)] +enum Command { + #[command( + about = "Run a script file against the browser", + long_about = "Run a script file against the browser.\n\nThe file is executed the same way as stdin input: as top-level JavaScript with `await`, `browser`, and `console` available.\n\nUse top-level flags before `run`, for example `dev-browser --browser my-project run script.js`." + )] + Run { + #[arg( + value_name = "FILE", + help = "Path to a JavaScript file to execute", + long_help = "Path to the JavaScript file to execute.\n\nThis is equivalent to `dev-browser < script.js`, but can be easier to script or combine with shell tooling." + )] + file: String, + }, + #[command( + about = "Install Playwright browsers (Chromium)", + long_about = "Install Playwright browsers (Chromium).\n\nDownloads the Chromium build used for daemon-managed browser instances." + )] + 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, 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( + long, + help = "Install the skill into ~/.claude/skills without prompting" + )] + claude: bool, + #[arg( + long, + help = "Install the skill into ~/.agents/skills without prompting" + )] + agents: bool, + #[arg( + long, + help = "Install the skill into ~/.codex/skills without prompting" + )] + codex: bool, + }, + #[command( + about = "List all managed browser instances", + long_about = "List all managed browser instances.\n\nShows the browser name, whether it is daemon-launched or externally connected, its status, and any named pages currently registered." + )] + 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." + )] + Status, + #[command( + about = "Stop the daemon and all browsers", + long_about = "Stop the daemon and all browsers.\n\nThis stops the background daemon process and closes every browser instance it currently manages." + )] + Stop, +} + +#[derive(Debug, Deserialize)] +struct BrowserSummary { + name: String, + #[serde(rename = "type")] + kind: String, + status: String, + pages: Vec, +} + +#[derive(Debug, Deserialize)] +struct StatusSummary { + pid: i32, + #[serde(rename = "uptimeMs")] + uptime_ms: u64, + #[serde(rename = "browserCount")] + browser_count: usize, + #[serde(rename = "socketPath")] + socket_path: String, + browsers: Vec, +} + +enum ResultMode { + None, + Json, + Browsers, + Status, +} + +fn main() { + let exit_code = match run() { + Ok(code) => code, + Err(error) => { + eprintln!("Error: {error}"); + 1 + } + }; + + process::exit(exit_code); +} + +fn run() -> Result> { + let cli = Cli::parse(); + + match &cli.command { + Some(Command::Run { file }) => { + let script = fs::read_to_string(file)?; + run_script(&cli, script) + } + Some(Command::Browsers) => { + ensure_daemon()?; + send_request( + json!({ + "id": request_id("browsers"), + "type": "browsers", + }), + ResultMode::Browsers, + ) + } + Some(Command::Install) => { + install_daemon_runtime()?; + Ok(0) + } + Some(Command::InstallSkill { + claude, + agents, + codex, + }) => { + install_skill(*claude, *agents, *codex)?; + Ok(0) + } + Some(Command::Status) => { + ensure_daemon()?; + send_request( + json!({ + "id": request_id("status"), + "type": "status", + }), + ResultMode::Status, + ) + } + Some(Command::Stop) => { + if !is_daemon_running() { + println!("Daemon is not running."); + return Ok(0); + } + + let daemon_pid = current_daemon_pid(); + + let exit_code = send_request( + json!({ + "id": request_id("stop"), + "type": "stop", + }), + ResultMode::None, + )?; + + if exit_code == 0 { + if let Some(pid) = daemon_pid { + wait_for_daemon_exit(pid, Duration::from_secs(10))?; + } + println!("Daemon stopped."); + } + + Ok(exit_code) + } + None => { + if stdin_is_tty() { + let mut command = Cli::command(); + command.print_help()?; + println!(); + return Ok(2); + } + + let script = read_script_from_stdin()?; + run_script(&cli, script) + } + } +} + +fn run_script(cli: &Cli, script: String) -> Result> { + ensure_daemon()?; + + let timeout_ms = u64::from(cli.timeout) + .checked_mul(1_000) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "Timeout value is too large"))?; + + let mut request = json!({ + "id": request_id("execute"), + "type": "execute", + "browser": cli.browser, + "script": script, + "timeoutMs": timeout_ms, + }); + + if cli.headless { + request["headless"] = Value::Bool(true); + } + + if cli.ignore_https_errors { + request["ignoreHTTPSErrors"] = Value::Bool(true); + } + + if let Some(endpoint) = &cli.connect { + request["connect"] = Value::String(endpoint.clone()); + } + + send_request(request, ResultMode::Json) +} + +fn send_request(message: Value, result_mode: ResultMode) -> Result> { + let mut stream = connect_to_daemon()?; + send_message(&mut stream, &message)?; + let mut reader = BufReader::new(stream); + stream_responses(&mut reader, result_mode) +} + +fn stream_responses( + reader: &mut R, + result_mode: ResultMode, +) -> Result> { + loop { + let line = read_line(reader)?; + let message: Value = serde_json::from_str(line.trim_end())?; + + match message.get("type").and_then(Value::as_str) { + Some("stdout") => { + if let Some(data) = message.get("data").and_then(Value::as_str) { + print!("{data}"); + io::stdout().flush()?; + } + } + Some("stderr") => { + if let Some(data) = message.get("data").and_then(Value::as_str) { + eprint!("{data}"); + io::stderr().flush()?; + } + } + Some("result") => { + if let Some(data) = message.get("data") { + render_result(data, &result_mode)?; + } + } + Some("complete") => return Ok(0), + Some("error") => { + let error_message = message + .get("message") + .and_then(Value::as_str) + .unwrap_or("Unknown daemon error"); + eprintln!("{error_message}"); + return Ok(1); + } + _ => {} + } + } +} + +fn render_result(data: &Value, result_mode: &ResultMode) -> Result<(), Box> { + match result_mode { + ResultMode::None => {} + ResultMode::Json => { + if data.is_null() { + return Ok(()); + } + + if let Some(text) = data.as_str() { + println!("{text}"); + } else { + println!("{}", serde_json::to_string_pretty(data)?); + } + } + ResultMode::Browsers => print_browsers(data)?, + ResultMode::Status => print_status(data)?, + } + + Ok(()) +} + +fn print_browsers(data: &Value) -> Result<(), Box> { + let browsers: Vec = serde_json::from_value(data.clone())?; + if browsers.is_empty() { + println!("No browsers."); + return Ok(()); + } + + let page_values: Vec = browsers + .iter() + .map(|browser| { + if browser.pages.is_empty() { + "-".to_string() + } else { + browser.pages.join(", ") + } + }) + .collect(); + + let name_width = browsers + .iter() + .map(|browser| browser.name.len()) + .max() + .unwrap_or(4) + .max("NAME".len()); + let type_width = browsers + .iter() + .map(|browser| browser.kind.len()) + .max() + .unwrap_or(4) + .max("TYPE".len()); + let status_width = browsers + .iter() + .map(|browser| browser.status.len()) + .max() + .unwrap_or(6) + .max("STATUS".len()); + + println!( + "{: Result<(), Box> { + let status: StatusSummary = serde_json::from_value(data.clone())?; + + println!("PID: {}", status.pid); + println!("Uptime: {}", format_duration_ms(status.uptime_ms)); + println!("Browsers: {}", status.browser_count); + println!("Socket: {}", status.socket_path); + + if !status.browsers.is_empty() { + let managed = status + .browsers + .iter() + .map(|browser| format!("{} ({}, {})", browser.name, browser.kind, browser.status)) + .collect::>() + .join(", "); + println!("Managed: {managed}"); + } + + Ok(()) +} + +fn read_script_from_stdin() -> io::Result { + let mut script = String::new(); + io::stdin().read_to_string(&mut script)?; + Ok(script) +} + +fn stdin_is_tty() -> bool { + io::stdin().is_terminal() +} + +fn request_id(prefix: &str) -> String { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis(); + format!("{prefix}-{now}-{}", process::id()) +} + +fn format_duration_ms(duration_ms: u64) -> String { + if duration_ms < 1_000 { + return format!("{duration_ms}ms"); + } + + if duration_ms < 60_000 { + return format!("{:.1}s", duration_ms as f64 / 1_000.0); + } + + let total_seconds = duration_ms / 1_000; + let minutes = total_seconds / 60; + let seconds = total_seconds % 60; + format!("{minutes}m {seconds}s") +} diff --git a/cli/src/skill.rs b/cli/src/skill.rs new file mode 100644 index 00000000..61cc564f --- /dev/null +++ b/cli/src/skill.rs @@ -0,0 +1,283 @@ +use dialoguer::{theme::ColorfulTheme, MultiSelect}; +use std::error::Error; +use std::fs; +use std::io::{self, IsTerminal}; +use std::path::{Path, PathBuf}; +use std::process; +use std::time::{SystemTime, UNIX_EPOCH}; + +const EMBEDDED_DEV_BROWSER_SKILL: &str = include_str!("../../skills/dev-browser/SKILL.md"); + +struct InstallTarget { + prompt_label: &'static str, + root_display: &'static str, + file_display: &'static str, + root_relative_path: &'static str, +} + +const INSTALL_TARGETS: [InstallTarget; 3] = [ + InstallTarget { + prompt_label: "~/.claude/skills/dev-browser/", + root_display: "~/.claude/skills", + file_display: "~/.claude/skills/dev-browser/SKILL.md", + root_relative_path: ".claude/skills", + }, + InstallTarget { + prompt_label: "~/.agents/skills/dev-browser/", + root_display: "~/.agents/skills", + file_display: "~/.agents/skills/dev-browser/SKILL.md", + root_relative_path: ".agents/skills", + }, + InstallTarget { + prompt_label: "~/.codex/skills/dev-browser/", + root_display: "~/.codex/skills", + file_display: "~/.codex/skills/dev-browser/SKILL.md", + root_relative_path: ".codex/skills", + }, +]; + +enum SyncResult { + Installed, + Updated, + AlreadyInstalled, +} + +enum InstallTargetSelection { + Prompt, + Selected(Vec), +} + +pub fn install_skill( + install_claude: bool, + install_agents: bool, + install_codex: bool, +) -> Result<(), Box> { + let selections = match resolve_install_target_selection( + install_claude, + install_agents, + install_codex, + interactive_terminal_available(), + ) { + InstallTargetSelection::Prompt => { + let Some(selections) = prompt_for_install_targets()? else { + println!("Cancelled."); + return Ok(()); + }; + + selections + } + InstallTargetSelection::Selected(selections) => selections, + }; + + let home_dir = + dirs::home_dir().ok_or("Could not determine the home directory for skill installation.")?; + + if selections.is_empty() { + println!("No install targets selected."); + return Ok(()); + } + + for selection in selections { + let target = &INSTALL_TARGETS[selection]; + let result = install_target(&home_dir, target)?; + match result { + SyncResult::Installed => { + println!("Installed dev-browser skill to {}", target.file_display); + } + SyncResult::Updated => { + println!("Updated dev-browser skill at {}", target.file_display); + } + SyncResult::AlreadyInstalled => { + println!( + "dev-browser skill is already installed at {}", + target.file_display + ); + } + } + } + + Ok(()) +} + +fn resolve_install_target_selection( + install_claude: bool, + install_agents: bool, + install_codex: bool, + interactive_terminal: bool, +) -> InstallTargetSelection { + if install_claude || install_agents || install_codex { + let mut selections = Vec::new(); + if install_claude { + selections.push(0); + } + if install_agents { + selections.push(1); + } + if install_codex { + selections.push(2); + } + + return InstallTargetSelection::Selected(selections); + } + + if interactive_terminal { + InstallTargetSelection::Prompt + } else { + InstallTargetSelection::Selected((0..INSTALL_TARGETS.len()).collect()) + } +} + +fn prompt_for_install_targets() -> Result>, Box> { + let options: Vec<&str> = INSTALL_TARGETS + .iter() + .map(|target| target.prompt_label) + .collect(); + let defaults = vec![true; options.len()]; + + let selections = MultiSelect::with_theme(&ColorfulTheme::default()) + .with_prompt("Select skill directories to install dev-browser into") + .items(&options) + .defaults(&defaults) + .interact_opt()?; + + Ok(selections) +} + +fn install_target(home_dir: &Path, target: &InstallTarget) -> Result> { + let root_dir = home_dir.join(target.root_relative_path); + ensure_directory(&root_dir, target.root_display, true)?; + + let skill_dir = root_dir.join("dev-browser"); + ensure_directory(&skill_dir, target.prompt_label.trim_end_matches('/'), false)?; + + let skill_file = skill_dir.join("SKILL.md"); + sync_skill_file(&skill_file) +} + +fn ensure_directory( + path: &Path, + display_path: &str, + announce_create: bool, +) -> Result<(), Box> { + match fs::metadata(path) { + Ok(metadata) => { + if metadata.is_dir() { + return Ok(()); + } + + Err(format!("{display_path} exists but is not a directory.").into()) + } + Err(error) if error.kind() == io::ErrorKind::NotFound => { + fs::create_dir_all(path).map_err(|create_error| { + format!("Failed to create {display_path}: {create_error}") + })?; + if announce_create { + println!("Created {display_path}"); + } + Ok(()) + } + Err(error) => Err(format!("Failed to inspect {display_path}: {error}").into()), + } +} + +fn sync_skill_file(path: &Path) -> Result> { + match fs::metadata(path) { + Ok(metadata) => { + if !metadata.is_file() { + return Err(format!("{} exists but is not a file.", path.display()).into()); + } + + let existing = fs::read_to_string(path) + .map_err(|error| format!("Failed to read {}: {error}", path.display()))?; + if existing == EMBEDDED_DEV_BROWSER_SKILL { + return Ok(SyncResult::AlreadyInstalled); + } + + atomic_write(path, EMBEDDED_DEV_BROWSER_SKILL)?; + Ok(SyncResult::Updated) + } + Err(error) if error.kind() == io::ErrorKind::NotFound => { + atomic_write(path, EMBEDDED_DEV_BROWSER_SKILL)?; + Ok(SyncResult::Installed) + } + Err(error) => Err(format!("Failed to inspect {}: {error}", path.display()).into()), + } +} + +fn atomic_write(path: &Path, contents: &str) -> Result<(), Box> { + let temp_path = temp_path_for(path)?; + fs::write(&temp_path, contents) + .map_err(|error| format!("Failed to write {}: {error}", temp_path.display()))?; + + if let Err(error) = fs::rename(&temp_path, path) { + let _ = fs::remove_file(&temp_path); + return Err(format!("Failed to replace {}: {error}", path.display()).into()); + } + + Ok(()) +} + +fn temp_path_for(path: &Path) -> Result> { + let file_name = path + .file_name() + .ok_or_else(|| format!("Could not determine a file name for {}", path.display()))? + .to_string_lossy(); + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + + Ok(path.with_file_name(format!(".{file_name}.tmp-{}-{nonce}", process::id()))) +} + +fn interactive_terminal_available() -> bool { + io::stdin().is_terminal() && io::stderr().is_terminal() +} + +#[cfg(test)] +mod tests { + use super::{resolve_install_target_selection, InstallTargetSelection}; + + #[test] + fn explicit_claude_flag_skips_prompt() { + 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, false, true); + assert_selected(selection, &[1]); + } + + #[test] + 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, false, true); + assert!(matches!(selection, InstallTargetSelection::Prompt)); + } + + #[test] + 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]) { + match selection { + InstallTargetSelection::Prompt => panic!("expected explicit selection"), + InstallTargetSelection::Selected(actual) => assert_eq!(actual, expected), + } + } +} diff --git a/daemon/package.json b/daemon/package.json new file mode 100644 index 00000000..18d1d026 --- /dev/null +++ b/daemon/package.json @@ -0,0 +1,29 @@ +{ + "name": "dev-browser-daemon", + "version": "0.1.0", + "private": true, + "type": "module", + "packageManager": "pnpm@10.30.1", + "scripts": { + "build": "tsc", + "bundle": "esbuild src/daemon.ts --bundle --platform=node --format=esm --outfile=dist/daemon.bundle.mjs --external:playwright --external:quickjs-emscripten --external:@jitl/*", + "bundle:sandbox-client": "tsx scripts/bundle-sandbox-client.ts", + "dev": "tsx src/daemon.ts", + "format": "prettier --write \"**/*.{ts,json,md}\"", + "format:check": "prettier --check \"**/*.{ts,json,md}\"" + }, + "dependencies": { + "playwright": "1.58.2", + "playwright-core": "1.58.2", + "quickjs-emscripten": "^0.32.0", + "zod": "^3.25.76" + }, + "devDependencies": { + "@types/node": "^24.0.0", + "esbuild": "^0.27.4", + "prettier": "^3.6.2", + "tsx": "^4.20.0", + "typescript": "^5.8.0", + "vitest": "^4.1.0" + } +} diff --git a/daemon/pnpm-lock.yaml b/daemon/pnpm-lock.yaml new file mode 100644 index 00000000..562568d7 --- /dev/null +++ b/daemon/pnpm-lock.yaml @@ -0,0 +1,1176 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + playwright: + specifier: 1.58.2 + version: 1.58.2 + playwright-core: + specifier: 1.58.2 + version: 1.58.2 + quickjs-emscripten: + specifier: ^0.32.0 + version: 0.32.0 + zod: + specifier: ^3.25.76 + version: 3.25.76 + devDependencies: + '@types/node': + specifier: ^24.0.0 + version: 24.12.0 + esbuild: + specifier: ^0.27.4 + version: 0.27.4 + prettier: + specifier: ^3.6.2 + version: 3.8.1 + tsx: + specifier: ^4.20.0 + version: 4.21.0 + typescript: + specifier: ^5.8.0 + version: 5.9.3 + vitest: + specifier: ^4.1.0 + version: 4.1.0(@types/node@24.12.0)(vite@8.0.1(@types/node@24.12.0)(esbuild@0.27.4)(tsx@4.21.0)) + +packages: + + '@emnapi/core@1.9.0': + resolution: {integrity: sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w==} + + '@emnapi/runtime@1.9.0': + resolution: {integrity: sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw==} + + '@emnapi/wasi-threads@1.2.0': + resolution: {integrity: sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==} + + '@esbuild/aix-ppc64@0.27.4': + resolution: {integrity: sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.4': + resolution: {integrity: sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.4': + resolution: {integrity: sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.4': + resolution: {integrity: sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.4': + resolution: {integrity: sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.4': + resolution: {integrity: sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.4': + resolution: {integrity: sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.4': + resolution: {integrity: sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.4': + resolution: {integrity: sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.4': + resolution: {integrity: sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.4': + resolution: {integrity: sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.4': + resolution: {integrity: sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.4': + resolution: {integrity: sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.4': + resolution: {integrity: sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.4': + resolution: {integrity: sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.4': + resolution: {integrity: sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.4': + resolution: {integrity: sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.4': + resolution: {integrity: sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.4': + resolution: {integrity: sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.4': + resolution: {integrity: sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.4': + resolution: {integrity: sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.4': + resolution: {integrity: sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.4': + resolution: {integrity: sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.4': + resolution: {integrity: sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.4': + resolution: {integrity: sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.4': + resolution: {integrity: sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@jitl/quickjs-ffi-types@0.32.0': + resolution: {integrity: sha512-v9T+GQpmk43VDJ7d72sf0Nexhk+ArvtUihW27dy7lqAl0zBObFKtSBBIm5RBjwIhE8VwsPPm9PNuvPvNqLWUEg==} + + '@jitl/quickjs-wasmfile-debug-asyncify@0.32.0': + resolution: {integrity: sha512-EX8zbXwGqCgAE764M+qvkHtyXDi/FUoMBea0JnES7vCM3P7a2+EOZOjGv85wtZ2sJhI1oJ+nekmqpOODFDY+hw==} + + '@jitl/quickjs-wasmfile-debug-sync@0.32.0': + resolution: {integrity: sha512-LeYWrPGC1uNCTBWvibo3ZLJj0CSVNYUXvJpXMCmuQ5Sap2cCACc3uvGvYV4homHHBAzfw5akoTqMMS4YFRtw+Q==} + + '@jitl/quickjs-wasmfile-release-asyncify@0.32.0': + resolution: {integrity: sha512-3oSwPfja12ICz4aIblB58cuY8JlEq5Txt8Cut4VLo+LH47QN+mzCnSgnbB03hWzg1LBcc+VyyI9UOag7a1NF+Q==} + + '@jitl/quickjs-wasmfile-release-sync@0.32.0': + resolution: {integrity: sha512-BKNDI/TPBfGlLNGYpLrhcDGXmIk4xHm4MRAisOBnOzpXVn9HZWsfmMAc9WMBrAHjvvds6HOikKeaOBKdPdpVrg==} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@napi-rs/wasm-runtime@1.1.1': + resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==} + + '@oxc-project/types@0.120.0': + resolution: {integrity: sha512-k1YNu55DuvAip/MGE1FTsIuU3FUCn6v/ujG9V7Nq5Df/kX2CWb13hhwD0lmJGMGqE+bE1MXvv9SZVnMzEXlWcg==} + + '@rolldown/binding-android-arm64@1.0.0-rc.10': + resolution: {integrity: sha512-jOHxwXhxmFKuXztiu1ORieJeTbx5vrTkcOkkkn2d35726+iwhrY1w/+nYY/AGgF12thg33qC3R1LMBF5tHTZHg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.0.0-rc.10': + resolution: {integrity: sha512-gED05Teg/vtTZbIJBc4VNMAxAFDUPkuO/rAIyyxZjTj1a1/s6z5TII/5yMGZ0uLRCifEtwUQn8OlYzuYc0m70w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.0.0-rc.10': + resolution: {integrity: sha512-rI15NcM1mA48lqrIxVkHfAqcyFLcQwyXWThy+BQ5+mkKKPvSO26ir+ZDp36AgYoYVkqvMcdS8zOE6SeBsR9e8A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.0.0-rc.10': + resolution: {integrity: sha512-XZRXHdTa+4ME1MuDVp021+doQ+z6Ei4CCFmNc5/sKbqb8YmkiJdj8QKlV3rCI0AJtAeSB5n0WGPuJWNL9p/L2w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.10': + resolution: {integrity: sha512-R0SQMRluISSLzFE20sPWYHVmJdDQnRyc/FzSCN72BqQmh2SOZUFG+N3/vBZpR4C6WpEUVYJLrYUXaj43sJsNLA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.10': + resolution: {integrity: sha512-Y1reMrV/o+cwpduYhJuOE3OMKx32RMYCidf14y+HssARRmhDuWXJ4yVguDg2R/8SyyGNo+auzz64LnPK9Hq6jg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.10': + resolution: {integrity: sha512-vELN+HNb2IzuzSBUOD4NHmP9yrGwl1DVM29wlQvx1OLSclL0NgVWnVDKl/8tEks79EFek/kebQKnNJkIAA4W2g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.10': + resolution: {integrity: sha512-ZqrufYTgzxbHwpqOjzSsb0UV/aV2TFIY5rP8HdsiPTv/CuAgCRjM6s9cYFwQ4CNH+hf9Y4erHW1GjZuZ7WoI7w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.10': + resolution: {integrity: sha512-gSlmVS1FZJSRicA6IyjoRoKAFK7IIHBs7xJuHRSmjImqk3mPPWbR7RhbnfH2G6bcmMEllCt2vQ/7u9e6bBnByg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.10': + resolution: {integrity: sha512-eOCKUpluKgfObT2pHjztnaWEIbUabWzk3qPZ5PuacuPmr4+JtQG4k2vGTY0H15edaTnicgU428XW/IH6AimcQw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.0.0-rc.10': + resolution: {integrity: sha512-Xdf2jQbfQowJnLcgYfD/m0Uu0Qj5OdxKallD78/IPPfzaiaI4KRAwZzHcKQ4ig1gtg1SuzC7jovNiM2TzQsBXA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.0.0-rc.10': + resolution: {integrity: sha512-o1hYe8hLi1EY6jgPFyxQgQ1wcycX+qz8eEbVmot2hFkgUzPxy9+kF0u0NIQBeDq+Mko47AkaFFaChcvZa9UX9Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.0.0-rc.10': + resolution: {integrity: sha512-Ugv9o7qYJudqQO5Y5y2N2SOo6S4WiqiNOpuQyoPInnhVzCY+wi/GHltcLHypG9DEUYMB0iTB/huJrpadiAcNcA==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.10': + resolution: {integrity: sha512-7UODQb4fQUNT/vmgDZBl3XOBAIOutP5R3O/rkxg0aLfEGQ4opbCgU5vOw/scPe4xOqBwL9fw7/RP1vAMZ6QlAQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.10': + resolution: {integrity: sha512-PYxKHMVHOb5NJuDL53vBUl1VwUjymDcYI6rzpIni0C9+9mTiJedvUxSk7/RPp7OOAm3v+EjgMu9bIy3N6b408w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.0-rc.10': + resolution: {integrity: sha512-UkVDEFk1w3mveXeKgaTuYfKWtPbvgck1dT8TUG3bnccrH0XtLTuAyfCoks4Q/M5ZGToSVJTIQYCzy2g/atAOeg==} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@tybys/wasm-util@0.10.1': + resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/node@24.12.0': + resolution: {integrity: sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==} + + '@vitest/expect@4.1.0': + resolution: {integrity: sha512-EIxG7k4wlWweuCLG9Y5InKFwpMEOyrMb6ZJ1ihYu02LVj/bzUwn2VMU+13PinsjRW75XnITeFrQBMH5+dLvCDA==} + + '@vitest/mocker@4.1.0': + resolution: {integrity: sha512-evxREh+Hork43+Y4IOhTo+h5lGmVRyjqI739Rz4RlUPqwrkFFDF6EMvOOYjTx4E8Tl6gyCLRL8Mu7Ry12a13Tw==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.0': + resolution: {integrity: sha512-3RZLZlh88Ib0J7NQTRATfc/3ZPOnSUn2uDBUoGNn5T36+bALixmzphN26OUD3LRXWkJu4H0s5vvUeqBiw+kS0A==} + + '@vitest/runner@4.1.0': + resolution: {integrity: sha512-Duvx2OzQ7d6OjchL+trw+aSrb9idh7pnNfxrklo14p3zmNL4qPCDeIJAK+eBKYjkIwG96Bc6vYuxhqDXQOWpoQ==} + + '@vitest/snapshot@4.1.0': + resolution: {integrity: sha512-0Vy9euT1kgsnj1CHttwi9i9o+4rRLEaPRSOJ5gyv579GJkNpgJK+B4HSv/rAWixx2wdAFci1X4CEPjiu2bXIMg==} + + '@vitest/spy@4.1.0': + resolution: {integrity: sha512-pz77k+PgNpyMDv2FV6qmk5ZVau6c3R8HC8v342T2xlFxQKTrSeYw9waIJG8KgV9fFwAtTu4ceRzMivPTH6wSxw==} + + '@vitest/utils@4.1.0': + resolution: {integrity: sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + es-module-lexer@2.0.0: + resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==} + + esbuild@0.27.4: + resolution: {integrity: sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==} + engines: {node: '>=18'} + hasBin: true + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + get-tsconfig@4.13.6: + resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==} + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + engines: {node: '>=12'} + + playwright-core@1.58.2: + resolution: {integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.58.2: + resolution: {integrity: sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==} + engines: {node: '>=18'} + hasBin: true + + postcss@8.5.8: + resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} + engines: {node: ^10 || ^12 || >=14} + + prettier@3.8.1: + resolution: {integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==} + engines: {node: '>=14'} + hasBin: true + + quickjs-emscripten-core@0.32.0: + resolution: {integrity: sha512-QFnPfjFey8EqknSrSxe1hZrf1/8z7/6s1QzGOmKo6++02r7QRRX7ZoyNaZh7JuVjWsVW87KnQrbZqnHkOAzUyg==} + + quickjs-emscripten@0.32.0: + resolution: {integrity: sha512-So0Sqw869y/S2oE3Nuc0uT3Dhqgvsj8FSrwBdsuTosVsG8ME5/OcudU1GxsrIFdFABgy17GHnTVO9TYV/bLQcA==} + engines: {node: '>=16.0.0'} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + rolldown@1.0.0-rc.10: + resolution: {integrity: sha512-q7j6vvarRFmKpgJUT8HCAUljkgzEp4LAhPlJUvQhA5LA1SUL36s5QCysMutErzL3EbNOZOkoziSx9iZC4FddKA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.0.0: + resolution: {integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.0.4: + resolution: {integrity: sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==} + engines: {node: '>=18'} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tsx@4.21.0: + resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} + engines: {node: '>=18.0.0'} + hasBin: true + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + + vite@8.0.1: + resolution: {integrity: sha512-wt+Z2qIhfFt85uiyRt5LPU4oVEJBXj8hZNWKeqFG4gRG/0RaRGJ7njQCwzFVjO+v4+Ipmf5CY7VdmZRAYYBPHw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.1.0 + esbuild: ^0.27.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.0: + resolution: {integrity: sha512-YbDrMF9jM2Lqc++2530UourxZHmkKLxrs4+mYhEwqWS97WJ7wOYEkcr+QfRgJ3PW9wz3odRijLZjHEaRLTNbqw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.0 + '@vitest/browser-preview': 4.1.0 + '@vitest/browser-webdriverio': 4.1.0 + '@vitest/ui': 4.1.0 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0-0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + +snapshots: + + '@emnapi/core@1.9.0': + dependencies: + '@emnapi/wasi-threads': 1.2.0 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.9.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@esbuild/aix-ppc64@0.27.4': + optional: true + + '@esbuild/android-arm64@0.27.4': + optional: true + + '@esbuild/android-arm@0.27.4': + optional: true + + '@esbuild/android-x64@0.27.4': + optional: true + + '@esbuild/darwin-arm64@0.27.4': + optional: true + + '@esbuild/darwin-x64@0.27.4': + optional: true + + '@esbuild/freebsd-arm64@0.27.4': + optional: true + + '@esbuild/freebsd-x64@0.27.4': + optional: true + + '@esbuild/linux-arm64@0.27.4': + optional: true + + '@esbuild/linux-arm@0.27.4': + optional: true + + '@esbuild/linux-ia32@0.27.4': + optional: true + + '@esbuild/linux-loong64@0.27.4': + optional: true + + '@esbuild/linux-mips64el@0.27.4': + optional: true + + '@esbuild/linux-ppc64@0.27.4': + optional: true + + '@esbuild/linux-riscv64@0.27.4': + optional: true + + '@esbuild/linux-s390x@0.27.4': + optional: true + + '@esbuild/linux-x64@0.27.4': + optional: true + + '@esbuild/netbsd-arm64@0.27.4': + optional: true + + '@esbuild/netbsd-x64@0.27.4': + optional: true + + '@esbuild/openbsd-arm64@0.27.4': + optional: true + + '@esbuild/openbsd-x64@0.27.4': + optional: true + + '@esbuild/openharmony-arm64@0.27.4': + optional: true + + '@esbuild/sunos-x64@0.27.4': + optional: true + + '@esbuild/win32-arm64@0.27.4': + optional: true + + '@esbuild/win32-ia32@0.27.4': + optional: true + + '@esbuild/win32-x64@0.27.4': + optional: true + + '@jitl/quickjs-ffi-types@0.32.0': {} + + '@jitl/quickjs-wasmfile-debug-asyncify@0.32.0': + dependencies: + '@jitl/quickjs-ffi-types': 0.32.0 + + '@jitl/quickjs-wasmfile-debug-sync@0.32.0': + dependencies: + '@jitl/quickjs-ffi-types': 0.32.0 + + '@jitl/quickjs-wasmfile-release-asyncify@0.32.0': + dependencies: + '@jitl/quickjs-ffi-types': 0.32.0 + + '@jitl/quickjs-wasmfile-release-sync@0.32.0': + dependencies: + '@jitl/quickjs-ffi-types': 0.32.0 + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@napi-rs/wasm-runtime@1.1.1': + dependencies: + '@emnapi/core': 1.9.0 + '@emnapi/runtime': 1.9.0 + '@tybys/wasm-util': 0.10.1 + optional: true + + '@oxc-project/types@0.120.0': {} + + '@rolldown/binding-android-arm64@1.0.0-rc.10': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.0-rc.10': + optional: true + + '@rolldown/binding-darwin-x64@1.0.0-rc.10': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.0-rc.10': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.10': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.10': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.10': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.10': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.10': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.10': + optional: true + + '@rolldown/binding-linux-x64-musl@1.0.0-rc.10': + optional: true + + '@rolldown/binding-openharmony-arm64@1.0.0-rc.10': + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.0-rc.10': + dependencies: + '@napi-rs/wasm-runtime': 1.1.1 + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.10': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.10': + optional: true + + '@rolldown/pluginutils@1.0.0-rc.10': {} + + '@standard-schema/spec@1.1.0': {} + + '@tybys/wasm-util@0.10.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.8': {} + + '@types/node@24.12.0': + dependencies: + undici-types: 7.16.0 + + '@vitest/expect@4.1.0': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.0 + '@vitest/utils': 4.1.0 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.0(vite@8.0.1(@types/node@24.12.0)(esbuild@0.27.4)(tsx@4.21.0))': + dependencies: + '@vitest/spy': 4.1.0 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.0.1(@types/node@24.12.0)(esbuild@0.27.4)(tsx@4.21.0) + + '@vitest/pretty-format@4.1.0': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.0': + dependencies: + '@vitest/utils': 4.1.0 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.0': + dependencies: + '@vitest/pretty-format': 4.1.0 + '@vitest/utils': 4.1.0 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.0': {} + + '@vitest/utils@4.1.0': + dependencies: + '@vitest/pretty-format': 4.1.0 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + + assertion-error@2.0.1: {} + + chai@6.2.2: {} + + convert-source-map@2.0.0: {} + + detect-libc@2.1.2: {} + + es-module-lexer@2.0.0: {} + + esbuild@0.27.4: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.4 + '@esbuild/android-arm': 0.27.4 + '@esbuild/android-arm64': 0.27.4 + '@esbuild/android-x64': 0.27.4 + '@esbuild/darwin-arm64': 0.27.4 + '@esbuild/darwin-x64': 0.27.4 + '@esbuild/freebsd-arm64': 0.27.4 + '@esbuild/freebsd-x64': 0.27.4 + '@esbuild/linux-arm': 0.27.4 + '@esbuild/linux-arm64': 0.27.4 + '@esbuild/linux-ia32': 0.27.4 + '@esbuild/linux-loong64': 0.27.4 + '@esbuild/linux-mips64el': 0.27.4 + '@esbuild/linux-ppc64': 0.27.4 + '@esbuild/linux-riscv64': 0.27.4 + '@esbuild/linux-s390x': 0.27.4 + '@esbuild/linux-x64': 0.27.4 + '@esbuild/netbsd-arm64': 0.27.4 + '@esbuild/netbsd-x64': 0.27.4 + '@esbuild/openbsd-arm64': 0.27.4 + '@esbuild/openbsd-x64': 0.27.4 + '@esbuild/openharmony-arm64': 0.27.4 + '@esbuild/sunos-x64': 0.27.4 + '@esbuild/win32-arm64': 0.27.4 + '@esbuild/win32-ia32': 0.27.4 + '@esbuild/win32-x64': 0.27.4 + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + + expect-type@1.3.0: {} + + fdir@6.5.0(picomatch@4.0.3): + optionalDependencies: + picomatch: 4.0.3 + + fsevents@2.3.2: + optional: true + + fsevents@2.3.3: + optional: true + + get-tsconfig@4.13.6: + dependencies: + resolve-pkg-maps: 1.0.0 + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + nanoid@3.3.11: {} + + obug@2.1.1: {} + + pathe@2.0.3: {} + + picocolors@1.1.1: {} + + picomatch@4.0.3: {} + + playwright-core@1.58.2: {} + + playwright@1.58.2: + dependencies: + playwright-core: 1.58.2 + optionalDependencies: + fsevents: 2.3.2 + + postcss@8.5.8: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prettier@3.8.1: {} + + quickjs-emscripten-core@0.32.0: + dependencies: + '@jitl/quickjs-ffi-types': 0.32.0 + + quickjs-emscripten@0.32.0: + dependencies: + '@jitl/quickjs-wasmfile-debug-asyncify': 0.32.0 + '@jitl/quickjs-wasmfile-debug-sync': 0.32.0 + '@jitl/quickjs-wasmfile-release-asyncify': 0.32.0 + '@jitl/quickjs-wasmfile-release-sync': 0.32.0 + quickjs-emscripten-core: 0.32.0 + + resolve-pkg-maps@1.0.0: {} + + rolldown@1.0.0-rc.10: + dependencies: + '@oxc-project/types': 0.120.0 + '@rolldown/pluginutils': 1.0.0-rc.10 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.0-rc.10 + '@rolldown/binding-darwin-arm64': 1.0.0-rc.10 + '@rolldown/binding-darwin-x64': 1.0.0-rc.10 + '@rolldown/binding-freebsd-x64': 1.0.0-rc.10 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.10 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.10 + '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.10 + '@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.10 + '@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.10 + '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.10 + '@rolldown/binding-linux-x64-musl': 1.0.0-rc.10 + '@rolldown/binding-openharmony-arm64': 1.0.0-rc.10 + '@rolldown/binding-wasm32-wasi': 1.0.0-rc.10 + '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.10 + '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.10 + + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@4.0.0: {} + + tinybench@2.9.0: {} + + tinyexec@1.0.4: {} + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + + tinyrainbow@3.1.0: {} + + tslib@2.8.1: + optional: true + + tsx@4.21.0: + dependencies: + esbuild: 0.27.4 + get-tsconfig: 4.13.6 + optionalDependencies: + fsevents: 2.3.3 + + typescript@5.9.3: {} + + undici-types@7.16.0: {} + + vite@8.0.1(@types/node@24.12.0)(esbuild@0.27.4)(tsx@4.21.0): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.3 + postcss: 8.5.8 + rolldown: 1.0.0-rc.10 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 24.12.0 + esbuild: 0.27.4 + fsevents: 2.3.3 + tsx: 4.21.0 + + vitest@4.1.0(@types/node@24.12.0)(vite@8.0.1(@types/node@24.12.0)(esbuild@0.27.4)(tsx@4.21.0)): + dependencies: + '@vitest/expect': 4.1.0 + '@vitest/mocker': 4.1.0(vite@8.0.1(@types/node@24.12.0)(esbuild@0.27.4)(tsx@4.21.0)) + '@vitest/pretty-format': 4.1.0 + '@vitest/runner': 4.1.0 + '@vitest/snapshot': 4.1.0 + '@vitest/spy': 4.1.0 + '@vitest/utils': 4.1.0 + es-module-lexer: 2.0.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 4.0.0 + tinybench: 2.9.0 + tinyexec: 1.0.4 + tinyglobby: 0.2.15 + tinyrainbow: 3.1.0 + vite: 8.0.1(@types/node@24.12.0)(esbuild@0.27.4)(tsx@4.21.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.12.0 + transitivePeerDependencies: + - msw + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + zod@3.25.76: {} diff --git a/daemon/scripts/bundle-sandbox-client.ts b/daemon/scripts/bundle-sandbox-client.ts new file mode 100644 index 00000000..43b937ad --- /dev/null +++ b/daemon/scripts/bundle-sandbox-client.ts @@ -0,0 +1,24 @@ +import { mkdir } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; + +import { build } from "esbuild"; + +const daemonDir = resolve(import.meta.dirname, ".."); +const entryPoint = resolve(daemonDir, "src/sandbox/forked-client/bundle-entry.ts"); +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, + format: "iife", + globalName: "__PlaywrightClient", + outfile, + platform: "neutral", + target: "es2022", +}); diff --git a/daemon/src/browser-manager-pages.test.ts b/daemon/src/browser-manager-pages.test.ts new file mode 100644 index 00000000..9955c16c --- /dev/null +++ b/daemon/src/browser-manager-pages.test.ts @@ -0,0 +1,140 @@ +import { mkdtemp } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; + +import { BrowserManager } from "./browser-manager.js"; +import { removeDirectoryWithRetries } from "./test-cleanup.js"; + +const browserName = "browser-manager-pages"; + +function createDataUrl(title: string, body: string): string { + return `data:text/html,${encodeURIComponent(`${title}${body}`)}`; +} + +describe.sequential("BrowserManager page discovery", () => { + let browserRootDir = ""; + let manager: BrowserManager; + + beforeAll(async () => { + browserRootDir = await mkdtemp(path.join(os.tmpdir(), "dev-browser-manager-pages-")); + manager = new BrowserManager(path.join(browserRootDir, "browsers")); + }, 180_000); + + afterEach(async () => { + await manager.stopBrowser(browserName); + }, 180_000); + + afterAll(async () => { + await manager.stopAll(); + await removeDirectoryWithRetries(browserRootDir); + }, 180_000); + + async function ensureBrowser(): Promise { + await manager.ensureBrowser(browserName, { + headless: true, + }); + } + + it("listPages returns objects with id, url, title, and name fields", async () => { + await ensureBrowser(); + + const anonymousPage = await manager.newPage(browserName); + await anonymousPage.goto(createDataUrl("Anonymous Tab", "

anonymous

")); + + const pages = await manager.listPages(browserName); + const anonymousSummary = pages.find( + (page) => page.name === null && page.title === "Anonymous Tab" + ); + + expect(anonymousSummary).toBeDefined(); + expect(anonymousSummary).toEqual( + expect.objectContaining({ + id: expect.stringMatching(/^[a-f0-9]+$/i), + name: null, + title: "Anonymous Tab", + url: expect.stringContaining("data:text/html"), + }) + ); + + for (const page of pages) { + expect(typeof page.id).toBe("string"); + expect(typeof page.url).toBe("string"); + expect(typeof page.title).toBe("string"); + expect(page.name === null || typeof page.name === "string").toBe(true); + } + }, 120_000); + + it("listPages includes pages created via getPage with their name", async () => { + await ensureBrowser(); + + const namedPage = await manager.getPage(browserName, "dashboard"); + await namedPage.goto(createDataUrl("Dashboard", "
named page
")); + + await expect(manager.listPages(browserName)).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: expect.stringMatching(/^[a-f0-9]+$/i), + name: "dashboard", + title: "Dashboard", + url: expect.stringContaining("data:text/html"), + }), + ]) + ); + }, 120_000); + + it("getPage accepts a targetId for an existing tab", async () => { + await ensureBrowser(); + + const existingPage = await manager.newPage(browserName); + await existingPage.goto(createDataUrl("Target Tab", "

existing tab

")); + + const targetSummary = (await manager.listPages(browserName)).find( + (page) => page.name === null && page.title === "Target Tab" + ); + + expect(targetSummary).toBeDefined(); + + const connectedPage = await manager.getPage(browserName, targetSummary!.id); + + expect(connectedPage).toBe(existingPage); + expect( + (await manager.listPages(browserName)).filter((page) => page.title === "Target Tab") + ).toHaveLength(1); + }, 120_000); + + it("getPage with a name still returns the existing named page", async () => { + await ensureBrowser(); + + const firstPage = await manager.getPage(browserName, "persist"); + await firstPage.goto(createDataUrl("Persist", "
same page
")); + await firstPage.evaluate(() => { + window.name = "persisted-state"; + }); + + const secondPage = await manager.getPage(browserName, "persist"); + + expect(secondPage).toBe(firstPage); + await expect(secondPage.evaluate(() => window.name)).resolves.toBe("persisted-state"); + expect( + (await manager.listPages(browserName)).filter((page) => page.name === "persist") + ).toHaveLength(1); + }, 120_000); + + it("stopBrowser closes launched browser pages before removing the browser", async () => { + await ensureBrowser(); + + const namedPage = await manager.getPage(browserName, "cleanup"); + const anonymousPage = await manager.newPage(browserName); + + await namedPage.goto(createDataUrl("Cleanup Named", "
named
")); + await anonymousPage.goto(createDataUrl("Cleanup Anonymous", "
anon
")); + + await manager.stopBrowser(browserName); + + expect(namedPage.isClosed()).toBe(true); + expect(anonymousPage.isClosed()).toBe(true); + expect(manager.listBrowsers()).toEqual([]); + }, 120_000); +}); diff --git a/daemon/src/browser-manager-title-timeout.test.ts b/daemon/src/browser-manager-title-timeout.test.ts new file mode 100644 index 00000000..52c6f78a --- /dev/null +++ b/daemon/src/browser-manager-title-timeout.test.ts @@ -0,0 +1,128 @@ +import type { Browser, BrowserContext, Page } from "playwright"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { BrowserManager, type BrowserEntry } from "./browser-manager.js"; + +const browserName = "browser-manager-title-timeout"; + +type BrowserManagerInternals = { + browsers: Map; + getPageTargetId: (context: BrowserContext, page: Page) => Promise; +}; + +function createMockEntry(pages: Page | Page[]): BrowserEntry { + const pageList = Array.isArray(pages) ? pages : [pages]; + const context = { + pages: () => pageList, + } as unknown as BrowserContext; + + const browser = { + contexts: () => [context], + isConnected: () => true, + } as unknown as Browser; + + return { + name: browserName, + type: "connected", + browser, + context, + pages: new Map(), + endpoint: "ws://127.0.0.1:9222/devtools/browser/test", + headless: false, + ignoreHTTPSErrors: false, + }; +} + +describe("BrowserManager listPages title handling", () => { + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it("falls back to an empty title when page.title never resolves", async () => { + vi.useFakeTimers(); + + const page = { + isClosed: () => false, + on: () => undefined, + title: () => new Promise(() => {}), + url: () => "chrome://blank", + } as unknown as Page; + + const manager = new BrowserManager("/tmp/dev-browser-title-timeout"); + const internals = manager as unknown as BrowserManagerInternals; + internals.browsers.set(browserName, createMockEntry(page)); + vi.spyOn(internals, "getPageTargetId").mockResolvedValue("target-1"); + + const pagesPromise = manager.listPages(browserName); + await vi.advanceTimersByTimeAsync(1_500); + + await expect(pagesPromise).resolves.toEqual([ + { + id: "target-1", + name: null, + title: "", + url: "chrome://blank", + }, + ]); + }); + + 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, + on: () => undefined, + title: () => Promise.reject(new Error("title failed")), + url: () => "chrome://broken", + } as unknown as Page; + + const manager = new BrowserManager("/tmp/dev-browser-title-errors"); + const internals = manager as unknown as BrowserManagerInternals; + internals.browsers.set(browserName, createMockEntry(page)); + vi.spyOn(internals, "getPageTargetId").mockResolvedValue("target-2"); + + await expect(manager.listPages(browserName)).rejects.toThrow("title failed"); + }); +}); diff --git a/daemon/src/browser-manager.ts b/daemon/src/browser-manager.ts new file mode 100644 index 00000000..f0ba0f43 --- /dev/null +++ b/daemon/src/browser-manager.ts @@ -0,0 +1,939 @@ +import { mkdir, readFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { chromium, type Browser, type BrowserContext, type Page } from "playwright"; + +export interface BrowserEntry { + name: string; + type: "launched" | "connected"; + browser: Browser; + context: BrowserContext; + pages: Map; + profileDir?: string; + endpoint?: string; + headless: boolean; + ignoreHTTPSErrors: boolean; +} + +interface BrowserSummary { + name: string; + type: BrowserEntry["type"]; + status: "running" | "connected" | "disconnected"; + pages: string[]; +} + +interface BrowserPageSummary { + id: string; + url: string; + title: string; + name: string | null; +} + +type BrowserManagerDependencies = { + connectOverCDP: typeof chromium.connectOverCDP; + fetch: typeof globalThis.fetch; + homedir: () => string; + launchPersistentContext: typeof chromium.launchPersistentContext; + mkdir: typeof mkdir; + platform: NodeJS.Platform; + readFile: typeof readFile; +}; + +interface BrowserOperationOptions { + deadline?: number; + signal?: AbortSignal; +} + +type DebuggerWebSocketLookupResult = + | { + status: "ok"; + webSocketDebuggerUrl: string; + } + | { + status: "not-found" | "unavailable"; + }; + +const DISCOVERY_PORTS = [9222, 9223, 9224, 9225, 9226, 9227, 9228, 9229]; +const PROBE_TIMEOUT_MS = 750; +const MANUAL_CONNECT_TIMEOUT_MS = 5_000; +const PAGE_TITLE_TIMEOUT_MS = 1_500; +const TARGET_ID_PATTERN = /^[a-f0-9]{16,}$/i; + +function isIgnorableFileError(error: unknown): boolean { + const code = (error as NodeJS.ErrnoException | undefined)?.code; + return code === "ENOENT" || code === "ENOTDIR" || code === "EACCES"; +} + +function isHttpEndpoint(endpoint: string): boolean { + return endpoint.startsWith("http://") || endpoint.startsWith("https://"); +} + +export class BrowserManager { + private readonly browsers = new Map(); + private readonly baseDir: string; + private readonly dependencies: BrowserManagerDependencies; + + constructor( + baseDir = path.join(os.homedir(), ".dev-browser", "browsers"), + dependencies: Partial = {} + ) { + this.baseDir = baseDir; + this.dependencies = { + connectOverCDP: chromium.connectOverCDP.bind(chromium) as typeof chromium.connectOverCDP, + fetch: globalThis.fetch, + homedir: os.homedir, + launchPersistentContext: chromium.launchPersistentContext.bind( + chromium + ) as typeof chromium.launchPersistentContext, + mkdir, + platform: process.platform, + readFile, + ...dependencies, + }; + } + + async ensureBrowser( + name: string, + 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 = + options.ignoreHTTPSErrors ?? existing?.ignoreHTTPSErrors ?? false; + + if (existing) { + const needsRelaunch = + existing.type !== "launched" || + !existing.browser.isConnected() || + (options.headless !== undefined && existing.headless !== requestedHeadless) || + (options.ignoreHTTPSErrors !== undefined && + existing.ignoreHTTPSErrors !== requestedIgnoreHTTPSErrors); + + if (!needsRelaunch) { + return existing; + } + + await this.stopBrowser(name); + } + + return this.launchBrowser(name, requestedHeadless, requestedIgnoreHTTPSErrors, options); + } + + async autoConnect(name: string, options: BrowserOperationOptions = {}): Promise { + this.throwIfOperationAborted(options); + await this.ensureBaseDir(); + this.throwIfOperationAborted(options); + + const existing = this.browsers.get(name); + if (existing?.type === "connected" && existing.browser.isConnected()) { + return existing; + } + + if (existing) { + await this.stopBrowser(name); + } + + const attemptedEndpoints = new Set(); + let lastError: unknown; + + const tryEndpoint = async (endpoint: string | null): Promise => { + if (!endpoint || attemptedEndpoints.has(endpoint)) { + return null; + } + + attemptedEndpoints.add(endpoint); + + try { + return await this.openConnectedBrowser(name, endpoint, options); + } catch (error) { + this.throwIfOperationAborted(options); + lastError = error; + return null; + } + }; + + const devToolsEndpoint = await this.readDevToolsActivePort(); + this.throwIfOperationAborted(options); + const devToolsBrowser = await tryEndpoint(devToolsEndpoint); + if (devToolsBrowser) { + return devToolsBrowser; + } + + for (const port of DISCOVERY_PORTS) { + this.throwIfOperationAborted(options); + const endpoint = await this.probePort(port); + this.throwIfOperationAborted(options); + const connectedBrowser = await tryEndpoint(endpoint); + if (connectedBrowser) { + return connectedBrowser; + } + } + + throw new Error(this.buildAutoConnectError(lastError)); + } + + async connectBrowser( + name: string, + endpoint: string, + options: BrowserOperationOptions = {} + ): Promise { + if (endpoint === "auto") { + return this.autoConnect(name, options); + } + + this.throwIfOperationAborted(options); + await this.ensureBaseDir(); + this.throwIfOperationAborted(options); + const resolvedEndpoint = await this.resolveEndpoint(endpoint); + this.throwIfOperationAborted(options); + + const existing = this.browsers.get(name); + if (existing) { + const isSameConnection = + existing.type === "connected" && + existing.endpoint === resolvedEndpoint && + existing.browser.isConnected(); + + if (isSameConnection) { + return existing; + } + + await this.stopBrowser(name); + } + + return this.openConnectedBrowser(name, resolvedEndpoint, options); + } + + getBrowser(name: string): BrowserEntry | undefined { + const entry = this.browsers.get(name); + if (!entry || !entry.browser.isConnected()) { + return undefined; + } + + return entry; + } + + async getPage(browserName: string, pageNameOrId: string): Promise { + const entry = this.getBrowserEntry(browserName); + const existingPage = entry.pages.get(pageNameOrId); + + if (existingPage && !existingPage.isClosed()) { + return existingPage; + } + + entry.pages.delete(pageNameOrId); + + if (TARGET_ID_PATTERN.test(pageNameOrId)) { + const page = await this.findPageByTargetId(entry, pageNameOrId); + if (page) { + return page; + } + } + + const page = await entry.context.newPage(); + this.registerNamedPage(entry, pageNameOrId, page); + return page; + } + + async newPage(browserName: string): Promise { + const entry = this.getBrowserEntry(browserName); + return entry.context.newPage(); + } + + async listPages(browserName: string): Promise { + const entry = this.browsers.get(browserName); + if (!entry || !entry.browser.isConnected()) { + return []; + } + + this.pruneClosedPages(entry); + const namesByPage = this.getNamedPagesByPage(entry); + 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, + }; + } + ) + ); + + return summaries.filter((summary): summary is BrowserPageSummary => summary !== null); + } + + async closePage(browserName: string, pageName: string): Promise { + const entry = this.getBrowserEntry(browserName); + const page = entry.pages.get(pageName); + + if (!page || page.isClosed()) { + entry.pages.delete(pageName); + throw new Error(`Page "${browserName}/${pageName}" not found`); + } + + entry.pages.delete(pageName); + + if (!page.isClosed()) { + await page.close(); + } + } + + listBrowsers(): BrowserSummary[] { + return Array.from(this.browsers.values()) + .map((entry) => { + this.pruneClosedPages(entry); + + const status: BrowserSummary["status"] = + entry.type === "connected" + ? entry.browser.isConnected() + ? "connected" + : "disconnected" + : entry.browser.isConnected() + ? "running" + : "disconnected"; + + return { + name: entry.name, + type: entry.type, + status, + pages: this.listNamedPages(entry), + }; + }) + .sort((left, right) => left.name.localeCompare(right.name)); + } + + async stopBrowser(name: string): Promise { + const entry = this.browsers.get(name); + if (!entry) { + return; + } + + this.browsers.delete(name); + entry.pages.clear(); + + try { + if (entry.type === "launched") { + await this.closeLaunchedBrowser(entry); + } else { + await entry.browser.close(); + } + } catch { + // Best effort during shutdown or reconnect. + } + } + + async stopAll(): Promise { + const names = Array.from(this.browsers.keys()); + await Promise.allSettled(names.map(async (name) => this.stopBrowser(name))); + } + + browserCount(): number { + return this.browsers.size; + } + + private async ensureBaseDir(): Promise { + await this.dependencies.mkdir(this.baseDir, { recursive: true }); + } + + private getBrowserEntry(name: string): BrowserEntry { + const entry = this.browsers.get(name); + if (!entry || !entry.browser.isConnected()) { + throw new Error(`Browser "${name}" is not running`); + } + + return entry; + } + + private async launchBrowser( + name: string, + headless: boolean, + ignoreHTTPSErrors: boolean, + operation: BrowserOperationOptions = {} + ): Promise { + const profileDir = path.join(this.baseDir, name, "chromium-profile"); + await this.dependencies.mkdir(profileDir, { recursive: true }); + + const timeout = this.remainingOperationTimeout(operation); + const context = await this.dependencies.launchPersistentContext(profileDir, { + 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}"`); + } + + const entry: BrowserEntry = { + name, + type: "launched", + browser, + context, + pages: new Map(), + profileDir, + headless, + ignoreHTTPSErrors, + }; + + this.attachBrowserLifecycle(entry); + this.browsers.set(name, entry); + return entry; + } + + 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) + // still opens a fresh tab unless a targetId is provided. + for (const browserContext of contexts) { + browserContext.pages(); + } + + const context = contexts[0] ?? (await browser.newContext()); + + const entry: BrowserEntry = { + name, + type: "connected", + browser, + context, + pages: new Map(), + endpoint, + headless: false, + ignoreHTTPSErrors: false, + }; + + this.attachBrowserLifecycle(entry); + this.browsers.set(name, entry); + return entry; + } + + private attachBrowserLifecycle(entry: BrowserEntry): void { + entry.browser.on("disconnected", () => { + const current = this.browsers.get(entry.name); + if (current !== entry) { + return; + } + + entry.pages.clear(); + + if (entry.type === "launched") { + this.browsers.delete(entry.name); + } + }); + } + + 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())); + + if (entry.browser.isConnected()) { + await entry.browser.close().catch(() => undefined); + } + } + + private async discoverChrome(): Promise { + const devToolsEndpoint = await this.readDevToolsActivePort(); + if (devToolsEndpoint) { + return devToolsEndpoint; + } + + for (const port of DISCOVERY_PORTS) { + const endpoint = await this.probePort(port); + if (endpoint) { + return endpoint; + } + } + + return null; + } + + private async readDevToolsActivePort(expectedPort?: number): Promise { + for (const candidate of this.getDevToolsActivePortCandidates()) { + let contents: string; + + try { + contents = await this.dependencies.readFile(candidate, "utf8"); + } catch (error) { + if (isIgnorableFileError(error)) { + continue; + } + + throw error; + } + + const endpoint = this.parseDevToolsActivePort(contents, expectedPort); + if (endpoint) { + return endpoint; + } + } + + return null; + } + + private async probePort(port: number): Promise { + const endpoint = `http://127.0.0.1:${port}`; + const result = await this.fetchDebuggerWebSocketUrl(endpoint, PROBE_TIMEOUT_MS); + + if (result.status === "ok") { + return result.webSocketDebuggerUrl; + } + + if (result.status === "not-found") { + return this.readDevToolsActivePort(port); + } + + return null; + } + + private getDevToolsActivePortCandidates(): string[] { + const homeDir = this.dependencies.homedir(); + + switch (this.dependencies.platform) { + case "darwin": + return [ + path.join( + homeDir, + "Library", + "Application Support", + "Google", + "Chrome", + "DevToolsActivePort" + ), + path.join( + homeDir, + "Library", + "Application Support", + "Google", + "Chrome Canary", + "DevToolsActivePort" + ), + path.join(homeDir, "Library", "Application Support", "Chromium", "DevToolsActivePort"), + path.join( + homeDir, + "Library", + "Application Support", + "BraveSoftware", + "Brave-Browser", + "DevToolsActivePort" + ), + ]; + case "linux": + return [ + path.join(homeDir, ".config", "google-chrome", "DevToolsActivePort"), + path.join(homeDir, ".config", "chromium", "DevToolsActivePort"), + path.join(homeDir, ".config", "google-chrome-beta", "DevToolsActivePort"), + path.join(homeDir, ".config", "google-chrome-unstable", "DevToolsActivePort"), + path.join(homeDir, ".config", "BraveSoftware", "Brave-Browser", "DevToolsActivePort"), + ]; + case "win32": + return [ + path.join( + homeDir, + "AppData", + "Local", + "Google", + "Chrome", + "User Data", + "DevToolsActivePort" + ), + path.join( + homeDir, + "AppData", + "Local", + "Google", + "Chrome Beta", + "User Data", + "DevToolsActivePort" + ), + path.join( + homeDir, + "AppData", + "Local", + "Google", + "Chrome SxS", + "User Data", + "DevToolsActivePort" + ), + path.join(homeDir, "AppData", "Local", "Chromium", "User Data", "DevToolsActivePort"), + path.join( + homeDir, + "AppData", + "Local", + "BraveSoftware", + "Brave-Browser", + "User Data", + "DevToolsActivePort" + ), + ]; + default: + return []; + } + } + + private async resolveEndpoint(endpoint: string): Promise { + if (endpoint === "auto") { + const discoveredEndpoint = await this.discoverChrome(); + if (discoveredEndpoint) { + return discoveredEndpoint; + } + + throw new Error(this.buildAutoConnectError()); + } + + if (isHttpEndpoint(endpoint)) { + const discoveredEndpoint = await this.resolveHttpEndpoint( + endpoint, + MANUAL_CONNECT_TIMEOUT_MS + ); + + if (!discoveredEndpoint) { + throw new Error(this.buildManualConnectError(endpoint)); + } + + return discoveredEndpoint; + } + + return endpoint; + } + + private async fetchDebuggerWebSocketUrl( + endpoint: string, + timeoutMs: number + ): Promise { + let response: Response; + + try { + response = await this.dependencies.fetch(this.toJsonVersionUrl(endpoint), { + headers: { + accept: "application/json", + }, + signal: AbortSignal.timeout(timeoutMs), + }); + } catch { + return { status: "unavailable" }; + } + + if (response.status === 404) { + return { status: "not-found" }; + } + + if (!response.ok) { + return { status: "unavailable" }; + } + + let payload: unknown; + + try { + payload = await response.json(); + } catch { + return { status: "unavailable" }; + } + + const webSocketDebuggerUrl = + typeof payload === "object" && payload !== null + ? (payload as { webSocketDebuggerUrl?: unknown }).webSocketDebuggerUrl + : undefined; + + return typeof webSocketDebuggerUrl === "string" && webSocketDebuggerUrl.length > 0 + ? { + status: "ok", + webSocketDebuggerUrl, + } + : { status: "unavailable" }; + } + + private toJsonVersionUrl(endpoint: string): URL { + const url = new URL(endpoint); + if (url.pathname !== "/json/version") { + url.pathname = "/json/version"; + url.search = ""; + url.hash = ""; + } + + return url; + } + + private buildAutoConnectError(lastError?: unknown): string { + const launchCommand = + this.dependencies.platform === "darwin" + ? "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome --remote-debugging-port=9222" + : this.dependencies.platform === "win32" + ? "chrome.exe --remote-debugging-port=9222" + : "google-chrome --remote-debugging-port=9222"; + + const details = [ + "Could not auto-discover a running Chrome instance with remote debugging enabled.", + "Enable Chrome remote debugging at chrome://inspect/#remote-debugging", + `or launch Chrome with: ${launchCommand}`, + ]; + const lastErrorMessage = + lastError instanceof Error + ? lastError.message + : typeof lastError === "string" && lastError.length > 0 + ? lastError + : null; + + if (lastErrorMessage) { + details.push(`Last connection error: ${lastErrorMessage}`); + } + + return details.join("\n"); + } + + private async resolveHttpEndpoint(endpoint: string, timeoutMs: number): Promise { + const result = await this.fetchDebuggerWebSocketUrl(endpoint, timeoutMs); + if (result.status === "ok") { + return result.webSocketDebuggerUrl; + } + + if (result.status === "not-found") { + const port = this.getEndpointPort(endpoint); + if (port !== null) { + return this.readDevToolsActivePort(port); + } + } + + return null; + } + + private parseDevToolsActivePort(contents: string, expectedPort?: number): string | null { + const lines = contents + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0); + const port = Number.parseInt(lines[0] ?? "", 10); + const webSocketPath = lines[1] ?? ""; + + if (!Number.isInteger(port) || port < 1 || port > 65_535) { + return null; + } + + if (expectedPort !== undefined && port !== expectedPort) { + return null; + } + + if (!webSocketPath.startsWith("/devtools/browser/")) { + return null; + } + + return `ws://127.0.0.1:${port}${webSocketPath}`; + } + + private getEndpointPort(endpoint: string): number | null { + let url: URL; + + try { + url = new URL(endpoint); + } catch { + return null; + } + + const rawPort = + url.port || (url.protocol === "https:" ? "443" : url.protocol === "http:" ? "80" : ""); + const port = Number.parseInt(rawPort, 10); + + return Number.isInteger(port) && port > 0 && port <= 65_535 ? port : null; + } + + private buildManualConnectError(endpoint: string): string { + return [ + `Could not resolve a CDP WebSocket endpoint from ${endpoint}.`, + "If Chrome is using built-in remote debugging, run `dev-browser --connect` without a URL so DevToolsActivePort can be auto-discovered.", + "Or connect with the exact ws://127.0.0.1:/devtools/browser/... URL from DevToolsActivePort, or launch Chrome with --remote-debugging-port=9222.", + ].join("\n"); + } + + private registerNamedPage(entry: BrowserEntry, pageName: string, page: Page): void { + entry.pages.set(pageName, page); + + page.on("close", () => { + const current = entry.pages.get(pageName); + if (current === page) { + entry.pages.delete(pageName); + } + }); + } + + private pruneClosedPages(entry: BrowserEntry): void { + for (const [pageName, page] of entry.pages.entries()) { + if (page.isClosed()) { + entry.pages.delete(pageName); + } + } + } + + private listNamedPages(entry: BrowserEntry): string[] { + this.pruneClosedPages(entry); + + return Array.from(entry.pages.entries()) + .filter(([, page]) => !page.isClosed()) + .map(([name]) => name) + .sort((left, right) => left.localeCompare(right)); + } + + private getNamedPagesByPage(entry: BrowserEntry): Map { + const namesByPage = new Map(); + + for (const [name, page] of entry.pages.entries()) { + if (!page.isClosed() && !namesByPage.has(page)) { + namesByPage.set(page, name); + } + } + + return namesByPage; + } + + private getBrowserContexts(entry: BrowserEntry): BrowserContext[] { + return [...new Set([entry.context, ...entry.browser.contexts()])]; + } + + private getContextPages(entry: BrowserEntry): Array<{ context: BrowserContext; page: Page }> { + const pages: Array<{ context: BrowserContext; page: Page }> = []; + + for (const context of this.getBrowserContexts(entry)) { + for (const page of context.pages()) { + if (!page.isClosed()) { + pages.push({ context, page }); + } + } + } + + return pages; + } + + private async getPageTitle(page: Page): Promise { + let timeoutId: ReturnType | undefined; + + try { + return await Promise.race([ + page.title(), + new Promise((resolve) => { + timeoutId = setTimeout(() => resolve(""), PAGE_TITLE_TIMEOUT_MS); + }), + ]); + } finally { + if (timeoutId !== undefined) { + clearTimeout(timeoutId); + } + } + } + + private async findPageByTargetId(entry: BrowserEntry, targetId: string): Promise { + for (const { context, page } of this.getContextPages(entry)) { + const pageTargetId = await this.getPageTargetId(context, page); + if (pageTargetId === targetId) { + return page; + } + } + + return null; + } + + private async getPageTargetId(context: BrowserContext, page: Page): Promise { + let session: Awaited> | undefined; + + try { + session = await context.newCDPSession(page); + const result = await session.send("Target.getTargetInfo"); + const targetId = + typeof result === "object" && + result !== null && + "targetInfo" in result && + typeof result.targetInfo === "object" && + result.targetInfo !== null && + "targetId" in result.targetInfo + ? result.targetInfo.targetId + : undefined; + + if (typeof targetId !== "string" || targetId.length === 0) { + throw new Error("CDP target info did not include a targetId"); + } + + return targetId; + } catch (error) { + if (page.isClosed()) { + return null; + } + + throw error; + } finally { + await session?.detach().catch(() => undefined); + } + } +} diff --git a/daemon/src/daemon.ts b/daemon/src/daemon.ts new file mode 100644 index 00000000..5e5123e9 --- /dev/null +++ b/daemon/src/daemon.ts @@ -0,0 +1,544 @@ +import { spawn } from "node:child_process"; +import { 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 { createKeyedLock, createMutex } from "./lock.js"; +import { + getBrowsersDir, + getDaemonEndpoint, + getDevBrowserBaseDir, + getPidPath, + requiresDaemonEndpointCleanup, +} from "./local-endpoint.js"; +import { parseRequest, serialize, type ExecuteRequest, type Response } from "./protocol.js"; +import { runScript } from "./sandbox/script-runner-quickjs.js"; +import { ensureDevBrowserTempDir } from "./temp-files.js"; + +const BASE_DIR = getDevBrowserBaseDir(); +const SOCKET_PATH = getDaemonEndpoint(); +const PID_PATH = getPidPath(); +const BROWSERS_DIR = getBrowsersDir(); +const DEFAULT_SCRIPT_TIMEOUT_MS = 30_000; +const SOCKET_CLOSE_TIMEOUT_MS = 500; +// 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, + type: "module", + dependencies: { + playwright: "1.58.2", + "playwright-core": "1.58.2", + "quickjs-emscripten": "^0.32.0", + }, +}); + +// 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(); + +let server: net.Server | null = null; +let shuttingDown: Promise | null = null; +let ownsEndpoint = false; + +async function writeMessage(socket: net.Socket, message: Response): Promise { + if (socket.destroyed) { + return; + } + + await new Promise((resolve, reject) => { + const payload = serialize(message); + socket.write(payload, (error?: Error | null) => { + if (error) { + reject(error); + return; + } + + resolve(); + }); + }); +} + +async function unlinkIfExists(filePath: string): Promise { + try { + await unlink(filePath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + throw error; + } + } +} + +async function closeServerInstance(serverToClose: net.Server): Promise { + await new Promise((resolve) => { + serverToClose.close(() => { + resolve(); + }); + }); +} + +async function closeClientSocket(socket: net.Socket): Promise { + if (socket.destroyed) { + return; + } + + await new Promise((resolve) => { + const timeout = setTimeout(() => { + if (!socket.destroyed) { + socket.destroy(); + } + }, SOCKET_CLOSE_TIMEOUT_MS); + timeout.unref(); + + const finish = () => { + clearTimeout(timeout); + resolve(); + }; + + socket.once("close", finish); + socket.once("error", finish); + socket.end(); + }); +} + +function createMessageQueue(socket: net.Socket) { + let queue = Promise.resolve(); + + return { + push(message: Response): Promise { + queue = queue.then(() => writeMessage(socket, message)).catch(() => undefined); + return queue; + }, + async drain(): Promise { + await queue; + }, + }; +} + +async function handleExecute(socket: net.Socket, request: ExecuteRequest): Promise { + 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 }; + 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, + }); + } + }, + runScript: async (currentRequest, output, context) => { + await runScript(currentRequest.script, manager, currentRequest.browser, output, { + signal: context.signal, + timeout: Math.max(1, context.deadline - Date.now()), + }); + }, + } + ); +} + +async function handleInstall(socket: net.Socket, request: { id: string }): Promise { + await withInstallLock(async () => { + const output = createMessageQueue(socket); + try { + await mkdir(BASE_DIR, { recursive: true }); + await writeFile(path.join(BASE_DIR, "package.json"), EMBEDDED_PACKAGE_JSON); + const npmProgram = process.platform === "win32" ? "npm.cmd" : "npm"; + await runInstallCommand(output, request.id, npmProgram, ["install"], BASE_DIR, "npm install"); + await runInstallCommand( + output, + request.id, + npmProgram, + ["exec", "--", "playwright", "install", "chromium"], + BASE_DIR, + "Playwright install" + ); + 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), + }); + } + }); +} + +async function runInstallCommand( + output: ReturnType, + requestId: string, + program: string, + args: string[], + cwd: string, + label: string +): Promise { + const child = spawn(program, args, { + cwd, + env: process.env, + stdio: ["ignore", "pipe", "pipe"], + }); + + child.stdout?.setEncoding("utf8"); + child.stdout?.on("data", (data: string) => { + void output.push({ + id: requestId, + type: "stdout", + data, + }); + }); + + child.stderr?.setEncoding("utf8"); + child.stderr?.on("data", (data: string) => { + void output.push({ + id: requestId, + type: "stderr", + data, + }); + }); + + const result = await new Promise<{ code: number | null; signal: NodeJS.Signals | null }>( + (resolve, reject) => { + child.once("error", reject); + child.once("close", (code, signal) => { + resolve({ code, signal }); + }); + } + ); + + await output.drain(); + + if (result.code === 0) { + return; + } + + const reason = + result.signal !== null + ? `${label} terminated by signal ${result.signal}` + : `${label} failed with exit code ${result.code ?? "unknown"}`; + + throw new Error(reason); +} + +async function handleRequest(socket: net.Socket, line: string): Promise { + const parsed = parseRequest(line); + if (!parsed.success) { + await writeMessage(socket, { + id: parsed.id ?? "unknown", + type: "error", + message: parsed.error, + }); + return; + } + + const { request } = parsed; + + if (shuttingDown && request.type !== "stop") { + await writeMessage(socket, { + id: request.id, + type: "error", + message: "Daemon is shutting down", + }); + return; + } + + switch (request.type) { + case "execute": + await handleExecute(socket, request); + return; + + case "browsers": + await writeMessage(socket, { + id: request.id, + type: "result", + data: manager.listBrowsers(), + }); + await writeMessage(socket, { + id: request.id, + type: "complete", + success: true, + }); + return; + + case "browser-stop": + await withBrowserLock(request.browser, () => manager.stopBrowser(request.browser)); + await writeMessage(socket, { + id: request.id, + type: "result", + data: { browser: request.browser, stopped: true }, + }); + await writeMessage(socket, { + id: request.id, + type: "complete", + success: true, + }); + return; + + case "status": + await writeMessage(socket, { + id: request.id, + type: "result", + data: { + pid: process.pid, + uptimeMs: Date.now() - startedAt, + browserCount: manager.browserCount(), + browsers: manager.listBrowsers(), + socketPath: SOCKET_PATH, + }, + }); + await writeMessage(socket, { + id: request.id, + type: "complete", + success: true, + }); + return; + + case "install": + await handleInstall(socket, request); + return; + + case "stop": + await writeMessage(socket, { + id: request.id, + type: "result", + data: { stopping: true }, + }); + await writeMessage(socket, { + id: request.id, + type: "complete", + success: true, + }); + void shutdown(0); + return; + } +} + +async function shutdown(exitCode = 0): Promise { + if (shuttingDown) { + return shuttingDown; + } + + shuttingDown = (async () => { + const serverToClose = server; + server = null; + const serverClosed = serverToClose ? closeServerInstance(serverToClose) : Promise.resolve(); + + await manager.stopAll(); + await Promise.allSettled(Array.from(clients, (socket) => closeClientSocket(socket))); + await serverClosed; + // 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); + + clients.clear(); + + process.exit(exitCode); + })(); + + 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 }); + await ensureDevBrowserTempDir(); + + server = net.createServer((socket) => { + if (shuttingDown) { + socket.end(); + return; + } + + clients.add(socket); + socket.setEncoding("utf8"); + + let buffer = ""; + let queue = Promise.resolve(); + + socket.on("data", (chunk: string) => { + buffer += chunk; + 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) { + continue; + } + + queue = queue + .then(() => handleRequest(socket, line)) + .catch(async (error) => { + console.error("Request handling error:", error); + if (!socket.destroyed) { + await writeMessage(socket, { + id: "unknown", + type: "error", + message: formatError(error), + }); + } + }); + } + }); + + socket.on("close", () => { + clients.delete(socket); + }); + + socket.on("error", () => { + clients.delete(socket); + }); + }); + + 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); + }); + + ownsEndpoint = true; + await writeFile(PID_PATH, `${process.pid}\n`); + + process.stderr.write("daemon ready\n"); +} + +function registerShutdownHandlers(): void { + const handleSignal = () => { + void shutdown(0); + }; + + const handleFatalError = (error: unknown) => { + console.error("Fatal daemon error:", error); + void shutdown(1); + }; + + process.on("SIGINT", handleSignal); + process.on("SIGTERM", handleSignal); + process.on("SIGHUP", handleSignal); + process.on("uncaughtException", handleFatalError); + process.on("unhandledRejection", handleFatalError); +} + +registerShutdownHandlers(); + +start().catch((error) => { + console.error("Failed to start daemon:", error); + void shutdown(1); +}); 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/local-endpoint.test.ts b/daemon/src/local-endpoint.test.ts new file mode 100644 index 00000000..963492a3 --- /dev/null +++ b/daemon/src/local-endpoint.test.ts @@ -0,0 +1,36 @@ +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { + getBrowsersDir, + getDaemonEndpoint, + getDevBrowserBaseDir, + getPidPath, + requiresDaemonEndpointCleanup, +} from "./local-endpoint.js"; + +describe("local endpoint helpers", () => { + it("builds filesystem-backed daemon paths on unix-like platforms", () => { + const homedir = "/Users/tester"; + + expect(getDevBrowserBaseDir(homedir)).toBe(path.join(homedir, ".dev-browser")); + expect(getDaemonEndpoint({ homedir, platform: "darwin" })).toBe( + path.join(homedir, ".dev-browser", "daemon.sock") + ); + expect(getPidPath(homedir)).toBe(path.join(homedir, ".dev-browser", "daemon.pid")); + expect(getBrowsersDir(homedir)).toBe(path.join(homedir, ".dev-browser", "browsers")); + expect(requiresDaemonEndpointCleanup("linux")).toBe(true); + }); + + it("builds a user-scoped named pipe path on Windows", () => { + expect( + getDaemonEndpoint({ + homedir: "C:\\Users\\Tester", + platform: "win32", + username: "Tester Name", + }) + ).toBe("\\\\.\\pipe\\dev-browser-daemon-tester-name"); + expect(requiresDaemonEndpointCleanup("win32")).toBe(false); + }); +}); diff --git a/daemon/src/local-endpoint.ts b/daemon/src/local-endpoint.ts new file mode 100644 index 00000000..f2c61003 --- /dev/null +++ b/daemon/src/local-endpoint.ts @@ -0,0 +1,62 @@ +import os from "node:os"; +import path from "node:path"; + +function sanitizePipeSegment(value: string): string { + const sanitized = value + .replace(/[^A-Za-z0-9._-]/g, "-") + .replace(/^-+|-+$/g, "") + .toLowerCase(); + return sanitized.length > 0 ? sanitized : "user"; +} + +function getDefaultUsername(homedir: string): string { + const fromEnv = process.env.USERNAME || process.env.USER; + if (typeof fromEnv === "string" && fromEnv.trim().length > 0) { + return fromEnv; + } + + try { + const username = os.userInfo().username; + if (username.trim().length > 0) { + return username; + } + } catch { + // Fall back to the home directory name. + } + + return path.basename(homedir) || "user"; +} + +export function getDevBrowserBaseDir(homedir = os.homedir()): string { + return path.join(homedir, ".dev-browser"); +} + +export function getDaemonEndpoint( + options: { + homedir?: string; + platform?: NodeJS.Platform; + username?: string; + } = {} +): string { + const homedir = options.homedir ?? os.homedir(); + const platform = options.platform ?? process.platform; + + if (platform === "win32") { + const username = sanitizePipeSegment(options.username ?? getDefaultUsername(homedir)); + return `\\\\.\\pipe\\dev-browser-daemon-${username}`; + } + + return path.join(getDevBrowserBaseDir(homedir), "daemon.sock"); +} + +export function getPidPath(homedir = os.homedir()): string { + return path.join(getDevBrowserBaseDir(homedir), "daemon.pid"); +} + +export function getBrowsersDir(homedir = os.homedir()): string { + return path.join(getDevBrowserBaseDir(homedir), "browsers"); +} + +export function requiresDaemonEndpointCleanup(platform = process.platform): boolean { + return platform !== "win32"; +} diff --git a/daemon/src/lock.test.ts b/daemon/src/lock.test.ts new file mode 100644 index 00000000..4087dab5 --- /dev/null +++ b/daemon/src/lock.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; + +import { createKeyedLock, createMutex } from "./lock.js"; + +function createDeferred() { + let resolve!: (value: T | PromiseLike) => void; + const promise = new Promise((innerResolve) => { + resolve = innerResolve; + }); + + return { promise, resolve }; +} + +describe("createKeyedLock", () => { + it("serializes actions with the same key", async () => { + const withLock = createKeyedLock(); + const firstGate = createDeferred(); + const secondGate = createDeferred(); + const events: string[] = []; + + const first = withLock("chromium", async () => { + events.push("first:start"); + await firstGate.promise; + events.push("first:end"); + }); + + await Promise.resolve(); + + const second = withLock("chromium", async () => { + events.push("second:start"); + secondGate.resolve(); + events.push("second:end"); + }); + + await Promise.resolve(); + expect(events).toEqual(["first:start"]); + + firstGate.resolve(); + await first; + await secondGate.promise; + await second; + + expect(events).toEqual(["first:start", "first:end", "second:start", "second:end"]); + }); +}); + +describe("createMutex", () => { + it("serializes all actions through a single lock", async () => { + const withMutex = createMutex(); + const firstGate = createDeferred(); + const secondStarted = createDeferred(); + const events: string[] = []; + + const first = withMutex(async () => { + events.push("first:start"); + await firstGate.promise; + events.push("first:end"); + }); + + await Promise.resolve(); + + const second = withMutex(async () => { + events.push("second:start"); + secondStarted.resolve(); + events.push("second:end"); + }); + + await Promise.resolve(); + expect(events).toEqual(["first:start"]); + + firstGate.resolve(); + await first; + await secondStarted.promise; + await second; + + expect(events).toEqual(["first:start", "first:end", "second:start", "second:end"]); + }); +}); diff --git a/daemon/src/lock.ts b/daemon/src/lock.ts new file mode 100644 index 00000000..301465a8 --- /dev/null +++ b/daemon/src/lock.ts @@ -0,0 +1,70 @@ +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, + 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.then(() => current); + locks.set(key, tail); + + 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(); + void tail.then(() => { + if (locks.get(key) === tail) { + locks.delete(key); + } + }); + } + }; +} + +export function createMutex() { + const withLock = createKeyedLock(); + const lockKey = Symbol("mutex"); + + return function withMutex(action: AsyncAction): Promise { + return withLock(lockKey, action); + }; +} diff --git a/daemon/src/protocol.ts b/daemon/src/protocol.ts new file mode 100644 index 00000000..e5f3a9a6 --- /dev/null +++ b/daemon/src/protocol.ts @@ -0,0 +1,138 @@ +import { z } from "zod"; + +const RequestBaseSchema = z.object({ + id: z.string().min(1), +}); + +const ExecuteRequestSchema = RequestBaseSchema.extend({ + type: z.literal("execute"), + browser: z.string().min(1).default("default"), + script: z.string(), + headless: z.boolean().optional(), + ignoreHTTPSErrors: z.boolean().optional(), + connect: z.string().min(1).optional(), + timeoutMs: z.number().int().positive().optional(), +}); + +const BrowsersRequestSchema = RequestBaseSchema.extend({ + type: z.literal("browsers"), +}); + +const BrowserStopRequestSchema = RequestBaseSchema.extend({ + type: z.literal("browser-stop"), + browser: z.string().min(1), +}); + +const StatusRequestSchema = RequestBaseSchema.extend({ + type: z.literal("status"), +}); + +const InstallRequestSchema = RequestBaseSchema.extend({ + type: z.literal("install"), +}); + +const StopRequestSchema = RequestBaseSchema.extend({ + type: z.literal("stop"), +}); + +const RequestSchema = z.discriminatedUnion("type", [ + ExecuteRequestSchema, + BrowsersRequestSchema, + BrowserStopRequestSchema, + StatusRequestSchema, + InstallRequestSchema, + StopRequestSchema, +]); + +const ResponseBaseSchema = z.object({ + id: z.string().min(1), +}); + +const StdoutMessageSchema = ResponseBaseSchema.extend({ + type: z.literal("stdout"), + data: z.string(), +}); + +const StderrMessageSchema = ResponseBaseSchema.extend({ + type: z.literal("stderr"), + data: z.string(), +}); + +const CompleteMessageSchema = ResponseBaseSchema.extend({ + type: z.literal("complete"), + success: z.literal(true), +}); + +const ErrorMessageSchema = ResponseBaseSchema.extend({ + type: z.literal("error"), + message: z.string(), +}); + +const ResultMessageSchema = ResponseBaseSchema.extend({ + type: z.literal("result"), + data: z.unknown(), +}); + +const ResponseSchema = z.discriminatedUnion("type", [ + StdoutMessageSchema, + StderrMessageSchema, + CompleteMessageSchema, + ErrorMessageSchema, + ResultMessageSchema, +]); + +type Request = z.infer; +export type ExecuteRequest = z.infer; +export type Response = z.infer; + +type ParseSuccess = { success: true; request: Request }; +type ParseFailure = { success: false; error: string; id?: string }; + +function describeZodError(error: z.ZodError): string { + return error.issues + .map((issue) => { + const path = issue.path.length > 0 ? issue.path.join(".") : "request"; + return `${path}: ${issue.message}`; + }) + .join("; "); +} + +function extractId(value: unknown): string | undefined { + if (!value || typeof value !== "object") { + return undefined; + } + + const maybeId = (value as { id?: unknown }).id; + return typeof maybeId === "string" && maybeId.length > 0 ? maybeId : undefined; +} + +export function parseRequest(line: string): ParseSuccess | ParseFailure { + let parsed: unknown; + + try { + parsed = JSON.parse(line); + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : "Invalid JSON request", + }; + } + + const result = RequestSchema.safeParse(parsed); + if (!result.success) { + return { + success: false, + error: describeZodError(result.error), + id: extractId(parsed), + }; + } + + return { + success: true, + request: result.data, + }; +} + +export function serialize(message: Response): string { + return `${JSON.stringify(ResponseSchema.parse(message))}\n`; +} diff --git a/daemon/src/sandbox/__tests__/auto-connect.test.ts b/daemon/src/sandbox/__tests__/auto-connect.test.ts new file mode 100644 index 00000000..86057f27 --- /dev/null +++ b/daemon/src/sandbox/__tests__/auto-connect.test.ts @@ -0,0 +1,898 @@ +import { createServer } from "node:http"; +import { EventEmitter } from "node:events"; +import path from "node:path"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { BrowserManager } from "../../browser-manager.js"; +import { parseRequest } from "../../protocol.js"; + +class MockCDPSession { + private readonly targetId: string; + + constructor(targetId: string) { + this.targetId = targetId; + } + + async send(method: string): Promise<{ targetInfo: { targetId: string } }> { + expect(method).toBe("Target.getTargetInfo"); + return { + targetInfo: { + targetId: this.targetId, + }, + }; + } + + async detach(): Promise {} +} + +class MockPage extends EventEmitter { + private closed = false; + readonly targetId: string; + readonly pageTitle: string; + readonly pageUrl: string; + + constructor(options: { targetId?: string; title?: string; url?: string } = {}) { + super(); + this.targetId = options.targetId ?? "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + this.pageTitle = options.title ?? ""; + this.pageUrl = options.url ?? "about:blank"; + } + + isClosed(): boolean { + return this.closed; + } + + url(): string { + return this.pageUrl; + } + + async title(): Promise { + return this.pageTitle; + } + + async close(): Promise { + this.closed = true; + this.emit("close"); + } +} + +class MockContext { + readonly pagesList: MockPage[]; + newPageCalls = 0; + closeCalls = 0; + private browserHandle: MockBrowser | null = null; + + constructor(pages: MockPage[] = []) { + this.pagesList = pages; + } + + setBrowser(browser: MockBrowser): void { + this.browserHandle = browser; + } + + browser(): MockBrowser | null { + return this.browserHandle; + } + + pages(): MockPage[] { + return this.pagesList; + } + + async newCDPSession(page: MockPage): Promise { + return new MockCDPSession(page.targetId); + } + + async newPage(): Promise { + this.newPageCalls += 1; + const page = new MockPage({ + targetId: `${this.newPageCalls}`.padStart(32, "0"), + }); + this.pagesList.push(page); + return page; + } + + async close(): Promise { + this.closeCalls += 1; + } +} + +class MockBrowser extends EventEmitter { + readonly contextsList: MockContext[]; + closeCalls = 0; + newContextCalls = 0; + private connected = true; + + constructor(contexts: MockContext[] = []) { + super(); + this.contextsList = contexts; + } + + contexts(): MockContext[] { + return this.contextsList; + } + + async newContext(): Promise { + this.newContextCalls += 1; + const context = new MockContext(); + this.contextsList.push(context); + return context; + } + + isConnected(): boolean { + return this.connected; + } + + async close(): Promise { + this.closeCalls += 1; + this.connected = false; + this.emit("disconnected"); + } + + disconnect(): void { + this.connected = false; + this.emit("disconnected"); + } +} + +type BrowserManagerInternals = { + readDevToolsActivePort(expectedPort?: number): Promise; + discoverChrome(): Promise; + probePort(port: number): Promise; +}; + +function createEnoentError(filePath: string): NodeJS.ErrnoException { + const error = new Error( + `ENOENT: no such file or directory, open '${filePath}'` + ) as NodeJS.ErrnoException; + error.code = "ENOENT"; + return error; +} + +function createManager( + options: { + connectOverCDP?: ReturnType; + fetch?: typeof globalThis.fetch; + homedir?: () => string; + launchPersistentContext?: ReturnType; + platform?: NodeJS.Platform; + readFile?: ReturnType; + } = {} +) { + const connectOverCDP = options.connectOverCDP ?? vi.fn(); + const fetch = + options.fetch ?? + (vi.fn(async () => { + throw new Error("unexpected fetch"); + }) as typeof globalThis.fetch); + const readFile = + options.readFile ?? + (vi.fn(async (filePath: string) => { + throw createEnoentError(filePath); + }) as ReturnType); + const launchPersistentContext = + options.launchPersistentContext ?? (vi.fn() as ReturnType); + + const manager = new BrowserManager(path.join("/tmp", "dev-browser-auto-connect-tests"), { + connectOverCDP: connectOverCDP as never, + fetch, + homedir: options.homedir ?? (() => "/Users/tester"), + launchPersistentContext: launchPersistentContext as never, + mkdir: vi.fn(async () => undefined) as never, + platform: options.platform ?? "darwin", + readFile: readFile as never, + }); + + return { + manager, + connectOverCDP, + fetch, + launchPersistentContext, + readFile, + }; +} + +function getInternals(manager: BrowserManager): BrowserManagerInternals { + return manager as unknown as BrowserManagerInternals; +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("BrowserManager auto-connect", () => { + it("passes ignoreHTTPSErrors to launched browsers and only relaunches when it changes", async () => { + const launchPersistentContext = vi.fn(async () => { + const context = new MockContext(); + const browser = new MockBrowser([context]); + context.setBrowser(browser); + return context; + }); + const { manager } = createManager({ + launchPersistentContext, + }); + + const firstEntry = await manager.ensureBrowser("launched", { + ignoreHTTPSErrors: true, + }); + const reusedEntry = await manager.ensureBrowser("launched"); + + expect(launchPersistentContext).toHaveBeenCalledTimes(1); + expect(launchPersistentContext).toHaveBeenNthCalledWith( + 1, + path.join("/tmp/dev-browser-auto-connect-tests", "launched", "chromium-profile"), + expect.objectContaining({ + headless: false, + ignoreHTTPSErrors: true, + }) + ); + expect(firstEntry.ignoreHTTPSErrors).toBe(true); + expect(reusedEntry).toBe(firstEntry); + + const relaunchedEntry = await manager.ensureBrowser("launched", { + ignoreHTTPSErrors: false, + }); + + expect(launchPersistentContext).toHaveBeenCalledTimes(2); + expect(launchPersistentContext).toHaveBeenNthCalledWith( + 2, + path.join("/tmp/dev-browser-auto-connect-tests", "launched", "chromium-profile"), + expect.objectContaining({ + headless: false, + ignoreHTTPSErrors: false, + }) + ); + expect(relaunchedEntry).not.toBe(firstEntry); + expect(relaunchedEntry.ignoreHTTPSErrors).toBe(false); + }); + + it("preserves ignoreHTTPSErrors when relaunching for a headless change", async () => { + const launchPersistentContext = vi.fn(async () => { + const context = new MockContext(); + const browser = new MockBrowser([context]); + context.setBrowser(browser); + return context; + }); + const { manager } = createManager({ + launchPersistentContext, + }); + + const firstEntry = await manager.ensureBrowser("launched", { + ignoreHTTPSErrors: true, + }); + const relaunchedEntry = await manager.ensureBrowser("launched", { + headless: true, + }); + + expect(launchPersistentContext).toHaveBeenCalledTimes(2); + expect(launchPersistentContext).toHaveBeenNthCalledWith( + 2, + path.join("/tmp/dev-browser-auto-connect-tests", "launched", "chromium-profile"), + expect.objectContaining({ + headless: true, + ignoreHTTPSErrors: true, + }) + ); + expect(relaunchedEntry).not.toBe(firstEntry); + expect(relaunchedEntry.headless).toBe(true); + 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( + homeDir, + "Library", + "Application Support", + "Google", + "Chrome", + "DevToolsActivePort" + ); + const websocketUrl = "ws://127.0.0.1:9333/devtools/browser/from-port-file"; + const readFile = vi.fn(async (filePath: string) => { + if (filePath === devToolsPath) { + return "9333\n/devtools/browser/from-port-file\n"; + } + + throw createEnoentError(filePath); + }); + const { manager } = createManager({ + homedir: () => homeDir, + readFile, + }); + + await expect(getInternals(manager).readDevToolsActivePort()).resolves.toBe(websocketUrl); + }); + + it("checks Windows Chrome-family DevToolsActivePort locations", async () => { + const homeDir = "C:\\Users\\tester"; + const devToolsPath = path.join( + homeDir, + "AppData", + "Local", + "Google", + "Chrome", + "User Data", + "DevToolsActivePort" + ); + const readFile = vi.fn(async (filePath: string) => { + if (filePath === devToolsPath) { + return "9222\n/devtools/browser/windows-port-file\n"; + } + + throw createEnoentError(filePath); + }); + const { manager } = createManager({ + homedir: () => homeDir, + platform: "win32", + readFile, + }); + + await expect(getInternals(manager).readDevToolsActivePort()).resolves.toBe( + "ws://127.0.0.1:9222/devtools/browser/windows-port-file" + ); + }); + + it("returns null when DevToolsActivePort is missing", async () => { + const { manager } = createManager(); + + await expect(getInternals(manager).readDevToolsActivePort()).resolves.toBeNull(); + }); + + it("ignores malformed DevToolsActivePort files", async () => { + const homeDir = "/Users/tester"; + const malformedPath = path.join( + homeDir, + "Library", + "Application Support", + "Google", + "Chrome", + "DevToolsActivePort" + ); + const readFile = vi.fn(async (filePath: string) => { + if (filePath === malformedPath) { + return "not-a-port\n/not-a-browser-path\n"; + } + + throw createEnoentError(filePath); + }); + const { manager } = createManager({ + homedir: () => homeDir, + readFile, + }); + + await expect(getInternals(manager).readDevToolsActivePort()).resolves.toBeNull(); + }); + + it("discovers Chrome by trying DevToolsActivePort before common ports", async () => { + const homeDir = "/Users/tester"; + const devToolsPath = path.join( + homeDir, + "Library", + "Application Support", + "Google", + "Chrome", + "DevToolsActivePort" + ); + const requests: string[] = []; + const websocketUrl = "ws://127.0.0.1:9555/devtools/browser/from-discovery"; + const readFile = vi.fn(async (filePath: string) => { + if (filePath === devToolsPath) { + return "9555\n/devtools/browser/from-discovery\n"; + } + + throw createEnoentError(filePath); + }); + const fetch = vi.fn() as typeof globalThis.fetch; + const { manager } = createManager({ fetch, homedir: () => homeDir, readFile }); + + await expect(getInternals(manager).discoverChrome()).resolves.toBe(websocketUrl); + expect(requests).toEqual([]); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("probes a running CDP port via /json/version", async () => { + const websocketUrl = "ws://127.0.0.1/devtools/browser/probed"; + const server = createServer((request, response) => { + if (request.url !== "/json/version") { + response.statusCode = 404; + response.end(); + return; + } + + response.setHeader("content-type", "application/json"); + response.end(JSON.stringify({ webSocketDebuggerUrl: websocketUrl })); + }); + + try { + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + server.off("error", reject); + resolve(); + }); + }); + + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("failed to resolve server port"); + } + + const { manager } = createManager({ + fetch: globalThis.fetch, + }); + + await expect(getInternals(manager).probePort(address.port)).resolves.toBe(websocketUrl); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + return; + } + + resolve(); + }); + }); + } + }); + + it("connectBrowser resolves HTTP endpoints, registers a connected browser, and creates fresh named pages", async () => { + const existingPage = new MockPage({ + targetId: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + title: "Existing tab", + url: "https://example.com/existing", + }); + const context = new MockContext([existingPage]); + const browser = new MockBrowser([context]); + const connectOverCDP = vi.fn(async () => browser); + const fetch = vi.fn(async (input: RequestInfo | URL) => { + expect(String(input)).toBe("http://127.0.0.1:9222/json/version"); + return new Response( + JSON.stringify({ + webSocketDebuggerUrl: "ws://127.0.0.1:9222/devtools/browser/connected-browser", + }), + { + status: 200, + headers: { + "content-type": "application/json", + }, + } + ); + }) as typeof globalThis.fetch; + const { manager } = createManager({ + connectOverCDP, + fetch, + }); + + await manager.connectBrowser("attached", "http://127.0.0.1:9222"); + const namedPage = await manager.getPage("attached", "dashboard"); + + expect(connectOverCDP).toHaveBeenCalledWith( + "ws://127.0.0.1:9222/devtools/browser/connected-browser" + ); + expect(namedPage).not.toBe(existingPage); + expect(context.newPageCalls).toBe(1); + await expect(manager.listPages("attached")).resolves.toEqual([ + { + id: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + name: null, + title: "Existing tab", + url: "https://example.com/existing", + }, + { + id: "00000000000000000000000000000001", + name: "dashboard", + title: "", + url: "about:blank", + }, + ]); + expect(manager.listBrowsers()).toEqual([ + { + name: "attached", + pages: ["dashboard"], + status: "connected", + type: "connected", + }, + ]); + }); + + 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); + const { manager } = createManager({ + connectOverCDP, + }); + + await manager.connectBrowser( + "attached", + "ws://127.0.0.1:9222/devtools/browser/external-session" + ); + + const entry = manager.getBrowser("attached"); + + expect(entry).toMatchObject({ + name: "attached", + type: "connected", + browser, + }); + expect(connectOverCDP).toHaveBeenCalledTimes(1); + + browser.disconnect(); + + expect(manager.getBrowser("attached")).toBeUndefined(); + }); + + it("connectBrowser falls back to DevToolsActivePort when /json/version returns 404", async () => { + const homeDir = "/Users/tester"; + const devToolsPath = path.join( + homeDir, + "Library", + "Application Support", + "Google", + "Chrome", + "DevToolsActivePort" + ); + const browser = new MockBrowser([new MockContext()]); + const connectOverCDP = vi.fn(async () => browser); + 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; + const readFile = vi.fn(async (filePath: string) => { + if (filePath === devToolsPath) { + return "9222\n/devtools/browser/from-active-port\n"; + } + + throw createEnoentError(filePath); + }); + const { manager } = createManager({ + connectOverCDP, + fetch, + homedir: () => homeDir, + readFile, + }); + + await manager.connectBrowser("attached", "http://127.0.0.1:9222"); + + expect(connectOverCDP).toHaveBeenCalledWith( + "ws://127.0.0.1:9222/devtools/browser/from-active-port" + ); + }); + + it("reports a helpful error when /json/version returns 404 and no matching DevToolsActivePort exists", async () => { + const homeDir = "/Users/tester"; + const readFile = vi.fn(async (filePath: string) => { + if (filePath.endsWith("DevToolsActivePort")) { + return "9333\n/devtools/browser/different-port\n"; + } + + throw createEnoentError(filePath); + }); + const fetch = vi.fn( + async () => new Response("not found", { status: 404 }) + ) as typeof globalThis.fetch; + const { manager } = createManager({ + fetch, + homedir: () => homeDir, + readFile, + }); + + let error: Error | undefined; + try { + await manager.connectBrowser("missing", "http://127.0.0.1:9222"); + } catch (reason) { + error = reason as Error; + } + + expect(error).toBeInstanceOf(Error); + expect(error?.message).toMatch(/DevToolsActivePort/); + expect(error?.message).toMatch(/remote-debugging-port=9222/); + }); + + it("keeps external browsers alive on stopAll by only closing the CDP connection", async () => { + const context = new MockContext(); + const browser = new MockBrowser([context]); + const connectOverCDP = vi.fn(async () => browser); + const { manager } = createManager({ + connectOverCDP, + }); + + await manager.connectBrowser( + "attached", + "ws://127.0.0.1:9222/devtools/browser/external-session" + ); + await manager.stopAll(); + + expect(browser.closeCalls).toBe(1); + expect(context.closeCalls).toBe(0); + expect(manager.browserCount()).toBe(0); + }); + + it("autoConnect falls through discovery methods until a browser is found", async () => { + const browser = new MockBrowser([new MockContext()]); + const requests: string[] = []; + const connectOverCDP = vi.fn(async () => browser); + const fetch = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + requests.push(url); + + if (url === "http://127.0.0.1:9223/json/version") { + return new Response( + JSON.stringify({ + webSocketDebuggerUrl: "ws://127.0.0.1:9223/devtools/browser/discovered", + }), + { + status: 200, + headers: { + "content-type": "application/json", + }, + } + ); + } + + throw new Error("connection refused"); + }) as typeof globalThis.fetch; + const readFile = vi.fn(async (filePath: string) => { + throw createEnoentError(filePath); + }); + const { manager } = createManager({ + connectOverCDP, + fetch, + readFile, + }); + + await manager.autoConnect("auto-browser"); + + expect(requests.slice(0, 2)).toEqual([ + "http://127.0.0.1:9222/json/version", + "http://127.0.0.1:9223/json/version", + ]); + expect(connectOverCDP).toHaveBeenCalledWith("ws://127.0.0.1:9223/devtools/browser/discovered"); + expect(manager.listBrowsers()).toEqual([ + { + name: "auto-browser", + pages: [], + status: "connected", + type: "connected", + }, + ]); + }); + + it("autoConnect falls back from DevToolsActivePort to port probing when the direct websocket is stale", async () => { + const homeDir = "/Users/tester"; + const devToolsPath = path.join( + homeDir, + "Library", + "Application Support", + "Google", + "Chrome", + "DevToolsActivePort" + ); + const staleEndpoint = "ws://127.0.0.1:9222/devtools/browser/from-active-port"; + const discoveredEndpoint = "ws://127.0.0.1:9223/devtools/browser/discovered"; + const browser = new MockBrowser([new MockContext()]); + const requests: string[] = []; + const connectOverCDP = vi.fn(async (endpoint: string) => { + if (endpoint === staleEndpoint) { + throw new Error("stale DevToolsActivePort"); + } + + return browser; + }); + const fetch = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + requests.push(url); + + if (url === "http://127.0.0.1:9222/json/version") { + return new Response("not found", { status: 404 }); + } + + if (url === "http://127.0.0.1:9223/json/version") { + return new Response( + JSON.stringify({ + webSocketDebuggerUrl: discoveredEndpoint, + }), + { + status: 200, + headers: { + "content-type": "application/json", + }, + } + ); + } + + throw new Error("connection refused"); + }) as typeof globalThis.fetch; + const readFile = vi.fn(async (filePath: string) => { + if (filePath === devToolsPath) { + return "9222\n/devtools/browser/from-active-port\n"; + } + + throw createEnoentError(filePath); + }); + const { manager } = createManager({ + connectOverCDP, + fetch, + homedir: () => homeDir, + readFile, + }); + + await manager.autoConnect("auto-browser"); + + expect(connectOverCDP).toHaveBeenCalledTimes(2); + expect(connectOverCDP).toHaveBeenNthCalledWith(1, staleEndpoint); + expect(connectOverCDP).toHaveBeenNthCalledWith(2, discoveredEndpoint); + expect(requests).toEqual([ + "http://127.0.0.1:9222/json/version", + "http://127.0.0.1:9223/json/version", + ]); + }); + + it("reports a clear error when auto-discovery finds no running Chrome", async () => { + const readFile = vi.fn(async (filePath: string) => { + throw createEnoentError(filePath); + }); + const fetch = vi.fn(async () => { + throw new Error("connection refused"); + }) as typeof globalThis.fetch; + const { manager } = createManager({ + fetch, + readFile, + }); + + await expect(manager.autoConnect("missing")).rejects.toThrowError(/remote-debugging-port=9222/); + }); + + it("reports a Windows-specific launch hint when auto-discovery fails on Windows", async () => { + const readFile = vi.fn(async () => { + throw createEnoentError( + "C:\\Users\\tester\\AppData\\Local\\Google\\Chrome\\User Data\\DevToolsActivePort" + ); + }); + const fetch = vi.fn(async () => { + throw new Error("connection refused"); + }) as typeof globalThis.fetch; + const { manager } = createManager({ + fetch, + platform: "win32", + readFile, + }); + + await expect(manager.autoConnect("missing")).rejects.toThrowError( + /chrome\.exe --remote-debugging-port=9222/ + ); + }); +}); + +describe("protocol execute request", () => { + it('accepts connect="auto" and explicit CDP URLs', () => { + const autoResult = parseRequest( + JSON.stringify({ + id: "req-auto", + type: "execute", + browser: "default", + script: 'console.log("hi")', + connect: "auto", + }) + ); + const manualResult = parseRequest( + JSON.stringify({ + id: "req-manual", + type: "execute", + browser: "default", + script: 'console.log("hi")', + connect: "http://127.0.0.1:9222", + }) + ); + + expect(autoResult).toEqual({ + success: true, + request: { + id: "req-auto", + type: "execute", + browser: "default", + script: 'console.log("hi")', + connect: "auto", + }, + }); + expect(manualResult).toEqual({ + success: true, + request: { + id: "req-manual", + type: "execute", + browser: "default", + script: 'console.log("hi")', + connect: "http://127.0.0.1:9222", + }, + }); + }); + + it("accepts an execution timeout", () => { + const result = parseRequest( + JSON.stringify({ + id: "req-timeout", + type: "execute", + browser: "default", + script: 'console.log("hi")', + timeoutMs: 10_000, + }) + ); + + expect(result).toEqual({ + success: true, + request: { + id: "req-timeout", + type: "execute", + browser: "default", + script: 'console.log("hi")', + timeoutMs: 10_000, + }, + }); + }); + + it("accepts ignoreHTTPSErrors for execute requests", () => { + const result = parseRequest( + JSON.stringify({ + id: "req-ignore-https", + type: "execute", + browser: "default", + script: 'console.log("hi")', + ignoreHTTPSErrors: true, + }) + ); + + expect(result).toEqual({ + success: true, + request: { + id: "req-ignore-https", + type: "execute", + browser: "default", + script: 'console.log("hi")', + ignoreHTTPSErrors: true, + }, + }); + }); +}); diff --git a/daemon/src/sandbox/__tests__/bundle-test-helpers.ts b/daemon/src/sandbox/__tests__/bundle-test-helpers.ts new file mode 100644 index 00000000..2bf278df --- /dev/null +++ b/daemon/src/sandbox/__tests__/bundle-test-helpers.ts @@ -0,0 +1,15 @@ +import { execFile } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); +const daemonDir = fileURLToPath(new URL("../../../", import.meta.url)); +const tsxCliPath = fileURLToPath( + new URL("../../../node_modules/tsx/dist/cli.mjs", import.meta.url) +); + +export async function ensureSandboxClientBundle(): Promise { + await execFileAsync(process.execPath, [tsxCliPath, "scripts/bundle-sandbox-client.ts"], { + cwd: daemonDir, + }); +} 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__/forked-client-bundle.test.ts b/daemon/src/sandbox/__tests__/forked-client-bundle.test.ts new file mode 100644 index 00000000..21276e5a --- /dev/null +++ b/daemon/src/sandbox/__tests__/forked-client-bundle.test.ts @@ -0,0 +1,53 @@ +import { readFile } from "node:fs/promises"; + +import { afterEach, beforeAll, describe, expect, it } from "vitest"; + +import { QuickJSHost } from "../quickjs-host.js"; +import { ensureSandboxClientBundle } from "./bundle-test-helpers.js"; + +const bundleUrl = new URL("../../../dist/sandbox-client.js", import.meta.url); + +const hosts = new Set(); + +let bundleCode = ""; + +async function createHost(): Promise { + const host = await QuickJSHost.create(); + hosts.add(host); + return host; +} + +afterEach(() => { + for (const host of hosts) { + host.dispose(); + } + hosts.clear(); +}); + +beforeAll(async () => { + await ensureSandboxClientBundle(); + bundleCode = await readFile(bundleUrl, "utf8"); +}, 120_000); + +describe("forked Playwright bundle", () => { + it("loads into QuickJS and exposes the client entry points", async () => { + const host = await createHost(); + + expect(() => + host.executeScriptSync(bundleCode, { + filename: "sandbox-client.js", + }) + ).not.toThrow(); + + expect(host.executeScriptSync("typeof __PlaywrightClient.Connection")).toBe("function"); + expect(host.executeScriptSync("typeof __PlaywrightClient.quickjsPlatform")).toBe("object"); + + expect(() => + host.executeScriptSync(` + globalThis.__sandboxConnection = new __PlaywrightClient.Connection(); + `) + ).not.toThrow(); + + expect(host.executeScriptSync("typeof __sandboxConnection.dispatch")).toBe("function"); + }, 120_000); +}); diff --git a/daemon/src/sandbox/__tests__/named-pages.test.ts b/daemon/src/sandbox/__tests__/named-pages.test.ts new file mode 100644 index 00000000..ae05997b --- /dev/null +++ b/daemon/src/sandbox/__tests__/named-pages.test.ts @@ -0,0 +1,238 @@ +import { mkdtemp } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; + +import { BrowserManager } from "../../browser-manager.js"; +import { removeDirectoryWithRetries } from "../../test-cleanup.js"; +import { QuickJSSandbox } from "../quickjs-sandbox.js"; + +const browserName = "named-pages"; + +interface CapturedOutput { + stdout: string[]; + stderr: string[]; +} + +function pageNames(pages: Array<{ name: string | null }>): string[] { + return pages + .map((page) => page.name) + .filter((name): name is string => typeof name === "string") + .sort((left, right) => left.localeCompare(right)); +} + +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 outputLines(output: CapturedOutput): string[] { + return output.stdout.map((line) => line.trim()).filter((line) => line.length > 0); +} + +describe.sequential("QuickJS named page management", () => { + let browserRootDir = ""; + let manager: BrowserManager; + + beforeAll(async () => { + browserRootDir = await mkdtemp(path.join(os.tmpdir(), "dev-browser-quickjs-named-pages-")); + manager = new BrowserManager(path.join(browserRootDir, "browsers")); + }, 180_000); + + afterEach(async () => { + await manager.stopBrowser(browserName); + }, 180_000); + + afterAll(async () => { + await manager.stopAll(); + await removeDirectoryWithRetries(browserRootDir); + }, 180_000); + + async function createSandbox(output: ReturnType): Promise { + await manager.ensureBrowser(browserName, { + headless: true, + }); + + const sandbox = new QuickJSSandbox({ + manager, + browserName, + onStdout: output.sink.onStdout, + onStderr: output.sink.onStderr, + timeoutMs: 60_000, + }); + await sandbox.initialize(); + return sandbox; + } + + async function runUserScript(sandbox: QuickJSSandbox, script: string): Promise { + await sandbox.executeScript(`(async () => {\n${script}\n})()`); + } + + it("persists named pages across script executions", async () => { + const output = createOutput(); + const sandbox = await createSandbox(output); + + try { + await runUserScript( + sandbox, + ` + const page = await browser.getPage("persist"); + await page.goto("https://example.com"); + await page.evaluate(() => { + window.name = "persisted-state"; + }); + console.log(await page.title()); + ` + ); + + await runUserScript( + sandbox, + ` + const page = await browser.getPage("persist"); + console.log(page.url()); + console.log(await page.title()); + console.log(await page.evaluate(() => window.name)); + ` + ); + } finally { + await sandbox.dispose(); + } + + expect(output.stderr).toEqual([]); + expect(outputLines(output)).toContain("https://example.com/"); + expect(outputLines(output)).toContain("Example Domain"); + expect(outputLines(output)).toContain("persisted-state"); + expect(pageNames(await manager.listPages(browserName))).toEqual(["persist"]); + }, 120_000); + + it("cleans up anonymous pages after each script execution", async () => { + const output = createOutput(); + await manager.ensureBrowser(browserName, { + headless: true, + }); + const entry = await manager.ensureBrowser(browserName); + const baselinePageCount = entry.context.pages().filter((page) => !page.isClosed()).length; + const sandbox = await createSandbox(output); + + try { + await runUserScript( + sandbox, + ` + const page = await browser.newPage(); + await page.goto("https://example.com"); + console.log(await page.title()); + ` + ); + } finally { + await sandbox.dispose(); + } + + const livePages = entry.context.pages().filter((page) => !page.isClosed()); + + expect(output.stderr).toEqual([]); + expect(outputLines(output)).toContain("Example Domain"); + expect(livePages).toHaveLength(baselinePageCount); + expect(livePages.map((page) => page.url())).not.toContain("https://example.com/"); + expect(pageNames(await manager.listPages(browserName))).toEqual([]); + }, 120_000); + + it("lists the current named pages", async () => { + const output = createOutput(); + const sandbox = await createSandbox(output); + + try { + await runUserScript( + sandbox, + ` + await browser.getPage("beta"); + await browser.getPage("alpha"); + console.log(JSON.stringify(await browser.listPages())); + ` + ); + } finally { + await sandbox.dispose(); + } + + expect(output.stderr).toEqual([]); + const listedPages = JSON.parse(outputLines(output).at(-1) ?? "[]") as Array<{ + id: string; + title: string; + url: string; + name: string | null; + }>; + + expect(listedPages).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: expect.any(String), + name: "alpha", + title: expect.any(String), + url: expect.any(String), + }), + expect.objectContaining({ + id: expect.any(String), + name: "beta", + title: expect.any(String), + url: expect.any(String), + }), + ]) + ); + expect(pageNames(listedPages)).toEqual(["alpha", "beta"]); + expect(pageNames(await manager.listPages(browserName))).toEqual(["alpha", "beta"]); + }, 120_000); + + it("closes named pages by name", async () => { + const output = createOutput(); + const sandbox = await createSandbox(output); + + try { + await runUserScript( + sandbox, + ` + await browser.getPage("close-me"); + ` + ); + await runUserScript( + sandbox, + ` + console.log(JSON.stringify(await browser.listPages())); + await browser.closePage("close-me"); + console.log(JSON.stringify(await browser.listPages())); + ` + ); + } finally { + await sandbox.dispose(); + } + + expect(output.stderr).toEqual([]); + const listedBeforeClose = JSON.parse(outputLines(output).at(0) ?? "[]") as Array<{ + name: string | null; + }>; + const listedAfterClose = JSON.parse(outputLines(output).at(1) ?? "[]") as Array<{ + name: string | null; + }>; + + expect(pageNames(listedBeforeClose)).toEqual(["close-me"]); + expect(pageNames(listedAfterClose)).toEqual([]); + expect(pageNames(await manager.listPages(browserName))).toEqual([]); + }, 120_000); +}); diff --git a/daemon/src/sandbox/__tests__/playwright-api.test.ts b/daemon/src/sandbox/__tests__/playwright-api.test.ts new file mode 100644 index 00000000..8ba92212 --- /dev/null +++ b/daemon/src/sandbox/__tests__/playwright-api.test.ts @@ -0,0 +1,916 @@ +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 { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { BrowserManager } from "../../browser-manager.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 TEST_PAGE_HTML = String.raw` + + + Test Page + + + +

Hello World

+

Some text

+
Inner HTML
+ + + + + + + + +
+
    +
  • Item 1
  • +
  • Item 2
  • +
  • Item 3
  • +
+
+ Alpha + Child A +
+
+ Beta + Child B +
+
+ Nested child +
+ +
Transient element
+ + Example Link +
Mouse Target
+
+ + + +`; + +interface CapturedOutput { + stdout: string[]; + stderr: string[]; +} + +interface JsonSandboxHarness { + dispose: () => Promise; + runJson: (script: string) => Promise; +} + +interface NavigationServer { + 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 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 withTestPage(pageName: string, body: string): string { + return ` + const page = await browser.getPage(${JSON.stringify(pageName)}); + await page.setContent(${JSON.stringify(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 navigationPageHtml(title: string, route: string, nextPath?: string): string { + const nextLink = nextPath + ? `Next` + : 'No next link'; + + return ` + + + ${title} + + +

${title}

+
${route}
+ ${nextLink} + +`; +} + +function handleNavigationRequest(request: IncomingMessage, response: ServerResponse): void { + const url = new URL(request.url ?? "/", "http://127.0.0.1"); + let html = ""; + + switch (url.pathname) { + case "/nav/first": + html = navigationPageHtml("First Page", "/nav/first", "/nav/second"); + break; + case "/nav/second": + html = navigationPageHtml("Second Page", "/nav/second", "/nav/third"); + break; + case "/nav/third": + html = navigationPageHtml("Third Page", "/nav/third"); + break; + default: + 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 Playwright Page API coverage", () => { + let browserRootDir = ""; + let manager: BrowserManager; + + beforeAll(async () => { + await ensureSandboxClientBundle(); + + browserRootDir = await mkdtemp(path.join(os.tmpdir(), "dev-browser-playwright-api-")); + manager = new BrowserManager(path.join(browserRootDir, "browsers")); + }, 180_000); + + afterAll(async () => { + await manager.stopAll(); + await removeDirectoryWithRetries(browserRootDir); + }, 180_000); + + describe.sequential("navigation", () => { + const browserName = "playwright-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("supports goto with waitUntil, url(), and waitForURL()", async () => { + const firstUrl = `${navigationServer.baseUrl}/nav/first`; + const result = await harness.runJson<{ + firstUrl: string; + secondUrl: string; + firstTitle: string; + secondTitle: string; + }>(` + const page = await browser.getPage("navigation-goto"); + await page.goto(${JSON.stringify(firstUrl)}, { waitUntil: "domcontentloaded" }); + const firstTitle = await page.title(); + const firstUrl = page.url(); + const secondNavigation = page.waitForURL("**/nav/second"); + await page.click("#next-link"); + await secondNavigation; + console.log(JSON.stringify({ + firstUrl, + secondUrl: page.url(), + firstTitle, + secondTitle: await page.title(), + })); + `); + + expect(result.firstUrl).toBe(firstUrl); + expect(result.secondUrl).toBe(`${navigationServer.baseUrl}/nav/second`); + expect(result.firstTitle).toBe("First Page"); + expect(result.secondTitle).toBe("Second Page"); + }, 15_000); + + it("supports goBack(), goForward(), and reload()", async () => { + const firstUrl = `${navigationServer.baseUrl}/nav/first`; + const secondUrl = `${navigationServer.baseUrl}/nav/second`; + + const result = await harness.runJson<{ + backUrl: string; + backTitle: string; + forwardUrl: string; + reloadTitle: string; + }>(` + const page = await browser.getPage("navigation-history"); + await page.goto(${JSON.stringify(firstUrl)}, { waitUntil: "load" }); + await page.goto(${JSON.stringify(secondUrl)}, { waitUntil: "load" }); + await page.goBack({ waitUntil: "load" }); + const backUrl = page.url(); + const backTitle = await page.title(); + await page.goForward({ waitUntil: "load" }); + const forwardUrl = page.url(); + await page.evaluate(() => { + document.title = "Mutated Title"; + }); + await page.reload({ waitUntil: "load" }); + console.log(JSON.stringify({ + backUrl, + backTitle, + forwardUrl, + reloadTitle: await page.title(), + })); + `); + + expect(result.backUrl).toBe(firstUrl); + expect(result.backTitle).toBe("First Page"); + expect(result.forwardUrl).toBe(secondUrl); + expect(result.reloadTitle).toBe("Second Page"); + }); + }); + + describe.sequential("content and evaluation", () => { + const browserName = "playwright-content-evaluation"; + let harness: JsonSandboxHarness; + + beforeAll(async () => { + harness = await createSandboxHarness(manager, browserName); + }, 180_000); + + afterAll(async () => { + await harness.dispose(); + await manager.stopBrowser(browserName); + }, 180_000); + + it("reads page content through page methods", async () => { + const result = await harness.runJson<{ + title: string; + contentHasTitle: boolean; + contentHasFooter: boolean; + text: string | null; + html: string; + innerText: string; + kind: string | null; + href: string | null; + }>( + withTestPage( + "content-reading", + ` + const content = await page.content(); + console.log(JSON.stringify({ + title: await page.title(), + contentHasTitle: content.includes("Test Page"), + contentHasFooter: content.includes("Footer content"), + text: await page.textContent("#text"), + html: await page.innerHTML("#html-block"), + innerText: await page.innerText("#text"), + kind: await page.getAttribute("#text", "data-kind"), + href: await page.getAttribute("#link", "href"), + })); + ` + ) + ); + + expect(result.title).toBe("Test Page"); + expect(result.contentHasTitle).toBe(true); + expect(result.contentHasFooter).toBe(true); + expect(result.text).toBe("Some text"); + expect(result.html).toContain("HTML"); + expect(result.innerText).toBe("Some text"); + expect(result.kind).toBe("primary"); + expect(result.href).toBe("https://example.com"); + }); + + it("supports evaluate(), evaluate(arg), $eval(), and $$eval()", async () => { + const result = await harness.runJson<{ + pageTitle: string; + sum: number; + upperText: string; + listItems: string[]; + }>( + withTestPage( + "evaluation-methods", + ` + const pageTitle = await page.evaluate(() => document.title); + const sum = await page.evaluate((values) => values.left + values.right, { + left: 2, + right: 3, + }); + const upperText = await page.$eval("#text", (element) => { + return (element.textContent ?? "").toUpperCase(); + }); + const listItems = await page.$$eval("#list li", (elements) => { + return elements.map((element) => element.textContent ?? ""); + }); + console.log(JSON.stringify({ + pageTitle, + sum, + upperText, + listItems, + })); + ` + ) + ); + + expect(result.pageTitle).toBe("Test Page"); + expect(result.sum).toBe(5); + expect(result.upperText).toBe("SOME TEXT"); + expect(result.listItems).toEqual(["Item 1", "Item 2", "Item 3"]); + }); + }); + + describe.sequential("form interaction and waiting", () => { + const browserName = "playwright-form-waiting"; + let harness: JsonSandboxHarness; + + beforeAll(async () => { + harness = await createSandboxHarness(manager, browserName); + }, 180_000); + + afterAll(async () => { + await harness.dispose(); + await manager.stopBrowser(browserName); + }, 180_000); + + it("supports fill(), click(), type(), press(), check(), uncheck(), and selectOption()", async () => { + const result = await harness.runJson<{ + bio: string; + name: string; + email: string; + inputCount: number; + enterResult: string | null; + clickResult: string | null; + checkedAfterCheck: boolean; + checkedAfterUncheck: boolean; + selectedValues: string[]; + selectedValue: string; + }>( + withTestPage( + "form-interaction", + ` + await page.fill("#bio", "QuickJS bio"); + await page.type("#name", "Ada"); + const name = await page.inputValue("#name"); + const inputCount = await page.evaluate(() => window.events.inputCount); + await page.press("#name", "Enter"); + const enterResult = await page.textContent("#result"); + await page.type("#email", "ada@example.com"); + await page.check("#agree"); + const checkedAfterCheck = await page.isChecked("#agree"); + const selectedValues = await page.selectOption("#color", "blue"); + const selectedValue = await page.inputValue("#color"); + await page.click("#submit"); + const clickResult = await page.textContent("#result"); + await page.uncheck("#agree"); + const checkedAfterUncheck = await page.isChecked("#agree"); + console.log(JSON.stringify({ + bio: await page.inputValue("#bio"), + name, + email: await page.inputValue("#email"), + inputCount, + enterResult, + clickResult, + checkedAfterCheck, + checkedAfterUncheck, + selectedValues, + selectedValue, + })); + ` + ) + ); + + expect(result.bio).toBe("QuickJS bio"); + expect(result.name).toBe("Ada"); + expect(result.email).toBe("ada@example.com"); + expect(result.inputCount).toBe(3); + expect(result.enterResult).toBe("enter:Ada"); + expect(result.clickResult).toBe("clicked:Ada:blue"); + expect(result.checkedAfterCheck).toBe(true); + expect(result.checkedAfterUncheck).toBe(false); + expect(result.selectedValues).toEqual(["blue"]); + expect(result.selectedValue).toBe("blue"); + }); + + it("supports waitForSelector(), waitForTimeout(), and waitForFunction()", async () => { + const result = await harness.runJson<{ + timeoutElapsed: number; + waitTargetText: string | null; + transientHidden: boolean; + readyValue: string; + }>( + withTestPage( + "wait-methods", + ` + const start = Date.now(); + await page.waitForTimeout(40); + const timeoutElapsed = Date.now() - start; + await page.waitForSelector("#wait-target"); + await page.waitForSelector("#transient", { state: "hidden" }); + const readyHandle = await page.waitForFunction(() => window.extraReady); + const readyValue = await readyHandle.jsonValue(); + await readyHandle.dispose(); + console.log(JSON.stringify({ + timeoutElapsed, + waitTargetText: await page.textContent("#wait-target"), + transientHidden: await page.isHidden("#transient"), + readyValue, + })); + ` + ) + ); + + expect(result.timeoutElapsed).toBeGreaterThanOrEqual(20); + expect(result.waitTargetText).toBe("Loaded later"); + expect(result.transientHidden).toBe(true); + expect(result.readyValue).toBe("ok"); + }); + }); + + describe.sequential("locators and multiple elements", () => { + const browserName = "playwright-locators"; + let harness: JsonSandboxHarness; + + beforeAll(async () => { + harness = await createSandboxHarness(manager, browserName); + }, 180_000); + + afterAll(async () => { + await harness.dispose(); + await manager.stopBrowser(browserName); + }, 180_000); + + it("supports locator actions, text helpers, visibility, enabled state, and nth accessors", async () => { + const result = await harness.runJson<{ + textContent: string | null; + innerText: string; + kind: string | null; + visible: boolean; + hiddenVisible: boolean; + submitEnabled: boolean; + disabledEnabled: boolean; + count: number; + first: string | null; + last: string | null; + second: string | null; + clickResult: string | null; + }>( + withTestPage( + "locator-basics", + ` + await page.locator("#name").fill("Grace"); + await page.locator("#submit").click(); + console.log(JSON.stringify({ + textContent: await page.locator("#text").textContent(), + innerText: await page.locator("#text").innerText(), + kind: await page.locator("#text").getAttribute("data-kind"), + visible: await page.locator("#text").isVisible(), + hiddenVisible: await page.locator("#hidden").isVisible(), + submitEnabled: await page.locator("#submit").isEnabled(), + disabledEnabled: await page.locator("#disabled").isEnabled(), + count: await page.locator("#list li").count(), + first: await page.locator("#list li").first().textContent(), + last: await page.locator("#list li").last().textContent(), + second: await page.locator("#list li").nth(1).textContent(), + clickResult: await page.locator("#result").textContent(), + })); + ` + ) + ); + + expect(result.textContent).toBe("Some text"); + expect(result.innerText).toBe("Some text"); + expect(result.kind).toBe("primary"); + expect(result.visible).toBe(true); + expect(result.hiddenVisible).toBe(false); + expect(result.submitEnabled).toBe(true); + expect(result.disabledEnabled).toBe(false); + expect(result.count).toBe(3); + expect(result.first).toBe("Item 1"); + expect(result.last).toBe("Item 3"); + expect(result.second).toBe("Item 2"); + expect(result.clickResult).toBe("clicked:Grace:red"); + }); + + it("supports filter(), chained locators, locator.all(), and iterating multiple elements", async () => { + const result = await harness.runJson<{ + betaLabel: string | null; + betaChild: string | null; + nestedChild: string | null; + items: string[]; + }>( + withTestPage( + "locator-advanced", + ` + const betaCard = page.locator(".card").filter({ hasText: "Beta" }); + const items = []; + for (const item of await page.locator("#list li").all()) { + items.push(await item.innerText()); + } + console.log(JSON.stringify({ + betaLabel: await betaCard.locator(".label").textContent(), + betaChild: await betaCard.locator(".child").textContent(), + nestedChild: await page.locator(".parent").locator(".child").textContent(), + items, + })); + ` + ) + ); + + expect(result.betaLabel).toBe("Beta"); + expect(result.betaChild).toBe("Child B"); + expect(result.nestedChild).toBe("Nested child"); + expect(result.items).toEqual(["Item 1", "Item 2", "Item 3"]); + }); + }); + + describe.sequential("AI snapshots", () => { + const browserName = "playwright-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("supports page.snapshotForAI()", async () => { + const result = await harness.runJson<{ + full: string; + incremental?: string; + }>( + withTestPage( + "snapshot-main", + ` + const result = await page.snapshotForAI(); + console.log(JSON.stringify(result)); + ` + ) + ); + + expect(result.incremental).toBeUndefined(); + 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", () => { + const browserName = "playwright-media-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("supports screenshot() and screenshot({ fullPage: true })", async () => { + const result = await harness.runJson<{ + screenshotLength: number; + fullPageLength: number; + }>( + withTestPage( + "screenshots", + ` + const screenshot = await page.screenshot(); + const fullPage = await page.screenshot({ fullPage: true }); + console.log(JSON.stringify({ + screenshotLength: screenshot.length, + fullPageLength: fullPage.length, + })); + ` + ) + ); + + expect(result.screenshotLength).toBeGreaterThan(0); + expect(result.fullPageLength).toBeGreaterThan(0); + }); + + it("supports page.keyboard and page.mouse", async () => { + const result = await harness.runJson<{ + typedValue: string; + enterResult: string | null; + mouseResult: string | null; + }>( + withTestPage( + "input-devices", + ` + await page.click("#name"); + await page.keyboard.type("hello"); + await page.keyboard.press("Enter"); + const enterResult = await page.textContent("#result"); + await page.mouse.click(80, 80); + console.log(JSON.stringify({ + typedValue: await page.inputValue("#name"), + enterResult, + mouseResult: await page.getAttribute("#result", "data-mouse"), + })); + ` + ) + ); + + expect(result.typedValue).toBe("hello"); + expect(result.enterResult).toBe("enter:hello"); + expect(result.mouseResult).toBe("clicked"); + }); + }); + + describe.sequential("events", () => { + const browserName = "playwright-events"; + let harness: JsonSandboxHarness; + + beforeAll(async () => { + harness = await createSandboxHarness(manager, browserName); + }, 180_000); + + afterAll(async () => { + await harness.dispose(); + await manager.stopBrowser(browserName); + }, 180_000); + + it("supports page.on('console')", async () => { + const result = await harness.runJson<{ + messages: Array<{ type: string; text: string }>; + }>( + withTestPage( + "console-events", + ` + const messages = []; + page.on("console", (message) => { + messages.push({ + type: message.type(), + text: message.text(), + }); + }); + await page.evaluate(() => { + console.log("from-page", 123); + }); + await page.waitForTimeout(50); + console.log(JSON.stringify({ messages })); + ` + ) + ); + + expect(result.messages).toEqual([ + { + type: "log", + text: "from-page 123", + }, + ]); + }); + }); +}); diff --git a/daemon/src/sandbox/__tests__/protocol-bridge.test.ts b/daemon/src/sandbox/__tests__/protocol-bridge.test.ts new file mode 100644 index 00000000..61da58c5 --- /dev/null +++ b/daemon/src/sandbox/__tests__/protocol-bridge.test.ts @@ -0,0 +1,59 @@ +import type { Browser, Page } from "playwright"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { createProtocolBridge, type ProtocolBridge } from "../protocol-bridge.js"; +import type { PlaywrightClientLike } from "../playwright-internals.js"; + +describe.sequential("protocol bridge", () => { + let bridge: ProtocolBridge; + let playwright: PlaywrightClientLike; + let browser: Browser | undefined; + let page: Page | undefined; + + beforeAll(async () => { + bridge = createProtocolBridge(); + playwright = await bridge.initializePlaywright(); + }, 60_000); + + afterAll(async () => { + if (page && !page.isClosed()) { + await page.close(); + } + + if (browser?.isConnected()) { + await browser.close(); + } + + await bridge.dispose("test teardown"); + }, 60_000); + + it("initializes Playwright through the bridge", () => { + expect(playwright.chromium).toBeDefined(); + }); + + it("launches Chromium and executes core page operations", async () => { + browser = await playwright.chromium.launch({ headless: true }); + page = await browser.newPage(); + + await page.goto("https://example.com"); + + await expect(page.title()).resolves.toBe("Example Domain"); + await expect(page.locator("h1").textContent()).resolves.toBe("Example Domain"); + await expect(page.evaluate(() => document.title)).resolves.toBe("Example Domain"); + }, 120_000); + + it("cleans up page, browser, and bridge connection", async () => { + const activePage = page; + const activeBrowser = browser; + + expect(activePage).toBeDefined(); + expect(activeBrowser).toBeDefined(); + + await activePage?.close(); + expect(activePage?.isClosed()).toBe(true); + + await activeBrowser?.close(); + expect(activeBrowser?.isConnected()).toBe(false); + + await bridge.dispose("test cleanup"); + }, 60_000); +}); diff --git a/daemon/src/sandbox/__tests__/quickjs-host.test.ts b/daemon/src/sandbox/__tests__/quickjs-host.test.ts new file mode 100644 index 00000000..6af0d52e --- /dev/null +++ b/daemon/src/sandbox/__tests__/quickjs-host.test.ts @@ -0,0 +1,159 @@ +import { afterEach, describe, expect, it } from "vitest"; + +import { QuickJSHost } from "../quickjs-host.js"; + +const hosts = new Set(); + +async function createHost( + options: Parameters[0] = {} +): Promise { + const host = await QuickJSHost.create(options); + hosts.add(host); + return host; +} + +afterEach(() => { + for (const host of hosts) { + host.dispose(); + } + hosts.clear(); +}); + +describe("QuickJSHost", () => { + it("executes simple expressions", async () => { + const host = await createHost({ + globals: { + appName: "dev-browser", + }, + }); + + expect(host.executeScriptSync("1 + 2 + 3")).toBe(6); + expect(host.executeScriptSync("appName")).toBe("dev-browser"); + }); + + it("exposes host functions and transport callbacks", async () => { + const sentMessages: string[] = []; + const host = await createHost({ + hostFunctions: { + add: (left: unknown, right: unknown) => Number(left) + Number(right), + }, + onTransportSend: (message) => { + sentMessages.push(message); + }, + }); + + expect(host.executeScriptSync('__hostCall("add", JSON.stringify([4, 5]))')).toBe(9); + expect( + host.executeScriptSync('__transport_send(JSON.stringify({ type: "ping", id: 1 }))') + ).toBeUndefined(); + expect(sentMessages).toEqual(['{"type":"ping","id":1}']); + }); + + it("handles async host functions", async () => { + const host = await createHost({ + hostFunctions: { + addAsync: async (left: unknown, right: unknown) => { + await new Promise((resolve) => setTimeout(resolve, 5)); + return Number(left) + Number(right); + }, + }, + }); + + await expect( + host.executeScript(` + (async () => { + return await __hostCall("addAsync", JSON.stringify([7, 8])); + })() + `) + ).resolves.toBe(15); + }); + + it("lets the host call functions inside the sandbox", async () => { + const host = await createHost(); + + host.executeScriptSync(` + globalThis.__transport_receive = (message) => { + globalThis.lastMessage = message; + return message.toUpperCase(); + }; + `); + + await expect(host.callFunction("__transport_receive", "hello from host")).resolves.toBe( + "HELLO FROM HOST" + ); + expect(host.executeScriptSync("lastMessage")).toBe("hello from host"); + }); + + it("supports setTimeout and clearTimeout", async () => { + const host = await createHost(); + + await expect( + host.executeScript(` + (async () => { + let fired = false; + const cancelled = setTimeout(() => { + fired = true; + }, 1); + clearTimeout(cancelled); + + return await new Promise((resolve) => { + setTimeout(() => resolve(fired ? "wrong" : "done"), 5); + }); + })() + `) + ).resolves.toBe("done"); + }); + + it("enforces memory limits", async () => { + const host = await createHost({ + memoryLimitBytes: 1024 * 1024, + }); + + await expect( + host.executeScript(` + const chunks = []; + while (true) { + chunks.push("x".repeat(1024)); + } + `) + ).rejects.toThrow(/out of memory/i); + }); + + it("enforces CPU time limits", async () => { + const host = await createHost({ + cpuTimeoutMs: 10, + }); + + await expect(host.executeScript("while (true) {}")).rejects.toThrow(/interrupted/i); + }); + + it("routes console output to the host callback", async () => { + const entries: Array<{ level: string; args: unknown[] }> = []; + const host = await createHost({ + onConsole: (level, args) => { + entries.push({ level, args }); + }, + }); + + expect(host.executeScriptSync('console.log("hello", 42, { ok: true })')).toBeUndefined(); + expect(entries).toEqual([ + { + level: "log", + args: ["hello", 42, { ok: true }], + }, + ]); + }); + + it("disposes resources cleanly", async () => { + const host = await createHost(); + + host.executeScriptSync("setTimeout(() => {}, 1)"); + + expect(() => host.dispose()).not.toThrow(); + expect(host.disposed).toBe(true); + expect(() => host.dispose()).not.toThrow(); + await expect(host.executeScript("1 + 1")).rejects.toThrow(/disposed/i); + + hosts.delete(host); + }); +}); diff --git a/daemon/src/sandbox/__tests__/sandbox-file-io.test.ts b/daemon/src/sandbox/__tests__/sandbox-file-io.test.ts new file mode 100644 index 00000000..2584dafd --- /dev/null +++ b/daemon/src/sandbox/__tests__/sandbox-file-io.test.ts @@ -0,0 +1,276 @@ +import { + mkdtemp, + readFile as readFileFs, + rm, + stat, + symlink, + writeFile as writeFileFs, +} from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; + +import { BrowserManager } from "../../browser-manager.js"; +import { + DEV_BROWSER_TMP_DIR, + ensureDevBrowserTempDir, + resolveDevBrowserTempPath, +} from "../../temp-files.js"; +import { removeDirectoryWithRetries } from "../../test-cleanup.js"; +import { runScript } from "../script-runner-quickjs.js"; +import { ensureSandboxClientBundle } from "./bundle-test-helpers.js"; + +const browserName = "sandbox-file-io"; +const INVALID_PATH_ERROR = + /absolute paths are not allowed|null bytes|must not contain|escapes the controlled temp directory|symlink/i; + +interface CapturedOutput { + stdout: string[]; + stderr: string[]; +} + +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 parseLastJsonLine(output: CapturedOutput): T { + const lines = output.stdout.map((line) => line.trim()).filter((line) => line.length > 0); + const lastLine = lines.at(-1); + if (!lastLine) { + throw new Error("Expected sandbox output"); + } + + return JSON.parse(lastLine) as T; +} + +describe.sequential("QuickJS sandbox file I/O", () => { + let browserRootDir = ""; + let manager: BrowserManager; + const cleanupPaths = new Set(); + + beforeAll(async () => { + await ensureSandboxClientBundle(); + + await ensureDevBrowserTempDir(); + + browserRootDir = await mkdtemp(path.join(os.tmpdir(), "dev-browser-quickjs-file-io-")); + manager = new BrowserManager(path.join(browserRootDir, "browsers")); + await manager.ensureBrowser(browserName, { + headless: true, + }); + }, 180_000); + + afterEach(async () => { + for (const filePath of cleanupPaths) { + await rm(filePath, { + recursive: true, + force: true, + }); + } + cleanupPaths.clear(); + }); + + afterAll(async () => { + await manager.stopAll(); + await removeDirectoryWithRetries(browserRootDir); + }, 180_000); + + async function runSandboxScript(script: string): Promise { + const output = createOutput(); + await runScript(script, manager, browserName, output.sink, { + timeout: 60_000, + }); + return output; + } + + async function expectSandboxScriptToThrow( + script: string, + matcher = INVALID_PATH_ERROR + ): Promise { + const output = createOutput(); + await expect( + runScript(script, manager, browserName, output.sink, { timeout: 60_000 }) + ).rejects.toThrow(matcher); + } + + it("saves screenshot buffers into the controlled temp directory", async () => { + const requestedName = "sandbox file io screenshot?.png"; + const expectedPath = await resolveDevBrowserTempPath(requestedName); + cleanupPaths.add(expectedPath); + + const output = await runSandboxScript(` + const page = await browser.getPage("file-io-save-screenshot"); + await page.setContent("

Screenshot

"); + const screenshot = await page.screenshot(); + const savedPath = await saveScreenshot(screenshot, ${JSON.stringify(requestedName)}); + console.log(JSON.stringify({ savedPath, size: screenshot.length })); + `); + + const result = parseLastJsonLine<{ savedPath: string; size: number }>(output); + expect(result.savedPath).toBe(expectedPath); + expect(result.savedPath.startsWith(`${path.resolve(DEV_BROWSER_TMP_DIR)}${path.sep}`)).toBe( + true + ); + expect(result.size).toBeGreaterThan(0); + expect((await stat(result.savedPath)).size).toBeGreaterThan(0); + }, 120_000); + + it("writes page.screenshot({ path }) into the controlled temp directory", async () => { + const requestedName = "page screenshot path?.png"; + const expectedPath = await resolveDevBrowserTempPath(requestedName); + cleanupPaths.add(expectedPath); + + const output = await runSandboxScript(` + const page = await browser.getPage("file-io-page-screenshot"); + await page.setContent("

Path Screenshot

"); + const options = { path: ${JSON.stringify(requestedName)} }; + const screenshot = await page.screenshot(options); + console.log(JSON.stringify({ savedPath: options.path, size: screenshot.length })); + `); + + const result = parseLastJsonLine<{ savedPath: string; size: number }>(output); + expect(result.savedPath).toBe(expectedPath); + expect(result.size).toBeGreaterThan(0); + expect((await stat(result.savedPath)).size).toBeGreaterThan(0); + }, 120_000); + + it("writes and reads back controlled temp files", async () => { + const requestedName = "sandbox file io data?.json"; + const expectedPath = await resolveDevBrowserTempPath(requestedName); + cleanupPaths.add(expectedPath); + + const output = await runSandboxScript(` + const savedPath = await writeFile( + ${JSON.stringify(requestedName)}, + JSON.stringify({ ok: true, value: 42 }), + ); + const content = await readFile(${JSON.stringify(requestedName)}); + console.log(JSON.stringify({ savedPath, content })); + `); + + const result = parseLastJsonLine<{ savedPath: string; content: string }>(output); + expect(result.savedPath).toBe(expectedPath); + expect(result.content).toBe('{"ok":true,"value":42}'); + expect(await readFileFs(result.savedPath, "utf8")).toBe(result.content); + }); + + it("sanitizes harmless filename characters before writing", async () => { + const requestedName = "report 2026:03:18?.json"; + const expectedPath = await resolveDevBrowserTempPath(requestedName); + cleanupPaths.add(expectedPath); + + const output = await runSandboxScript(` + const savedPath = await writeFile(${JSON.stringify(requestedName)}, "sanitized"); + console.log(JSON.stringify({ savedPath })); + `); + + const result = parseLastJsonLine<{ savedPath: string }>(output); + expect(result.savedPath).toBe(expectedPath); + expect(path.basename(result.savedPath)).toBe("report_2026_03_18_.json"); + expect(await readFileFs(result.savedPath, "utf8")).toBe("sanitized"); + }); + + it("rejects traversal, absolute paths, and null bytes", async () => { + const invalidNames = [ + "../escape", + "..\\escape", + "../../etc/passwd", + "safe/../../escape", + "/absolute/path", + "C:\\evil.txt", + "bad\0name.txt", + ]; + + const output = await runSandboxScript(` + const invalidNames = ${JSON.stringify(invalidNames)}; + const results = []; + const operations = [ + ["writeFile", async (name) => await writeFile(name, "blocked")], + ["readFile", async (name) => await readFile(name)], + [ + "saveScreenshot", + async (name) => await saveScreenshot(new Uint8Array([1, 2, 3]), name), + ], + ]; + + for (const invalidName of invalidNames) { + for (const [label, operation] of operations) { + try { + await operation(invalidName); + results.push({ invalidName, label, ok: true, message: null }); + } catch (error) { + results.push({ + invalidName, + label, + ok: false, + message: error instanceof Error ? error.message : String(error), + }); + } + } + } + + console.log(JSON.stringify(results)); + `); + + const results = parseLastJsonLine< + Array<{ + invalidName: string; + label: string; + ok: boolean; + message: string | null; + }> + >(output); + + expect(results).toHaveLength(invalidNames.length * 3); + + for (const result of results) { + expect(result.ok).toBe(false); + expect(result.message ?? "").toMatch(INVALID_PATH_ERROR); + } + }); + + it("rejects unsafe screenshot path options", async () => { + await expectSandboxScriptToThrow(` + const page = await browser.getPage("file-io-invalid-screenshot"); + await page.setContent("

Invalid

"); + await page.screenshot({ path: "../escape.png" }); + `); + }, 120_000); + + it("rejects symlinked temp-file targets", async () => { + const symlinkName = "sandbox-file-io-symlink.txt"; + const symlinkPath = path.join(DEV_BROWSER_TMP_DIR, symlinkName); + const targetPath = path.join(os.tmpdir(), "sandbox-file-io-symlink-target.txt"); + + cleanupPaths.add(symlinkPath); + cleanupPaths.add(targetPath); + + await writeFileFs(targetPath, "outside", "utf8"); + await symlink(targetPath, symlinkPath); + + await expectSandboxScriptToThrow( + `await writeFile(${JSON.stringify(symlinkName)}, "should fail");` + ); + await expectSandboxScriptToThrow(`await readFile(${JSON.stringify(symlinkName)});`); + }); +}); diff --git a/daemon/src/sandbox/__tests__/sandbox-integration.test.ts b/daemon/src/sandbox/__tests__/sandbox-integration.test.ts new file mode 100644 index 00000000..923ce8bc --- /dev/null +++ b/daemon/src/sandbox/__tests__/sandbox-integration.test.ts @@ -0,0 +1,286 @@ +import { mkdtemp } from "node:fs/promises"; +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 { 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"; + +interface CapturedOutput { + stdout: string[]; + stderr: string[]; +} + +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); + }, + }, + }; +} + +describe.sequential("QuickJS sandbox integration", () => { + let browserRootDir = ""; + let manager: BrowserManager; + + beforeAll(async () => { + await ensureSandboxClientBundle(); + + browserRootDir = await mkdtemp(path.join(os.tmpdir(), "dev-browser-quickjs-")); + manager = new BrowserManager(path.join(browserRootDir, "browsers")); + await manager.ensureBrowser("default", { + headless: true, + }); + }, 180_000); + + afterAll(async () => { + await manager.stopAll(); + await removeDirectoryWithRetries(browserRootDir); + }, 180_000); + + it("navigates through QuickJS and logs the page title", async () => { + const output = createOutput(); + + await runScript( + ` + const page = await browser.getPage("nav"); + await page.goto("https://example.com"); + console.log(await page.title()); + `, + manager, + "default", + output.sink, + { + timeout: 60_000, + } + ); + + expect(output.stdout.join("")).toContain("Example Domain"); + expect(output.stderr.join("")).toBe(""); + }, 120_000); + + it("supports locator operations", async () => { + const output = createOutput(); + + await runScript( + ` + const page = await browser.getPage("locator"); + await page.goto("https://example.com"); + console.log(await page.locator("h1").textContent()); + `, + manager, + "default", + output.sink, + { + timeout: 60_000, + } + ); + + expect(output.stdout.join("")).toContain("Example Domain"); + }, 120_000); + + it("supports page.evaluate", async () => { + const output = createOutput(); + + await runScript( + ` + const page = await browser.getPage("evaluate"); + await page.goto("https://example.com"); + console.log(await page.evaluate(() => document.title)); + `, + manager, + "default", + output.sink, + { + timeout: 60_000, + } + ); + + expect(output.stdout.join("")).toContain("Example Domain"); + }, 120_000); + + it("reports thrown script errors", async () => { + const output = createOutput(); + + await expect( + runScript( + ` + throw new Error("boom"); + `, + manager, + "default", + output.sink + ) + ).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(); + + await expect( + runScript( + ` + while (true) {} + `, + manager, + "default", + output.sink, + { + timeout: 25, + } + ) + ).rejects.toThrow(/timed out|interrupted/i); + }, 120_000); + + it("enforces wall-clock timeouts for async scripts", async () => { + const output = createOutput(); + + await expect( + runScript( + ` + await new Promise((resolve) => setTimeout(resolve, 100)); + `, + manager, + "default", + output.sink, + { + timeout: 25, + } + ) + ).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(); + + await runScript( + ` + console.log("sandbox", 42, { ok: true }); + `, + manager, + "default", + output.sink + ); + + 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 new file mode 100644 index 00000000..1806c84b --- /dev/null +++ b/daemon/src/sandbox/__tests__/sandbox-security.test.ts @@ -0,0 +1,220 @@ +import { mkdtemp } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; + +import { BrowserManager } from "../../browser-manager.js"; +import { removeDirectoryWithRetries } from "../../test-cleanup.js"; +import { runScript } from "../script-runner-quickjs.js"; + +const browserName = "sandbox-security"; + +interface CapturedOutput { + stdout: string[]; + stderr: string[]; +} + +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); + }, + }, + }; +} + +describe.sequential("QuickJS sandbox security", () => { + let browserRootDir = ""; + let manager: BrowserManager; + + beforeAll(async () => { + browserRootDir = await mkdtemp(path.join(os.tmpdir(), "dev-browser-quickjs-security-")); + manager = new BrowserManager(path.join(browserRootDir, "browsers")); + await manager.ensureBrowser(browserName, { + headless: true, + }); + }, 180_000); + + afterAll(async () => { + await manager.stopAll(); + await removeDirectoryWithRetries(browserRootDir); + }, 180_000); + + async function runSandboxScript( + script: string, + options: { timeout?: number; memoryLimitBytes?: number } = {} + ): Promise { + const output = createOutput(); + await runScript(script, manager, browserName, output.sink, options); + return output; + } + + function expectSandboxScriptToThrow( + script: string, + matcher: RegExp, + options: { timeout?: number; memoryLimitBytes?: number } = {} + ): Promise { + const output = createOutput(); + return expect(runScript(script, manager, browserName, output.sink, options)).rejects.toThrow( + matcher + ); + } + + it("does not expose require", async () => { + await expectSandboxScriptToThrow(`require("fs");`, /require|not defined/i); + }); + + it("does not expose process", async () => { + await expectSandboxScriptToThrow(`process.exit();`, /process|not defined/i); + }); + + it("does not expose fetch", async () => { + await expectSandboxScriptToThrow(`fetch("https://evil.com");`, /fetch|not defined/i); + }); + + it("does not expose WebSocket", async () => { + await expectSandboxScriptToThrow( + `new WebSocket("wss://evil.example");`, + /WebSocket|not defined/i + ); + }); + + it("does not escape through the constructor chain", async () => { + await expectSandboxScriptToThrow( + `this.constructor.constructor("return process")();`, + /process|not defined/i + ); + }); + + it("does not allow dynamic imports", async () => { + await expectSandboxScriptToThrow(`await import("node:fs");`, /import|module|load/i); + }); + + it("enforces memory limits", async () => { + await expectSandboxScriptToThrow( + ` + const chunks = []; + while (true) { + chunks.push("x".repeat(1024)); + } + `, + /out of memory/i, + { + memoryLimitBytes: 1024 * 1024, + } + ); + }, 120_000); + + it("enforces CPU time limits", async () => { + await expectSandboxScriptToThrow(`while (true) {}`, /timed out|interrupted/i, { + timeout: 25, + }); + }, 120_000); + + it("only exposes the expected globals and browser API", async () => { + const output = await runSandboxScript(` + console.log( + JSON.stringify({ + globals: Object.getOwnPropertyNames(globalThis).sort(), + browserKeys: Object.getOwnPropertyNames(browser).sort(), + browserHasNullPrototype: Object.getPrototypeOf(browser) === null, + }), + ); + `); + + expect(output.stderr).toEqual([]); + expect(output.stdout).toHaveLength(1); + + const reportLine = output.stdout[0]; + if (reportLine === undefined) { + throw new Error("Sandbox globals report was not captured"); + } + + const payload = JSON.parse(reportLine) as { + globals: string[]; + browserKeys: string[]; + browserHasNullPrototype: boolean; + }; + + expect(payload.globals).not.toContain("require"); + expect(payload.globals).not.toContain("process"); + expect(payload.globals).not.toContain("fetch"); + expect(payload.globals).not.toContain("__dirname"); + expect(payload.globals).not.toContain("__filename"); + expect(payload.globals).not.toContain("__hostCall"); + expect(payload.globals).not.toContain("__transport_send"); + expect(payload.globals).not.toContain("__connection"); + expect(payload.globals).not.toContain("__playwright"); + expect(payload.globals).not.toContain("__browser"); + expect(payload.globals).not.toContain("__PlaywrightClient"); + expect(payload.globals).not.toContain("__createPlaywrightClient"); + expect(payload.globals).toContain("readFile"); + expect(payload.globals).toContain("saveScreenshot"); + expect(payload.globals).toContain("writeFile"); + expect(payload.browserKeys).toEqual(["closePage", "getPage", "listPages", "newPage"]); + 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"); + let leakedToHostStdout = false; + + try { + await runScript(`console.log("secret");`, manager, browserName, output.sink); + leakedToHostStdout = stdoutSpy.mock.calls.some((args) => + args.some((value) => String(value).includes("secret")) + ); + } finally { + stdoutSpy.mockRestore(); + } + + expect(output.stdout.join("")).toContain("secret"); + expect(output.stderr).toEqual([]); + expect(leakedToHostStdout).toBe(false); + }); +}); diff --git a/daemon/src/sandbox/forked-client/README.md b/daemon/src/sandbox/forked-client/README.md new file mode 100644 index 00000000..a27ea1d6 --- /dev/null +++ b/daemon/src/sandbox/forked-client/README.md @@ -0,0 +1,375 @@ +# Forked Playwright Client + +## What this is + +This directory is a forked subset of Playwright's client-side code, adapted to run inside the QuickJS WASM sandbox used by dev-browser. + +The goal is simple: + +- sandboxed user scripts should get normal Playwright client objects like `Page`, `Frame`, `Locator`, and `ElementHandle` +- the sandbox should not get direct access to the host filesystem, processes, sockets, or browser ownership + +The fork sits on the client side only. The real browser, dispatcher graph, and browser lifecycle stay on the host side. + +```text +User script + -> QuickJS sandbox + -> forked Playwright client + -> transport bridge + -> host Playwright dispatcher + -> real Playwright + -> browser +``` + +Relevant local paths: + +```text +bundle-entry.ts +quickjs-platform.ts +src/client/* +src/protocol/* +src/utils/isomorphic/* +types/* +../quickjs-sandbox.ts +../host-bridge.ts +../protocol-bridge.ts +../sandbox-transport.ts +``` + +## Why we forked it + +Upstream Playwright client code is not a drop-in fit for QuickJS: + +- it assumes Node.js APIs such as `fs`, `path`, and stream types +- it expects upstream transports like WebSocket or Playwright's local pipe helpers +- it includes surfaces that do not make sense in the sandbox, such as Android, Electron, downloads/artifacts-to-disk, and local utility helpers + +The fork lets dev-browser keep the Playwright object model and protocol semantics while replacing the runtime assumptions around it. + +## Source version and provenance + +The provenance markers for this fork do not fully agree, so treat this directory as the source of truth. + +- `../../../package.json` declares: + +```json +"playwright": "^1.52.0", +"playwright-core": "^1.52.0" +``` + +- The research notes at `/Users/sawyerhood/.middleman/notes/dev-browser/research/playwright-fork-sandbox.md` record upstream commit `3912da7`. +- In practice, the checked-in fork aligns most closely with that `3912da7` snapshot, with local edits layered on top. + +When updating, diff against the exact upstream tag or commit you choose. Do not trust the semver range in `../../../package.json` by itself. + +Upstream path mapping for this fork: + +```text +src/client/* <- packages/playwright-core/src/client/* +src/protocol/channels.d.ts <- packages/protocol/src/channels.d.ts +src/protocol/* <- packages/playwright-core/src/protocol/* +src/utils/isomorphic/* <- packages/playwright-core/src/utils/isomorphic/* +types/* <- packages/playwright-core/types/* +``` + +## Change tiers + +### Tier 1: verbatim + +Strict byte-for-byte copies are rare in this fork because most `.ts` files add `// @ts-nocheck` and/or replace monorepo alias imports with local relative imports. + +Strict verbatim file list: + +```text +types/structs.d.ts +``` + +Everything else should be treated as fork-maintained, even when the runtime logic is effectively upstream. + +### Tier 2: modified + +These files have intentional behavior changes or important local wiring: + +- `quickjs-platform.ts` + - new file + - provides a minimal `Platform` implementation for QuickJS + - supplies colors, GUID generation, a lightweight pseudo-SHA1, noop zones/logging, and throws for Node-only APIs like `fs`, `path`, and streams + +- `bundle-entry.ts` + - new file + - esbuild entry point for the sandbox bundle + - exports `Connection`, `quickjsPlatform`, and the public client-side types used by the sandbox loader + +- `src/client/platform.ts` + - re-exports `quickjsPlatform` + - replaces upstream monorepo alias imports with local relative imports so this directory can bundle standalone + +- `src/client/connection.ts` + - defaults `new Connection()` to `quickjsPlatform` + - keeps the client object graph local to this fork by importing `../protocol/channels` instead of upstream monorepo aliases + - still creates Android/Electron objects when the protocol says they exist, but those classes are stubbed locally + +- `src/client/browserType.ts` + - hard-disables `browserType.connect()` and `browserType.launchPersistentContext()` + - keeps browser ownership on the host side instead of letting sandboxed code launch or attach directly + - leaves `launch()` and `connectOverCDP()` in place for the protocol surface, subject to host policy + +- `src/client/fileUtils.ts` + - replaces upstream filesystem helpers with sandbox-safe behavior + - `mkdirIfNeeded()` becomes a noop + - `writeTempFile()` delegates to `globalThis.writeFile`, which is injected by `../quickjs-sandbox.ts` + +- `src/client/page.ts` + - intercepts `page.screenshot({ path })` + - strips `path` before sending the protocol request + - saves the returned buffer through `writeTempFile()` so sandboxed code never writes host paths directly + +- `src/client/elementHandle.ts` + - same screenshot-path interception as `src/client/page.ts` + +- `src/client/browserContext.ts` + - removes `Debugger` wiring from the client-side initializer + - replaces upstream `@recorder/actions` imports with local `types/recorder-actions.d.ts` + +- `src/protocol/channels.d.ts` + - vendored locally from Playwright's protocol package + - kept here so the fork bundles without depending on the upstream monorepo layout + +- `src/protocol/validator.ts` + - local validator snapshot matching the vendored protocol/types in this directory + +- `types/protocol.d.ts` +- `types/types.d.ts` + - vendored type declarations that preserve the normal Playwright API surface inside the sandbox + - note that the types intentionally still describe some APIs that later throw at runtime because the runtime is stubbed + +- `src/client/connect.ts` + - copied into the fork, but it is not the main QuickJS transport path + - the sandbox path constructs `Connection` directly in `../quickjs-sandbox.ts` and pumps protocol JSON through `../host-bridge.ts` + +- Most other `.ts` files in `src/client/`, `src/protocol/`, and `src/utils/isomorphic/` + - mechanical edits only + - mainly `// @ts-nocheck` plus replacing upstream aliases like `@protocol/*`, `@isomorphic/*`, and `@recorder/*` with local relative imports + +### Tier 3: stubbed + +These files keep the object graph and type surface intact, but replace the implementation with empty or throwing behavior: + +```text +src/client/android.ts +src/client/artifact.ts +src/client/electron.ts +src/client/fetch.ts +src/client/harRouter.ts +src/client/localUtils.ts +src/client/screencast.ts +src/client/stream.ts +src/client/tracing.ts +src/client/video.ts +src/client/writableStream.ts +types/recorder-actions.d.ts +``` + +Why they are stubbed: + +- Android and Electron are not part of the sandbox execution model +- artifacts, streams, tracing, and HAR helpers assume filesystem or stream access the sandbox does not have +- `APIRequest` and `LocalUtils` would otherwise pull in more host capabilities than this sandbox should expose +- `types/recorder-actions.d.ts` exists only to satisfy local type imports from `src/client/browserContext.ts` + +Related note: + +- `src/client/download.ts` is not itself stubbed, but it delegates to `Artifact`, so download file access is effectively unsupported in the sandbox + +### Tier 4: skipped + +These upstream files are intentionally not copied into this fork: + +```text +src/client/DEPS.list +src/protocol/DEPS.list +src/protocol/callMetadata.d.ts +src/protocol/progress.d.ts +src/protocol/protocol.yml +src/utils/isomorphic/DEPS.list +src/utils/isomorphic/trace/DEPS.list +src/utils/isomorphic/trace/entries.ts +src/utils/isomorphic/trace/snapshotRenderer.ts +src/utils/isomorphic/trace/snapshotServer.ts +src/utils/isomorphic/trace/snapshotStorage.ts +src/utils/isomorphic/trace/traceLoader.ts +src/utils/isomorphic/trace/traceModel.ts +src/utils/isomorphic/trace/traceModernizer.ts +src/utils/isomorphic/trace/traceUtils.ts +src/utils/isomorphic/trace/versions/traceV3.ts +src/utils/isomorphic/trace/versions/traceV4.ts +src/utils/isomorphic/trace/versions/traceV5.ts +src/utils/isomorphic/trace/versions/traceV6.ts +src/utils/isomorphic/trace/versions/traceV7.ts +src/utils/isomorphic/trace/versions/traceV8.ts +``` + +Why they are skipped: + +- `DEPS.list` and protocol generator inputs are monorepo/build metadata, not runtime requirements for the sandbox bundle +- the trace viewer and trace model helpers are not needed to expose the Playwright page automation surface inside QuickJS + +## `quickjs-platform.ts` + +`quickjs-platform.ts` exists because upstream Playwright expects a `Platform` object and QuickJS is neither Node nor a browser runtime in the way Playwright assumes. + +It does three important things: + +- provides the small cross-cutting utilities the client always needs +- explicitly throws for host features the sandbox must not get +- gives `Connection` a safe default runtime via `src/client/connection.ts` + +The file is intentionally small. If you are adding a new path-based or stream-based API to the sandbox, this is one of the first places to inspect. + +## `bundle-entry.ts` + +`bundle-entry.ts` is the esbuild entry point for the fork. + +It does not try to recreate the published `playwright` package. It exports only the client-side pieces the sandbox loader needs: + +```text +Connection +quickjsPlatform +Browser +BrowserContext +Frame +Locator +Page +Playwright +``` + +`../quickjs-sandbox.ts` loads the built bundle, evaluates it inside QuickJS, and captures the resulting `__PlaywrightClient` global. + +## `stubs/` + +There is currently no `stubs/` directory in this fork. + +Stubbed behavior lives inline in the files listed under Tier 3 instead. + +## How the bundle works + +Build inputs and outputs: + +```text +entry point: bundle-entry.ts +build script: ../../../scripts/bundle-sandbox-client.ts +output: ../../../dist/sandbox-client.js +format: IIFE +global: __PlaywrightClient +platform: neutral +target: es2022 +``` + +Rebuild command: + +```bash +cd daemon && pnpm run bundle:sandbox-client +``` + +The build uses `platform: "neutral"` on purpose. This code is not a normal Node bundle and not a normal browser bundle. It is a self-contained client bundle that QuickJS can evaluate safely. + +## Architecture context + +The fork is only one piece of the stack: + +```text +User script + -> QuickJS sandbox + -> forked Playwright client + -> transport bridge + -> host Playwright dispatcher + -> real Playwright + -> browser +``` + +Concrete local pieces: + +- `../quickjs-sandbox.ts` loads `../../../dist/sandbox-client.js`, installs host callbacks like `writeFile`, and creates the sandbox-side `Connection` +- `../host-bridge.ts` owns the host-side `DispatcherConnection`, `RootDispatcher`, and `PlaywrightDispatcher` +- `../protocol-bridge.ts` wires both sides together +- `../sandbox-transport.ts` shows the same shape in the pure Node test bridge + +One subtle but important detail: + +- the QuickJS transport path does not go through `src/client/browserType.connect()` +- instead, the sandbox creates `Connection` directly and exchanges raw Playwright protocol messages with the host bridge + +That is why `browserType.connect()` is stubbed even though the sandbox still has a transport bridge. + +## How to update the fork + +1. Clone Playwright at the exact target tag or commit you want to adopt. + + Do not rely only on the `^1.52.0` range in `../../../package.json`. Pick one concrete upstream revision first. + +2. Diff the upstream files against this directory. + + Compare at least these upstream paths: + + ```text + packages/playwright-core/src/client/* + packages/playwright-core/src/protocol/* + packages/playwright-core/src/utils/isomorphic/* + packages/playwright-core/types/* + packages/protocol/src/channels.d.ts + ``` + +3. Reapply the local fork changes while keeping upstream behavior where possible. + + The most conflict-prone files are: + + ```text + quickjs-platform.ts + bundle-entry.ts + src/client/connection.ts + src/client/platform.ts + src/client/browserType.ts + src/client/fileUtils.ts + src/client/page.ts + src/client/elementHandle.ts + src/client/browserContext.ts + src/client/artifact.ts + src/client/fetch.ts + src/client/localUtils.ts + src/client/harRouter.ts + src/client/tracing.ts + src/client/android.ts + src/client/electron.ts + src/client/stream.ts + src/client/writableStream.ts + src/protocol/channels.d.ts + src/protocol/validator.ts + types/types.d.ts + types/protocol.d.ts + types/recorder-actions.d.ts + ``` + + These are the places where upstream changes are most likely to collide with sandbox-specific behavior. + +4. Rebuild the sandbox client bundle. + + ```bash + cd daemon && pnpm run bundle:sandbox-client + ``` + +5. Run the daemon test suite. + + ```bash + cd daemon && pnpm vitest run + ``` + +6. Update the version/provenance section in this README. + + Record the new upstream tag or commit and keep any package-version mismatch explicit. + +## Practical rules + +- Prefer copying upstream code first, then reapplying the sandbox edits. +- Keep new unsupported features stubbed explicitly instead of silently half-working. +- If an upstream change touches screenshot path handling, `Platform`, or protocol type generation, expect manual merge work. +- If you add a new stubbed surface, keep the runtime error message explicit so sandbox users fail fast. diff --git a/daemon/src/sandbox/forked-client/bundle-entry.ts b/daemon/src/sandbox/forked-client/bundle-entry.ts new file mode 100644 index 00000000..555636a1 --- /dev/null +++ b/daemon/src/sandbox/forked-client/bundle-entry.ts @@ -0,0 +1,9 @@ +export { Connection } from "./src/client/connection"; +export { quickjsPlatform } from "./quickjs-platform"; + +export type { Browser } from "./src/client/browser"; +export type { BrowserContext } from "./src/client/browserContext"; +export type { Frame } from "./src/client/frame"; +export type { Locator } from "./src/client/locator"; +export type { Page } from "./src/client/page"; +export type { Playwright } from "./src/client/playwright"; diff --git a/daemon/src/sandbox/forked-client/package.json b/daemon/src/sandbox/forked-client/package.json new file mode 100644 index 00000000..5bbefffb --- /dev/null +++ b/daemon/src/sandbox/forked-client/package.json @@ -0,0 +1,3 @@ +{ + "type": "commonjs" +} diff --git a/daemon/src/sandbox/forked-client/quickjs-platform.ts b/daemon/src/sandbox/forked-client/quickjs-platform.ts new file mode 100644 index 00000000..445d7e8a --- /dev/null +++ b/daemon/src/sandbox/forked-client/quickjs-platform.ts @@ -0,0 +1,56 @@ +import { webColors } from "./src/utils/isomorphic/colors"; + +import type { Platform, Zone } from "./src/client/platform"; + +const noopZone: Zone = { + push: () => noopZone, + pop: () => noopZone, + run: (callback: () => T) => callback(), + data: () => undefined as T | undefined, +}; + +function unsupported(apiName: string): never { + throw new Error(`${apiName} is not available in the QuickJS sandbox`); +} + +function pseudoSha1(text: string): string { + let hash = 2166136261; + for (let index = 0; index < text.length; index += 1) { + hash ^= text.charCodeAt(index); + hash = Math.imul(hash, 16777619); + } + return (hash >>> 0).toString(16).padStart(8, "0"); +} + +export const quickjsPlatform: Platform = { + name: "empty", + boxedStackPrefixes: () => [], + calculateSha1: async (text: string) => pseudoSha1(text), + colors: webColors, + createGuid: () => + "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (token) => { + const value = Math.floor(Math.random() * 16); + const nibble = token === "x" ? value : (value & 0x3) | 0x8; + return nibble.toString(16); + }), + defaultMaxListeners: () => 10, + env: {}, + fs: () => unsupported("fs"), + inspectCustom: undefined, + isDebugMode: () => false, + isJSDebuggerAttached: () => false, + isLogEnabled: () => false, + isUnderTest: () => false, + log: () => {}, + path: () => unsupported("path"), + pathSeparator: "/", + showInternalStackFrames: () => false, + streamFile: () => unsupported("streamFile"), + streamReadable: () => unsupported("streamReadable"), + streamWritable: () => unsupported("streamWritable"), + zodToJsonSchema: () => unsupported("zodToJsonSchema"), + zones: { + empty: noopZone, + current: () => noopZone, + }, +}; diff --git a/daemon/src/sandbox/forked-client/src/client/android.ts b/daemon/src/sandbox/forked-client/src/client/android.ts new file mode 100644 index 00000000..62659beb --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/android.ts @@ -0,0 +1,28 @@ +// @ts-nocheck +import { ChannelOwner } from "./channelOwner"; + +export class Android extends ChannelOwner { + static from(channel) { + return channel?._object; + } +} + +export class AndroidDevice extends ChannelOwner { + static from(channel) { + return channel?._object; + } +} + +export class AndroidSocket extends ChannelOwner { + static from(channel) { + return channel?._object; + } +} + +export class AndroidInput {} + +export class AndroidWebView extends ChannelOwner { + static from(channel) { + return channel?._object; + } +} diff --git a/daemon/src/sandbox/forked-client/src/client/api.ts b/daemon/src/sandbox/forked-client/src/client/api.ts new file mode 100644 index 00000000..e66f2da5 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/api.ts @@ -0,0 +1,49 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { Android, AndroidDevice, AndroidInput, AndroidSocket, AndroidWebView } from "./android"; +export { Browser } from "./browser"; +export { BrowserContext } from "./browserContext"; +export type { BrowserServer } from "./browserType"; +export { BrowserType } from "./browserType"; +export { Clock } from "./clock"; +export { ConsoleMessage } from "./consoleMessage"; +export { Coverage } from "./coverage"; +export { Debugger } from "./debugger"; +export { Dialog } from "./dialog"; +export type { Disposable } from "./disposable"; +export { Download } from "./download"; +export { Electron, ElectronApplication } from "./electron"; +export { FrameLocator, Locator } from "./locator"; +export { ElementHandle } from "./elementHandle"; +export { FileChooser } from "./fileChooser"; +export type { Screencast } from "./screencast"; +export type { Logger } from "./types"; +export { TimeoutError } from "./errors"; +export { Frame } from "./frame"; +export { Keyboard, Mouse, Touchscreen } from "./input"; +export { JSHandle } from "./jsHandle"; +export { Request, Response, Route, WebSocket, WebSocketRoute } from "./network"; +export { APIRequest, APIRequestContext, APIResponse } from "./fetch"; +export { Page } from "./page"; +export { Selectors } from "./selectors"; +export { Tracing } from "./tracing"; +export { Video } from "./video"; +export { Worker } from "./worker"; +export { CDPSession } from "./cdpSession"; +export { Playwright } from "./playwright"; +export { WebError } from "./webError"; diff --git a/daemon/src/sandbox/forked-client/src/client/artifact.ts b/daemon/src/sandbox/forked-client/src/client/artifact.ts new file mode 100644 index 00000000..bb9e6534 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/artifact.ts @@ -0,0 +1,36 @@ +// @ts-nocheck +import { ChannelOwner } from "./channelOwner"; + +export class Artifact extends ChannelOwner { + static from(channel) { + return channel?._object; + } + + async pathAfterFinished() { + throw new Error("Artifacts are not available in the QuickJS sandbox"); + } + + async saveAs() { + throw new Error("Artifacts are not available in the QuickJS sandbox"); + } + + async failure() { + return null; + } + + async createReadStream() { + throw new Error("Artifacts are not available in the QuickJS sandbox"); + } + + async readIntoBuffer() { + throw new Error("Artifacts are not available in the QuickJS sandbox"); + } + + async cancel() { + await this._channel.cancel?.(); + } + + async delete() { + await this._channel.delete?.(); + } +} diff --git a/daemon/src/sandbox/forked-client/src/client/browser.ts b/daemon/src/sandbox/forked-client/src/client/browser.ts new file mode 100644 index 00000000..a6bd12ff --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/browser.ts @@ -0,0 +1,226 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Artifact } from "./artifact"; +import { BrowserContext, prepareBrowserContextParams } from "./browserContext"; +import { CDPSession } from "./cdpSession"; +import { ChannelOwner } from "./channelOwner"; +import { isTargetClosedError } from "./errors"; +import { Events } from "./events"; +import { mkdirIfNeeded } from "./fileUtils"; + +import type { BrowserType } from "./browserType"; +import type { Page } from "./page"; +import type { BrowserContextOptions, LaunchOptions, Logger } from "./types"; +import type * as api from "../../types/types"; +import type * as channels from "../protocol/channels"; + +export class Browser extends ChannelOwner implements api.Browser { + readonly _contexts = new Set(); + private _isConnected = true; + private _closedPromise: Promise; + _shouldCloseConnectionOnClose = false; + _browserType!: BrowserType; + private _options: LaunchOptions = {}; + private _userDataDir: string | undefined; + readonly _name: string; + private _path: string | undefined; + _closeReason: string | undefined; + + static from(browser: channels.BrowserChannel): Browser { + return (browser as any)._object; + } + + constructor( + parent: ChannelOwner, + type: string, + guid: string, + initializer: channels.BrowserInitializer + ) { + super(parent, type, guid, initializer); + this._name = initializer.name; + this._channel.on("context", ({ context }) => + this._didCreateContext(BrowserContext.from(context)) + ); + this._channel.on("close", () => this._didClose()); + this._closedPromise = new Promise((f) => this.once(Events.Browser.Disconnected, f)); + } + + browserType(): BrowserType { + return this._browserType; + } + + async newContext(options: BrowserContextOptions = {}): Promise { + return await this._innerNewContext(options, false); + } + + async _newContextForReuse(options: BrowserContextOptions = {}): Promise { + return await this._innerNewContext(options, true); + } + + async _disconnectFromReusedContext(reason: string) { + const context = [...this._contexts].find((context) => context._forReuse); + if (!context) return; + await this._instrumentation.runBeforeCloseBrowserContext(context); + for (const page of context.pages()) page._onClose(); + context._onClose(); + await this._channel.disconnectFromReusedContext({ reason }); + } + + async _innerNewContext( + userOptions: BrowserContextOptions = {}, + forReuse: boolean + ): Promise { + const options = this._browserType._playwright.selectors._withSelectorOptions(userOptions); + await this._instrumentation.runBeforeCreateBrowserContext(options); + const contextOptions = await prepareBrowserContextParams(this._platform, options); + const response = forReuse + ? await this._channel.newContextForReuse(contextOptions) + : await this._channel.newContext(contextOptions); + const context = BrowserContext.from(response.context); + if (forReuse) context._forReuse = true; + if (options.logger) context._logger = options.logger; + await context._initializeHarFromOptions(options.recordHar); + await this._instrumentation.runAfterCreateBrowserContext(context); + return context; + } + + _connectToBrowserType( + browserType: BrowserType, + browserOptions: LaunchOptions, + logger: Logger | undefined, + userDataDir?: string + ) { + // Note: when using connect(), `browserType` is different from `this._parent`. + // This is why browser type is not wired up in the constructor, + // and instead this separate method is called later on. + this._browserType = browserType; + this._options = browserOptions; + this._userDataDir = userDataDir; + this._logger = logger; + for (const context of this._contexts) this._setupBrowserContext(context); + } + + private _didCreateContext(context: BrowserContext) { + context._browser = this; + this._contexts.add(context); + // Note: when connecting to a browser, initial contexts arrive before `browserType` is set, + // and will be configured later in `_connectToBrowserType`. + if (this._browserType) this._setupBrowserContext(context); + } + + private _setupBrowserContext(context: BrowserContext) { + context._logger = this._logger; + context.tracing._tracesDir = this._options.tracesDir; + this._browserType._contexts.add(context); + this._browserType._playwright.selectors._contextsForSelectors.add(context); + context.setDefaultTimeout(this._browserType._playwright._defaultContextTimeout); + context.setDefaultNavigationTimeout( + this._browserType._playwright._defaultContextNavigationTimeout + ); + } + + contexts(): BrowserContext[] { + return [...this._contexts]; + } + + version(): string { + return this._initializer.version; + } + + async _register( + title: string, + options: { workspaceDir?: string; metadata?: Record; wsPath?: string } = {} + ): Promise<{ pipeName: string }> { + const { pipeName } = await this._channel.startServer({ title, ...options }); + return { pipeName }; + } + + async _unregister(): Promise { + await this._channel.stopServer(); + } + + async newPage(options: BrowserContextOptions = {}): Promise { + return await this._wrapApiCall( + async () => { + const context = await this.newContext(options); + const page = await context.newPage(); + page._ownedContext = context; + context._ownerPage = page; + return page; + }, + { title: "Create page" } + ); + } + + isConnected(): boolean { + return this._isConnected; + } + + launchOptions(): LaunchOptions { + return this._options; + } + + userDataDir(): string | null { + return this._userDataDir ?? null; + } + + async newBrowserCDPSession(): Promise { + return CDPSession.from((await this._channel.newBrowserCDPSession()).session); + } + + async startTracing( + page?: Page, + options: { path?: string; screenshots?: boolean; categories?: string[] } = {} + ) { + this._path = options.path; + await this._channel.startTracing({ ...options, page: page ? page._channel : undefined }); + } + + async stopTracing(): Promise { + const artifact = Artifact.from((await this._channel.stopTracing()).artifact); + const buffer = await artifact.readIntoBuffer(); + await artifact.delete(); + if (this._path) { + await mkdirIfNeeded(this._platform, this._path); + await this._platform.fs().promises.writeFile(this._path, buffer); + this._path = undefined; + } + return buffer; + } + + async [Symbol.asyncDispose]() { + await this.close(); + } + + async close(options: { reason?: string } = {}): Promise { + this._closeReason = options.reason; + try { + if (this._shouldCloseConnectionOnClose) this._connection.close(); + else await this._channel.close(options); + await this._closedPromise; + } catch (e) { + if (isTargetClosedError(e)) return; + throw e; + } + } + + _didClose() { + this._isConnected = false; + this.emit(Events.Browser.Disconnected, this); + } +} diff --git a/daemon/src/sandbox/forked-client/src/client/browserContext.ts b/daemon/src/sandbox/forked-client/src/client/browserContext.ts new file mode 100644 index 00000000..527a9594 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/browserContext.ts @@ -0,0 +1,760 @@ +// @ts-nocheck +/** + * Copyright 2017 Google Inc. All rights reserved. + * Modifications copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Artifact } from "./artifact"; +import { Browser } from "./browser"; +import { CDPSession } from "./cdpSession"; +import { ChannelOwner } from "./channelOwner"; +import { evaluationScript } from "./clientHelper"; +import { Clock } from "./clock"; +import { ConsoleMessage } from "./consoleMessage"; +import { Dialog } from "./dialog"; +import { DisposableObject, DisposableStub } from "./disposable"; +import { TargetClosedError, parseError } from "./errors"; +import { Events } from "./events"; +import { APIRequestContext } from "./fetch"; +import { Frame } from "./frame"; +import { HarRouter } from "./harRouter"; +import * as network from "./network"; +import { BindingCall, Page } from "./page"; +import { Tracing } from "./tracing"; +import { Waiter } from "./waiter"; +import { WebError } from "./webError"; +import { Worker } from "./worker"; +import { TimeoutSettings } from "./timeoutSettings"; +import { mkdirIfNeeded } from "./fileUtils"; +import { headersArrayToObject, headersObjectToArray } from "../utils/isomorphic/headers"; +import { urlMatchesEqual } from "../utils/isomorphic/urlMatch"; +import { isRegExp, isString } from "../utils/isomorphic/rtti"; +import { rewriteErrorMessage } from "../utils/isomorphic/stackTrace"; + +import type { + BrowserContextOptions, + Headers, + SetStorageState, + StorageState, + WaitForEventOptions, +} from "./types"; +import type * as structs from "../../types/structs"; +import type * as api from "../../types/types"; +import type { URLMatch } from "../utils/isomorphic/urlMatch"; +import type { Platform } from "./platform"; +import type * as channels from "../protocol/channels"; +import type * as actions from "../../types/recorder-actions"; + +interface RecorderEventSink { + actionAdded?(page: Page, actionInContext: actions.ActionInContext, code: string): void; + actionUpdated?(page: Page, actionInContext: actions.ActionInContext, code: string): void; + signalAdded?(page: Page, signal: actions.SignalInContext): void; +} + +export class BrowserContext + extends ChannelOwner + implements api.BrowserContext +{ + _pages = new Set(); + _routes: network.RouteHandler[] = []; + _webSocketRoutes: network.WebSocketRouteHandler[] = []; + // Browser is null for browser contexts created outside of normal browser, e.g. android or electron. + _browser: Browser | null = null; + readonly _bindings = new Map any>(); + _timeoutSettings: TimeoutSettings; + _ownerPage: Page | undefined; + _forReuse = false; + private _closedPromise: Promise; + readonly _options: channels.BrowserNewContextParams; + + readonly request: APIRequestContext; + readonly tracing: Tracing; + readonly clock: Clock; + + readonly _serviceWorkers = new Set(); + private _harRecorders = new Map< + string, + { path: string; content: "embed" | "attach" | "omit" | undefined } + >(); + private _closingStatus: "none" | "closing" | "closed" = "none"; + private _closeReason: string | undefined; + private _harRouters: HarRouter[] = []; + private _onRecorderEventSink: RecorderEventSink | undefined; + + static from(context: channels.BrowserContextChannel): BrowserContext { + return (context as any)._object; + } + + static fromNullable(context: channels.BrowserContextChannel | null): BrowserContext | null { + return context ? BrowserContext.from(context) : null; + } + + constructor( + parent: ChannelOwner, + type: string, + guid: string, + initializer: channels.BrowserContextInitializer + ) { + super(parent, type, guid, initializer); + this._options = initializer.options; + this._timeoutSettings = new TimeoutSettings(this._platform); + this.tracing = Tracing.from(initializer.tracing); + this.request = APIRequestContext.from(initializer.requestContext); + this.request._timeoutSettings = this._timeoutSettings; + this.clock = new Clock(this); + + this._channel.on("bindingCall", ({ binding }) => this._onBinding(BindingCall.from(binding))); + this._channel.on("close", () => this._onClose()); + this._channel.on("page", ({ page }) => this._onPage(Page.from(page))); + this._channel.on("route", ({ route }) => this._onRoute(network.Route.from(route))); + this._channel.on("webSocketRoute", ({ webSocketRoute }) => + this._onWebSocketRoute(network.WebSocketRoute.from(webSocketRoute)) + ); + this._channel.on("serviceWorker", ({ worker }) => { + const serviceWorker = Worker.from(worker); + serviceWorker._context = this; + this._serviceWorkers.add(serviceWorker); + this.emit(Events.BrowserContext.ServiceWorker, serviceWorker); + }); + this._channel.on("console", (event) => { + const worker = Worker.fromNullable(event.worker); + const page = Page.fromNullable(event.page); + const consoleMessage = new ConsoleMessage(this._platform, event, page, worker); + worker?.emit(Events.Worker.Console, consoleMessage); + page?.emit(Events.Page.Console, consoleMessage); + if (worker && this._serviceWorkers.has(worker)) { + const scope = this._serviceWorkerScope(worker); + for (const page of this._pages) { + if (scope && page.url().startsWith(scope)) page.emit(Events.Page.Console, consoleMessage); + } + } + this.emit(Events.BrowserContext.Console, consoleMessage); + }); + this._channel.on("pageError", ({ error, page }) => { + const pageObject = Page.from(page); + const parsedError = parseError(error); + this.emit(Events.BrowserContext.WebError, new WebError(pageObject, parsedError)); + if (pageObject) pageObject.emit(Events.Page.PageError, parsedError); + }); + this._channel.on("dialog", ({ dialog }) => { + const dialogObject = Dialog.from(dialog); + let hasListeners = this.emit(Events.BrowserContext.Dialog, dialogObject); + const page = dialogObject.page(); + if (page) hasListeners = page.emit(Events.Page.Dialog, dialogObject) || hasListeners; + if (!hasListeners) { + // Although we do similar handling on the server side, we still need this logic + // on the client side due to a possible race condition between two async calls: + // a) removing "dialog" listener subscription (client->server) + // b) actual "dialog" event (server->client) + if (dialogObject.type() === "beforeunload") dialog.accept({}).catch(() => {}); + else dialog.dismiss().catch(() => {}); + } + }); + this._channel.on("request", ({ request, page }) => + this._onRequest(network.Request.from(request), Page.fromNullable(page)) + ); + this._channel.on("requestFailed", ({ request, failureText, responseEndTiming, page }) => + this._onRequestFailed( + network.Request.from(request), + responseEndTiming, + failureText, + Page.fromNullable(page) + ) + ); + this._channel.on("requestFinished", (params) => this._onRequestFinished(params)); + this._channel.on("response", ({ response, page }) => + this._onResponse(network.Response.from(response), Page.fromNullable(page)) + ); + this._channel.on("recorderEvent", ({ event, data, page, code }) => { + if (event === "actionAdded") + this._onRecorderEventSink?.actionAdded?.( + Page.from(page), + data as actions.ActionInContext, + code + ); + else if (event === "actionUpdated") + this._onRecorderEventSink?.actionUpdated?.( + Page.from(page), + data as actions.ActionInContext, + code + ); + else if (event === "signalAdded") + this._onRecorderEventSink?.signalAdded?.(Page.from(page), data as actions.SignalInContext); + }); + this._closedPromise = new Promise((f) => this.once(Events.BrowserContext.Close, f)); + + this._setEventToSubscriptionMapping( + new Map([ + [Events.BrowserContext.Console, "console"], + [Events.BrowserContext.Dialog, "dialog"], + [Events.BrowserContext.Request, "request"], + [Events.BrowserContext.Response, "response"], + [Events.BrowserContext.RequestFinished, "requestFinished"], + [Events.BrowserContext.RequestFailed, "requestFailed"], + ]) + ); + } + + async _initializeHarFromOptions(recordHar: BrowserContextOptions["recordHar"]) { + if (!recordHar) return; + const defaultContent = recordHar.path.endsWith(".zip") ? "attach" : "embed"; + await this._recordIntoHAR(recordHar.path, null, { + url: recordHar.urlFilter, + updateContent: recordHar.content ?? (recordHar.omitContent ? "omit" : defaultContent), + updateMode: recordHar.mode ?? "full", + }); + } + + private _onPage(page: Page): void { + this._pages.add(page); + this.emit(Events.BrowserContext.Page, page); + if (page._opener && !page._opener.isClosed()) page._opener.emit(Events.Page.Popup, page); + } + + private _onRequest(request: network.Request, page: Page | null) { + this.emit(Events.BrowserContext.Request, request); + if (page) page.emit(Events.Page.Request, request); + } + + private _onResponse(response: network.Response, page: Page | null) { + this.emit(Events.BrowserContext.Response, response); + if (page) page.emit(Events.Page.Response, response); + } + + private _onRequestFailed( + request: network.Request, + responseEndTiming: number, + failureText: string | undefined, + page: Page | null + ) { + request._failureText = failureText || null; + request._setResponseEndTiming(responseEndTiming); + this.emit(Events.BrowserContext.RequestFailed, request); + if (page) page.emit(Events.Page.RequestFailed, request); + } + + private _onRequestFinished(params: channels.BrowserContextRequestFinishedEvent) { + const { responseEndTiming } = params; + const request = network.Request.from(params.request); + const response = network.Response.fromNullable(params.response); + const page = Page.fromNullable(params.page); + request._setResponseEndTiming(responseEndTiming); + this.emit(Events.BrowserContext.RequestFinished, request); + if (page) page.emit(Events.Page.RequestFinished, request); + if (response) response._finishedPromise.resolve(null); + } + + async _onRoute(route: network.Route) { + route._context = this; + const page = route.request()._safePage(); + const routeHandlers = this._routes.slice(); + for (const routeHandler of routeHandlers) { + // If the page or the context was closed we stall all requests right away. + if (page?._closeWasCalled || this.isClosedOrClosing()) return; + if (!routeHandler.matches(route.request().url())) continue; + const index = this._routes.indexOf(routeHandler); + if (index === -1) continue; + if (routeHandler.willExpire()) this._routes.splice(index, 1); + const handled = await routeHandler.handle(route); + if (!this._routes.length) + this._updateInterceptionPatterns({ internal: true }).catch(() => {}); + if (handled) return; + } + // If the page is closed or unrouteAll() was called without waiting and interception disabled, + // the method will throw an error - silence it. + await route._innerContinue(true /* isFallback */).catch(() => {}); + } + + async _onWebSocketRoute(webSocketRoute: network.WebSocketRoute) { + const routeHandler = this._webSocketRoutes.find((route) => route.matches(webSocketRoute.url())); + if (routeHandler) await routeHandler.handle(webSocketRoute); + else webSocketRoute.connectToServer(); + } + + async _onBinding(bindingCall: BindingCall) { + const func = this._bindings.get(bindingCall._initializer.name); + if (!func) return; + await bindingCall.call(func); + } + + private _serviceWorkerScope(serviceWorker: Worker) { + try { + let url = new URL(".", serviceWorker.url()).href; + if (!url.endsWith("/")) url += "/"; + return url; + } catch { + return null; + } + } + + setDefaultNavigationTimeout(timeout: number | undefined) { + this._timeoutSettings.setDefaultNavigationTimeout(timeout); + } + + setDefaultTimeout(timeout: number | undefined) { + this._timeoutSettings.setDefaultTimeout(timeout); + } + + browser(): Browser | null { + return this._browser; + } + + contextOptions() { + return contextParamsToPublicOptions(this._options); + } + + pages(): Page[] { + return [...this._pages]; + } + + isClosedOrClosing(): boolean { + return this._closingStatus !== "none"; + } + + async newPage(): Promise { + if (this._ownerPage) throw new Error("Please use browser.newContext()"); + return Page.from((await this._channel.newPage()).page); + } + + async cookies(urls?: string | string[]): Promise { + if (!urls) urls = []; + if (urls && typeof urls === "string") urls = [urls]; + return (await this._channel.cookies({ urls: urls as string[] })).cookies; + } + + async addCookies(cookies: network.SetNetworkCookieParam[]): Promise { + await this._channel.addCookies({ cookies }); + } + + async clearCookies(options: network.ClearNetworkCookieOptions = {}): Promise { + await this._channel.clearCookies({ + name: isString(options.name) ? options.name : undefined, + nameRegexSource: isRegExp(options.name) ? options.name.source : undefined, + nameRegexFlags: isRegExp(options.name) ? options.name.flags : undefined, + domain: isString(options.domain) ? options.domain : undefined, + domainRegexSource: isRegExp(options.domain) ? options.domain.source : undefined, + domainRegexFlags: isRegExp(options.domain) ? options.domain.flags : undefined, + path: isString(options.path) ? options.path : undefined, + pathRegexSource: isRegExp(options.path) ? options.path.source : undefined, + pathRegexFlags: isRegExp(options.path) ? options.path.flags : undefined, + }); + } + + async grantPermissions(permissions: string[], options?: { origin?: string }): Promise { + await this._channel.grantPermissions({ permissions, ...options }); + } + + async clearPermissions(): Promise { + await this._channel.clearPermissions(); + } + + async setGeolocation( + geolocation: { longitude: number; latitude: number; accuracy?: number } | null + ): Promise { + await this._channel.setGeolocation({ geolocation: geolocation || undefined }); + } + + async setExtraHTTPHeaders(headers: Headers): Promise { + network.validateHeaders(headers); + await this._channel.setExtraHTTPHeaders({ headers: headersObjectToArray(headers) }); + } + + async setOffline(offline: boolean): Promise { + await this._channel.setOffline({ offline }); + } + + async setHTTPCredentials( + httpCredentials: { username: string; password: string } | null + ): Promise { + await this._channel.setHTTPCredentials({ httpCredentials: httpCredentials || undefined }); + } + + async addInitScript(script: Function | string | { path?: string; content?: string }, arg?: any) { + const source = await evaluationScript(this._platform, script, arg); + return DisposableObject.from((await this._channel.addInitScript({ source })).disposable); + } + + async exposeBinding( + name: string, + callback: (source: structs.BindingSource, ...args: any[]) => any, + options: { handle?: boolean } = {} + ): Promise { + const result = await this._channel.exposeBinding({ name, needsHandle: options.handle }); + this._bindings.set(name, callback); + return DisposableObject.from(result.disposable); + } + + async exposeFunction(name: string, callback: Function): Promise { + const result = await this._channel.exposeBinding({ name }); + const binding = (source: structs.BindingSource, ...args: any[]) => callback(...args); + this._bindings.set(name, binding); + return DisposableObject.from(result.disposable); + } + + async route( + url: URLMatch, + handler: network.RouteHandlerCallback, + options: { times?: number } = {} + ): Promise { + this._routes.unshift( + new network.RouteHandler(this._platform, this._options.baseURL, url, handler, options.times) + ); + await this._updateInterceptionPatterns({ title: "Route requests" }); + return new DisposableStub(() => this.unroute(url, handler)); + } + + async routeWebSocket( + url: URLMatch, + handler: network.WebSocketRouteHandlerCallback + ): Promise { + this._webSocketRoutes.unshift( + new network.WebSocketRouteHandler(this._options.baseURL, url, handler) + ); + await this._updateWebSocketInterceptionPatterns({ title: "Route WebSockets" }); + } + + async _recordIntoHAR( + har: string, + page: Page | null, + options: { + url?: string | RegExp; + updateContent?: "attach" | "embed" | "omit"; + updateMode?: "minimal" | "full"; + } = {} + ): Promise { + const { harId } = await this._channel.harStart({ + page: page?._channel, + options: { + zip: har.endsWith(".zip"), + content: options.updateContent ?? "attach", + urlGlob: isString(options.url) ? options.url : undefined, + urlRegexSource: isRegExp(options.url) ? options.url.source : undefined, + urlRegexFlags: isRegExp(options.url) ? options.url.flags : undefined, + mode: options.updateMode ?? "minimal", + }, + }); + this._harRecorders.set(harId, { path: har, content: options.updateContent ?? "attach" }); + } + + async routeFromHAR( + har: string, + options: { + url?: string | RegExp; + notFound?: "abort" | "fallback"; + update?: boolean; + updateContent?: "attach" | "embed"; + updateMode?: "minimal" | "full"; + } = {} + ): Promise { + const localUtils = this._connection.localUtils(); + if (!localUtils) throw new Error("Route from har is not supported in thin clients"); + if (options.update) { + await this._recordIntoHAR(har, null, options); + return; + } + const harRouter = await HarRouter.create(localUtils, har, options.notFound || "abort", { + urlMatch: options.url, + }); + this._harRouters.push(harRouter); + await harRouter.addContextRoute(this); + } + + private _disposeHarRouters() { + this._harRouters.forEach((router) => router.dispose()); + this._harRouters = []; + } + + async unrouteAll(options?: { behavior?: "wait" | "ignoreErrors" | "default" }): Promise { + await this._unrouteInternal(this._routes, [], options?.behavior); + this._disposeHarRouters(); + } + + async unroute(url: URLMatch, handler?: network.RouteHandlerCallback): Promise { + const removed = []; + const remaining = []; + for (const route of this._routes) { + if (urlMatchesEqual(route.url, url) && (!handler || route.handler === handler)) + removed.push(route); + else remaining.push(route); + } + await this._unrouteInternal(removed, remaining, "default"); + } + + private async _unrouteInternal( + removed: network.RouteHandler[], + remaining: network.RouteHandler[], + behavior?: "wait" | "ignoreErrors" | "default" + ): Promise { + this._routes = remaining; + if (behavior && behavior !== "default") { + const promises = removed.map((routeHandler) => routeHandler.stop(behavior)); + await Promise.all(promises); + } + await this._updateInterceptionPatterns({ title: "Unroute requests" }); + } + + private async _updateInterceptionPatterns(options: { internal: true } | { title: string }) { + const patterns = network.RouteHandler.prepareInterceptionPatterns(this._routes); + await this._wrapApiCall( + () => this._channel.setNetworkInterceptionPatterns({ patterns }), + options + ); + } + + private async _updateWebSocketInterceptionPatterns( + options: { internal: true } | { title: string } + ) { + const patterns = network.WebSocketRouteHandler.prepareInterceptionPatterns( + this._webSocketRoutes + ); + await this._wrapApiCall( + () => this._channel.setWebSocketInterceptionPatterns({ patterns }), + options + ); + } + + _effectiveCloseReason(): string | undefined { + return this._closeReason || this._browser?._closeReason; + } + + async waitForEvent(event: string, optionsOrPredicate: WaitForEventOptions = {}): Promise { + return await this._wrapApiCall(async () => { + const timeout = this._timeoutSettings.timeout( + typeof optionsOrPredicate === "function" ? {} : optionsOrPredicate + ); + const predicate = + typeof optionsOrPredicate === "function" + ? optionsOrPredicate + : optionsOrPredicate.predicate; + const waiter = Waiter.createForEvent(this, event); + waiter.rejectOnTimeout( + timeout, + `Timeout ${timeout}ms exceeded while waiting for event "${event}"` + ); + if (event !== Events.BrowserContext.Close) + waiter.rejectOnEvent( + this, + Events.BrowserContext.Close, + () => new TargetClosedError(this._effectiveCloseReason()) + ); + const result = await waiter.waitForEvent(this, event, predicate as any); + waiter.dispose(); + return result; + }); + } + + async storageState(options: { path?: string; indexedDB?: boolean } = {}): Promise { + const state = await this._channel.storageState({ indexedDB: options.indexedDB }); + if (options.path) { + await mkdirIfNeeded(this._platform, options.path); + await this._platform + .fs() + .promises.writeFile(options.path, JSON.stringify(state, undefined, 2), "utf8"); + } + return state; + } + + async setStorageState(storageState: string | SetStorageState): Promise { + const state = await prepareStorageState(this._platform, storageState); + await this._channel.setStorageState({ storageState: state }); + } + + backgroundPages(): Page[] { + return []; + } + + serviceWorkers(): Worker[] { + return [...this._serviceWorkers]; + } + + async newCDPSession(page: Page | Frame): Promise { + // channelOwner.ts's validation messages don't handle the pseudo-union type, so we're explicit here + if (!(page instanceof Page) && !(page instanceof Frame)) + throw new Error("page: expected Page or Frame"); + const result = await this._channel.newCDPSession( + page instanceof Page ? { page: page._channel } : { frame: page._channel } + ); + return CDPSession.from(result.session); + } + + _onClose() { + this._closingStatus = "closed"; + this._browser?._contexts.delete(this); + this._browser?._browserType._contexts.delete(this); + this._browser?._browserType._playwright.selectors._contextsForSelectors.delete(this); + this._disposeHarRouters(); + this.tracing._resetStackCounter(); + this.emit(Events.BrowserContext.Close, this); + } + + async [Symbol.asyncDispose]() { + await this.close(); + } + + async close(options: { reason?: string } = {}): Promise { + if (this.isClosedOrClosing()) return; + this._closeReason = options.reason; + this._closingStatus = "closing"; + await this.request.dispose(options); + await this._instrumentation.runBeforeCloseBrowserContext(this); + await this._wrapApiCall( + async () => { + for (const [harId, harParams] of this._harRecorders) { + const har = await this._channel.harExport({ harId }); + const artifact = Artifact.from(har.artifact); + // Server side will compress artifact if content is attach or if file is .zip. + const isCompressed = harParams.content === "attach" || harParams.path.endsWith(".zip"); + const needCompressed = harParams.path.endsWith(".zip"); + if (isCompressed && !needCompressed) { + const localUtils = this._connection.localUtils(); + if (!localUtils) throw new Error("Uncompressed har is not supported in thin clients"); + await artifact.saveAs(harParams.path + ".tmp"); + await localUtils.harUnzip({ + zipFile: harParams.path + ".tmp", + harFile: harParams.path, + }); + } else { + await artifact.saveAs(harParams.path); + } + await artifact.delete(); + } + }, + { internal: true } + ); + await this._channel.close(options); + await this._closedPromise; + } + + async _enableRecorder( + params: channels.BrowserContextEnableRecorderParams, + eventSink?: RecorderEventSink + ) { + if (eventSink) this._onRecorderEventSink = eventSink; + await this._channel.enableRecorder(params); + } + + async _disableRecorder() { + this._onRecorderEventSink = undefined; + await this._channel.disableRecorder(); + } + + async _exposeConsoleApi() { + await this._channel.exposeConsoleApi(); + } +} + +async function prepareStorageState( + platform: Platform, + storageState: string | SetStorageState +): Promise> { + if (typeof storageState !== "string") return storageState as any; + try { + return JSON.parse(await platform.fs().promises.readFile(storageState, "utf8")); + } catch (e) { + rewriteErrorMessage(e, `Error reading storage state from ${storageState}:\n` + e.message); + throw e; + } +} + +export async function prepareBrowserContextParams( + platform: Platform, + options: BrowserContextOptions +): Promise { + if (options.videoSize && !options.videosPath) + throw new Error(`"videoSize" option requires "videosPath" to be specified`); + if (options.extraHTTPHeaders) network.validateHeaders(options.extraHTTPHeaders); + const contextParams: channels.BrowserNewContextParams = { + ...options, + viewport: options.viewport === null ? undefined : options.viewport, + noDefaultViewport: options.viewport === null, + extraHTTPHeaders: options.extraHTTPHeaders + ? headersObjectToArray(options.extraHTTPHeaders) + : undefined, + storageState: options.storageState + ? await prepareStorageState(platform, options.storageState) + : undefined, + serviceWorkers: options.serviceWorkers, + colorScheme: options.colorScheme === null ? "no-override" : options.colorScheme, + reducedMotion: options.reducedMotion === null ? "no-override" : options.reducedMotion, + forcedColors: options.forcedColors === null ? "no-override" : options.forcedColors, + contrast: options.contrast === null ? "no-override" : options.contrast, + acceptDownloads: toAcceptDownloadsProtocol(options.acceptDownloads), + clientCertificates: await toClientCertificatesProtocol(platform, options.clientCertificates), + }; + if (!contextParams.recordVideo && options.videosPath) { + contextParams.recordVideo = { + dir: options.videosPath, + size: options.videoSize, + }; + } + if (contextParams.recordVideo && contextParams.recordVideo.dir) + contextParams.recordVideo.dir = platform.path().resolve(contextParams.recordVideo.dir); + return contextParams; +} + +function contextParamsToPublicOptions( + params: channels.BrowserNewContextParams +): api.BrowserContextOptions { + const result = { + ...params, + viewport: params.noDefaultViewport ? null : params.viewport, + extraHTTPHeaders: params.extraHTTPHeaders + ? headersArrayToObject(params.extraHTTPHeaders, false) + : undefined, + colorScheme: params.colorScheme === "no-override" ? null : params.colorScheme, + reducedMotion: params.reducedMotion === "no-override" ? null : params.reducedMotion, + forcedColors: params.forcedColors === "no-override" ? null : params.forcedColors, + contrast: params.contrast === "no-override" ? null : params.contrast, + acceptDownloads: + params.acceptDownloads === "accept" + ? true + : params.acceptDownloads === "deny" + ? false + : undefined, + storageState: undefined, + }; + delete result.clientCertificates; + delete result.noDefaultViewport; + delete result.selectorEngines; + return result; +} + +function toAcceptDownloadsProtocol(acceptDownloads?: boolean) { + if (acceptDownloads === undefined) return undefined; + if (acceptDownloads) return "accept"; + return "deny"; +} + +export async function toClientCertificatesProtocol( + platform: Platform, + certs?: BrowserContextOptions["clientCertificates"] +): Promise { + if (!certs) return undefined; + + const bufferizeContent = async (value?: Buffer, path?: string): Promise => { + if (value) return value; + if (path) return await platform.fs().promises.readFile(path); + }; + + return await Promise.all( + certs.map(async (cert) => ({ + origin: cert.origin, + cert: await bufferizeContent(cert.cert, cert.certPath), + key: await bufferizeContent(cert.key, cert.keyPath), + pfx: await bufferizeContent(cert.pfx, cert.pfxPath), + passphrase: cert.passphrase, + })) + ); +} diff --git a/daemon/src/sandbox/forked-client/src/client/browserType.ts b/daemon/src/sandbox/forked-client/src/client/browserType.ts new file mode 100644 index 00000000..009f1f59 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/browserType.ts @@ -0,0 +1,187 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Browser } from "./browser"; +import { BrowserContext, prepareBrowserContextParams } from "./browserContext"; +import { ChannelOwner } from "./channelOwner"; +import { envObjectToArray } from "./clientHelper"; +import { assert } from "../utils/isomorphic/assert"; +import { headersObjectToArray } from "../utils/isomorphic/headers"; +import { TimeoutSettings } from "./timeoutSettings"; + +import type { Playwright } from "./playwright"; +import type { + ConnectOptions, + LaunchOptions, + LaunchPersistentContextOptions, + LaunchServerOptions, +} from "./types"; +import type * as api from "../../types/types"; +import type * as channels from "../protocol/channels"; +import type { ChildProcess } from "child_process"; + +export interface BrowserServerLauncher { + launchServer(options?: LaunchServerOptions): Promise; +} + +// This is here just for api generation and checking. +export interface BrowserServer extends api.BrowserServer { + process(): ChildProcess; + wsEndpoint(): string; + close(): Promise; + kill(): Promise; +} + +export class BrowserType + extends ChannelOwner + implements api.BrowserType +{ + _serverLauncher?: BrowserServerLauncher; + _contexts = new Set(); + _playwright!: Playwright; + + static from(browserType: channels.BrowserTypeChannel): BrowserType { + return (browserType as any)._object; + } + + executablePath(): string { + if (!this._initializer.executablePath) + throw new Error("Browser is not supported on current platform"); + return this._initializer.executablePath; + } + + name(): string { + return this._initializer.name; + } + + async launch(options: LaunchOptions = {}): Promise { + assert( + !(options as any).userDataDir, + "userDataDir option is not supported in `browserType.launch`. Use `browserType.launchPersistentContext` instead" + ); + assert(!(options as any).port, "Cannot specify a port without launching as a server."); + + const logger = options.logger || this._playwright._defaultLaunchOptions?.logger; + options = { ...this._playwright._defaultLaunchOptions, ...options }; + const launchOptions: channels.BrowserTypeLaunchParams = { + ...options, + ignoreDefaultArgs: Array.isArray(options.ignoreDefaultArgs) + ? options.ignoreDefaultArgs + : undefined, + ignoreAllDefaultArgs: + !!options.ignoreDefaultArgs && !Array.isArray(options.ignoreDefaultArgs), + env: options.env ? envObjectToArray(options.env) : undefined, + timeout: new TimeoutSettings(this._platform).launchTimeout(options), + }; + return await this._wrapApiCall(async () => { + const browser = Browser.from((await this._channel.launch(launchOptions)).browser); + browser._connectToBrowserType(this, options, logger); + return browser; + }); + } + + async launchServer(options: LaunchServerOptions = {}): Promise { + if (!this._serverLauncher) throw new Error("Launching server is not supported"); + options = { ...this._playwright._defaultLaunchOptions, ...options }; + return await this._serverLauncher.launchServer(options); + } + + async launchPersistentContext( + userDataDir: string, + options: LaunchPersistentContextOptions = {} + ): Promise { + void userDataDir; + void options; + throw new Error( + "browserType.launchPersistentContext() is not available in the QuickJS sandbox" + ); + } + + connect(options: api.ConnectOptions & { wsEndpoint: string }): Promise; + connect(endpoint: string, options?: api.ConnectOptions): Promise; + async connect( + optionsOrEndpoint: string | (api.ConnectOptions & { wsEndpoint?: string }), + options?: api.ConnectOptions + ): Promise { + if (typeof optionsOrEndpoint === "string") + return await this._connect({ ...options, endpoint: optionsOrEndpoint }); + assert(optionsOrEndpoint.wsEndpoint, "options.wsEndpoint is required"); + return await this._connect({ ...options, endpoint: optionsOrEndpoint.wsEndpoint }); + } + + async _connect(params: ConnectOptions): Promise { + void params; + throw new Error("browserType.connect() is not available in the QuickJS sandbox"); + } + + async connectOverCDP( + options: api.ConnectOverCDPOptions & { wsEndpoint?: string } + ): Promise; + async connectOverCDP( + endpointURL: string, + options?: api.ConnectOverCDPOptions + ): Promise; + async connectOverCDP( + endpointURLOrOptions: (api.ConnectOverCDPOptions & { wsEndpoint?: string }) | string, + options?: api.ConnectOverCDPOptions + ) { + if (typeof endpointURLOrOptions === "string") + return await this._connectOverCDP(endpointURLOrOptions, options); + const endpointURL = + "endpointURL" in endpointURLOrOptions + ? endpointURLOrOptions.endpointURL + : endpointURLOrOptions.wsEndpoint; + assert(endpointURL, "Cannot connect over CDP without wsEndpoint."); + return await this.connectOverCDP(endpointURL, endpointURLOrOptions); + } + + async _connectOverCDP( + endpointURL: string, + params: api.ConnectOverCDPOptions = {} + ): Promise { + if (this.name() !== "chromium") + throw new Error("Connecting over CDP is only supported in Chromium."); + const headers = params.headers ? headersObjectToArray(params.headers) : undefined; + const result = await this._channel.connectOverCDP({ + endpointURL, + headers, + slowMo: params.slowMo, + timeout: new TimeoutSettings(this._platform).timeout(params), + isLocal: params.isLocal, + }); + const browser = Browser.from(result.browser); + browser._connectToBrowserType(this, {}, params.logger); + if (result.defaultContext) + await this._instrumentation.runAfterCreateBrowserContext( + BrowserContext.from(result.defaultContext) + ); + return browser; + } + + async _connectOverCDPTransport(transport: /* ConnectionTransport */ any) { + if (this.name() !== "chromium") + throw new Error("Connecting over CDP is only supported in Chromium."); + const result = await this._channel.connectOverCDPTransport({ transport }); + const browser = Browser.from(result.browser); + browser._connectToBrowserType(this, {}, undefined); + if (result.defaultContext) + await this._instrumentation.runAfterCreateBrowserContext( + BrowserContext.from(result.defaultContext) + ); + return browser; + } +} diff --git a/daemon/src/sandbox/forked-client/src/client/cdpSession.ts b/daemon/src/sandbox/forked-client/src/client/cdpSession.ts new file mode 100644 index 00000000..4958c813 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/cdpSession.ts @@ -0,0 +1,64 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ChannelOwner } from "./channelOwner"; + +import type * as api from "../../types/types"; +import type { Protocol } from "../server/chromium/protocol"; +import type * as channels from "../protocol/channels"; + +export class CDPSession extends ChannelOwner implements api.CDPSession { + static from(cdpSession: channels.CDPSessionChannel): CDPSession { + return (cdpSession as any)._object; + } + + constructor( + parent: ChannelOwner, + type: string, + guid: string, + initializer: channels.CDPSessionInitializer + ) { + super(parent, type, guid, initializer); + + this._channel.on("event", (event) => { + this.emit(event.method, event.params); + this.emit("event", event); + }); + + this._channel.on("close", () => { + this.emit("close"); + }); + + this.on = super.on; + this.addListener = super.addListener; + this.off = super.removeListener; + this.removeListener = super.removeListener; + this.once = super.once; + } + + async send( + method: T, + params?: Protocol.CommandParameters[T] + ): Promise { + const result = await this._channel.send({ method, params }); + return result.result as Protocol.CommandReturnValues[T]; + } + + async detach() { + return await this._channel.detach(); + } +} diff --git a/daemon/src/sandbox/forked-client/src/client/channelOwner.ts b/daemon/src/sandbox/forked-client/src/client/channelOwner.ts new file mode 100644 index 00000000..5921b1dc --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/channelOwner.ts @@ -0,0 +1,277 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the 'License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { EventEmitter } from "./eventEmitter"; +import { ValidationError, maybeFindValidator } from "../protocol/validator"; +import { methodMetainfo } from "../utils/isomorphic/protocolMetainfo"; +import { captureLibraryStackTrace } from "./clientStackTrace"; +import { stringifyStackFrames } from "../utils/isomorphic/stackTrace"; + +import type { ClientInstrumentation } from "./clientInstrumentation"; +import type { Connection } from "./connection"; +import type { Logger } from "./types"; +import type { ValidatorContext } from "../protocol/validator"; +import type { Platform } from "./platform"; +import type * as channels from "../protocol/channels"; + +type Listener = (...args: any[]) => void; + +export abstract class ChannelOwner< + T extends channels.Channel = channels.Channel, +> extends EventEmitter { + readonly _connection: Connection; + private _parent: ChannelOwner | undefined; + private _objects = new Map(); + + readonly _type: string; + readonly _guid: string; + readonly _channel: T; + readonly _initializer: channels.InitializerTraits; + _logger: Logger | undefined; + readonly _instrumentation: ClientInstrumentation; + private _eventToSubscriptionMapping: Map = new Map(); + _wasCollected: boolean = false; + + constructor( + parent: ChannelOwner | Connection, + type: string, + guid: string, + initializer: channels.InitializerTraits + ) { + const connection = parent instanceof ChannelOwner ? parent._connection : parent; + super(connection._platform); + this.setMaxListeners(0); + this._connection = connection; + this._type = type; + this._guid = guid; + this._parent = parent instanceof ChannelOwner ? parent : undefined; + this._instrumentation = this._connection._instrumentation; + + this._connection._objects.set(guid, this); + if (this._parent) { + this._parent._objects.set(guid, this); + this._logger = this._parent._logger; + } + + this._channel = this._createChannel(new EventEmitter(connection._platform)); + this._initializer = initializer; + } + + _setEventToSubscriptionMapping(mapping: Map) { + this._eventToSubscriptionMapping = mapping; + } + + private _updateSubscription(event: string | symbol, enabled: boolean) { + const protocolEvent = this._eventToSubscriptionMapping.get(String(event)); + if (protocolEvent) + (this._channel as any).updateSubscription({ event: protocolEvent, enabled }).catch(() => {}); + } + + override on(event: string | symbol, listener: Listener): this { + if (!this.listenerCount(event)) this._updateSubscription(event, true); + super.on(event, listener); + return this; + } + + override addListener(event: string | symbol, listener: Listener): this { + if (!this.listenerCount(event)) this._updateSubscription(event, true); + super.addListener(event, listener); + return this; + } + + override prependListener(event: string | symbol, listener: Listener): this { + if (!this.listenerCount(event)) this._updateSubscription(event, true); + super.prependListener(event, listener); + return this; + } + + override off(event: string | symbol, listener: Listener): this { + super.off(event, listener); + if (!this.listenerCount(event)) this._updateSubscription(event, false); + return this; + } + + override removeListener(event: string | symbol, listener: Listener): this { + super.removeListener(event, listener); + if (!this.listenerCount(event)) this._updateSubscription(event, false); + return this; + } + + _adopt(child: ChannelOwner) { + child._parent!._objects.delete(child._guid); + this._objects.set(child._guid, child); + child._parent = this; + } + + _dispose(reason: "gc" | undefined) { + // Clean up from parent and connection. + if (this._parent) this._parent._objects.delete(this._guid); + this._connection._objects.delete(this._guid); + this._wasCollected = reason === "gc"; + + // Dispose all children. + for (const object of [...this._objects.values()]) object._dispose(reason); + this._objects.clear(); + } + + _debugScopeState(): any { + return { + _guid: this._guid, + objects: Array.from(this._objects.values()).map((o) => o._debugScopeState()), + }; + } + + private _validatorToWireContext(): ValidatorContext { + return { + tChannelImpl: tChannelImplToWire, + binary: this._connection.rawBuffers() ? "buffer" : "toBase64", + isUnderTest: () => this._platform.isUnderTest(), + }; + } + + private _createChannel(base: Object): T { + const channel = new Proxy(base, { + get: (obj: any, prop: string | symbol) => { + if (typeof prop === "string") { + const validator = maybeFindValidator(this._type, prop, "Params"); + const { internal } = methodMetainfo.get(this._type + "." + prop) || {}; + if (validator) { + return async (params: any) => { + return await this._wrapApiCall( + async (apiZone) => { + const validatedParams = validator(params, "", this._validatorToWireContext()); + if (!apiZone.internal && !apiZone.reported) { + // Reporting/tracing/logging this api call for the first time. + apiZone.reported = true; + this._instrumentation.onApiCallBegin(apiZone, { + type: this._type, + method: prop, + params, + }); + logApiCall(this._platform, this._logger, `=> ${apiZone.apiName} started`); + return await this._connection.sendMessageToServer( + this, + prop, + validatedParams, + apiZone + ); + } + // Since this api call is either internal, or has already been reported/traced once, + // passing as internal. + return await this._connection.sendMessageToServer(this, prop, validatedParams, { + internal: true, + }); + }, + { internal } + ); + }; + } + } + return obj[prop]; + }, + }); + (channel as any)._object = this; + return channel; + } + + async _wrapApiCall( + func: (apiZone: ApiZone) => Promise, + options?: { internal?: boolean; title?: string } + ): Promise { + const logger = this._logger; + const existingApiZone = this._platform.zones.current().data(); + if (existingApiZone) return await func(existingApiZone); + + const stackTrace = captureLibraryStackTrace(this._platform); + const apiZone: ApiZone = { + title: options?.title, + apiName: stackTrace.apiName, + frames: stackTrace.frames, + internal: options?.internal ?? false, + reported: false, + userData: undefined, + stepId: undefined, + }; + + try { + const result = await this._platform.zones + .current() + .push(apiZone) + .run(async () => await func(apiZone)); + if (!options?.internal) { + logApiCall(this._platform, logger, `<= ${apiZone.apiName} succeeded`); + this._instrumentation.onApiCallEnd(apiZone); + } + return result; + } catch (e) { + const innerError = + (this._platform.showInternalStackFrames() || this._platform.isUnderTest()) && e.stack + ? "\n\n" + e.stack + : ""; + if (apiZone.apiName && !apiZone.apiName.includes("")) + e.message = apiZone.apiName + ": " + e.message; + const stackFrames = "\n" + stringifyStackFrames(stackTrace.frames).join("\n") + innerError; + if (stackFrames.trim()) e.stack = e.message + stackFrames; + else e.stack = ""; + if (!options?.internal) { + apiZone.error = e; + logApiCall(this._platform, logger, `<= ${apiZone.apiName} failed`); + this._instrumentation.onApiCallEnd(apiZone); + } + throw e; + } + } + + private toJSON() { + // Jest's expect library tries to print objects sometimes. + // RPC objects can contain links to lots of other objects, + // which can cause jest to crash. Let's help it out + // by just returning the important values. + return { + _type: this._type, + _guid: this._guid, + }; + } +} + +function logApiCall(platform: Platform, logger: Logger | undefined, message: string) { + if (logger && logger.isEnabled("api", "info")) + logger.log("api", "info", message, [], { color: "cyan" }); + platform.log("api", message); +} + +function tChannelImplToWire( + names: "*" | string[], + arg: any, + path: string, + context: ValidatorContext +) { + if (arg._object instanceof ChannelOwner && (names === "*" || names.includes(arg._object._type))) + return { guid: arg._object._guid }; + throw new ValidationError(`${path}: expected channel ${names.toString()}`); +} + +type ApiZone = { + apiName: string; + frames: channels.StackFrame[]; + title?: string; + internal?: boolean; + reported: boolean; + userData: any; + stepId?: string; + error?: Error; +}; diff --git a/daemon/src/sandbox/forked-client/src/client/clientHelper.ts b/daemon/src/sandbox/forked-client/src/client/clientHelper.ts new file mode 100644 index 00000000..d4108c3c --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/clientHelper.ts @@ -0,0 +1,55 @@ +// @ts-nocheck +/** + * Copyright 2017 Google Inc. All rights reserved. + * Modifications copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { isString } from "../utils/isomorphic/rtti"; + +import type { Platform } from "./platform"; + +export function envObjectToArray(env: NodeJS.ProcessEnv): { name: string; value: string }[] { + const result: { name: string; value: string }[] = []; + for (const name in env) { + if (!Object.is(env[name], undefined)) result.push({ name, value: String(env[name]) }); + } + return result; +} + +export async function evaluationScript( + platform: Platform, + fun: Function | string | { path?: string; content?: string }, + arg?: any, + addSourceUrl: boolean = true +): Promise { + if (typeof fun === "function") { + const source = fun.toString(); + const argString = Object.is(arg, undefined) ? "undefined" : JSON.stringify(arg); + return `(${source})(${argString})`; + } + if (arg !== undefined) throw new Error("Cannot evaluate a string with arguments"); + if (isString(fun)) return fun; + if (fun.content !== undefined) return fun.content; + if (fun.path !== undefined) { + let source = await platform.fs().promises.readFile(fun.path, "utf8"); + if (addSourceUrl) source = addSourceUrlToScript(source, fun.path); + return source; + } + throw new Error("Either path or content property must be present"); +} + +export function addSourceUrlToScript(source: string, path: string): string { + return `${source}\n//# sourceURL=${path.replace(/\n/g, "")}`; +} diff --git a/daemon/src/sandbox/forked-client/src/client/clientInstrumentation.ts b/daemon/src/sandbox/forked-client/src/client/clientInstrumentation.ts new file mode 100644 index 00000000..42f4a4b4 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/clientInstrumentation.ts @@ -0,0 +1,97 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { BrowserContext } from "./browserContext"; +import type { APIRequestContext, NewContextOptions } from "./fetch"; +import type { StackFrame } from "../protocol/channels"; +import type { Page } from "./page"; +import type { BrowserContextOptions } from "./types"; + +// Instrumentation can mutate the data, for example change apiName or stepId. +export interface ApiCallData { + apiName: string; + title?: string; + frames: StackFrame[]; + userData: any; + stepId?: string; + error?: Error; +} + +export interface ClientInstrumentation { + addListener(listener: ClientInstrumentationListener): void; + removeListener(listener: ClientInstrumentationListener): void; + removeAllListeners(): void; + onApiCallBegin( + apiCall: ApiCallData, + channel: { type: string; method: string; params?: Record } + ): void; + onApiCallEnd(apiCall: ApiCallData): void; + onWillPause(options: { keepTestTimeout: boolean }): void; + onPage(page: Page): void; + + runBeforeCreateBrowserContext(options: BrowserContextOptions): Promise; + runBeforeCreateRequestContext(options: NewContextOptions): Promise; + runAfterCreateBrowserContext(context: BrowserContext): Promise; + runAfterCreateRequestContext(context: APIRequestContext): Promise; + runBeforeCloseBrowserContext(context: BrowserContext): Promise; + runBeforeCloseRequestContext(context: APIRequestContext): Promise; +} + +export interface ClientInstrumentationListener { + onApiCallBegin?( + apiCall: ApiCallData, + channel: { type: string; method: string; params?: Record } + ): void; + onApiCallEnd?(apiCall: ApiCallData): void; + onWillPause?(options: { keepTestTimeout: boolean }): void; + onPage?(page: Page): void; + runBeforeCreateBrowserContext?(options: BrowserContextOptions): Promise; + runBeforeCreateRequestContext?(options: NewContextOptions): Promise; + runAfterCreateBrowserContext?(context: BrowserContext): Promise; + runAfterCreateRequestContext?(context: APIRequestContext): Promise; + runBeforeCloseBrowserContext?(context: BrowserContext): Promise; + runBeforeCloseRequestContext?(context: APIRequestContext): Promise; +} + +export function createInstrumentation(): ClientInstrumentation { + const listeners: ClientInstrumentationListener[] = []; + return new Proxy( + {}, + { + get: (obj: any, prop: string | symbol) => { + if (typeof prop !== "string") return obj[prop]; + if (prop === "addListener") + return (listener: ClientInstrumentationListener) => listeners.push(listener); + if (prop === "removeListener") + return (listener: ClientInstrumentationListener) => + listeners.splice(listeners.indexOf(listener), 1); + if (prop === "removeAllListeners") return () => listeners.splice(0, listeners.length); + if (prop.startsWith("run")) { + return async (...params: any[]) => { + for (const listener of listeners) await (listener as any)[prop]?.(...params); + }; + } + if (prop.startsWith("on")) { + return (...params: any[]) => { + for (const listener of listeners) (listener as any)[prop]?.(...params); + }; + } + return obj[prop]; + }, + } + ); +} diff --git a/daemon/src/sandbox/forked-client/src/client/clientStackTrace.ts b/daemon/src/sandbox/forked-client/src/client/clientStackTrace.ts new file mode 100644 index 00000000..a8ef4904 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/clientStackTrace.ts @@ -0,0 +1,82 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { captureRawStack, parseStackFrame } from "../utils/isomorphic/stackTrace"; + +import type { Platform } from "./platform"; +import type { StackFrame } from "@isomorphic/stackTrace"; + +export function captureLibraryStackTrace(platform: Platform): { + frames: StackFrame[]; + apiName: string; +} { + const stack = captureRawStack(); + + type ParsedFrame = { + frame: StackFrame; + frameText: string; + isPlaywrightLibrary: boolean; + }; + let parsedFrames = stack + .map((line) => { + const frame = parseStackFrame( + line, + platform.pathSeparator, + platform.showInternalStackFrames() + ); + if (!frame || !frame.file) return null; + const isPlaywrightLibrary = !!platform.coreDir && frame.file.startsWith(platform.coreDir); + const parsed: ParsedFrame = { + frame, + frameText: line, + isPlaywrightLibrary, + }; + return parsed; + }) + .filter(Boolean) as ParsedFrame[]; + + let apiName = ""; + + // Deepest transition between non-client code calling into client + // code is the api entry. + for (let i = 0; i < parsedFrames.length - 1; i++) { + const parsedFrame = parsedFrames[i]; + if (parsedFrame.isPlaywrightLibrary && !parsedFrames[i + 1].isPlaywrightLibrary) { + apiName = apiName || normalizeAPIName(parsedFrame.frame.function); + break; + } + } + + function normalizeAPIName(name?: string): string { + if (!name) return ""; + const match = name.match(/(API|JS|CDP|[A-Z])(.*)/); + if (!match) return name; + return match[1].toLowerCase() + match[2]; + } + + // This is for the inspector so that it did not include the test runner stack frames. + const filterPrefixes = platform.boxedStackPrefixes(); + parsedFrames = parsedFrames.filter((f) => { + if (filterPrefixes.some((prefix) => f.frame.file.startsWith(prefix))) return false; + return true; + }); + + return { + frames: parsedFrames.map((p) => p.frame), + apiName, + }; +} diff --git a/daemon/src/sandbox/forked-client/src/client/clock.ts b/daemon/src/sandbox/forked-client/src/client/clock.ts new file mode 100644 index 00000000..3108e588 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/clock.ts @@ -0,0 +1,71 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { BrowserContext } from "./browserContext"; +import type * as api from "../../types/types"; + +export class Clock implements api.Clock { + private _browserContext: BrowserContext; + + constructor(browserContext: BrowserContext) { + this._browserContext = browserContext; + } + + async install(options: { time?: number | string | Date } = {}) { + await this._browserContext._channel.clockInstall( + options.time !== undefined ? parseTime(options.time) : {} + ); + } + + async fastForward(ticks: number | string) { + await this._browserContext._channel.clockFastForward(parseTicks(ticks)); + } + + async pauseAt(time: number | string | Date) { + await this._browserContext._channel.clockPauseAt(parseTime(time)); + } + + async resume() { + await this._browserContext._channel.clockResume({}); + } + + async runFor(ticks: number | string) { + await this._browserContext._channel.clockRunFor(parseTicks(ticks)); + } + + async setFixedTime(time: string | number | Date) { + await this._browserContext._channel.clockSetFixedTime(parseTime(time)); + } + + async setSystemTime(time: string | number | Date) { + await this._browserContext._channel.clockSetSystemTime(parseTime(time)); + } +} + +function parseTime(time: string | number | Date): { timeNumber?: number; timeString?: string } { + if (typeof time === "number") return { timeNumber: time }; + if (typeof time === "string") return { timeString: time }; + if (!isFinite(time.getTime())) throw new Error(`Invalid date: ${time}`); + return { timeNumber: time.getTime() }; +} + +function parseTicks(ticks: string | number): { ticksNumber?: number; ticksString?: string } { + return { + ticksNumber: typeof ticks === "number" ? ticks : undefined, + ticksString: typeof ticks === "string" ? ticks : undefined, + }; +} diff --git a/daemon/src/sandbox/forked-client/src/client/connect.ts b/daemon/src/sandbox/forked-client/src/client/connect.ts new file mode 100644 index 00000000..2a5dc789 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/connect.ts @@ -0,0 +1,175 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { monotonicTime } from "../utils/isomorphic/time"; +import { raceAgainstDeadline } from "../utils/isomorphic/timeoutRunner"; +import { Browser } from "./browser"; +import { ChannelOwner } from "./channelOwner"; +import { Connection } from "./connection"; +import { Events } from "./events"; + +import type { Playwright } from "./playwright"; +import type { ConnectOptions, HeadersArray } from "./types"; +import type * as channels from "../protocol/channels"; + +export async function connectToBrowser( + playwright: Playwright, + params: ConnectOptions +): Promise { + const deadline = params.timeout ? monotonicTime() + params.timeout : 0; + const nameParam = params.browserName ? { "x-playwright-browser": params.browserName } : {}; + const headers = { ...nameParam, ...params.headers }; + const connectParams: channels.LocalUtilsConnectParams = { + endpoint: params.endpoint!, + headers, + exposeNetwork: params.exposeNetwork, + slowMo: params.slowMo, + timeout: params.timeout || 0, + }; + if ((params as any).__testHookRedirectPortForwarding) + connectParams.socksProxyRedirectPortForTest = (params as any).__testHookRedirectPortForwarding; + const connection = await connectToEndpoint(playwright._connection, connectParams); + let browser: Browser; + connection.on("close", () => { + // Emulate all pages, contexts and the browser closing upon disconnect. + for (const context of browser?.contexts() || []) { + for (const page of context.pages()) page._onClose(); + context._onClose(); + } + setTimeout(() => browser?._didClose(), 0); + }); + + const result = await raceAgainstDeadline(async () => { + // For tests. + if ((params as any).__testHookBeforeCreateBrowser) + await (params as any).__testHookBeforeCreateBrowser(); + + const playwright = await connection!.initializePlaywright(); + if (!playwright._initializer.preLaunchedBrowser) { + connection.close(); + throw new Error("Malformed endpoint. Did you use BrowserType.launchServer method?"); + } + playwright.selectors = playwright.selectors; + browser = Browser.from(playwright._initializer.preLaunchedBrowser!); + browser._shouldCloseConnectionOnClose = true; + browser.on(Events.Browser.Disconnected, () => connection.close()); + return browser; + }, deadline); + if (!result.timedOut) { + return result.result; + } else { + connection.close(); + throw new Error(`Timeout ${params.timeout}ms exceeded`); + } +} + +export async function connectToEndpoint( + parentConnection: Connection, + params: channels.LocalUtilsConnectParams +): Promise { + const localUtils = parentConnection.localUtils(); + const transport = localUtils ? new JsonPipeTransport(localUtils) : new WebSocketTransport(); + const connectHeaders = await transport.connect(params); + const connection = new Connection( + parentConnection._platform, + localUtils, + parentConnection._instrumentation, + connectHeaders + ); + connection.markAsRemote(); + connection.on("close", () => transport.close()); + + let closeError: string | undefined; + const onTransportClosed = (reason?: string) => { + connection.close(reason || closeError); + }; + transport.onClose((reason) => onTransportClosed(reason)); + connection.onmessage = (message) => transport.send(message).catch(() => onTransportClosed()); + transport.onMessage((message) => { + try { + connection!.dispatch(message); + } catch (e) { + closeError = String(e); + transport.close().catch(() => {}); + } + }); + return connection; +} + +interface Transport { + connect(params: channels.LocalUtilsConnectParams): Promise; + send(message: any): Promise; + onMessage(callback: (message: object) => void): void; + onClose(callback: (reason?: string) => void): void; + close(): Promise; +} + +class JsonPipeTransport implements Transport { + private _pipe: channels.JsonPipeChannel | undefined; + private _owner: ChannelOwner; + + constructor(owner: ChannelOwner) { + this._owner = owner; + } + + async connect(params: channels.LocalUtilsConnectParams) { + const { pipe, headers: connectHeaders } = await this._owner._channel.connect(params); + this._pipe = pipe; + return connectHeaders; + } + + async send(message: object) { + await this._pipe!.send({ message }); + } + + onMessage(callback: (message: object) => void) { + this._pipe!.on("message", ({ message }) => callback(message)); + } + + onClose(callback: (reason?: string) => void) { + this._pipe!.on("closed", ({ reason }) => callback(reason)); + } + + async close() { + await this._pipe!.close().catch(() => {}); + } +} + +class WebSocketTransport implements Transport { + private _ws: WebSocket | undefined; + + async connect(params: channels.LocalUtilsConnectParams) { + this._ws = new window.WebSocket(params.endpoint); + return []; + } + + async send(message: object) { + this._ws!.send(JSON.stringify(message)); + } + + onMessage(callback: (message: object) => void) { + this._ws!.addEventListener("message", (event) => callback(JSON.parse(event.data))); + } + + onClose(callback: (reason?: string) => void) { + this._ws!.addEventListener("close", () => callback()); + } + + async close() { + this._ws!.close(); + } +} diff --git a/daemon/src/sandbox/forked-client/src/client/connection.ts b/daemon/src/sandbox/forked-client/src/client/connection.ts new file mode 100644 index 00000000..4b39924c --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/connection.ts @@ -0,0 +1,394 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the 'License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { EventEmitter } from "./eventEmitter"; +import { Android, AndroidDevice, AndroidSocket } from "./android"; +import { Artifact } from "./artifact"; +import { Browser } from "./browser"; +import { BrowserContext } from "./browserContext"; +import { BrowserType } from "./browserType"; +import { CDPSession } from "./cdpSession"; +import { ChannelOwner } from "./channelOwner"; +import { createInstrumentation } from "./clientInstrumentation"; +import { Debugger } from "./debugger"; +import { Dialog } from "./dialog"; +import { DisposableObject } from "./disposable"; +import { Electron, ElectronApplication } from "./electron"; +import { ElementHandle } from "./elementHandle"; +import { TargetClosedError, parseError } from "./errors"; +import { APIRequestContext } from "./fetch"; +import { Frame } from "./frame"; +import { JSHandle } from "./jsHandle"; +import { JsonPipe } from "./jsonPipe"; +import { LocalUtils } from "./localUtils"; +import { Request, Response, Route, WebSocket, WebSocketRoute } from "./network"; +import { BindingCall, Page } from "./page"; +import { Playwright } from "./playwright"; +import { Stream } from "./stream"; +import { Tracing } from "./tracing"; +import { Worker } from "./worker"; +import { WritableStream } from "./writableStream"; +import { ValidationError, findValidator } from "../protocol/validator"; +import { rewriteErrorMessage } from "../utils/isomorphic/stackTrace"; +import { quickjsPlatform } from "../../quickjs-platform"; +import type { ClientInstrumentation } from "./clientInstrumentation"; +import type { HeadersArray } from "./types"; +import type { ValidatorContext } from "../protocol/validator"; +import type { Platform } from "./platform"; +import type * as channels from "../protocol/channels"; + +class Root extends ChannelOwner { + constructor(connection: Connection) { + super(connection, "Root", "", {}); + } + + async initialize(): Promise { + return Playwright.from( + ( + await this._channel.initialize({ + sdkLanguage: "javascript", + }) + ).playwright + ); + } +} + +class DummyChannelOwner extends ChannelOwner {} + +export class Connection extends EventEmitter { + readonly _objects = new Map(); + onmessage = (message: object): void => {}; + private _lastId = 0; + private _callbacks = new Map< + number, + { + resolve: (a: any) => void; + reject: (a: Error) => void; + title: string | undefined; + type: string; + method: string; + } + >(); + private _rootObject: Root; + private _closedError: Error | undefined; + private _isRemote = false; + private _localUtils?: LocalUtils; + private _rawBuffers = false; + // Some connections allow resolving in-process dispatchers. + toImpl: ((client: ChannelOwner | Connection) => any) | undefined; + private _tracingCount = 0; + readonly _instrumentation: ClientInstrumentation; + // Used from @playwright/test fixtures -> TODO remove? + readonly headers: HeadersArray; + + constructor( + platform: Platform = quickjsPlatform, + localUtils?: LocalUtils, + instrumentation?: ClientInstrumentation, + headers: HeadersArray = [] + ) { + super(platform); + this._instrumentation = instrumentation || createInstrumentation(); + this._localUtils = localUtils; + this._rootObject = new Root(this); + this.headers = headers; + } + + markAsRemote() { + this._isRemote = true; + } + + isRemote() { + return this._isRemote; + } + + useRawBuffers() { + this._rawBuffers = true; + } + + rawBuffers() { + return this._rawBuffers; + } + + localUtils(): LocalUtils | undefined { + return this._localUtils; + } + + async initializePlaywright(): Promise { + return await this._rootObject.initialize(); + } + + getObjectWithKnownName(guid: string): any { + return this._objects.get(guid)!; + } + + setIsTracing(isTracing: boolean) { + if (isTracing) this._tracingCount++; + else this._tracingCount--; + } + + async sendMessageToServer( + object: ChannelOwner, + method: string, + params: any, + options: { + apiName?: string; + title?: string; + internal?: boolean; + frames?: channels.StackFrame[]; + stepId?: string; + } + ): Promise { + if (this._closedError) throw this._closedError; + if (object._wasCollected) + throw new Error("The object has been collected to prevent unbounded heap growth."); + + const guid = object._guid; + const type = object._type; + const id = ++this._lastId; + const message = { id, guid, method, params }; + if (this._platform.isLogEnabled("channel")) { + // Do not include metadata in debug logs to avoid noise. + this._platform.log("channel", "SEND> " + JSON.stringify(message)); + } + const location = options.frames?.[0] + ? { + file: options.frames[0].file, + line: options.frames[0].line, + column: options.frames[0].column, + } + : undefined; + const metadata: channels.Metadata = { + title: options.title, + location, + internal: options.internal, + stepId: options.stepId, + }; + if (this._tracingCount && options.frames && type !== "LocalUtils") + this._localUtils + ?.addStackToTracingNoReply({ callData: { stack: options.frames ?? [], id } }) + .catch(() => {}); + // We need to exit zones before calling into the server, otherwise + // when we receive events from the server, we would be in an API zone. + this._platform.zones.empty.run(() => this.onmessage({ ...message, metadata })); + return await new Promise((resolve, reject) => + this._callbacks.set(id, { resolve, reject, title: options.title, type, method }) + ); + } + + private _validatorFromWireContext(): ValidatorContext { + return { + tChannelImpl: this._tChannelImplFromWire.bind(this), + binary: this._rawBuffers ? "buffer" : "fromBase64", + isUnderTest: () => this._platform.isUnderTest(), + }; + } + + dispatch(message: object) { + if (this._closedError) return; + + const { id, guid, method, params, result, error, log } = message as any; + if (id) { + if (this._platform.isLogEnabled("channel")) + this._platform.log("channel", "; + const validator = findValidator(type, "", "Initializer"); + initializer = validator(initializer, "", this._validatorFromWireContext()); + switch (type) { + case "Android": + result = new Android(parent, type, guid, initializer); + break; + case "AndroidSocket": + result = new AndroidSocket(parent, type, guid, initializer); + break; + case "AndroidDevice": + result = new AndroidDevice(parent, type, guid, initializer); + break; + case "APIRequestContext": + result = new APIRequestContext(parent, type, guid, initializer); + break; + case "Artifact": + result = new Artifact(parent, type, guid, initializer); + break; + case "BindingCall": + result = new BindingCall(parent, type, guid, initializer); + break; + case "Browser": + result = new Browser(parent, type, guid, initializer); + break; + case "BrowserContext": + result = new BrowserContext(parent, type, guid, initializer); + break; + case "BrowserType": + result = new BrowserType(parent, type, guid, initializer); + break; + case "CDPSession": + result = new CDPSession(parent, type, guid, initializer); + break; + case "Debugger": + result = new Debugger(parent, type, guid, initializer); + break; + case "Dialog": + result = new Dialog(parent, type, guid, initializer); + break; + case "Disposable": + result = new DisposableObject(parent, type, guid, initializer); + break; + case "Electron": + result = new Electron(parent, type, guid, initializer); + break; + case "ElectronApplication": + result = new ElectronApplication(parent, type, guid, initializer); + break; + case "ElementHandle": + result = new ElementHandle(parent, type, guid, initializer); + break; + case "Frame": + result = new Frame(parent, type, guid, initializer); + break; + case "JSHandle": + result = new JSHandle(parent, type, guid, initializer); + break; + case "JsonPipe": + result = new JsonPipe(parent, type, guid, initializer); + break; + case "LocalUtils": + result = new LocalUtils(parent, type, guid, initializer); + if (!this._localUtils) this._localUtils = result as LocalUtils; + break; + case "Page": + result = new Page(parent, type, guid, initializer); + break; + case "Playwright": + result = new Playwright(parent, type, guid, initializer); + break; + case "Request": + result = new Request(parent, type, guid, initializer); + break; + case "Response": + result = new Response(parent, type, guid, initializer); + break; + case "Route": + result = new Route(parent, type, guid, initializer); + break; + case "Stream": + result = new Stream(parent, type, guid, initializer); + break; + case "SocksSupport": + result = new DummyChannelOwner(parent, type, guid, initializer); + break; + case "Tracing": + result = new Tracing(parent, type, guid, initializer); + break; + case "WebSocket": + result = new WebSocket(parent, type, guid, initializer); + break; + case "WebSocketRoute": + result = new WebSocketRoute(parent, type, guid, initializer); + break; + case "Worker": + result = new Worker(parent, type, guid, initializer); + break; + case "WritableStream": + result = new WritableStream(parent, type, guid, initializer); + break; + default: + throw new Error("Missing type " + type); + } + return result; + } +} + +function formatCallLog(platform: Platform, log: string[] | undefined): string { + if (!log || !log.some((l) => !!l)) return ""; + return ` +Call log: +${platform.colors.dim(log.join("\n"))} +`; +} diff --git a/daemon/src/sandbox/forked-client/src/client/consoleMessage.ts b/daemon/src/sandbox/forked-client/src/client/consoleMessage.ts new file mode 100644 index 00000000..36c6f651 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/consoleMessage.ts @@ -0,0 +1,76 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { JSHandle } from "./jsHandle"; + +import type * as api from "../../types/types"; +import type { Platform } from "./platform"; +import type * as channels from "../protocol/channels"; +import type { Page } from "./page"; +import type { Worker } from "./worker"; + +type ConsoleMessageLocation = channels.BrowserContextConsoleEvent["location"]; + +export class ConsoleMessage implements api.ConsoleMessage { + private _page: Page | null; + private _worker: Worker | null; + private _event: channels.BrowserContextConsoleEvent | channels.ElectronApplicationConsoleEvent; + + constructor( + platform: Platform, + event: channels.BrowserContextConsoleEvent | channels.ElectronApplicationConsoleEvent, + page: Page | null, + worker: Worker | null + ) { + this._page = page; + this._worker = worker; + this._event = event; + if (platform.inspectCustom) (this as any)[platform.inspectCustom] = () => this._inspect(); + } + + worker() { + return this._worker; + } + + page() { + return this._page; + } + + type(): ReturnType { + return this._event.type as ReturnType; + } + + text(): string { + return this._event.text; + } + + args(): JSHandle[] { + return this._event.args.map(JSHandle.from); + } + + location(): ConsoleMessageLocation { + return this._event.location; + } + + timestamp(): number { + return this._event.timestamp; + } + + private _inspect() { + return this.text(); + } +} diff --git a/daemon/src/sandbox/forked-client/src/client/coverage.ts b/daemon/src/sandbox/forked-client/src/client/coverage.ts new file mode 100644 index 00000000..0fb61794 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/coverage.ts @@ -0,0 +1,43 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type * as api from "../../types/types"; +import type * as channels from "../protocol/channels"; + +export class Coverage implements api.Coverage { + private _channel: channels.PageChannel; + + constructor(channel: channels.PageChannel) { + this._channel = channel; + } + + async startJSCoverage(options: channels.PageStartJSCoverageOptions = {}) { + await this._channel.startJSCoverage(options); + } + + async stopJSCoverage(): Promise { + return (await this._channel.stopJSCoverage()).entries; + } + + async startCSSCoverage(options: channels.PageStartCSSCoverageOptions = {}) { + await this._channel.startCSSCoverage(options); + } + + async stopCSSCoverage(): Promise { + return (await this._channel.stopCSSCoverage()).entries; + } +} 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/debugger.ts b/daemon/src/sandbox/forked-client/src/client/debugger.ts new file mode 100644 index 00000000..db9bfaa8 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/debugger.ts @@ -0,0 +1,65 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ChannelOwner } from "./channelOwner"; +import { Events } from "./events"; + +import type * as api from "../../types/types"; +import type * as channels from "../protocol/channels"; + +type PausedDetail = { location: { file: string; line?: number; column?: number }; title: string }; + +export class Debugger extends ChannelOwner implements api.Debugger { + private _pausedDetails: PausedDetail[] = []; + + static from(channel: channels.DebuggerChannel): Debugger { + return (channel as any)._object; + } + + constructor( + parent: ChannelOwner, + type: string, + guid: string, + initializer: channels.DebuggerInitializer + ) { + super(parent, type, guid, initializer); + this._channel.on("pausedStateChanged", ({ pausedDetails }) => { + this._pausedDetails = pausedDetails; + this.emit(Events.Debugger.PausedStateChanged); + }); + } + + async pause(): Promise { + await this._channel.pause(); + } + + async resume(): Promise { + await this._channel.resume(); + } + + async next(): Promise { + await this._channel.next(); + } + + async runTo(location: { file: string; line?: number; column?: number }): Promise { + await this._channel.runTo({ location }); + } + + pausedDetails(): PausedDetail[] { + return this._pausedDetails; + } +} diff --git a/daemon/src/sandbox/forked-client/src/client/dialog.ts b/daemon/src/sandbox/forked-client/src/client/dialog.ts new file mode 100644 index 00000000..5b17e9a4 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/dialog.ts @@ -0,0 +1,66 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ChannelOwner } from "./channelOwner"; +import { Page } from "./page"; + +import type * as api from "../../types/types"; +import type * as channels from "../protocol/channels"; + +export class Dialog extends ChannelOwner implements api.Dialog { + static from(dialog: channels.DialogChannel): Dialog { + return (dialog as any)._object; + } + + private _page: Page | null; + + constructor( + parent: ChannelOwner, + type: string, + guid: string, + initializer: channels.DialogInitializer + ) { + super(parent, type, guid, initializer); + // Note: dialogs that open early during page initialization block it. + // Therefore, we must report the dialog without a page to be able to handle it. + this._page = Page.fromNullable(initializer.page); + } + + page() { + return this._page; + } + + type(): string { + return this._initializer.type; + } + + message(): string { + return this._initializer.message; + } + + defaultValue(): string { + return this._initializer.defaultValue; + } + + async accept(promptText: string | undefined) { + await this._channel.accept({ promptText }); + } + + async dismiss() { + await this._channel.dismiss(); + } +} diff --git a/daemon/src/sandbox/forked-client/src/client/disposable.ts b/daemon/src/sandbox/forked-client/src/client/disposable.ts new file mode 100644 index 00000000..1c06799b --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/disposable.ts @@ -0,0 +1,77 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ChannelOwner } from "./channelOwner"; +import { isTargetClosedError } from "./errors"; + +import type * as channels from "../protocol/channels"; + +export interface Disposable { + dispose: () => Promise; +} + +export class DisposableObject + extends ChannelOwner + implements Disposable +{ + static from(channel: channels.DisposableChannel): DisposableObject { + return (channel as any)._object; + } + + async [Symbol.asyncDispose]() { + await this.dispose(); + } + + async dispose() { + try { + await this._channel.dispose(); + } catch (e) { + if (isTargetClosedError(e)) return; + throw e; + } + } +} + +export class DisposableStub implements Disposable { + private _dispose: (() => Promise) | undefined; + + constructor(dispose: () => Promise) { + this._dispose = dispose; + } + + async [Symbol.asyncDispose]() { + await this.dispose(); + } + + async dispose() { + if (!this._dispose) return; + try { + const dispose = this._dispose; + this._dispose = undefined; + await dispose(); + } catch (e) { + if (isTargetClosedError(e)) return; + throw e; + } + } +} + +export async function disposeAll(disposables: Disposable[]) { + const copy = [...disposables]; + disposables.length = 0; + await Promise.all(copy.map((d) => d.dispose())); +} 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/download.ts b/daemon/src/sandbox/forked-client/src/client/download.ts new file mode 100644 index 00000000..81628393 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/download.ts @@ -0,0 +1,71 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { Artifact } from "./artifact"; +import type { Page } from "./page"; +import type * as api from "../../types/types"; +import type { Readable } from "stream"; + +export class Download implements api.Download { + private _page: Page; + private _url: string; + private _suggestedFilename: string; + private _artifact: Artifact; + + constructor(page: Page, url: string, suggestedFilename: string, artifact: Artifact) { + this._page = page; + this._url = url; + this._suggestedFilename = suggestedFilename; + this._artifact = artifact; + } + + page(): Page { + return this._page; + } + + url(): string { + return this._url; + } + + suggestedFilename(): string { + return this._suggestedFilename; + } + + async path(): Promise { + return await this._artifact.pathAfterFinished(); + } + + async saveAs(path: string): Promise { + return await this._artifact.saveAs(path); + } + + async failure(): Promise { + return await this._artifact.failure(); + } + + async createReadStream(): Promise { + return await this._artifact.createReadStream(); + } + + async cancel(): Promise { + return await this._artifact.cancel(); + } + + async delete(): Promise { + return await this._artifact.delete(); + } +} diff --git a/daemon/src/sandbox/forked-client/src/client/electron.ts b/daemon/src/sandbox/forked-client/src/client/electron.ts new file mode 100644 index 00000000..f374b513 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/electron.ts @@ -0,0 +1,14 @@ +// @ts-nocheck +import { ChannelOwner } from "./channelOwner"; + +export class Electron extends ChannelOwner { + static from(channel) { + return channel?._object; + } +} + +export class ElectronApplication extends ChannelOwner { + static from(channel) { + return channel?._object; + } +} diff --git a/daemon/src/sandbox/forked-client/src/client/elementHandle.ts b/daemon/src/sandbox/forked-client/src/client/elementHandle.ts new file mode 100644 index 00000000..32554627 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/elementHandle.ts @@ -0,0 +1,473 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Frame } from "./frame"; +import { JSHandle, parseResult, serializeArgument } from "./jsHandle"; +import { assert } from "../utils/isomorphic/assert"; +import { fileUploadSizeLimit, writeTempFile } from "./fileUtils"; +import { isString } from "../utils/isomorphic/rtti"; +import { WritableStream } from "./writableStream"; +import { getMimeTypeForPath } from "../utils/isomorphic/mimeType"; + +import type { BrowserContext } from "./browserContext"; +import type { ChannelOwner } from "./channelOwner"; +import type { Locator } from "./locator"; +import type { FilePayload, Rect, SelectOption, SelectOptionOptions, TimeoutOptions } from "./types"; +import type * as structs from "../../types/structs"; +import type * as api from "../../types/types"; +import type { Platform } from "./platform"; +import type * as channels from "../protocol/channels"; + +export class ElementHandle extends JSHandle implements api.ElementHandle { + private _frame: Frame; + readonly _elementChannel: channels.ElementHandleChannel; + + static override from(handle: channels.ElementHandleChannel): ElementHandle { + return (handle as any)._object; + } + + static fromNullable(handle: channels.ElementHandleChannel | undefined): ElementHandle | null { + return handle ? ElementHandle.from(handle) : null; + } + + constructor( + parent: ChannelOwner, + type: string, + guid: string, + initializer: channels.JSHandleInitializer + ) { + super(parent, type, guid, initializer); + this._frame = parent as Frame; + this._elementChannel = this._channel as channels.ElementHandleChannel; + } + + override asElement(): T extends Node ? ElementHandle : null { + return this as any; + } + + async ownerFrame(): Promise { + return Frame.fromNullable((await this._elementChannel.ownerFrame()).frame); + } + + async contentFrame(): Promise { + return Frame.fromNullable((await this._elementChannel.contentFrame()).frame); + } + + async getAttribute(name: string): Promise { + const value = (await this._elementChannel.getAttribute({ name })).value; + return value === undefined ? null : value; + } + + async inputValue(): Promise { + return (await this._elementChannel.inputValue()).value; + } + + async textContent(): Promise { + const value = (await this._elementChannel.textContent()).value; + return value === undefined ? null : value; + } + + async innerText(): Promise { + return (await this._elementChannel.innerText()).value; + } + + async innerHTML(): Promise { + return (await this._elementChannel.innerHTML()).value; + } + + async isChecked(): Promise { + return (await this._elementChannel.isChecked()).value; + } + + async isDisabled(): Promise { + return (await this._elementChannel.isDisabled()).value; + } + + async isEditable(): Promise { + return (await this._elementChannel.isEditable()).value; + } + + async isEnabled(): Promise { + return (await this._elementChannel.isEnabled()).value; + } + + async isHidden(): Promise { + return (await this._elementChannel.isHidden()).value; + } + + async isVisible(): Promise { + return (await this._elementChannel.isVisible()).value; + } + + async dispatchEvent(type: string, eventInit: Object = {}) { + await this._elementChannel.dispatchEvent({ type, eventInit: serializeArgument(eventInit) }); + } + + async scrollIntoViewIfNeeded( + options: channels.ElementHandleScrollIntoViewIfNeededOptions & TimeoutOptions = {} + ) { + await this._elementChannel.scrollIntoViewIfNeeded({ + ...options, + timeout: this._frame._timeout(options), + }); + } + + async hover(options: channels.ElementHandleHoverOptions & TimeoutOptions = {}): Promise { + await this._elementChannel.hover({ ...options, timeout: this._frame._timeout(options) }); + } + + async click(options: channels.ElementHandleClickOptions & TimeoutOptions = {}): Promise { + return await this._elementChannel.click({ ...options, timeout: this._frame._timeout(options) }); + } + + async dblclick( + options: channels.ElementHandleDblclickOptions & TimeoutOptions = {} + ): Promise { + return await this._elementChannel.dblclick({ + ...options, + timeout: this._frame._timeout(options), + }); + } + + async tap(options: channels.ElementHandleTapOptions & TimeoutOptions = {}): Promise { + return await this._elementChannel.tap({ ...options, timeout: this._frame._timeout(options) }); + } + + async selectOption( + values: + | string + | api.ElementHandle + | SelectOption + | string[] + | api.ElementHandle[] + | SelectOption[] + | null, + options: SelectOptionOptions = {} + ): Promise { + const result = await this._elementChannel.selectOption({ + ...convertSelectOptionValues(values), + ...options, + timeout: this._frame._timeout(options), + }); + return result.values; + } + + async fill( + value: string, + options: channels.ElementHandleFillOptions & TimeoutOptions = {} + ): Promise { + return await this._elementChannel.fill({ + value, + ...options, + timeout: this._frame._timeout(options), + }); + } + + async selectText( + options: channels.ElementHandleSelectTextOptions & TimeoutOptions = {} + ): Promise { + await this._elementChannel.selectText({ ...options, timeout: this._frame._timeout(options) }); + } + + async setInputFiles( + files: string | FilePayload | string[] | FilePayload[], + options: channels.ElementHandleSetInputFilesOptions & TimeoutOptions = {} + ) { + const frame = await this.ownerFrame(); + if (!frame) throw new Error("Cannot set input files to detached element"); + const converted = await convertInputFiles(this._platform, files, frame.page().context()); + await this._elementChannel.setInputFiles({ + ...converted, + ...options, + timeout: this._frame._timeout(options), + }); + } + + async focus(): Promise { + await this._elementChannel.focus(); + } + + async type( + text: string, + options: channels.ElementHandleTypeOptions & TimeoutOptions = {} + ): Promise { + await this._elementChannel.type({ text, ...options, timeout: this._frame._timeout(options) }); + } + + async press( + key: string, + options: channels.ElementHandlePressOptions & TimeoutOptions = {} + ): Promise { + await this._elementChannel.press({ key, ...options, timeout: this._frame._timeout(options) }); + } + + async check(options: channels.ElementHandleCheckOptions & TimeoutOptions = {}) { + return await this._elementChannel.check({ ...options, timeout: this._frame._timeout(options) }); + } + + async uncheck(options: channels.ElementHandleUncheckOptions & TimeoutOptions = {}) { + return await this._elementChannel.uncheck({ + ...options, + timeout: this._frame._timeout(options), + }); + } + + async setChecked(checked: boolean, options?: channels.ElementHandleCheckOptions) { + if (checked) await this.check(options); + else await this.uncheck(options); + } + + async boundingBox(): Promise { + const value = (await this._elementChannel.boundingBox()).value; + return value === undefined ? null : value; + } + + async screenshot( + options: Omit & + TimeoutOptions & { path?: string; mask?: api.Locator[] } = {} + ): Promise { + const mask = options.mask as Locator[] | undefined; + const copy: channels.ElementHandleScreenshotParams = { + ...options, + path: undefined, + mask: undefined, + timeout: this._frame._timeout(options), + }; + if (!copy.type) copy.type = determineScreenshotType(options); + if (mask) { + copy.mask = mask.map((locator) => ({ + frame: locator._frame._channel, + selector: locator._selector, + })); + } + const result = await this._elementChannel.screenshot(copy); + if (options.path) { + options.path = await writeTempFile(options.path, result.binary); + } + return result.binary; + } + + async $(selector: string): Promise | null> { + return ElementHandle.fromNullable( + (await this._elementChannel.querySelector({ selector })).element + ) as ElementHandle | null; + } + + async $$(selector: string): Promise[]> { + const result = await this._elementChannel.querySelectorAll({ selector }); + return result.elements.map( + (h) => ElementHandle.from(h) as ElementHandle + ); + } + + async $eval( + selector: string, + pageFunction: structs.PageFunctionOn, + arg?: Arg + ): Promise { + const result = await this._elementChannel.evalOnSelector({ + selector, + expression: String(pageFunction), + isFunction: typeof pageFunction === "function", + arg: serializeArgument(arg), + }); + return parseResult(result.value); + } + + async $$eval( + selector: string, + pageFunction: structs.PageFunctionOn, + arg?: Arg + ): Promise { + const result = await this._elementChannel.evalOnSelectorAll({ + selector, + expression: String(pageFunction), + isFunction: typeof pageFunction === "function", + arg: serializeArgument(arg), + }); + return parseResult(result.value); + } + + async waitForElementState( + state: "visible" | "hidden" | "stable" | "enabled" | "disabled", + options: TimeoutOptions = {} + ): Promise { + return await this._elementChannel.waitForElementState({ + state, + ...options, + timeout: this._frame._timeout(options), + }); + } + + waitForSelector( + selector: string, + options: channels.ElementHandleWaitForSelectorOptions & + TimeoutOptions & { state: "attached" | "visible" } + ): Promise>; + waitForSelector( + selector: string, + options?: channels.ElementHandleWaitForSelectorOptions & TimeoutOptions + ): Promise | null>; + async waitForSelector( + selector: string, + options: channels.ElementHandleWaitForSelectorOptions & TimeoutOptions = {} + ): Promise | null> { + const result = await this._elementChannel.waitForSelector({ + selector, + ...options, + timeout: this._frame._timeout(options), + }); + return ElementHandle.fromNullable(result.element) as ElementHandle< + SVGElement | HTMLElement + > | null; + } +} + +export function convertSelectOptionValues( + values: + | string + | api.ElementHandle + | SelectOption + | string[] + | api.ElementHandle[] + | SelectOption[] + | null +): { elements?: channels.ElementHandleChannel[]; options?: SelectOption[] } { + if (values === null) return {}; + if (!Array.isArray(values)) values = [values as any]; + if (!values.length) return {}; + for (let i = 0; i < values.length; i++) + assert(values[i] !== null, `options[${i}]: expected object, got null`); + if (values[0] instanceof ElementHandle) + return { elements: (values as ElementHandle[]).map((v: ElementHandle) => v._elementChannel) }; + if (isString(values[0])) + return { options: (values as string[]).map((valueOrLabel) => ({ valueOrLabel })) }; + return { options: values as SelectOption[] }; +} + +type SetInputFilesFiles = Pick< + channels.ElementHandleSetInputFilesParams, + "payloads" | "localPaths" | "localDirectory" | "streams" | "directoryStream" +>; + +function filePayloadExceedsSizeLimit(payloads: FilePayload[]) { + return ( + payloads.reduce((size, item) => size + (item.buffer ? item.buffer.byteLength : 0), 0) >= + fileUploadSizeLimit + ); +} + +async function resolvePathsAndDirectoryForInputFiles( + platform: Platform, + items: string[] +): Promise<[string[] | undefined, string | undefined]> { + let localPaths: string[] | undefined; + let localDirectory: string | undefined; + for (const item of items) { + const stat = await platform.fs().promises.stat(item as string); + if (stat.isDirectory()) { + if (localDirectory) throw new Error("Multiple directories are not supported"); + localDirectory = platform.path().resolve(item as string); + } else { + localPaths ??= []; + localPaths.push(platform.path().resolve(item as string)); + } + } + if (localPaths?.length && localDirectory) + throw new Error("File paths must be all files or a single directory"); + return [localPaths, localDirectory]; +} + +export async function convertInputFiles( + platform: Platform, + files: string | FilePayload | string[] | FilePayload[], + context: BrowserContext +): Promise { + const items: (string | FilePayload)[] = Array.isArray(files) ? files.slice() : [files]; + + if (items.some((item) => typeof item === "string")) { + if (!items.every((item) => typeof item === "string")) + throw new Error("File paths cannot be mixed with buffers"); + + const [localPaths, localDirectory] = await resolvePathsAndDirectoryForInputFiles( + platform, + items + ); + + if (context._connection.isRemote()) { + const files = localDirectory + ? ( + await platform + .fs() + .promises.readdir(localDirectory, { withFileTypes: true, recursive: true }) + ) + .filter((f) => f.isFile()) + .map((f) => platform.path().join(f.parentPath, f.name)) + : localPaths!; + const { writableStreams, rootDir } = await context._wrapApiCall( + async () => + context._channel.createTempFiles({ + rootDirName: localDirectory ? platform.path().basename(localDirectory) : undefined, + items: await Promise.all( + files.map(async (file) => { + const lastModifiedMs = (await platform.fs().promises.stat(file)).mtimeMs; + return { + name: localDirectory + ? platform.path().relative(localDirectory, file) + : platform.path().basename(file), + lastModifiedMs, + }; + }) + ), + }), + { internal: true } + ); + for (let i = 0; i < files.length; i++) { + const writable = WritableStream.from(writableStreams[i]); + await platform.streamFile(files[i], writable.stream()); + } + return { + directoryStream: rootDir, + streams: localDirectory ? undefined : writableStreams, + }; + } + return { + localPaths, + localDirectory, + }; + } + + const payloads = items as FilePayload[]; + if (filePayloadExceedsSizeLimit(payloads)) + throw new Error( + "Cannot set buffer larger than 50Mb, please write it to a file and pass its path instead." + ); + return { payloads }; +} + +export function determineScreenshotType(options: { + path?: string; + type?: "png" | "jpeg"; +}): "png" | "jpeg" | undefined { + if (options.path) { + const mimeType = getMimeTypeForPath(options.path); + if (mimeType === "image/png") return "png"; + else if (mimeType === "image/jpeg") return "jpeg"; + throw new Error(`path: unsupported mime type "${mimeType}"`); + } + return options.type; +} diff --git a/daemon/src/sandbox/forked-client/src/client/errors.ts b/daemon/src/sandbox/forked-client/src/client/errors.ts new file mode 100644 index 00000000..9e05120d --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/errors.ts @@ -0,0 +1,65 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { parseSerializedValue, serializeValue } from "../protocol/serializers"; +import { isError } from "../utils/isomorphic/rtti"; + +import type { SerializedError } from "../protocol/channels"; + +export class TimeoutError extends Error { + constructor(message: string) { + super(message); + this.name = "TimeoutError"; + } +} + +export class TargetClosedError extends Error { + constructor(cause?: string) { + super(cause || "Target page, context or browser has been closed"); + } +} + +export function isTargetClosedError(error: Error) { + return error instanceof TargetClosedError; +} + +export function serializeError(e: any): SerializedError { + if (isError(e)) return { error: { message: e.message, stack: e.stack, name: e.name } }; + return { value: serializeValue(e, (value) => ({ fallThrough: value })) }; +} + +export function parseError(error: SerializedError): Error { + if (!error.error) { + if (error.value === undefined) + throw new Error("Serialized error must have either an error or a value"); + return parseSerializedValue(error.value, undefined); + } + if (error.error.name === "TimeoutError") { + const e = new TimeoutError(error.error.message); + e.stack = error.error.stack || ""; + return e; + } + if (error.error.name === "TargetClosedError") { + const e = new TargetClosedError(error.error.message); + e.stack = error.error.stack || ""; + return e; + } + const e = new Error(error.error.message); + e.stack = error.error.stack || ""; + e.name = error.error.name; + return e; +} diff --git a/daemon/src/sandbox/forked-client/src/client/eventEmitter.ts b/daemon/src/sandbox/forked-client/src/client/eventEmitter.ts new file mode 100644 index 00000000..3b476b11 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/eventEmitter.ts @@ -0,0 +1,388 @@ +// @ts-nocheck +/** + * Copyright Joyent, Inc. and other Node contributors. + * Modifications copyright (c) Microsoft Corporation. + * + * Permission is hereby granted, free of charge, to any person obtaining a + * copy of this software and associated documentation files (the + * "Software"), to deal in the Software without restriction, including + * without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit + * persons to whom the Software is furnished to do so, subject to the + * following conditions: + * + * The above copyright notice and this permission notice shall be included + * in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS + * OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN + * NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR + * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE + * USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import type { EventEmitter as EventEmitterType } from "events"; +import type { Platform } from "./platform"; + +type EventType = string | symbol; +type Listener = (...args: any[]) => any; +type EventMap = Record; + +export class EventEmitter implements EventEmitterType { + private _events: EventMap | undefined = undefined; + private _eventsCount = 0; + private _maxListeners: number | undefined = undefined; + readonly _pendingHandlers = new Map>>(); + private _rejectionHandler: ((error: Error) => void) | undefined; + readonly _platform: Platform; + + constructor(platform: Platform) { + this._platform = platform; + if (this._events === undefined || this._events === Object.getPrototypeOf(this)._events) { + this._events = Object.create(null); + this._eventsCount = 0; + } + this._maxListeners = this._maxListeners || undefined; + this.on = this.addListener; + this.off = this.removeListener; + } + + setMaxListeners(n: number): this { + if (typeof n !== "number" || n < 0 || Number.isNaN(n)) + throw new RangeError( + 'The value of "n" is out of range. It must be a non-negative number. Received ' + n + "." + ); + this._maxListeners = n; + return this; + } + + getMaxListeners(): number { + return this._maxListeners === undefined + ? this._platform.defaultMaxListeners() + : this._maxListeners; + } + + emit(type: EventType, ...args: any[]): boolean { + const events = this._events; + if (events === undefined) return false; + + const handler = events?.[type]; + if (handler === undefined) return false; + + if (typeof handler === "function") { + this._callHandler(type, handler, args); + } else { + const len = handler.length; + const listeners = handler.slice(); + for (let i = 0; i < len; ++i) this._callHandler(type, listeners[i], args); + } + return true; + } + + private _callHandler(type: EventType, handler: Listener, args: any[]): void { + const promise = Reflect.apply(handler, this, args); + if (!(promise instanceof Promise)) return; + let set = this._pendingHandlers.get(type); + if (!set) { + set = new Set(); + this._pendingHandlers.set(type, set); + } + set.add(promise); + promise + .catch((e) => { + if (this._rejectionHandler) this._rejectionHandler(e); + else throw e; + }) + .finally(() => set.delete(promise)); + } + + addListener(type: EventType, listener: Listener): this { + return this._addListener(type, listener, false); + } + + on(type: EventType, listener: Listener): this { + return this._addListener(type, listener, false); + } + + private _addListener(type: EventType, listener: Listener, prepend: boolean): this { + checkListener(listener); + let events = this._events; + let existing; + if (events === undefined) { + events = this._events = Object.create(null); + this._eventsCount = 0; + } else { + // To avoid recursion in the case that type === "newListener"! Before + // adding it to the listeners, first emit "newListener". + if (events.newListener !== undefined) { + this.emit("newListener", type, unwrapListener(listener)); + + // Re-assign `events` because a newListener handler could have caused the + // this._events to be assigned to a new object + events = this._events!; + } + existing = events[type]; + } + + if (existing === undefined) { + // Optimize the case of one listener. Don't need the extra array object. + existing = events![type] = listener; + ++this._eventsCount; + } else { + if (typeof existing === "function") { + // Adding the second element, need to change to array. + existing = events![type] = prepend ? [listener, existing] : [existing, listener]; + // If we've already got an array, just append. + } else if (prepend) { + existing.unshift(listener); + } else { + existing.push(listener); + } + + // Check for listener leak + const m = this.getMaxListeners(); + if (m > 0 && existing.length > m && !(existing as any).warned) { + (existing as any).warned = true; + // No error code for this since it is a Warning + const w = new Error( + "Possible EventEmitter memory leak detected. " + + existing.length + + " " + + String(type) + + " listeners " + + "added. Use emitter.setMaxListeners() to " + + "increase limit" + ) as any; + w.name = "MaxListenersExceededWarning"; + w.emitter = this; + w.type = type; + w.count = existing.length; + if (!this._platform.isUnderTest()) { + // eslint-disable-next-line no-console + console.warn(w); + } + } + } + + return this; + } + + prependListener(type: EventType, listener: Listener): this { + return this._addListener(type, listener, true); + } + + once(type: EventType, listener: Listener): this { + checkListener(listener); + this.on(type, new OnceWrapper(this, type, listener).wrapperFunction); + return this; + } + + prependOnceListener(type: EventType, listener: Listener): this { + checkListener(listener); + this.prependListener(type, new OnceWrapper(this, type, listener).wrapperFunction); + return this; + } + + removeListener(type: EventType, listener: Listener): this { + checkListener(listener); + + const events = this._events; + if (events === undefined) return this; + + const list = events[type]; + if (list === undefined) return this; + + if (list === listener || (list as any).listener === listener) { + if (--this._eventsCount === 0) { + this._events = Object.create(null); + } else { + delete events[type]; + if (events.removeListener) + this.emit("removeListener", type, (list as any).listener ?? listener); + } + } else if (typeof list !== "function") { + let position = -1; + let originalListener; + + for (let i = list.length - 1; i >= 0; i--) { + if (list[i] === listener || wrappedListener(list[i]) === listener) { + originalListener = wrappedListener(list[i]); + position = i; + break; + } + } + + if (position < 0) return this; + + if (position === 0) list.shift(); + else list.splice(position, 1); + + if (list.length === 1) events[type] = list[0]; + + if (events.removeListener !== undefined) + this.emit("removeListener", type, originalListener || listener); + } + + return this; + } + + off(type: EventType, listener: Listener): this { + return this.removeListener(type, listener); + } + + removeAllListeners(type?: EventType): this; + removeAllListeners( + type: EventType | undefined, + options: { behavior?: "wait" | "ignoreErrors" | "default" } + ): Promise; + removeAllListeners( + type?: string, + options?: { behavior?: "wait" | "ignoreErrors" | "default" } + ): this | Promise { + this._removeAllListeners(type); + if (!options) return this; + + if (options.behavior === "wait") { + const errors: Error[] = []; + this._rejectionHandler = (error) => errors.push(error); + return this._waitFor(type).then(() => { + if (errors.length) throw errors[0]; + }); + } + + if (options.behavior === "ignoreErrors") this._rejectionHandler = () => {}; + + return Promise.resolve(); + } + + private _removeAllListeners(type?: string) { + const events = this._events; + if (!events) return; + + // not listening for removeListener, no need to emit + if (!events.removeListener) { + if (type === undefined) { + this._events = Object.create(null); + this._eventsCount = 0; + } else if (events[type] !== undefined) { + if (--this._eventsCount === 0) this._events = Object.create(null); + else delete events[type]; + } + return; + } + + // emit removeListener for all listeners on all events + if (type === undefined) { + const keys = Object.keys(events); + let key; + for (let i = 0; i < keys.length; ++i) { + key = keys[i]; + if (key === "removeListener") continue; + this._removeAllListeners(key); + } + this._removeAllListeners("removeListener"); + this._events = Object.create(null); + this._eventsCount = 0; + return; + } + + const listeners = events[type]; + + if (typeof listeners === "function") { + this.removeListener(type, listeners); + } else if (listeners !== undefined) { + // LIFO order + for (let i = listeners.length - 1; i >= 0; i--) this.removeListener(type, listeners[i]); + } + } + + listeners(type: EventType): Listener[] { + return this._listeners(this, type, true); + } + + rawListeners(type: EventType): Listener[] { + return this._listeners(this, type, false); + } + + listenerCount(type: EventType): number { + const events = this._events; + if (events !== undefined) { + const listener = events[type]; + if (typeof listener === "function") return 1; + if (listener !== undefined) return listener.length; + } + return 0; + } + + eventNames(): Array { + return this._eventsCount > 0 && this._events ? Reflect.ownKeys(this._events) : []; + } + + private async _waitFor(type?: EventType) { + let promises: Promise[] = []; + if (type) { + promises = [...(this._pendingHandlers.get(type) || [])]; + } else { + promises = []; + for (const [, pending] of this._pendingHandlers) promises.push(...pending); + } + await Promise.all(promises); + } + + private _listeners(target: EventEmitter, type: EventType, unwrap: boolean): Listener[] { + const events = target._events; + + if (events === undefined) return []; + + const listener = events[type]; + if (listener === undefined) return []; + + if (typeof listener === "function") return unwrap ? [unwrapListener(listener)] : [listener]; + + return unwrap ? unwrapListeners(listener) : listener.slice(); + } +} + +function checkListener(listener: any) { + if (typeof listener !== "function") + throw new TypeError( + 'The "listener" argument must be of type Function. Received type ' + typeof listener + ); +} + +class OnceWrapper { + private _fired = false; + readonly wrapperFunction: (...args: any[]) => Promise | void; + readonly _listener: Listener; + private _eventEmitter: EventEmitter; + private _eventType: EventType; + + constructor(eventEmitter: EventEmitter, eventType: EventType, listener: Listener) { + this._eventEmitter = eventEmitter; + this._eventType = eventType; + this._listener = listener; + this.wrapperFunction = this._handle.bind(this); + (this.wrapperFunction as any).listener = listener; + } + + private _handle(...args: any[]) { + if (this._fired) return; + this._fired = true; + this._eventEmitter.removeListener(this._eventType, this.wrapperFunction); + return this._listener.apply(this._eventEmitter, args); + } +} + +function unwrapListener(l: Listener): Listener { + return wrappedListener(l) ?? l; +} + +function unwrapListeners(arr: Listener[]): Listener[] { + return arr.map((l) => wrappedListener(l) ?? l); +} + +function wrappedListener(l: Listener): Listener { + return (l as any).listener; +} diff --git a/daemon/src/sandbox/forked-client/src/client/events.ts b/daemon/src/sandbox/forked-client/src/client/events.ts new file mode 100644 index 00000000..979648cb --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/events.ts @@ -0,0 +1,103 @@ +// @ts-nocheck +/** + * Copyright 2019 Google Inc. All rights reserved. + * Modifications copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export const Events = { + AndroidDevice: { + WebView: "webview", + Close: "close", + }, + + AndroidSocket: { + Data: "data", + Close: "close", + }, + + AndroidWebView: { + Close: "close", + }, + + Browser: { + Disconnected: "disconnected", + }, + + Debugger: { + PausedStateChanged: "pausedstatechanged", + }, + + BrowserContext: { + Console: "console", + Close: "close", + Dialog: "dialog", + Page: "page", + // Can't use just 'error' due to node.js special treatment of error events. + // @see https://nodejs.org/api/events.html#events_error_events + WebError: "weberror", + BackgroundPage: "backgroundpage", // Deprecated in v1.56, never emitted anymore. + ServiceWorker: "serviceworker", + Request: "request", + Response: "response", + RequestFailed: "requestfailed", + RequestFinished: "requestfinished", + }, + + BrowserServer: { + Close: "close", + }, + + Page: { + Close: "close", + Crash: "crash", + Console: "console", + Dialog: "dialog", + Download: "download", + FileChooser: "filechooser", + DOMContentLoaded: "domcontentloaded", + // Can't use just 'error' due to node.js special treatment of error events. + // @see https://nodejs.org/api/events.html#events_error_events + PageError: "pageerror", + Request: "request", + Response: "response", + RequestFailed: "requestfailed", + RequestFinished: "requestfinished", + FrameAttached: "frameattached", + FrameDetached: "framedetached", + FrameNavigated: "framenavigated", + Load: "load", + Popup: "popup", + WebSocket: "websocket", + Worker: "worker", + }, + + WebSocket: { + Close: "close", + Error: "socketerror", + FrameReceived: "framereceived", + FrameSent: "framesent", + }, + + Worker: { + Close: "close", + Console: "console", + }, + + ElectronApplication: { + Close: "close", + Console: "console", + Window: "window", + }, +}; diff --git a/daemon/src/sandbox/forked-client/src/client/fetch.ts b/daemon/src/sandbox/forked-client/src/client/fetch.ts new file mode 100644 index 00000000..66b2c9a3 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/fetch.ts @@ -0,0 +1,36 @@ +// @ts-nocheck +import { ChannelOwner } from "./channelOwner"; + +function unsupported() { + throw new Error("APIRequest is not available in the QuickJS sandbox"); +} + +export class APIRequestContext extends ChannelOwner { + static from(channel) { + return channel?._object; + } + + async storageState() { + return { cookies: [], origins: [] }; + } + + async dispose() { + await this._channel.dispose?.({}); + } +} + +export class APIRequest { + constructor(playwright) { + this._playwright = playwright; + } + + async newContext() { + unsupported(); + } +} + +export class APIResponse { + async body() { + unsupported(); + } +} diff --git a/daemon/src/sandbox/forked-client/src/client/fileChooser.ts b/daemon/src/sandbox/forked-client/src/client/fileChooser.ts new file mode 100644 index 00000000..c391a05e --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/fileChooser.ts @@ -0,0 +1,53 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { ElementHandle } from "./elementHandle"; +import type { Page } from "./page"; +import type { FilePayload, TimeoutOptions } from "./types"; +import type * as api from "../../types/types"; +import type * as channels from "../protocol/channels"; + +export class FileChooser implements api.FileChooser { + private _page: Page; + private _elementHandle: ElementHandle; + private _isMultiple: boolean; + + constructor(page: Page, elementHandle: ElementHandle, isMultiple: boolean) { + this._page = page; + this._elementHandle = elementHandle; + this._isMultiple = isMultiple; + } + + element(): ElementHandle { + return this._elementHandle; + } + + isMultiple(): boolean { + return this._isMultiple; + } + + page(): Page { + return this._page; + } + + async setFiles( + files: string | FilePayload | string[] | FilePayload[], + options?: channels.ElementHandleSetInputFilesOptions & TimeoutOptions + ) { + return await this._elementHandle.setInputFiles(files, options); + } +} diff --git a/daemon/src/sandbox/forked-client/src/client/fileUtils.ts b/daemon/src/sandbox/forked-client/src/client/fileUtils.ts new file mode 100644 index 00000000..8c63aa54 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/fileUtils.ts @@ -0,0 +1,11 @@ +// @ts-nocheck +export const fileUploadSizeLimit = 50 * 1024 * 1024; + +export async function mkdirIfNeeded() {} + +export async function writeTempFile(path, data) { + const writer = globalThis.writeFile; + if (typeof writer !== "function") + throw new Error("writeFile() is not available in the QuickJS sandbox"); + return await writer(path, data); +} diff --git a/daemon/src/sandbox/forked-client/src/client/frame.ts b/daemon/src/sandbox/forked-client/src/client/frame.ts new file mode 100644 index 00000000..b5c1c69f --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/frame.ts @@ -0,0 +1,750 @@ +// @ts-nocheck +/** + * Copyright 2017 Google Inc. All rights reserved. + * Modifications copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { EventEmitter } from "./eventEmitter"; +import { ChannelOwner } from "./channelOwner"; +import { addSourceUrlToScript } from "./clientHelper"; +import { ElementHandle, convertInputFiles, convertSelectOptionValues } from "./elementHandle"; +import { Events } from "./events"; +import { JSHandle, assertMaxArguments, parseResult, serializeArgument } from "./jsHandle"; +import { FrameLocator, Locator, testIdAttributeName } from "./locator"; +import * as network from "./network"; +import { kLifecycleEvents } from "./types"; +import { Waiter } from "./waiter"; +import { assert } from "../utils/isomorphic/assert"; +import { + getByAltTextSelector, + getByLabelSelector, + getByPlaceholderSelector, + getByRoleSelector, + getByTestIdSelector, + getByTextSelector, + getByTitleSelector, +} from "../utils/isomorphic/locatorUtils"; +import { urlMatches } from "../utils/isomorphic/urlMatch"; +import { TimeoutSettings } from "./timeoutSettings"; + +import type { LocatorOptions } from "./locator"; +import type { Page } from "./page"; +import type { + FilePayload, + LifecycleEvent, + SelectOption, + SelectOptionOptions, + StrictOptions, + TimeoutOptions, + WaitForFunctionOptions, +} from "./types"; +import type * as structs from "../../types/structs"; +import type * as api from "../../types/types"; +import type { ByRoleOptions } from "../utils/isomorphic/locatorUtils"; +import type { URLMatch } from "../utils/isomorphic/urlMatch"; +import type * as channels from "../protocol/channels"; + +export type WaitForNavigationOptions = { + timeout?: number; + waitUntil?: LifecycleEvent; + url?: URLMatch; +}; + +export class Frame extends ChannelOwner implements api.Frame { + _eventEmitter: EventEmitter; + _loadStates: Set; + _parentFrame: Frame | null = null; + _url = ""; + _name = ""; + _detached = false; + _childFrames = new Set(); + _page: Page | undefined; + + static from(frame: channels.FrameChannel): Frame { + return (frame as any)._object; + } + + static fromNullable(frame: channels.FrameChannel | undefined): Frame | null { + return frame ? Frame.from(frame) : null; + } + + constructor( + parent: ChannelOwner, + type: string, + guid: string, + initializer: channels.FrameInitializer + ) { + super(parent, type, guid, initializer); + this._eventEmitter = new EventEmitter(parent._platform); + this._eventEmitter.setMaxListeners(0); + this._parentFrame = Frame.fromNullable(initializer.parentFrame); + if (this._parentFrame) this._parentFrame._childFrames.add(this); + this._name = initializer.name; + this._url = initializer.url; + this._loadStates = new Set(initializer.loadStates); + this._channel.on("loadstate", (event) => { + if (event.add) { + this._loadStates.add(event.add); + this._eventEmitter.emit("loadstate", event.add); + } + if (event.remove) this._loadStates.delete(event.remove); + if (!this._parentFrame && event.add === "load" && this._page) + this._page.emit(Events.Page.Load, this._page); + if (!this._parentFrame && event.add === "domcontentloaded" && this._page) + this._page.emit(Events.Page.DOMContentLoaded, this._page); + }); + this._channel.on("navigated", (event) => { + this._url = event.url; + this._name = event.name; + this._eventEmitter.emit("navigated", event); + if (!event.error && this._page) this._page.emit(Events.Page.FrameNavigated, this); + }); + } + + page(): Page { + return this._page!; + } + + _timeout(options?: TimeoutOptions): number { + const timeoutSettings = this._page?._timeoutSettings || new TimeoutSettings(this._platform); + return timeoutSettings.timeout(options || {}); + } + + _navigationTimeout(options?: TimeoutOptions): number { + const timeoutSettings = this._page?._timeoutSettings || new TimeoutSettings(this._platform); + return timeoutSettings.navigationTimeout(options || {}); + } + + async goto( + url: string, + options: channels.FrameGotoOptions & TimeoutOptions = {} + ): Promise { + const waitUntil = verifyLoadState( + "waitUntil", + options.waitUntil === undefined ? "load" : options.waitUntil + ); + return network.Response.fromNullable( + ( + await this._channel.goto({ + url, + ...options, + waitUntil, + timeout: this._navigationTimeout(options), + }) + ).response + ); + } + + private _setupNavigationWaiter(options: { timeout?: number }): Waiter { + const waiter = new Waiter(this._page!, ""); + if (this._page!.isClosed()) waiter.rejectImmediately(this._page!._closeErrorWithReason()); + waiter.rejectOnEvent(this._page!, Events.Page.Close, () => this._page!._closeErrorWithReason()); + waiter.rejectOnEvent( + this._page!, + Events.Page.Crash, + new Error("Navigation failed because page crashed!") + ); + waiter.rejectOnEvent( + this._page!, + Events.Page.FrameDetached, + new Error("Navigating frame was detached!"), + (frame) => frame === this + ); + const timeout = this._page!._timeoutSettings.navigationTimeout(options); + waiter.rejectOnTimeout(timeout, `Timeout ${timeout}ms exceeded.`); + return waiter; + } + + async waitForNavigation( + options: WaitForNavigationOptions = {} + ): Promise { + return await this._page!._wrapApiCall( + async () => { + const waitUntil = verifyLoadState( + "waitUntil", + options.waitUntil === undefined ? "load" : options.waitUntil + ); + const waiter = this._setupNavigationWaiter(options); + + const toUrl = typeof options.url === "string" ? ` to "${options.url}"` : ""; + waiter.log(`waiting for navigation${toUrl} until "${waitUntil}"`); + + const navigatedEvent = await waiter.waitForEvent( + this._eventEmitter, + "navigated", + (event) => { + // Any failed navigation results in a rejection. + if (event.error) return true; + waiter.log(` navigated to "${event.url}"`); + return urlMatches(this._page?.context()._options.baseURL, event.url, options.url); + } + ); + if (navigatedEvent.error) { + const e = new Error(navigatedEvent.error); + e.stack = ""; + await waiter.waitForPromise(Promise.reject(e)); + } + + if (!this._loadStates.has(waitUntil)) { + await waiter.waitForEvent(this._eventEmitter, "loadstate", (s) => { + waiter.log(` "${s}" event fired`); + return s === waitUntil; + }); + } + + const request = navigatedEvent.newDocument + ? network.Request.fromNullable(navigatedEvent.newDocument.request) + : null; + const response = request + ? await waiter.waitForPromise(request._finalRequest()._internalResponse()) + : null; + waiter.dispose(); + return response; + }, + { title: "Wait for navigation" } + ); + } + + async waitForLoadState( + state: LifecycleEvent = "load", + options: { timeout?: number } = {} + ): Promise { + state = verifyLoadState("state", state); + return await this._page!._wrapApiCall( + async () => { + const waiter = this._setupNavigationWaiter(options); + if (this._loadStates.has(state)) { + waiter.log(` not waiting, "${state}" event already fired`); + } else { + await waiter.waitForEvent(this._eventEmitter, "loadstate", (s) => { + waiter.log(` "${s}" event fired`); + return s === state; + }); + } + waiter.dispose(); + }, + { title: `Wait for load state "${state}"` } + ); + } + + async waitForURL( + url: URLMatch, + options: { waitUntil?: LifecycleEvent; timeout?: number } = {} + ): Promise { + if (urlMatches(this._page?.context()._options.baseURL, this.url(), url)) + return await this.waitForLoadState(options.waitUntil, options); + + await this.waitForNavigation({ url, ...options }); + } + + async frameElement(): Promise { + return ElementHandle.from((await this._channel.frameElement()).element); + } + + async evaluateHandle( + pageFunction: structs.PageFunction, + arg?: Arg + ): Promise> { + assertMaxArguments(arguments.length, 2); + const result = await this._channel.evaluateExpressionHandle({ + expression: String(pageFunction), + isFunction: typeof pageFunction === "function", + arg: serializeArgument(arg), + }); + return JSHandle.from(result.handle) as any as structs.SmartHandle; + } + + async evaluate(pageFunction: structs.PageFunction, arg?: Arg): Promise { + assertMaxArguments(arguments.length, 2); + const result = await this._channel.evaluateExpression({ + expression: String(pageFunction), + isFunction: typeof pageFunction === "function", + arg: serializeArgument(arg), + }); + return parseResult(result.value); + } + + async _evaluateExposeUtilityScript( + pageFunction: structs.PageFunction, + arg?: Arg + ): Promise { + assertMaxArguments(arguments.length, 2); + const result = await this._channel.evaluateExpression({ + expression: String(pageFunction), + isFunction: typeof pageFunction === "function", + arg: serializeArgument(arg), + }); + return parseResult(result.value); + } + + async $( + selector: string, + options?: { strict?: boolean } + ): Promise | null> { + const result = await this._channel.querySelector({ selector, ...options }); + return ElementHandle.fromNullable(result.element) as ElementHandle< + SVGElement | HTMLElement + > | null; + } + + waitForSelector( + selector: string, + options: channels.FrameWaitForSelectorOptions & + TimeoutOptions & { state: "attached" | "visible" } + ): Promise>; + waitForSelector( + selector: string, + options?: channels.FrameWaitForSelectorOptions & TimeoutOptions + ): Promise | null>; + async waitForSelector( + selector: string, + options: channels.FrameWaitForSelectorOptions & TimeoutOptions = {} + ): Promise | null> { + if ((options as any).visibility) + throw new Error("options.visibility is not supported, did you mean options.state?"); + if ((options as any).waitFor && (options as any).waitFor !== "visible") + throw new Error("options.waitFor is not supported, did you mean options.state?"); + const result = await this._channel.waitForSelector({ + selector, + ...options, + timeout: this._timeout(options), + }); + return ElementHandle.fromNullable(result.element) as ElementHandle< + SVGElement | HTMLElement + > | null; + } + + async dispatchEvent( + selector: string, + type: string, + eventInit?: any, + options: channels.FrameDispatchEventOptions & TimeoutOptions = {} + ): Promise { + await this._channel.dispatchEvent({ + selector, + type, + eventInit: serializeArgument(eventInit), + ...options, + timeout: this._timeout(options), + }); + } + + async $eval( + selector: string, + pageFunction: structs.PageFunctionOn, + arg?: Arg + ): Promise { + assertMaxArguments(arguments.length, 3); + const result = await this._channel.evalOnSelector({ + selector, + expression: String(pageFunction), + isFunction: typeof pageFunction === "function", + arg: serializeArgument(arg), + }); + return parseResult(result.value); + } + + async $$eval( + selector: string, + pageFunction: structs.PageFunctionOn, + arg?: Arg + ): Promise { + assertMaxArguments(arguments.length, 3); + const result = await this._channel.evalOnSelectorAll({ + selector, + expression: String(pageFunction), + isFunction: typeof pageFunction === "function", + arg: serializeArgument(arg), + }); + return parseResult(result.value); + } + + async $$(selector: string): Promise[]> { + const result = await this._channel.querySelectorAll({ selector }); + return result.elements.map( + (e) => ElementHandle.from(e) as ElementHandle + ); + } + + async _queryCount(selector: string, options?: {}): Promise { + return (await this._channel.queryCount({ selector, ...options })).value; + } + + async content(): Promise { + return (await this._channel.content()).value; + } + + async setContent( + html: string, + options: channels.FrameSetContentOptions & TimeoutOptions = {} + ): Promise { + const waitUntil = verifyLoadState( + "waitUntil", + options.waitUntil === undefined ? "load" : options.waitUntil + ); + await this._channel.setContent({ + html, + ...options, + waitUntil, + timeout: this._navigationTimeout(options), + }); + } + + name(): string { + return this._name || ""; + } + + url(): string { + return this._url; + } + + parentFrame(): Frame | null { + return this._parentFrame; + } + + childFrames(): Frame[] { + return Array.from(this._childFrames); + } + + isDetached(): boolean { + return this._detached; + } + + async addScriptTag( + options: { url?: string; path?: string; content?: string; type?: string } = {} + ): Promise { + const copy = { ...options }; + if (copy.path) { + copy.content = (await this._platform.fs().promises.readFile(copy.path)).toString(); + copy.content = addSourceUrlToScript(copy.content, copy.path); + } + return ElementHandle.from((await this._channel.addScriptTag({ ...copy })).element); + } + + async addStyleTag( + options: { url?: string; path?: string; content?: string } = {} + ): Promise { + const copy = { ...options }; + if (copy.path) { + copy.content = (await this._platform.fs().promises.readFile(copy.path)).toString(); + copy.content += "/*# sourceURL=" + copy.path.replace(/\n/g, "") + "*/"; + } + return ElementHandle.from((await this._channel.addStyleTag({ ...copy })).element); + } + + async click(selector: string, options: channels.FrameClickOptions & TimeoutOptions = {}) { + return await this._channel.click({ selector, ...options, timeout: this._timeout(options) }); + } + + async dblclick(selector: string, options: channels.FrameDblclickOptions & TimeoutOptions = {}) { + return await this._channel.dblclick({ selector, ...options, timeout: this._timeout(options) }); + } + + async dragAndDrop( + source: string, + target: string, + options: channels.FrameDragAndDropOptions & TimeoutOptions = {} + ) { + return await this._channel.dragAndDrop({ + source, + target, + ...options, + timeout: this._timeout(options), + }); + } + + async tap(selector: string, options: channels.FrameTapOptions & TimeoutOptions = {}) { + return await this._channel.tap({ selector, ...options, timeout: this._timeout(options) }); + } + + async fill( + selector: string, + value: string, + options: channels.FrameFillOptions & TimeoutOptions = {} + ) { + return await this._channel.fill({ + selector, + value, + ...options, + timeout: this._timeout(options), + }); + } + + async _highlight(selector: string) { + return await this._channel.highlight({ selector }); + } + + locator(selector: string, options?: LocatorOptions): Locator { + return new Locator(this, selector, options); + } + + getByTestId(testId: string | RegExp): Locator { + return this.locator(getByTestIdSelector(testIdAttributeName(), testId)); + } + + getByAltText(text: string | RegExp, options?: { exact?: boolean }): Locator { + return this.locator(getByAltTextSelector(text, options)); + } + + getByLabel(text: string | RegExp, options?: { exact?: boolean }): Locator { + return this.locator(getByLabelSelector(text, options)); + } + + getByPlaceholder(text: string | RegExp, options?: { exact?: boolean }): Locator { + return this.locator(getByPlaceholderSelector(text, options)); + } + + getByText(text: string | RegExp, options?: { exact?: boolean }): Locator { + return this.locator(getByTextSelector(text, options)); + } + + getByTitle(text: string | RegExp, options?: { exact?: boolean }): Locator { + return this.locator(getByTitleSelector(text, options)); + } + + getByRole(role: string, options: ByRoleOptions = {}): Locator { + return this.locator(getByRoleSelector(role, options)); + } + + frameLocator(selector: string): FrameLocator { + return new FrameLocator(this, selector); + } + + async focus(selector: string, options: channels.FrameFocusOptions & TimeoutOptions = {}) { + await this._channel.focus({ selector, ...options, timeout: this._timeout(options) }); + } + + async textContent( + selector: string, + options: channels.FrameTextContentOptions & TimeoutOptions = {} + ): Promise { + const value = ( + await this._channel.textContent({ selector, ...options, timeout: this._timeout(options) }) + ).value; + return value === undefined ? null : value; + } + + async innerText( + selector: string, + options: channels.FrameInnerTextOptions & TimeoutOptions = {} + ): Promise { + return ( + await this._channel.innerText({ selector, ...options, timeout: this._timeout(options) }) + ).value; + } + + async innerHTML( + selector: string, + options: channels.FrameInnerHTMLOptions & TimeoutOptions = {} + ): Promise { + return ( + await this._channel.innerHTML({ selector, ...options, timeout: this._timeout(options) }) + ).value; + } + + async getAttribute( + selector: string, + name: string, + options: channels.FrameGetAttributeOptions & TimeoutOptions = {} + ): Promise { + const value = ( + await this._channel.getAttribute({ + selector, + name, + ...options, + timeout: this._timeout(options), + }) + ).value; + return value === undefined ? null : value; + } + + async inputValue( + selector: string, + options: channels.FrameInputValueOptions & TimeoutOptions = {} + ): Promise { + return ( + await this._channel.inputValue({ selector, ...options, timeout: this._timeout(options) }) + ).value; + } + + async isChecked( + selector: string, + options: channels.FrameIsCheckedOptions & TimeoutOptions = {} + ): Promise { + return ( + await this._channel.isChecked({ selector, ...options, timeout: this._timeout(options) }) + ).value; + } + + async isDisabled( + selector: string, + options: channels.FrameIsDisabledOptions & TimeoutOptions = {} + ): Promise { + return ( + await this._channel.isDisabled({ selector, ...options, timeout: this._timeout(options) }) + ).value; + } + + async isEditable( + selector: string, + options: channels.FrameIsEditableOptions & TimeoutOptions = {} + ): Promise { + return ( + await this._channel.isEditable({ selector, ...options, timeout: this._timeout(options) }) + ).value; + } + + async isEnabled( + selector: string, + options: channels.FrameIsEnabledOptions & TimeoutOptions = {} + ): Promise { + return ( + await this._channel.isEnabled({ selector, ...options, timeout: this._timeout(options) }) + ).value; + } + + async isHidden( + selector: string, + options: channels.FrameIsHiddenOptions & TimeoutOptions = {} + ): Promise { + return (await this._channel.isHidden({ selector, ...options })).value; + } + + async isVisible( + selector: string, + options: channels.FrameIsVisibleOptions & TimeoutOptions = {} + ): Promise { + return (await this._channel.isVisible({ selector, ...options })).value; + } + + async hover(selector: string, options: channels.FrameHoverOptions & TimeoutOptions = {}) { + await this._channel.hover({ selector, ...options, timeout: this._timeout(options) }); + } + + async selectOption( + selector: string, + values: + | string + | api.ElementHandle + | SelectOption + | string[] + | api.ElementHandle[] + | SelectOption[] + | null, + options: SelectOptionOptions & StrictOptions = {} + ): Promise { + return ( + await this._channel.selectOption({ + selector, + ...convertSelectOptionValues(values), + ...options, + timeout: this._timeout(options), + }) + ).values; + } + + async setInputFiles( + selector: string, + files: string | FilePayload | string[] | FilePayload[], + options: channels.FrameSetInputFilesOptions & TimeoutOptions = {} + ): Promise { + const converted = await convertInputFiles(this._platform, files, this.page().context()); + await this._channel.setInputFiles({ + selector, + ...converted, + ...options, + timeout: this._timeout(options), + }); + } + + async type( + selector: string, + text: string, + options: channels.FrameTypeOptions & TimeoutOptions = {} + ) { + await this._channel.type({ selector, text, ...options, timeout: this._timeout(options) }); + } + + async press( + selector: string, + key: string, + options: channels.FramePressOptions & TimeoutOptions = {} + ) { + await this._channel.press({ selector, key, ...options, timeout: this._timeout(options) }); + } + + async check(selector: string, options: channels.FrameCheckOptions & TimeoutOptions = {}) { + await this._channel.check({ selector, ...options, timeout: this._timeout(options) }); + } + + async uncheck(selector: string, options: channels.FrameUncheckOptions & TimeoutOptions = {}) { + await this._channel.uncheck({ selector, ...options, timeout: this._timeout(options) }); + } + + async setChecked(selector: string, checked: boolean, options?: channels.FrameCheckOptions) { + if (checked) await this.check(selector, options); + else await this.uncheck(selector, options); + } + + async waitForTimeout(timeout: number) { + await this._channel.waitForTimeout({ waitTimeout: timeout }); + } + + async waitForFunction( + pageFunction: structs.PageFunction, + arg?: Arg, + options: WaitForFunctionOptions = {} + ): Promise> { + if (typeof options.polling === "string") + assert(options.polling === "raf", "Unknown polling option: " + options.polling); + const result = await this._channel.waitForFunction({ + ...options, + pollingInterval: options.polling === "raf" ? undefined : options.polling, + expression: String(pageFunction), + isFunction: typeof pageFunction === "function", + arg: serializeArgument(arg), + timeout: this._timeout(options), + }); + return JSHandle.from(result.handle) as any as structs.SmartHandle; + } + + async title(): Promise { + return (await this._channel.title()).value; + } + + async _expect( + expression: string, + options: Omit + ): Promise<{ + matches: boolean; + received?: any; + log?: string[]; + timedOut?: boolean; + errorMessage?: string; + }> { + const params: channels.FrameExpectParams = { expression, ...options, isNot: !!options.isNot }; + params.expectedValue = serializeArgument(options.expectedValue); + const result = await this._channel.expect(params); + if (result.received !== undefined) result.received = parseResult(result.received); + return result; + } +} + +export function verifyLoadState(name: string, waitUntil: LifecycleEvent): LifecycleEvent { + if ((waitUntil as unknown) === "networkidle0") waitUntil = "networkidle"; + if (!kLifecycleEvents.has(waitUntil)) + throw new Error(`${name}: expected one of (load|domcontentloaded|networkidle|commit)`); + return waitUntil; +} diff --git a/daemon/src/sandbox/forked-client/src/client/harRouter.ts b/daemon/src/sandbox/forked-client/src/client/harRouter.ts new file mode 100644 index 00000000..96e70622 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/harRouter.ts @@ -0,0 +1,20 @@ +// @ts-nocheck +function unsupported() { + throw new Error("HAR routing is not available in the QuickJS sandbox"); +} + +export class HarRouter { + static async create() { + return new HarRouter(); + } + + async addContextRoute() { + unsupported(); + } + + async addPageRoute() { + unsupported(); + } + + async dispose() {} +} diff --git a/daemon/src/sandbox/forked-client/src/client/input.ts b/daemon/src/sandbox/forked-client/src/client/input.ts new file mode 100644 index 00000000..5b41ce98 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/input.ts @@ -0,0 +1,102 @@ +// @ts-nocheck +/** + * Copyright 2017 Google Inc. All rights reserved. + * Modifications copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { Page } from "./page"; +import type * as api from "../../types/types"; +import type * as channels from "../protocol/channels"; + +export class Keyboard implements api.Keyboard { + private _page: Page; + + constructor(page: Page) { + this._page = page; + } + + async down(key: string) { + await this._page._channel.keyboardDown({ key }); + } + + async up(key: string) { + await this._page._channel.keyboardUp({ key }); + } + + async insertText(text: string) { + await this._page._channel.keyboardInsertText({ text }); + } + + async type(text: string, options: channels.PageKeyboardTypeOptions = {}) { + await this._page._channel.keyboardType({ text, ...options }); + } + + async press(key: string, options: channels.PageKeyboardPressOptions = {}) { + await this._page._channel.keyboardPress({ key, ...options }); + } +} + +export class Mouse implements api.Mouse { + private _page: Page; + + constructor(page: Page) { + this._page = page; + } + + async move(x: number, y: number, options: { steps?: number } = {}) { + await this._page._channel.mouseMove({ x, y, ...options }); + } + + async down(options: channels.PageMouseDownOptions = {}) { + await this._page._channel.mouseDown({ ...options }); + } + + async up(options: channels.PageMouseUpOptions = {}) { + await this._page._channel.mouseUp(options); + } + + async click(x: number, y: number, options: channels.PageMouseClickOptions = {}) { + await this._page._channel.mouseClick({ x, y, ...options }); + } + + async dblclick( + x: number, + y: number, + options: Omit = {} + ) { + await this._page._wrapApiCall( + async () => { + await this.click(x, y, { ...options, clickCount: 2 }); + }, + { title: "Double click" } + ); + } + + async wheel(deltaX: number, deltaY: number) { + await this._page._channel.mouseWheel({ deltaX, deltaY }); + } +} + +export class Touchscreen implements api.Touchscreen { + private _page: Page; + + constructor(page: Page) { + this._page = page; + } + + async tap(x: number, y: number) { + await this._page._channel.touchscreenTap({ x, y }); + } +} diff --git a/daemon/src/sandbox/forked-client/src/client/jsHandle.ts b/daemon/src/sandbox/forked-client/src/client/jsHandle.ts new file mode 100644 index 00000000..b0b15e13 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/jsHandle.ts @@ -0,0 +1,130 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ChannelOwner } from "./channelOwner"; +import { isTargetClosedError } from "./errors"; +import { parseSerializedValue, serializeValue } from "../protocol/serializers"; + +import type * as structs from "../../types/structs"; +import type * as api from "../../types/types"; +import type * as channels from "../protocol/channels"; + +export class JSHandle + extends ChannelOwner + implements api.JSHandle +{ + private _preview: string; + + static from(handle: channels.JSHandleChannel): JSHandle { + return (handle as any)._object; + } + + constructor( + parent: ChannelOwner, + type: string, + guid: string, + initializer: channels.JSHandleInitializer + ) { + super(parent, type, guid, initializer); + this._preview = this._initializer.preview; + this._channel.on("previewUpdated", ({ preview }) => (this._preview = preview)); + } + + async evaluate(pageFunction: structs.PageFunctionOn, arg?: Arg): Promise { + const result = await this._channel.evaluateExpression({ + expression: String(pageFunction), + isFunction: typeof pageFunction === "function", + arg: serializeArgument(arg), + }); + return parseResult(result.value); + } + + async evaluateHandle( + pageFunction: structs.PageFunctionOn, + arg?: Arg + ): Promise> { + const result = await this._channel.evaluateExpressionHandle({ + expression: String(pageFunction), + isFunction: typeof pageFunction === "function", + arg: serializeArgument(arg), + }); + return JSHandle.from(result.handle) as any as structs.SmartHandle; + } + + async getProperty(propertyName: string): Promise { + const result = await this._channel.getProperty({ name: propertyName }); + return JSHandle.from(result.handle); + } + + async getProperties(): Promise> { + const map = new Map(); + for (const { name, value } of (await this._channel.getPropertyList()).properties) + map.set(name, JSHandle.from(value)); + return map; + } + + async jsonValue(): Promise { + return parseResult((await this._channel.jsonValue()).value); + } + + asElement(): T extends Node ? api.ElementHandle : null { + return null as any; + } + + async [Symbol.asyncDispose]() { + await this.dispose(); + } + + async dispose() { + try { + await this._channel.dispose(); + } catch (e) { + if (isTargetClosedError(e)) return; + throw e; + } + } + + override toString(): string { + return this._preview; + } +} + +// This function takes care of converting all JSHandles to their channels, +// so that generic channel serializer converts them to guids. +export function serializeArgument(arg: any): channels.SerializedArgument { + const handles: channels.Channel[] = []; + const pushHandle = (channel: channels.Channel): number => { + handles.push(channel); + return handles.length - 1; + }; + const value = serializeValue(arg, (value) => { + if (value instanceof JSHandle) return { h: pushHandle(value._channel) }; + return { fallThrough: value }; + }); + return { value, handles }; +} + +export function parseResult(value: channels.SerializedValue): any { + return parseSerializedValue(value, undefined); +} + +export function assertMaxArguments(count: number, max: number): asserts count { + if (count > max) + throw new Error( + "Too many arguments. If you need to pass more than 1 argument to the function wrap them in an object." + ); +} diff --git a/daemon/src/sandbox/forked-client/src/client/jsonPipe.ts b/daemon/src/sandbox/forked-client/src/client/jsonPipe.ts new file mode 100644 index 00000000..75c097d6 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/jsonPipe.ts @@ -0,0 +1,8 @@ +// @ts-nocheck +import { ChannelOwner } from "./channelOwner"; + +export class JsonPipe extends ChannelOwner { + static from(channel) { + return channel?._object; + } +} diff --git a/daemon/src/sandbox/forked-client/src/client/localUtils.ts b/daemon/src/sandbox/forked-client/src/client/localUtils.ts new file mode 100644 index 00000000..6e045c99 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/localUtils.ts @@ -0,0 +1,40 @@ +// @ts-nocheck +import { ChannelOwner } from "./channelOwner"; + +function unsupported() { + throw new Error("LocalUtils is not available in the QuickJS sandbox"); +} + +export class LocalUtils extends ChannelOwner { + devices = {}; + + static from(channel) { + return channel?._object; + } + + async connect() { + unsupported(); + } + + async addStackToTracingNoReply() {} + + async zip() { + unsupported(); + } + + async harOpen() { + unsupported(); + } + + async harLookup() { + unsupported(); + } + + async harClose() { + unsupported(); + } + + async harUnzip() { + unsupported(); + } +} diff --git a/daemon/src/sandbox/forked-client/src/client/locator.ts b/daemon/src/sandbox/forked-client/src/client/locator.ts new file mode 100644 index 00000000..87ab03d0 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/locator.ts @@ -0,0 +1,635 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ElementHandle } from "./elementHandle"; +import { + asLocatorDescription, + locatorCustomDescription, +} from "../utils/isomorphic/locatorGenerators"; +import { + getByAltTextSelector, + getByLabelSelector, + getByPlaceholderSelector, + getByRoleSelector, + getByTestIdSelector, + getByTextSelector, + getByTitleSelector, +} from "../utils/isomorphic/locatorUtils"; +import { escapeForTextSelector } from "../utils/isomorphic/stringUtils"; +import { isString } from "../utils/isomorphic/rtti"; +import { monotonicTime } from "../utils/isomorphic/time"; + +import type { Frame } from "./frame"; +import type { + FilePayload, + FrameExpectParams, + Rect, + SelectOption, + SelectOptionOptions, + TimeoutOptions, +} from "./types"; +import type * as structs from "../../types/structs"; +import type * as api from "../../types/types"; +import type { ByRoleOptions } from "../utils/isomorphic/locatorUtils"; +import type * as channels from "../protocol/channels"; + +export type LocatorOptions = { + hasText?: string | RegExp; + hasNotText?: string | RegExp; + has?: Locator; + hasNot?: Locator; + visible?: boolean; +}; + +export class Locator implements api.Locator { + _frame: Frame; + _selector: string; + + constructor(frame: Frame, selector: string, options?: LocatorOptions) { + this._frame = frame; + this._selector = selector; + + if (options?.hasText) + this._selector += ` >> internal:has-text=${escapeForTextSelector(options.hasText, false)}`; + + if (options?.hasNotText) + this._selector += ` >> internal:has-not-text=${escapeForTextSelector(options.hasNotText, false)}`; + + if (options?.has) { + const locator = options.has; + if (locator._frame !== frame) + throw new Error(`Inner "has" locator must belong to the same frame.`); + this._selector += ` >> internal:has=` + JSON.stringify(locator._selector); + } + + if (options?.hasNot) { + const locator = options.hasNot; + if (locator._frame !== frame) + throw new Error(`Inner "hasNot" locator must belong to the same frame.`); + this._selector += ` >> internal:has-not=` + JSON.stringify(locator._selector); + } + + if (options?.visible !== undefined) + this._selector += ` >> visible=${options.visible ? "true" : "false"}`; + + if (this._frame._platform.inspectCustom) + (this as any)[this._frame._platform.inspectCustom] = () => this._inspect(); + } + + private async _withElement( + task: (handle: ElementHandle, timeout?: number) => Promise, + options: { title: string; internal?: boolean; timeout?: number } + ): Promise { + const timeout = this._frame._timeout({ timeout: options.timeout }); + const deadline = timeout ? monotonicTime() + timeout : 0; + + return await this._frame._wrapApiCall( + async () => { + const result = await this._frame._channel.waitForSelector({ + selector: this._selector, + strict: true, + state: "attached", + timeout, + }); + const handle = ElementHandle.fromNullable(result.element) as ElementHandle< + SVGElement | HTMLElement + > | null; + if (!handle) throw new Error(`Could not resolve ${this._selector} to DOM Element`); + try { + return await task(handle, deadline ? deadline - monotonicTime() : 0); + } finally { + await handle.dispose(); + } + }, + { title: options.title, internal: options.internal } + ); + } + + _equals(locator: Locator) { + return this._frame === locator._frame && this._selector === locator._selector; + } + + page() { + return this._frame.page(); + } + + async boundingBox(options?: TimeoutOptions): Promise { + return await this._withElement((h) => h.boundingBox(), { + title: "Bounding box", + timeout: options?.timeout, + }); + } + + async check(options: channels.ElementHandleCheckOptions & TimeoutOptions = {}) { + return await this._frame.check(this._selector, { strict: true, ...options }); + } + + async click(options: channels.ElementHandleClickOptions & TimeoutOptions = {}): Promise { + return await this._frame.click(this._selector, { strict: true, ...options }); + } + + async dblclick( + options: channels.ElementHandleDblclickOptions & TimeoutOptions = {} + ): Promise { + await this._frame.dblclick(this._selector, { strict: true, ...options }); + } + + async dispatchEvent(type: string, eventInit: Object = {}, options?: TimeoutOptions) { + return await this._frame.dispatchEvent(this._selector, type, eventInit, { + strict: true, + ...options, + }); + } + + async dragTo(target: Locator, options: channels.FrameDragAndDropOptions & TimeoutOptions = {}) { + return await this._frame.dragAndDrop(this._selector, target._selector, { + strict: true, + ...options, + }); + } + + async evaluate( + pageFunction: structs.PageFunctionOn, + arg?: Arg, + options?: TimeoutOptions + ): Promise { + return await this._withElement((h) => h.evaluate(pageFunction, arg), { + title: "Evaluate", + timeout: options?.timeout, + }); + } + + async evaluateAll( + pageFunction: structs.PageFunctionOn, + arg?: Arg + ): Promise { + return await this._frame.$$eval(this._selector, pageFunction, arg); + } + + async evaluateHandle( + pageFunction: structs.PageFunctionOn, + arg?: Arg, + options?: TimeoutOptions + ): Promise> { + return await this._withElement((h) => h.evaluateHandle(pageFunction, arg), { + title: "Evaluate", + timeout: options?.timeout, + }); + } + + async fill( + value: string, + options: channels.ElementHandleFillOptions & TimeoutOptions = {} + ): Promise { + return await this._frame.fill(this._selector, value, { strict: true, ...options }); + } + + async clear(options: channels.ElementHandleFillOptions = {}): Promise { + await this._frame._wrapApiCall(() => this.fill("", options), { title: "Clear" }); + } + + async _highlight() { + // VS Code extension uses this one, keep it for now. + return await this._frame._highlight(this._selector); + } + + async highlight() { + return await this._frame._highlight(this._selector); + } + + locator(selectorOrLocator: string | Locator, options?: Omit): Locator { + if (isString(selectorOrLocator)) + return new Locator(this._frame, this._selector + " >> " + selectorOrLocator, options); + if (selectorOrLocator._frame !== this._frame) + throw new Error(`Locators must belong to the same frame.`); + return new Locator( + this._frame, + this._selector + " >> internal:chain=" + JSON.stringify(selectorOrLocator._selector), + options + ); + } + + getByTestId(testId: string | RegExp): Locator { + return this.locator(getByTestIdSelector(testIdAttributeName(), testId)); + } + + getByAltText(text: string | RegExp, options?: { exact?: boolean }): Locator { + return this.locator(getByAltTextSelector(text, options)); + } + + getByLabel(text: string | RegExp, options?: { exact?: boolean }): Locator { + return this.locator(getByLabelSelector(text, options)); + } + + getByPlaceholder(text: string | RegExp, options?: { exact?: boolean }): Locator { + return this.locator(getByPlaceholderSelector(text, options)); + } + + getByText(text: string | RegExp, options?: { exact?: boolean }): Locator { + return this.locator(getByTextSelector(text, options)); + } + + getByTitle(text: string | RegExp, options?: { exact?: boolean }): Locator { + return this.locator(getByTitleSelector(text, options)); + } + + getByRole(role: string, options: ByRoleOptions = {}): Locator { + return this.locator(getByRoleSelector(role, options)); + } + + frameLocator(selector: string): FrameLocator { + return new FrameLocator(this._frame, this._selector + " >> " + selector); + } + + filter(options?: LocatorOptions): Locator { + return new Locator(this._frame, this._selector, options); + } + + async elementHandle(options?: TimeoutOptions): Promise> { + return await this._frame.waitForSelector(this._selector, { + strict: true, + state: "attached", + ...options, + })!; + } + + async elementHandles(): Promise[]> { + return await this._frame.$$(this._selector); + } + + contentFrame() { + return new FrameLocator(this._frame, this._selector); + } + + describe(description: string) { + return new Locator( + this._frame, + this._selector + " >> internal:describe=" + JSON.stringify(description) + ); + } + + description(): string | null { + return locatorCustomDescription(this._selector) || null; + } + + first(): Locator { + return new Locator(this._frame, this._selector + " >> nth=0"); + } + + last(): Locator { + return new Locator(this._frame, this._selector + ` >> nth=-1`); + } + + nth(index: number): Locator { + return new Locator(this._frame, this._selector + ` >> nth=${index}`); + } + + and(locator: Locator): Locator { + if (locator._frame !== this._frame) throw new Error(`Locators must belong to the same frame.`); + return new Locator( + this._frame, + this._selector + ` >> internal:and=` + JSON.stringify(locator._selector) + ); + } + + or(locator: Locator): Locator { + if (locator._frame !== this._frame) throw new Error(`Locators must belong to the same frame.`); + return new Locator( + this._frame, + this._selector + ` >> internal:or=` + JSON.stringify(locator._selector) + ); + } + + async focus(options?: TimeoutOptions): Promise { + return await this._frame.focus(this._selector, { strict: true, ...options }); + } + + async blur(options?: TimeoutOptions): Promise { + await this._frame._channel.blur({ + selector: this._selector, + strict: true, + ...options, + timeout: this._frame._timeout(options), + }); + } + + // options are only here for testing + async count(_options?: {}): Promise { + return await this._frame._queryCount(this._selector, _options); + } + + async toCode(): Promise { + const { resolvedSelector } = await this._frame._channel.resolveSelector({ + selector: this._selector, + }); + return new Locator(this._frame, resolvedSelector).toString(); + } + + async getAttribute(name: string, options?: TimeoutOptions): Promise { + return await this._frame.getAttribute(this._selector, name, { strict: true, ...options }); + } + + async hover(options: channels.ElementHandleHoverOptions & TimeoutOptions = {}): Promise { + return await this._frame.hover(this._selector, { strict: true, ...options }); + } + + async innerHTML(options?: TimeoutOptions): Promise { + return await this._frame.innerHTML(this._selector, { strict: true, ...options }); + } + + async innerText(options?: TimeoutOptions): Promise { + return await this._frame.innerText(this._selector, { strict: true, ...options }); + } + + async inputValue(options?: TimeoutOptions): Promise { + return await this._frame.inputValue(this._selector, { strict: true, ...options }); + } + + async isChecked(options?: TimeoutOptions): Promise { + return await this._frame.isChecked(this._selector, { strict: true, ...options }); + } + + async isDisabled(options?: TimeoutOptions): Promise { + return await this._frame.isDisabled(this._selector, { strict: true, ...options }); + } + + async isEditable(options?: TimeoutOptions): Promise { + return await this._frame.isEditable(this._selector, { strict: true, ...options }); + } + + async isEnabled(options?: TimeoutOptions): Promise { + return await this._frame.isEnabled(this._selector, { strict: true, ...options }); + } + + async isHidden(options?: TimeoutOptions): Promise { + return await this._frame.isHidden(this._selector, { strict: true, ...options }); + } + + async isVisible(options?: TimeoutOptions): Promise { + return await this._frame.isVisible(this._selector, { strict: true, ...options }); + } + + async press( + key: string, + options: channels.ElementHandlePressOptions & TimeoutOptions = {} + ): Promise { + return await this._frame.press(this._selector, key, { strict: true, ...options }); + } + + async screenshot( + options: Omit & + TimeoutOptions & { path?: string; mask?: api.Locator[] } = {} + ): Promise { + const mask = options.mask as Locator[] | undefined; + return await this._withElement((h, timeout) => h.screenshot({ ...options, mask, timeout }), { + title: "Screenshot", + timeout: options.timeout, + }); + } + + async ariaSnapshot(options?: TimeoutOptions): Promise { + const result = await this._frame._channel.ariaSnapshot({ + ...options, + selector: this._selector, + timeout: this._frame._timeout(options), + }); + return result.snapshot; + } + + async scrollIntoViewIfNeeded( + options: channels.ElementHandleScrollIntoViewIfNeededOptions & TimeoutOptions = {} + ) { + return await this._withElement( + (h, timeout) => h.scrollIntoViewIfNeeded({ ...options, timeout }), + { title: "Scroll into view", timeout: options.timeout } + ); + } + + async selectOption( + values: + | string + | api.ElementHandle + | SelectOption + | string[] + | api.ElementHandle[] + | SelectOption[] + | null, + options: SelectOptionOptions = {} + ): Promise { + return await this._frame.selectOption(this._selector, values, { strict: true, ...options }); + } + + async selectText( + options: channels.ElementHandleSelectTextOptions & TimeoutOptions = {} + ): Promise { + return await this._withElement((h, timeout) => h.selectText({ ...options, timeout }), { + title: "Select text", + timeout: options.timeout, + }); + } + + async setChecked( + checked: boolean, + options?: channels.ElementHandleCheckOptions & TimeoutOptions + ) { + if (checked) await this.check(options); + else await this.uncheck(options); + } + + async setInputFiles( + files: string | FilePayload | string[] | FilePayload[], + options: channels.ElementHandleSetInputFilesOptions & TimeoutOptions = {} + ) { + return await this._frame.setInputFiles(this._selector, files, { strict: true, ...options }); + } + + async tap(options: channels.ElementHandleTapOptions & TimeoutOptions = {}): Promise { + return await this._frame.tap(this._selector, { strict: true, ...options }); + } + + async textContent(options?: TimeoutOptions): Promise { + return await this._frame.textContent(this._selector, { strict: true, ...options }); + } + + async type( + text: string, + options: channels.ElementHandleTypeOptions & TimeoutOptions = {} + ): Promise { + return await this._frame.type(this._selector, text, { strict: true, ...options }); + } + + async pressSequentially( + text: string, + options: channels.ElementHandleTypeOptions & TimeoutOptions = {} + ): Promise { + return await this.type(text, options); + } + + async uncheck(options: channels.ElementHandleUncheckOptions & TimeoutOptions = {}) { + return await this._frame.uncheck(this._selector, { strict: true, ...options }); + } + + async all(): Promise { + return new Array(await this.count()).fill(0).map((e, i) => this.nth(i)); + } + + async allInnerTexts(): Promise { + return await this._frame.$$eval(this._selector, (ee) => + ee.map((e) => (e as HTMLElement).innerText) + ); + } + + async allTextContents(): Promise { + return await this._frame.$$eval(this._selector, (ee) => ee.map((e) => e.textContent || "")); + } + + waitFor( + options: channels.FrameWaitForSelectorOptions & + TimeoutOptions & { state: "attached" | "visible" } + ): Promise; + waitFor(options?: channels.FrameWaitForSelectorOptions & TimeoutOptions): Promise; + async waitFor(options?: channels.FrameWaitForSelectorOptions & TimeoutOptions): Promise { + await this._frame._channel.waitForSelector({ + selector: this._selector, + strict: true, + omitReturnValue: true, + ...options, + timeout: this._frame._timeout(options), + }); + } + + async snapshotForAI( + options: TimeoutOptions & { depth?: number } = {} + ): Promise<{ full: string }> { + return await this._frame._page!._channel.snapshotForAI({ + timeout: this._frame._timeout(options), + selector: this._selector, + depth: options.depth, + }); + } + + async _expect( + expression: string, + options: FrameExpectParams + ): Promise<{ + matches: boolean; + received?: any; + log?: string[]; + timedOut?: boolean; + errorMessage?: string; + }> { + return this._frame._expect(expression, { + ...options, + selector: this._selector, + }); + } + + private _inspect() { + return this.toString(); + } + + toString() { + return asLocatorDescription("javascript", this._selector); + } +} + +export class FrameLocator implements api.FrameLocator { + private _frame: Frame; + private _frameSelector: string; + + constructor(frame: Frame, selector: string) { + this._frame = frame; + this._frameSelector = selector; + } + + locator(selectorOrLocator: string | Locator, options?: LocatorOptions): Locator { + if (isString(selectorOrLocator)) + return new Locator( + this._frame, + this._frameSelector + " >> internal:control=enter-frame >> " + selectorOrLocator, + options + ); + if (selectorOrLocator._frame !== this._frame) + throw new Error(`Locators must belong to the same frame.`); + return new Locator( + this._frame, + this._frameSelector + " >> internal:control=enter-frame >> " + selectorOrLocator._selector, + options + ); + } + + getByTestId(testId: string | RegExp): Locator { + return this.locator(getByTestIdSelector(testIdAttributeName(), testId)); + } + + getByAltText(text: string | RegExp, options?: { exact?: boolean }): Locator { + return this.locator(getByAltTextSelector(text, options)); + } + + getByLabel(text: string | RegExp, options?: { exact?: boolean }): Locator { + return this.locator(getByLabelSelector(text, options)); + } + + getByPlaceholder(text: string | RegExp, options?: { exact?: boolean }): Locator { + return this.locator(getByPlaceholderSelector(text, options)); + } + + getByText(text: string | RegExp, options?: { exact?: boolean }): Locator { + return this.locator(getByTextSelector(text, options)); + } + + getByTitle(text: string | RegExp, options?: { exact?: boolean }): Locator { + return this.locator(getByTitleSelector(text, options)); + } + + getByRole(role: string, options: ByRoleOptions = {}): Locator { + return this.locator(getByRoleSelector(role, options)); + } + + owner() { + return new Locator(this._frame, this._frameSelector); + } + + frameLocator(selector: string): FrameLocator { + return new FrameLocator( + this._frame, + this._frameSelector + " >> internal:control=enter-frame >> " + selector + ); + } + + first(): FrameLocator { + return new FrameLocator(this._frame, this._frameSelector + " >> nth=0"); + } + + last(): FrameLocator { + return new FrameLocator(this._frame, this._frameSelector + ` >> nth=-1`); + } + + nth(index: number): FrameLocator { + return new FrameLocator(this._frame, this._frameSelector + ` >> nth=${index}`); + } +} + +let _testIdAttributeName: string = "data-testid"; + +export function testIdAttributeName(): string { + return _testIdAttributeName; +} + +export function setTestIdAttribute(attributeName: string) { + _testIdAttributeName = attributeName; +} diff --git a/daemon/src/sandbox/forked-client/src/client/network.ts b/daemon/src/sandbox/forked-client/src/client/network.ts new file mode 100644 index 00000000..ad0f6306 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/network.ts @@ -0,0 +1,1020 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ChannelOwner } from "./channelOwner"; +import { isTargetClosedError } from "./errors"; +import { Events } from "./events"; +import { APIResponse } from "./fetch"; +import { Frame } from "./frame"; +import { Waiter } from "./waiter"; +import { Worker } from "./worker"; +import { assert } from "../utils/isomorphic/assert"; +import { headersObjectToArray } from "../utils/isomorphic/headers"; +import { serializeURLMatch, urlMatches } from "../utils/isomorphic/urlMatch"; +import { LongStandingScope, ManualPromise } from "../utils/isomorphic/manualPromise"; +import { MultiMap } from "../utils/isomorphic/multimap"; +import { isString } from "../utils/isomorphic/rtti"; +import { rewriteErrorMessage } from "../utils/isomorphic/stackTrace"; +import { getMimeTypeForPath } from "../utils/isomorphic/mimeType"; + +import type { BrowserContext } from "./browserContext"; +import type { Page } from "./page"; +import type { Headers, RemoteAddr, SecurityDetails, WaitForEventOptions } from "./types"; +import type { Serializable } from "../../types/structs"; +import type * as api from "../../types/types"; +import type { HeadersArray } from "../utils/isomorphic/types"; +import type { URLMatch } from "../utils/isomorphic/urlMatch"; +import type * as channels from "../protocol/channels"; +import type { Platform, Zone } from "./platform"; + +export type NetworkCookie = { + name: string; + value: string; + domain: string; + path: string; + expires: number; + httpOnly: boolean; + secure: boolean; + sameSite: "Strict" | "Lax" | "None"; +}; + +export type SetNetworkCookieParam = { + name: string; + value: string; + url?: string; + domain?: string; + path?: string; + expires?: number; + httpOnly?: boolean; + secure?: boolean; + sameSite?: "Strict" | "Lax" | "None"; +}; + +export type ClearNetworkCookieOptions = { + name?: string | RegExp; + domain?: string | RegExp; + path?: string | RegExp; +}; + +type SerializedFallbackOverrides = { + url?: string; + method?: string; + headers?: Headers; + postDataBuffer?: Buffer; +}; + +type FallbackOverrides = { + url?: string; + method?: string; + headers?: Headers; + postData?: string | Buffer | Serializable; +}; + +export class Request extends ChannelOwner implements api.Request { + private _redirectedFrom: Request | null = null; + private _redirectedTo: Request | null = null; + _failureText: string | null = null; + _response: Response | null = null; + private _hasResponse = false; + private _provisionalHeaders: RawHeaders; + private _actualHeadersPromise: Promise | undefined; + _timing: ResourceTiming; + private _fallbackOverrides: SerializedFallbackOverrides = {}; + + static from(request: channels.RequestChannel): Request { + return (request as any)._object; + } + + static fromNullable(request: channels.RequestChannel | undefined): Request | null { + return request ? Request.from(request) : null; + } + + constructor( + parent: ChannelOwner, + type: string, + guid: string, + initializer: channels.RequestInitializer + ) { + super(parent, type, guid, initializer); + this._redirectedFrom = Request.fromNullable(initializer.redirectedFrom); + if (this._redirectedFrom) this._redirectedFrom._redirectedTo = this; + this._provisionalHeaders = new RawHeaders(initializer.headers); + this._timing = { + startTime: 0, + domainLookupStart: -1, + domainLookupEnd: -1, + connectStart: -1, + secureConnectionStart: -1, + connectEnd: -1, + requestStart: -1, + responseStart: -1, + responseEnd: -1, + }; + this._hasResponse = this._initializer.hasResponse; + this._channel.on("response", () => (this._hasResponse = true)); + } + + url(): string { + return this._fallbackOverrides.url || this._initializer.url; + } + + resourceType(): string { + return this._initializer.resourceType; + } + + method(): string { + return this._fallbackOverrides.method || this._initializer.method; + } + + postData(): string | null { + return ( + (this._fallbackOverrides.postDataBuffer || this._initializer.postData)?.toString("utf-8") || + null + ); + } + + postDataBuffer(): Buffer | null { + return this._fallbackOverrides.postDataBuffer || this._initializer.postData || null; + } + + postDataJSON(): Object | null { + const postData = this.postData(); + if (!postData) return null; + + const contentType = this.headers()["content-type"]; + if (contentType?.includes("application/x-www-form-urlencoded")) { + const entries: Record = {}; + const parsed = new URLSearchParams(postData); + for (const [k, v] of parsed.entries()) entries[k] = v; + return entries; + } + + try { + return JSON.parse(postData); + } catch (e) { + throw new Error("POST data is not a valid JSON object: " + postData); + } + } + + /** + * @deprecated + */ + headers(): Headers { + if (this._fallbackOverrides.headers) + return RawHeaders._fromHeadersObjectLossy(this._fallbackOverrides.headers).headers(); + return this._provisionalHeaders.headers(); + } + + async _actualHeaders(): Promise { + if (this._fallbackOverrides.headers) + return RawHeaders._fromHeadersObjectLossy(this._fallbackOverrides.headers); + + if (!this._actualHeadersPromise) { + this._actualHeadersPromise = this._wrapApiCall( + async () => { + return new RawHeaders((await this._channel.rawRequestHeaders()).headers); + }, + { internal: true } + ); + } + return await this._actualHeadersPromise; + } + + async allHeaders(): Promise { + return (await this._actualHeaders()).headers(); + } + + async headersArray(): Promise { + return (await this._actualHeaders()).headersArray(); + } + + async headerValue(name: string): Promise { + return (await this._actualHeaders()).get(name); + } + + async response(): Promise { + return Response.fromNullable((await this._channel.response()).response); + } + + async _internalResponse(): Promise { + return Response.fromNullable((await this._channel.response()).response); + } + + existingResponse(): Response | null { + return this._response; + } + + frame(): Frame { + if (!this._initializer.frame) { + assert(this.serviceWorker()); + throw new Error("Service Worker requests do not have an associated frame."); + } + const frame = Frame.from(this._initializer.frame); + if (!frame._page) { + throw new Error( + [ + "Frame for this navigation request is not available, because the request", + "was issued before the frame is created. You can check whether the request", + "is a navigation request by calling isNavigationRequest() method.", + ].join("\n") + ); + } + return frame; + } + + _safePage(): Page | null { + return Frame.fromNullable(this._initializer.frame)?._page || null; + } + + serviceWorker(): Worker | null { + return this._initializer.serviceWorker ? Worker.from(this._initializer.serviceWorker) : null; + } + + isNavigationRequest(): boolean { + return this._initializer.isNavigationRequest; + } + + redirectedFrom(): Request | null { + return this._redirectedFrom; + } + + redirectedTo(): Request | null { + return this._redirectedTo; + } + + failure(): { errorText: string } | null { + if (this._failureText === null) return null; + return { + errorText: this._failureText, + }; + } + + timing(): ResourceTiming { + return this._timing; + } + + async sizes(): Promise { + const response = await this.response(); + if (!response) throw new Error("Unable to fetch sizes for failed request"); + return (await response._channel.sizes()).sizes; + } + + _setResponseEndTiming(responseEndTiming: number) { + this._timing.responseEnd = responseEndTiming; + if (this._timing.responseStart === -1) this._timing.responseStart = responseEndTiming; + } + + _finalRequest(): Request { + return this._redirectedTo ? this._redirectedTo._finalRequest() : this; + } + + _applyFallbackOverrides(overrides: FallbackOverrides) { + if (overrides.url) this._fallbackOverrides.url = overrides.url; + if (overrides.method) this._fallbackOverrides.method = overrides.method; + if (overrides.headers) this._fallbackOverrides.headers = overrides.headers; + + if (isString(overrides.postData)) + this._fallbackOverrides.postDataBuffer = Buffer.from(overrides.postData, "utf-8"); + else if (overrides.postData instanceof Buffer) + this._fallbackOverrides.postDataBuffer = overrides.postData; + else if (overrides.postData) + this._fallbackOverrides.postDataBuffer = Buffer.from( + JSON.stringify(overrides.postData), + "utf-8" + ); + } + + _fallbackOverridesForContinue() { + return this._fallbackOverrides; + } + + _targetClosedScope(): LongStandingScope { + return ( + this.serviceWorker()?._closedScope || + this._safePage()?._closedOrCrashedScope || + new LongStandingScope() + ); + } +} + +export class Route extends ChannelOwner implements api.Route { + private _handlingPromise: ManualPromise | null = null; + _context!: BrowserContext; + _didThrow: boolean = false; + + static from(route: channels.RouteChannel): Route { + return (route as any)._object; + } + + constructor( + parent: ChannelOwner, + type: string, + guid: string, + initializer: channels.RouteInitializer + ) { + super(parent, type, guid, initializer); + } + + request(): Request { + return Request.from(this._initializer.request); + } + + private async _raceWithTargetClose(promise: Promise): Promise { + // When page closes or crashes, we catch any potential rejects from this Route. + // Note that page could be missing when routing popup's initial request that + // does not have a Page initialized just yet. + return await this.request()._targetClosedScope().safeRace(promise); + } + + async _startHandling(): Promise { + this._handlingPromise = new ManualPromise(); + return await this._handlingPromise; + } + + async fallback(options: FallbackOverrides = {}) { + this._checkNotHandled(); + this.request()._applyFallbackOverrides(options); + this._reportHandled(false); + } + + async abort(errorCode?: string) { + await this._handleRoute(async () => { + await this._raceWithTargetClose(this._channel.abort({ errorCode })); + }); + } + + async _redirectNavigationRequest(url: string) { + await this._handleRoute(async () => { + await this._raceWithTargetClose(this._channel.redirectNavigationRequest({ url })); + }); + } + + async fetch( + options: FallbackOverrides & { + maxRedirects?: number; + maxRetries?: number; + timeout?: number; + } = {} + ): Promise { + return await this._wrapApiCall(async () => { + return await this._context.request._innerFetch({ + request: this.request(), + data: options.postData, + ...options, + }); + }); + } + + async fulfill( + options: { + response?: api.APIResponse; + status?: number; + headers?: Headers; + contentType?: string; + body?: string | Buffer; + json?: any; + path?: string; + } = {} + ) { + await this._handleRoute(async () => { + await this._innerFulfill(options); + }); + } + + private async _handleRoute(callback: () => Promise) { + this._checkNotHandled(); + try { + await callback(); + this._reportHandled(true); + } catch (e) { + this._didThrow = true; + throw e; + } + } + + private async _innerFulfill( + options: { + response?: api.APIResponse; + status?: number; + headers?: Headers; + contentType?: string; + body?: string | Buffer; + json?: any; + path?: string; + } = {} + ): Promise { + let fetchResponseUid; + let { status: statusOption, headers: headersOption, body } = options; + + if (options.json !== undefined) { + assert(options.body === undefined, "Can specify either body or json parameters"); + body = JSON.stringify(options.json); + } + + if (options.response instanceof APIResponse) { + statusOption ??= options.response.status(); + headersOption ??= options.response.headers(); + if (body === undefined && options.path === undefined) { + if (options.response._request._connection === this._connection) + fetchResponseUid = (options.response as APIResponse)._fetchUid(); + else body = await options.response.body(); + } + } + + let isBase64 = false; + let length = 0; + if (options.path) { + const buffer = await this._platform.fs().promises.readFile(options.path); + body = buffer.toString("base64"); + isBase64 = true; + length = buffer.length; + } else if (isString(body)) { + isBase64 = false; + length = Buffer.byteLength(body); + } else if (body) { + length = body.length; + body = body.toString("base64"); + isBase64 = true; + } + + const headers: Headers = {}; + for (const header of Object.keys(headersOption || {})) + headers[header.toLowerCase()] = String(headersOption![header]); + if (options.contentType) headers["content-type"] = String(options.contentType); + else if (options.json) headers["content-type"] = "application/json"; + else if (options.path) + headers["content-type"] = getMimeTypeForPath(options.path) || "application/octet-stream"; + if (length && !("content-length" in headers)) headers["content-length"] = String(length); + + await this._raceWithTargetClose( + this._channel.fulfill({ + status: statusOption || 200, + headers: headersObjectToArray(headers), + body, + isBase64, + fetchResponseUid, + }) + ); + } + + async continue(options: FallbackOverrides = {}) { + await this._handleRoute(async () => { + this.request()._applyFallbackOverrides(options); + await this._innerContinue(false /* isFallback */); + }); + } + + _checkNotHandled() { + if (!this._handlingPromise) throw new Error("Route is already handled!"); + } + + _reportHandled(done: boolean) { + const chain = this._handlingPromise!; + this._handlingPromise = null; + chain.resolve(done); + } + + async _innerContinue(isFallback: boolean) { + const options = this.request()._fallbackOverridesForContinue(); + return await this._raceWithTargetClose( + this._channel.continue({ + url: options.url, + method: options.method, + headers: options.headers ? headersObjectToArray(options.headers) : undefined, + postData: options.postDataBuffer, + isFallback, + }) + ); + } +} + +export class WebSocketRoute + extends ChannelOwner + implements api.WebSocketRoute +{ + static from(route: channels.WebSocketRouteChannel): WebSocketRoute { + return (route as any)._object; + } + + private _onPageMessage?: (message: string | Buffer) => any; + private _onPageClose?: (code: number | undefined, reason: string | undefined) => any; + private _onServerMessage?: (message: string | Buffer) => any; + private _onServerClose?: (code: number | undefined, reason: string | undefined) => any; + private _server: api.WebSocketRoute; + private _connected = false; + + constructor( + parent: ChannelOwner, + type: string, + guid: string, + initializer: channels.WebSocketRouteInitializer + ) { + super(parent, type, guid, initializer); + + this._server = { + onMessage: (handler: (message: string | Buffer) => any) => { + this._onServerMessage = handler; + }, + + onClose: (handler: (code: number | undefined, reason: string | undefined) => any) => { + this._onServerClose = handler; + }, + + connectToServer: () => { + throw new Error(`connectToServer must be called on the page-side WebSocketRoute`); + }, + + url: () => { + return this._initializer.url; + }, + + close: async (options: { code?: number; reason?: string } = {}) => { + await this._channel.closeServer({ ...options, wasClean: true }).catch(() => {}); + }, + + send: (message: string | Buffer) => { + if (isString(message)) + this._channel.sendToServer({ message, isBase64: false }).catch(() => {}); + else + this._channel + .sendToServer({ message: message.toString("base64"), isBase64: true }) + .catch(() => {}); + }, + + async [Symbol.asyncDispose]() { + await this.close(); + }, + }; + + this._channel.on("messageFromPage", ({ message, isBase64 }) => { + if (this._onPageMessage) + this._onPageMessage(isBase64 ? Buffer.from(message, "base64") : message); + else if (this._connected) this._channel.sendToServer({ message, isBase64 }).catch(() => {}); + }); + + this._channel.on("messageFromServer", ({ message, isBase64 }) => { + if (this._onServerMessage) + this._onServerMessage(isBase64 ? Buffer.from(message, "base64") : message); + else this._channel.sendToPage({ message, isBase64 }).catch(() => {}); + }); + + this._channel.on("closePage", ({ code, reason, wasClean }) => { + if (this._onPageClose) this._onPageClose(code, reason); + else this._channel.closeServer({ code, reason, wasClean }).catch(() => {}); + }); + + this._channel.on("closeServer", ({ code, reason, wasClean }) => { + if (this._onServerClose) this._onServerClose(code, reason); + else this._channel.closePage({ code, reason, wasClean }).catch(() => {}); + }); + } + + url() { + return this._initializer.url; + } + + async close(options: { code?: number; reason?: string } = {}) { + await this._channel.closePage({ ...options, wasClean: true }).catch(() => {}); + } + + connectToServer() { + if (this._connected) throw new Error("Already connected to the server"); + this._connected = true; + this._channel.connect().catch(() => {}); + return this._server; + } + + send(message: string | Buffer) { + if (isString(message)) this._channel.sendToPage({ message, isBase64: false }).catch(() => {}); + else + this._channel + .sendToPage({ message: message.toString("base64"), isBase64: true }) + .catch(() => {}); + } + + onMessage(handler: (message: string | Buffer) => any) { + this._onPageMessage = handler; + } + + onClose(handler: (code: number | undefined, reason: string | undefined) => any) { + this._onPageClose = handler; + } + + async [Symbol.asyncDispose]() { + await this.close(); + } + + async _afterHandle() { + if (this._connected) return; + // Ensure that websocket is "open" and can send messages without an actual server connection. + // If this happens after the page has been closed, ignore the error. + await this._channel.ensureOpened().catch(() => {}); + } +} + +export class WebSocketRouteHandler { + private readonly _baseURL: string | undefined; + readonly url: URLMatch; + readonly handler: WebSocketRouteHandlerCallback; + + constructor(baseURL: string | undefined, url: URLMatch, handler: WebSocketRouteHandlerCallback) { + this._baseURL = baseURL; + this.url = url; + this.handler = handler; + } + + static prepareInterceptionPatterns(handlers: WebSocketRouteHandler[]) { + const patterns: channels.BrowserContextSetWebSocketInterceptionPatternsParams["patterns"] = []; + let all = false; + for (const handler of handlers) { + const serialized = serializeURLMatch(handler.url); + if (serialized) patterns.push(serialized); + else all = true; + } + if (all) return [{ glob: "**/*" }]; + return patterns; + } + + public matches(wsURL: string): boolean { + return urlMatches(this._baseURL, wsURL, this.url, true); + } + + public async handle(webSocketRoute: WebSocketRoute) { + const handler = this.handler; + await handler(webSocketRoute); + await webSocketRoute._afterHandle(); + } +} + +export type RouteHandlerCallback = (route: Route, request: Request) => Promise | void; +export type WebSocketRouteHandlerCallback = (ws: WebSocketRoute) => Promise | void; + +export type ResourceTiming = { + startTime: number; + domainLookupStart: number; + domainLookupEnd: number; + connectStart: number; + secureConnectionStart: number; + connectEnd: number; + requestStart: number; + responseStart: number; + responseEnd: number; +}; + +export type RequestSizes = { + requestBodySize: number; + requestHeadersSize: number; + responseBodySize: number; + responseHeadersSize: number; +}; + +export class Response extends ChannelOwner implements api.Response { + private _provisionalHeaders: RawHeaders; + private _actualHeadersPromise: Promise | undefined; + private _request: Request; + readonly _finishedPromise = new ManualPromise(); + + static from(response: channels.ResponseChannel): Response { + return (response as any)._object; + } + + static fromNullable(response: channels.ResponseChannel | undefined): Response | null { + return response ? Response.from(response) : null; + } + + constructor( + parent: ChannelOwner, + type: string, + guid: string, + initializer: channels.ResponseInitializer + ) { + super(parent, type, guid, initializer); + this._provisionalHeaders = new RawHeaders(initializer.headers); + this._request = Request.from(this._initializer.request); + this._request._response = this; + Object.assign(this._request._timing, this._initializer.timing); + } + + url(): string { + return this._initializer.url; + } + + ok(): boolean { + // Status 0 is for file:// URLs + return ( + this._initializer.status === 0 || + (this._initializer.status >= 200 && this._initializer.status <= 299) + ); + } + + status(): number { + return this._initializer.status; + } + + statusText(): string { + return this._initializer.statusText; + } + + fromServiceWorker(): boolean { + return this._initializer.fromServiceWorker; + } + + /** + * @deprecated + */ + headers(): Headers { + return this._provisionalHeaders.headers(); + } + + async _actualHeaders(): Promise { + if (!this._actualHeadersPromise) { + this._actualHeadersPromise = (async () => { + return new RawHeaders((await this._channel.rawResponseHeaders()).headers); + })(); + } + return await this._actualHeadersPromise; + } + + async allHeaders(): Promise { + return (await this._actualHeaders()).headers(); + } + + async headersArray(): Promise { + return (await this._actualHeaders()).headersArray().slice(); + } + + async headerValue(name: string): Promise { + return (await this._actualHeaders()).get(name); + } + + async headerValues(name: string): Promise { + return (await this._actualHeaders()).getAll(name); + } + + async finished(): Promise { + return await this.request()._targetClosedScope().race(this._finishedPromise); + } + + async body(): Promise { + return (await this._channel.body()).binary; + } + + async text(): Promise { + const content = await this.body(); + return content.toString("utf8"); + } + + async json(): Promise { + const content = await this.text(); + return JSON.parse(content); + } + + request(): Request { + return this._request; + } + + frame(): Frame { + return this._request.frame(); + } + + async serverAddr(): Promise { + return (await this._channel.serverAddr()).value || null; + } + + async securityDetails(): Promise { + return (await this._channel.securityDetails()).value || null; + } + + async httpVersion(): Promise { + return (await this._channel.httpVersion()).value; + } +} + +export class WebSocket extends ChannelOwner implements api.WebSocket { + private _page: Page; + private _isClosed: boolean; + + static from(webSocket: channels.WebSocketChannel): WebSocket { + return (webSocket as any)._object; + } + + constructor( + parent: ChannelOwner, + type: string, + guid: string, + initializer: channels.WebSocketInitializer + ) { + super(parent, type, guid, initializer); + this._isClosed = false; + this._page = parent as Page; + this._channel.on("frameSent", (event) => { + if (event.opcode === 1) this.emit(Events.WebSocket.FrameSent, { payload: event.data }); + else if (event.opcode === 2) + this.emit(Events.WebSocket.FrameSent, { payload: Buffer.from(event.data, "base64") }); + }); + this._channel.on("frameReceived", (event) => { + if (event.opcode === 1) this.emit(Events.WebSocket.FrameReceived, { payload: event.data }); + else if (event.opcode === 2) + this.emit(Events.WebSocket.FrameReceived, { payload: Buffer.from(event.data, "base64") }); + }); + this._channel.on("socketError", ({ error }) => this.emit(Events.WebSocket.Error, error)); + this._channel.on("close", () => { + this._isClosed = true; + this.emit(Events.WebSocket.Close, this); + }); + } + + url(): string { + return this._initializer.url; + } + + isClosed(): boolean { + return this._isClosed; + } + + async waitForEvent(event: string, optionsOrPredicate: WaitForEventOptions = {}): Promise { + return await this._wrapApiCall(async () => { + const timeout = this._page._timeoutSettings.timeout( + typeof optionsOrPredicate === "function" ? {} : optionsOrPredicate + ); + const predicate = + typeof optionsOrPredicate === "function" + ? optionsOrPredicate + : optionsOrPredicate.predicate; + const waiter = Waiter.createForEvent(this, event); + waiter.rejectOnTimeout( + timeout, + `Timeout ${timeout}ms exceeded while waiting for event "${event}"` + ); + if (event !== Events.WebSocket.Error) + waiter.rejectOnEvent(this, Events.WebSocket.Error, new Error("Socket error")); + if (event !== Events.WebSocket.Close) + waiter.rejectOnEvent(this, Events.WebSocket.Close, new Error("Socket closed")); + waiter.rejectOnEvent(this._page, Events.Page.Close, () => this._page._closeErrorWithReason()); + const result = await waiter.waitForEvent(this, event, predicate as any); + waiter.dispose(); + return result; + }); + } +} + +export function validateHeaders(headers: Headers) { + for (const key of Object.keys(headers)) { + const value = headers[key]; + if (!Object.is(value, undefined) && !isString(value)) + throw new Error( + `Expected value of header "${key}" to be String, but "${typeof value}" is found.` + ); + } +} + +export class RouteHandler { + private handledCount = 0; + private readonly _baseURL: string | undefined; + private readonly _times: number; + readonly url: URLMatch; + readonly handler: RouteHandlerCallback; + private _ignoreException: boolean = false; + private _activeInvocations: Set<{ complete: Promise; route: Route }> = new Set(); + private _savedZone: Zone; + + constructor( + platform: Platform, + baseURL: string | undefined, + url: URLMatch, + handler: RouteHandlerCallback, + times: number = Number.MAX_SAFE_INTEGER + ) { + this._baseURL = baseURL; + this._times = times; + this.url = url; + this.handler = handler; + this._savedZone = platform.zones.current().pop(); + } + + static prepareInterceptionPatterns(handlers: RouteHandler[]) { + const patterns: channels.BrowserContextSetNetworkInterceptionPatternsParams["patterns"] = []; + let all = false; + for (const handler of handlers) { + const serialized = serializeURLMatch(handler.url); + if (serialized) patterns.push(serialized); + else all = true; + } + if (all) return [{ glob: "**/*" }]; + return patterns; + } + + public matches(requestURL: string): boolean { + return urlMatches(this._baseURL, requestURL, this.url); + } + + public async handle(route: Route): Promise { + return await this._savedZone.run(async () => this._handleImpl(route)); + } + + private async _handleImpl(route: Route): Promise { + const handlerInvocation = { complete: new ManualPromise(), route }; + this._activeInvocations.add(handlerInvocation); + try { + return await this._handleInternal(route); + } catch (e) { + // If the handler was stopped (without waiting for completion), we ignore all exceptions. + if (this._ignoreException) return false; + if (isTargetClosedError(e)) { + // We are failing in the handler because the target close closed. + // Give user a hint! + rewriteErrorMessage( + e, + `"${e.message}" while running route callback.\nConsider awaiting \`await page.unrouteAll({ behavior: 'ignoreErrors' })\`\nbefore the end of the test to ignore remaining routes in flight.` + ); + } + throw e; + } finally { + handlerInvocation.complete.resolve(); + this._activeInvocations.delete(handlerInvocation); + } + } + + async stop(behavior: "wait" | "ignoreErrors") { + // When a handler is manually unrouted or its page/context is closed we either + // - wait for the current handler invocations to finish + // - or do not wait, if the user opted out of it, but swallow all exceptions + // that happen after the unroute/close. + if (behavior === "ignoreErrors") { + this._ignoreException = true; + } else { + const promises = []; + for (const activation of this._activeInvocations) { + if (!activation.route._didThrow) promises.push(activation.complete); + } + await Promise.all(promises); + } + } + + private async _handleInternal(route: Route): Promise { + ++this.handledCount; + const handledPromise = route._startHandling(); + // Extract handler into a variable to avoid [RouteHandler.handler] in the stack. + const handler = this.handler; + const [handled] = await Promise.all([handledPromise, handler(route, route.request())]); + return handled; + } + + public willExpire(): boolean { + return this.handledCount + 1 >= this._times; + } +} + +export class RawHeaders { + private _headersArray: HeadersArray; + private _headersMap = new MultiMap(); + + static _fromHeadersObjectLossy(headers: Headers): RawHeaders { + const headersArray: HeadersArray = Object.entries(headers) + .map(([name, value]) => ({ + name, + value, + })) + .filter((header) => header.value !== undefined); + return new RawHeaders(headersArray); + } + + constructor(headers: HeadersArray) { + this._headersArray = headers; + for (const header of headers) this._headersMap.set(header.name.toLowerCase(), header.value); + } + + get(name: string): string | null { + const values = this.getAll(name); + if (!values || !values.length) return null; + return values.join(name.toLowerCase() === "set-cookie" ? "\n" : ", "); + } + + getAll(name: string): string[] { + return [...this._headersMap.get(name.toLowerCase())]; + } + + headers(): Headers { + const result: Headers = {}; + for (const name of this._headersMap.keys()) result[name] = this.get(name)!; + return result; + } + + headersArray(): HeadersArray { + return this._headersArray; + } +} diff --git a/daemon/src/sandbox/forked-client/src/client/page.ts b/daemon/src/sandbox/forked-client/src/client/page.ts new file mode 100644 index 00000000..a742b69a --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/page.ts @@ -0,0 +1,1201 @@ +// @ts-nocheck +/** + * Copyright 2017 Google Inc. All rights reserved. + * Modifications copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +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"; +import { Events } from "./events"; +import { FileChooser } from "./fileChooser"; +import { Frame, verifyLoadState } from "./frame"; +import { HarRouter } from "./harRouter"; +import { Keyboard, Mouse, Touchscreen } from "./input"; +import { JSHandle, assertMaxArguments, parseResult, serializeArgument } from "./jsHandle"; +import { + Request, + Response, + Route, + RouteHandler, + WebSocket, + WebSocketRoute, + WebSocketRouteHandler, + validateHeaders, +} from "./network"; +import { Video } from "./video"; +import { Screencast } from "./screencast"; +import { Waiter } from "./waiter"; +import { Worker } from "./worker"; +import { TimeoutSettings } from "./timeoutSettings"; +import { assert } from "../utils/isomorphic/assert"; +import { writeTempFile } from "./fileUtils"; +import { headersObjectToArray } from "../utils/isomorphic/headers"; +import { trimStringWithEllipsis } from "../utils/isomorphic/stringUtils"; +import { urlMatches, urlMatchesEqual } from "../utils/isomorphic/urlMatch"; +import { LongStandingScope } from "../utils/isomorphic/manualPromise"; +import { isObject, isRegExp, isString } from "../utils/isomorphic/rtti"; +import { ConsoleMessage } from "./consoleMessage"; +import type { BrowserContext } from "./browserContext"; +import type { Clock } from "./clock"; +import type { APIRequestContext } from "./fetch"; +import type { WaitForNavigationOptions } from "./frame"; +import type { FrameLocator, Locator, LocatorOptions } from "./locator"; +import type { RouteHandlerCallback, WebSocketRouteHandlerCallback } from "./network"; +import type { + FilePayload, + Headers, + LifecycleEvent, + SelectOption, + SelectOptionOptions, + Size, + TimeoutOptions, + WaitForEventOptions, + WaitForFunctionOptions, +} from "./types"; +import type * as structs from "../../types/structs"; +import type * as api from "../../types/types"; +import type { ByRoleOptions } from "../utils/isomorphic/locatorUtils"; +import type { URLMatch } from "../utils/isomorphic/urlMatch"; +import type * as channels from "../protocol/channels"; + +type PDFOptions = Omit & { + width?: string | number; + height?: string | number; + margin?: { + top?: string | number; + bottom?: string | number; + left?: string | number; + right?: string | number; + }; + path?: string; +}; + +export type ExpectScreenshotOptions = Omit< + channels.PageExpectScreenshotOptions, + "locator" | "expected" | "mask" +> & { + expected?: Buffer; + locator?: api.Locator; + timeout: number; + isNot: boolean; + mask?: api.Locator[]; +}; + +export class Page extends ChannelOwner implements api.Page { + private _browserContext: BrowserContext; + _ownedContext: BrowserContext | undefined; + + private _mainFrame: Frame; + private _frames = new Set(); + _workers = new Set(); + private _closed = false; + readonly _closedOrCrashedScope = new LongStandingScope(); + private _viewportSize: Size | undefined; + _routes: RouteHandler[] = []; + _webSocketRoutes: WebSocketRouteHandler[] = []; + + readonly coverage: Coverage; + readonly cua: Cua; + readonly domCua: DomCua; + readonly keyboard: Keyboard; + readonly mouse: Mouse; + readonly request: APIRequestContext; + readonly touchscreen: Touchscreen; + readonly clock: Clock; + readonly screencast: Screencast; + + readonly _bindings = new Map any>(); + readonly _timeoutSettings: TimeoutSettings; + private _video: Video; + readonly _opener: Page | null; + private _closeReason: string | undefined; + _closeWasCalled: boolean = false; + private _harRouters: HarRouter[] = []; + + private _locatorHandlers = new Map< + number, + { locator: Locator; handler: (locator: Locator) => any; times: number | undefined } + >(); + + static from(page: channels.PageChannel): Page { + return (page as any)._object; + } + + static fromNullable(page: channels.PageChannel | undefined): Page | null { + return page ? Page.from(page) : null; + } + + constructor( + parent: ChannelOwner, + type: string, + guid: string, + initializer: channels.PageInitializer + ) { + super(parent, type, guid, initializer); + this._instrumentation.onPage(this); + this._browserContext = parent as unknown as BrowserContext; + this._timeoutSettings = new TimeoutSettings( + this._platform, + 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; + this.touchscreen = new Touchscreen(this); + this.clock = this._browserContext.clock; + + this._mainFrame = Frame.from(initializer.mainFrame); + this._mainFrame._page = this; + this._frames.add(this._mainFrame); + this._viewportSize = initializer.viewportSize; + this._closed = initializer.isClosed; + this._opener = Page.fromNullable(initializer.opener); + this._video = new Video( + this, + this._connection, + initializer.video ? Artifact.from(initializer.video) : undefined + ); + this.screencast = new Screencast(this); + + this._channel.on("bindingCall", ({ binding }) => this._onBinding(BindingCall.from(binding))); + this._channel.on("close", () => this._onClose()); + this._channel.on("crash", () => this._onCrash()); + this._channel.on("download", ({ url, suggestedFilename, artifact }) => { + const artifactObject = Artifact.from(artifact); + this.emit(Events.Page.Download, new Download(this, url, suggestedFilename, artifactObject)); + }); + this._channel.on("fileChooser", ({ element, isMultiple }) => + this.emit( + Events.Page.FileChooser, + new FileChooser(this, ElementHandle.from(element), isMultiple) + ) + ); + this._channel.on("frameAttached", ({ frame }) => this._onFrameAttached(Frame.from(frame))); + this._channel.on("frameDetached", ({ frame }) => this._onFrameDetached(Frame.from(frame))); + this._channel.on("locatorHandlerTriggered", ({ uid }) => this._onLocatorHandlerTriggered(uid)); + this._channel.on("route", ({ route }) => this._onRoute(Route.from(route))); + this._channel.on("webSocketRoute", ({ webSocketRoute }) => + this._onWebSocketRoute(WebSocketRoute.from(webSocketRoute)) + ); + this._channel.on( + "viewportSizeChanged", + ({ viewportSize }) => (this._viewportSize = viewportSize) + ); + this._channel.on("webSocket", ({ webSocket }) => + this.emit(Events.Page.WebSocket, WebSocket.from(webSocket)) + ); + this._channel.on("worker", ({ worker }) => this._onWorker(Worker.from(worker))); + + this.coverage = new Coverage(this._channel); + + this.once(Events.Page.Close, () => + this._closedOrCrashedScope.close(this._closeErrorWithReason()) + ); + this.once(Events.Page.Crash, () => this._closedOrCrashedScope.close(new TargetClosedError())); + + this._setEventToSubscriptionMapping( + new Map([ + [Events.Page.Console, "console"], + [Events.Page.Dialog, "dialog"], + [Events.Page.Request, "request"], + [Events.Page.Response, "response"], + [Events.Page.RequestFinished, "requestFinished"], + [Events.Page.RequestFailed, "requestFailed"], + [Events.Page.FileChooser, "fileChooser"], + ]) + ); + } + + private _onFrameAttached(frame: Frame) { + frame._page = this; + this._frames.add(frame); + if (frame._parentFrame) frame._parentFrame._childFrames.add(frame); + this.emit(Events.Page.FrameAttached, frame); + } + + private _onFrameDetached(frame: Frame) { + this._frames.delete(frame); + frame._detached = true; + if (frame._parentFrame) frame._parentFrame._childFrames.delete(frame); + this.emit(Events.Page.FrameDetached, frame); + } + + private async _onRoute(route: Route) { + route._context = this.context(); + const routeHandlers = this._routes.slice(); + for (const routeHandler of routeHandlers) { + // If the page was closed we stall all requests right away. + if (this._closeWasCalled || this._browserContext.isClosedOrClosing()) return; + if (!routeHandler.matches(route.request().url())) continue; + const index = this._routes.indexOf(routeHandler); + if (index === -1) continue; + if (routeHandler.willExpire()) this._routes.splice(index, 1); + const handled = await routeHandler.handle(route); + if (!this._routes.length) + this._updateInterceptionPatterns({ internal: true }).catch(() => {}); + if (handled) return; + } + + await this._browserContext._onRoute(route); + } + + private async _onWebSocketRoute(webSocketRoute: WebSocketRoute) { + const routeHandler = this._webSocketRoutes.find((route) => route.matches(webSocketRoute.url())); + if (routeHandler) await routeHandler.handle(webSocketRoute); + else await this._browserContext._onWebSocketRoute(webSocketRoute); + } + + async _onBinding(bindingCall: BindingCall) { + const func = this._bindings.get(bindingCall._initializer.name); + if (func) { + await bindingCall.call(func); + return; + } + await this._browserContext._onBinding(bindingCall); + } + + _onWorker(worker: Worker): void { + this._workers.add(worker); + worker._page = this; + this.emit(Events.Page.Worker, worker); + } + + _onClose() { + this._closed = true; + this._browserContext._pages.delete(this); + this._disposeHarRouters(); + this.emit(Events.Page.Close, this); + } + + private _onCrash() { + this.emit(Events.Page.Crash, this); + } + + context(): BrowserContext { + return this._browserContext; + } + + async opener(): Promise { + if (!this._opener || this._opener.isClosed()) return null; + return this._opener; + } + + mainFrame(): Frame { + return this._mainFrame; + } + + frame(frameSelector: string | { name?: string; url?: URLMatch }): Frame | null { + const name = isString(frameSelector) ? frameSelector : frameSelector.name; + const url = isObject(frameSelector) ? frameSelector.url : undefined; + assert(name || url, "Either name or url matcher should be specified"); + return ( + this.frames().find((f) => { + if (name) return f.name() === name; + return urlMatches(this._browserContext._options.baseURL, f.url(), url); + }) || null + ); + } + + frames(): Frame[] { + return [...this._frames]; + } + + setDefaultNavigationTimeout(timeout: number) { + this._timeoutSettings.setDefaultNavigationTimeout(timeout); + } + + setDefaultTimeout(timeout: number) { + this._timeoutSettings.setDefaultTimeout(timeout); + } + + video(): Video { + return this._video; + } + + async pickLocator(): Promise { + const { selector } = await this._channel.pickLocator({}); + return this.locator(selector); + } + + async cancelPickLocator(): Promise { + await this._channel.cancelPickLocator({}); + } + + async $( + selector: string, + options?: { strict?: boolean } + ): Promise | null> { + return await this._mainFrame.$(selector, options); + } + + waitForSelector( + selector: string, + options: channels.FrameWaitForSelectorOptions & + TimeoutOptions & { state: "attached" | "visible" } + ): Promise>; + waitForSelector( + selector: string, + options?: channels.FrameWaitForSelectorOptions & TimeoutOptions + ): Promise | null>; + async waitForSelector( + selector: string, + options?: channels.FrameWaitForSelectorOptions & TimeoutOptions + ): Promise | null> { + return await this._mainFrame.waitForSelector(selector, options); + } + + async dispatchEvent( + selector: string, + type: string, + eventInit?: any, + options?: channels.FrameDispatchEventOptions + ): Promise { + return await this._mainFrame.dispatchEvent(selector, type, eventInit, options); + } + + async evaluateHandle( + pageFunction: structs.PageFunction, + arg?: Arg + ): Promise> { + assertMaxArguments(arguments.length, 2); + return await this._mainFrame.evaluateHandle(pageFunction, arg); + } + + async $eval( + selector: string, + pageFunction: structs.PageFunctionOn, + arg?: Arg + ): Promise { + assertMaxArguments(arguments.length, 3); + return await this._mainFrame.$eval(selector, pageFunction, arg); + } + + async $$eval( + selector: string, + pageFunction: structs.PageFunctionOn, + arg?: Arg + ): Promise { + assertMaxArguments(arguments.length, 3); + return await this._mainFrame.$$eval(selector, pageFunction, arg); + } + + async $$(selector: string): Promise[]> { + return await this._mainFrame.$$(selector); + } + + async addScriptTag( + options: { url?: string; path?: string; content?: string; type?: string } = {} + ): Promise { + return await this._mainFrame.addScriptTag(options); + } + + async addStyleTag( + options: { url?: string; path?: string; content?: string } = {} + ): Promise { + return await this._mainFrame.addStyleTag(options); + } + + async exposeFunction(name: string, callback: Function) { + const result = await this._channel.exposeBinding({ name }); + const binding = (source: structs.BindingSource, ...args: any[]) => callback(...args); + this._bindings.set(name, binding); + return DisposableObject.from(result.disposable); + } + + async exposeBinding( + name: string, + callback: (source: structs.BindingSource, ...args: any[]) => any, + options: { handle?: boolean } = {} + ) { + const result = await this._channel.exposeBinding({ name, needsHandle: options.handle }); + this._bindings.set(name, callback); + return DisposableObject.from(result.disposable); + } + + async setExtraHTTPHeaders(headers: Headers) { + validateHeaders(headers); + await this._channel.setExtraHTTPHeaders({ headers: headersObjectToArray(headers) }); + } + + url(): string { + return this._mainFrame.url(); + } + + async content(): Promise { + return await this._mainFrame.content(); + } + + async setContent( + html: string, + options?: channels.FrameSetContentOptions & TimeoutOptions + ): Promise { + return await this._mainFrame.setContent(html, options); + } + + async goto( + url: string, + options?: channels.FrameGotoOptions & TimeoutOptions + ): Promise { + return await this._mainFrame.goto(url, options); + } + + async reload( + options: channels.PageReloadOptions & TimeoutOptions = {} + ): Promise { + const waitUntil = verifyLoadState( + "waitUntil", + options.waitUntil === undefined ? "load" : options.waitUntil + ); + return Response.fromNullable( + ( + await this._channel.reload({ + ...options, + waitUntil, + timeout: this._timeoutSettings.navigationTimeout(options), + }) + ).response + ); + } + + async addLocatorHandler( + locator: Locator, + handler: (locator: Locator) => any, + options: { times?: number; noWaitAfter?: boolean } = {} + ): Promise { + if (locator._frame !== this._mainFrame) + throw new Error(`Locator must belong to the main frame of this page`); + if (options.times === 0) return; + const { uid } = await this._channel.registerLocatorHandler({ + selector: locator._selector, + noWaitAfter: options.noWaitAfter, + }); + this._locatorHandlers.set(uid, { locator, handler, times: options.times }); + } + + private async _onLocatorHandlerTriggered(uid: number) { + let remove = false; + try { + const handler = this._locatorHandlers.get(uid); + if (handler && handler.times !== 0) { + if (handler.times !== undefined) handler.times--; + await handler.handler(handler.locator); + } + remove = handler?.times === 0; + } finally { + if (remove) this._locatorHandlers.delete(uid); + this._channel.resolveLocatorHandlerNoReply({ uid, remove }).catch(() => {}); + } + } + + async removeLocatorHandler(locator: Locator): Promise { + for (const [uid, data] of this._locatorHandlers) { + if (data.locator._equals(locator)) { + this._locatorHandlers.delete(uid); + await this._channel.unregisterLocatorHandler({ uid }).catch(() => {}); + } + } + } + + async waitForLoadState(state?: LifecycleEvent, options?: TimeoutOptions): Promise { + return await this._mainFrame.waitForLoadState(state, options); + } + + async waitForNavigation(options?: WaitForNavigationOptions): Promise { + return await this._mainFrame.waitForNavigation(options); + } + + async waitForURL( + url: URLMatch, + options?: TimeoutOptions & { waitUntil?: LifecycleEvent } + ): Promise { + return await this._mainFrame.waitForURL(url, options); + } + + async waitForRequest( + urlOrPredicate: string | RegExp | ((r: Request) => boolean | Promise), + options: TimeoutOptions = {} + ): Promise { + const predicate = async (request: Request) => { + if (isString(urlOrPredicate) || isRegExp(urlOrPredicate)) + return urlMatches(this._browserContext._options.baseURL, request.url(), urlOrPredicate); + return await urlOrPredicate(request); + }; + const trimmedUrl = trimUrl(urlOrPredicate); + const logLine = trimmedUrl ? `waiting for request ${trimmedUrl}` : undefined; + return await this._waitForEvent( + Events.Page.Request, + { predicate, timeout: options.timeout }, + logLine + ); + } + + async waitForResponse( + urlOrPredicate: string | RegExp | ((r: Response) => boolean | Promise), + options: TimeoutOptions = {} + ): Promise { + const predicate = async (response: Response) => { + if (isString(urlOrPredicate) || isRegExp(urlOrPredicate)) + return urlMatches(this._browserContext._options.baseURL, response.url(), urlOrPredicate); + return await urlOrPredicate(response); + }; + const trimmedUrl = trimUrl(urlOrPredicate); + const logLine = trimmedUrl ? `waiting for response ${trimmedUrl}` : undefined; + return await this._waitForEvent( + Events.Page.Response, + { predicate, timeout: options.timeout }, + logLine + ); + } + + async waitForEvent(event: string, optionsOrPredicate: WaitForEventOptions = {}): Promise { + return await this._waitForEvent(event, optionsOrPredicate, `waiting for event "${event}"`); + } + + _closeErrorWithReason(): TargetClosedError { + return new TargetClosedError(this._closeReason || this._browserContext._effectiveCloseReason()); + } + + private async _waitForEvent( + event: string, + optionsOrPredicate: WaitForEventOptions, + logLine?: string + ): Promise { + return await this._wrapApiCall(async () => { + const timeout = this._timeoutSettings.timeout( + typeof optionsOrPredicate === "function" ? {} : optionsOrPredicate + ); + const predicate = + typeof optionsOrPredicate === "function" + ? optionsOrPredicate + : optionsOrPredicate.predicate; + const waiter = Waiter.createForEvent(this, event); + if (logLine) waiter.log(logLine); + waiter.rejectOnTimeout( + timeout, + `Timeout ${timeout}ms exceeded while waiting for event "${event}"` + ); + if (event !== Events.Page.Crash) + waiter.rejectOnEvent(this, Events.Page.Crash, new Error("Page crashed")); + if (event !== Events.Page.Close) + waiter.rejectOnEvent(this, Events.Page.Close, () => this._closeErrorWithReason()); + const result = await waiter.waitForEvent(this, event, predicate as any); + waiter.dispose(); + return result; + }); + } + + async goBack( + options: channels.PageGoBackOptions & TimeoutOptions = {} + ): Promise { + const waitUntil = verifyLoadState( + "waitUntil", + options.waitUntil === undefined ? "load" : options.waitUntil + ); + return Response.fromNullable( + ( + await this._channel.goBack({ + ...options, + waitUntil, + timeout: this._timeoutSettings.navigationTimeout(options), + }) + ).response + ); + } + + async goForward( + options: channels.PageGoForwardOptions & TimeoutOptions = {} + ): Promise { + const waitUntil = verifyLoadState( + "waitUntil", + options.waitUntil === undefined ? "load" : options.waitUntil + ); + return Response.fromNullable( + ( + await this._channel.goForward({ + ...options, + waitUntil, + timeout: this._timeoutSettings.navigationTimeout(options), + }) + ).response + ); + } + + async requestGC() { + await this._channel.requestGC(); + } + + async emulateMedia( + options: { + media?: "screen" | "print" | null; + colorScheme?: "dark" | "light" | "no-preference" | null; + reducedMotion?: "reduce" | "no-preference" | null; + forcedColors?: "active" | "none" | null; + contrast?: "no-preference" | "more" | null; + } = {} + ) { + await this._channel.emulateMedia({ + media: options.media === null ? "no-override" : options.media, + colorScheme: options.colorScheme === null ? "no-override" : options.colorScheme, + reducedMotion: options.reducedMotion === null ? "no-override" : options.reducedMotion, + forcedColors: options.forcedColors === null ? "no-override" : options.forcedColors, + contrast: options.contrast === null ? "no-override" : options.contrast, + }); + } + + async setViewportSize(viewportSize: Size) { + this._viewportSize = viewportSize; + await this._channel.setViewportSize({ viewportSize }); + } + + viewportSize(): Size | null { + return this._viewportSize || null; + } + + async evaluate(pageFunction: structs.PageFunction, arg?: Arg): Promise { + assertMaxArguments(arguments.length, 2); + return await this._mainFrame.evaluate(pageFunction, arg); + } + + async addInitScript(script: Function | string | { path?: string; content?: string }, arg?: any) { + const source = await evaluationScript(this._platform, script, arg); + return DisposableObject.from((await this._channel.addInitScript({ source })).disposable); + } + + async route( + url: URLMatch, + handler: RouteHandlerCallback, + options: { times?: number } = {} + ): Promise { + this._routes.unshift( + new RouteHandler( + this._platform, + this._browserContext._options.baseURL, + url, + handler, + options.times + ) + ); + await this._updateInterceptionPatterns({ title: "Route requests" }); + return new DisposableStub(() => this.unroute(url, handler)); + } + + async routeFromHAR( + har: string, + options: { + url?: string | RegExp; + notFound?: "abort" | "fallback"; + update?: boolean; + updateContent?: "attach" | "embed"; + updateMode?: "minimal" | "full"; + } = {} + ): Promise { + const localUtils = this._connection.localUtils(); + if (!localUtils) throw new Error("Route from har is not supported in thin clients"); + if (options.update) { + await this._browserContext._recordIntoHAR(har, this, options); + return; + } + const harRouter = await HarRouter.create(localUtils, har, options.notFound || "abort", { + urlMatch: options.url, + }); + this._harRouters.push(harRouter); + await harRouter.addPageRoute(this); + } + + async routeWebSocket(url: URLMatch, handler: WebSocketRouteHandlerCallback): Promise { + this._webSocketRoutes.unshift( + new WebSocketRouteHandler(this._browserContext._options.baseURL, url, handler) + ); + await this._updateWebSocketInterceptionPatterns({ title: "Route WebSockets" }); + } + + private _disposeHarRouters() { + this._harRouters.forEach((router) => router.dispose()); + this._harRouters = []; + } + + async unrouteAll(options?: { behavior?: "wait" | "ignoreErrors" | "default" }): Promise { + await this._unrouteInternal(this._routes, [], options?.behavior); + this._disposeHarRouters(); + } + + async unroute(url: URLMatch, handler?: RouteHandlerCallback): Promise { + const removed = []; + const remaining = []; + for (const route of this._routes) { + if (urlMatchesEqual(route.url, url) && (!handler || route.handler === handler)) + removed.push(route); + else remaining.push(route); + } + await this._unrouteInternal(removed, remaining, "default"); + } + + private async _unrouteInternal( + removed: RouteHandler[], + remaining: RouteHandler[], + behavior?: "wait" | "ignoreErrors" | "default" + ): Promise { + this._routes = remaining; + if (behavior && behavior !== "default") { + const promises = removed.map((routeHandler) => routeHandler.stop(behavior)); + await Promise.all(promises); + } + await this._updateInterceptionPatterns({ title: "Unroute requests" }); + } + + private async _updateInterceptionPatterns(options: { internal: true } | { title: string }) { + const patterns = RouteHandler.prepareInterceptionPatterns(this._routes); + await this._wrapApiCall( + () => this._channel.setNetworkInterceptionPatterns({ patterns }), + options + ); + } + + private async _updateWebSocketInterceptionPatterns( + options: { internal: true } | { title: string } + ) { + const patterns = WebSocketRouteHandler.prepareInterceptionPatterns(this._webSocketRoutes); + await this._wrapApiCall( + () => this._channel.setWebSocketInterceptionPatterns({ patterns }), + options + ); + } + + async screenshot( + options: Omit & + TimeoutOptions & { path?: string; mask?: api.Locator[] } = {} + ): Promise { + const mask = options.mask as Locator[] | undefined; + const copy: channels.PageScreenshotParams = { + ...options, + path: undefined, + mask: undefined, + timeout: this._timeoutSettings.timeout(options), + }; + if (!copy.type) copy.type = determineScreenshotType(options); + if (mask) { + copy.mask = mask.map((locator) => ({ + frame: locator._frame._channel, + selector: locator._selector, + })); + } + const result = await this._channel.screenshot(copy); + if (options.path) { + options.path = await writeTempFile(options.path, result.binary); + } + return result.binary; + } + + async _expectScreenshot(options: ExpectScreenshotOptions): Promise<{ + actual?: Buffer; + previous?: Buffer; + diff?: Buffer; + errorMessage?: string; + log?: string[]; + timedOut?: boolean; + }> { + const mask = options?.mask + ? options?.mask.map((locator) => ({ + frame: (locator as Locator)._frame._channel, + selector: (locator as Locator)._selector, + })) + : undefined; + const locator = options.locator + ? { + frame: (options.locator as Locator)._frame._channel, + selector: (options.locator as Locator)._selector, + } + : undefined; + return await this._channel.expectScreenshot({ + ...options, + isNot: !!options.isNot, + locator, + mask, + }); + } + + async title(): Promise { + return await this._mainFrame.title(); + } + + async bringToFront(): Promise { + await this._channel.bringToFront(); + } + + async [Symbol.asyncDispose]() { + await this.close(); + } + + async close(options: { runBeforeUnload?: boolean; reason?: string } = {}) { + this._closeReason = options.reason; + if (!options.runBeforeUnload) this._closeWasCalled = true; + try { + if (this._ownedContext) await this._ownedContext.close(); + else await this._channel.close(options); + } catch (e) { + if (isTargetClosedError(e) && !options.runBeforeUnload) return; + throw e; + } + } + + isClosed(): boolean { + return this._closed; + } + + async click(selector: string, options?: channels.FrameClickOptions & TimeoutOptions) { + return await this._mainFrame.click(selector, options); + } + + async dragAndDrop( + source: string, + target: string, + options?: channels.FrameDragAndDropOptions & TimeoutOptions + ) { + return await this._mainFrame.dragAndDrop(source, target, options); + } + + async dblclick(selector: string, options?: channels.FrameDblclickOptions & TimeoutOptions) { + await this._mainFrame.dblclick(selector, options); + } + + async tap(selector: string, options?: channels.FrameTapOptions & TimeoutOptions) { + return await this._mainFrame.tap(selector, options); + } + + async fill( + selector: string, + value: string, + options?: channels.FrameFillOptions & TimeoutOptions + ) { + return await this._mainFrame.fill(selector, value, options); + } + + async clearConsoleMessages(): Promise { + await this._channel.clearConsoleMessages(); + } + + async consoleMessages(options?: { + filter?: "all" | "sinceNavigation"; + }): Promise { + const { messages } = await this._channel.consoleMessages({ filter: options?.filter }); + return messages.map((message) => new ConsoleMessage(this._platform, message, this, null)); + } + + async clearPageErrors(): Promise { + await this._channel.clearPageErrors(); + } + + async pageErrors(options?: { filter?: "all" | "sinceNavigation" }): Promise { + const { errors } = await this._channel.pageErrors({ filter: options?.filter }); + return errors.map((error) => parseError(error)); + } + + locator(selector: string, options?: LocatorOptions): Locator { + 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); + } + + getByAltText(text: string | RegExp, options?: { exact?: boolean }): Locator { + return this.mainFrame().getByAltText(text, options); + } + + getByLabel(text: string | RegExp, options?: { exact?: boolean }): Locator { + return this.mainFrame().getByLabel(text, options); + } + + getByPlaceholder(text: string | RegExp, options?: { exact?: boolean }): Locator { + return this.mainFrame().getByPlaceholder(text, options); + } + + getByText(text: string | RegExp, options?: { exact?: boolean }): Locator { + return this.mainFrame().getByText(text, options); + } + + getByTitle(text: string | RegExp, options?: { exact?: boolean }): Locator { + return this.mainFrame().getByTitle(text, options); + } + + getByRole(role: string, options: ByRoleOptions = {}): Locator { + return this.mainFrame().getByRole(role, options); + } + + frameLocator(selector: string): FrameLocator { + return this.mainFrame().frameLocator(selector); + } + + async focus(selector: string, options?: channels.FrameFocusOptions & TimeoutOptions) { + return await this._mainFrame.focus(selector, options); + } + + async textContent( + selector: string, + options?: channels.FrameTextContentOptions & TimeoutOptions + ): Promise { + return await this._mainFrame.textContent(selector, options); + } + + async innerText( + selector: string, + options?: channels.FrameInnerTextOptions & TimeoutOptions + ): Promise { + return await this._mainFrame.innerText(selector, options); + } + + async innerHTML( + selector: string, + options?: channels.FrameInnerHTMLOptions & TimeoutOptions + ): Promise { + return await this._mainFrame.innerHTML(selector, options); + } + + async getAttribute( + selector: string, + name: string, + options?: channels.FrameGetAttributeOptions & TimeoutOptions + ): Promise { + return await this._mainFrame.getAttribute(selector, name, options); + } + + async inputValue( + selector: string, + options?: channels.FrameInputValueOptions & TimeoutOptions + ): Promise { + return await this._mainFrame.inputValue(selector, options); + } + + async isChecked( + selector: string, + options?: channels.FrameIsCheckedOptions & TimeoutOptions + ): Promise { + return await this._mainFrame.isChecked(selector, options); + } + + async isDisabled( + selector: string, + options?: channels.FrameIsDisabledOptions & TimeoutOptions + ): Promise { + return await this._mainFrame.isDisabled(selector, options); + } + + async isEditable( + selector: string, + options?: channels.FrameIsEditableOptions & TimeoutOptions + ): Promise { + return await this._mainFrame.isEditable(selector, options); + } + + async isEnabled( + selector: string, + options?: channels.FrameIsEnabledOptions & TimeoutOptions + ): Promise { + return await this._mainFrame.isEnabled(selector, options); + } + + async isHidden( + selector: string, + options?: channels.FrameIsHiddenOptions & TimeoutOptions + ): Promise { + return await this._mainFrame.isHidden(selector, options); + } + + async isVisible( + selector: string, + options?: channels.FrameIsVisibleOptions & TimeoutOptions + ): Promise { + return await this._mainFrame.isVisible(selector, options); + } + + async hover(selector: string, options?: channels.FrameHoverOptions & TimeoutOptions) { + return await this._mainFrame.hover(selector, options); + } + + async selectOption( + selector: string, + values: + | string + | api.ElementHandle + | SelectOption + | string[] + | api.ElementHandle[] + | SelectOption[] + | null, + options?: SelectOptionOptions + ): Promise { + return await this._mainFrame.selectOption(selector, values, options); + } + + async setInputFiles( + selector: string, + files: string | FilePayload | string[] | FilePayload[], + options?: channels.FrameSetInputFilesOptions & TimeoutOptions + ): Promise { + return await this._mainFrame.setInputFiles(selector, files, options); + } + + async type(selector: string, text: string, options?: channels.FrameTypeOptions & TimeoutOptions) { + return await this._mainFrame.type(selector, text, options); + } + + async press( + selector: string, + key: string, + options?: channels.FramePressOptions & TimeoutOptions + ) { + return await this._mainFrame.press(selector, key, options); + } + + async check(selector: string, options?: channels.FrameCheckOptions & TimeoutOptions) { + return await this._mainFrame.check(selector, options); + } + + async uncheck(selector: string, options?: channels.FrameUncheckOptions & TimeoutOptions) { + return await this._mainFrame.uncheck(selector, options); + } + + async setChecked( + selector: string, + checked: boolean, + options?: channels.FrameCheckOptions & TimeoutOptions + ) { + return await this._mainFrame.setChecked(selector, checked, options); + } + + async waitForTimeout(timeout: number) { + return await this._mainFrame.waitForTimeout(timeout); + } + + async waitForFunction( + pageFunction: structs.PageFunction, + arg?: Arg, + options?: WaitForFunctionOptions + ): Promise> { + return await this._mainFrame.waitForFunction(pageFunction, arg, options); + } + + async requests() { + const { requests } = await this._channel.requests(); + return requests.map((request) => Request.from(request)); + } + + workers(): Worker[] { + return [...this._workers]; + } + + async pause(_options?: { __testHookKeepTestTimeout: boolean }) { + if (this._platform.isJSDebuggerAttached()) return; + const defaultNavigationTimeout = + this._browserContext._timeoutSettings.defaultNavigationTimeout(); + const defaultTimeout = this._browserContext._timeoutSettings.defaultTimeout(); + this._browserContext.setDefaultNavigationTimeout(0); + this._browserContext.setDefaultTimeout(0); + this._instrumentation?.onWillPause({ keepTestTimeout: !!_options?.__testHookKeepTestTimeout }); + await this._closedOrCrashedScope.safeRace(this.context()._channel.pause()); + this._browserContext.setDefaultNavigationTimeout(defaultNavigationTimeout); + this._browserContext.setDefaultTimeout(defaultTimeout); + } + + async pdf(options: PDFOptions = {}): Promise { + const transportOptions: channels.PagePdfParams = { ...options } as channels.PagePdfParams; + if (transportOptions.margin) transportOptions.margin = { ...transportOptions.margin }; + if (typeof options.width === "number") transportOptions.width = options.width + "px"; + if (typeof options.height === "number") transportOptions.height = options.height + "px"; + for (const margin of ["top", "right", "bottom", "left"]) { + const index = margin as "top" | "right" | "bottom" | "left"; + if (options.margin && typeof options.margin[index] === "number") + transportOptions.margin![index] = transportOptions.margin![index] + "px"; + } + const result = await this._channel.pdf(transportOptions); + if (options.path) { + const platform = this._platform; + await platform + .fs() + .promises.mkdir(platform.path().dirname(options.path), { recursive: true }); + await platform.fs().promises.writeFile(options.path, result.pdf); + } + return result.pdf; + } + + async snapshotForAI( + options: TimeoutOptions & { track?: string; depth?: number } = {} + ): Promise<{ full: string; incremental?: string }> { + return await this._channel.snapshotForAI({ + timeout: this._timeoutSettings.timeout(options), + track: options.track, + depth: options.depth, + }); + } + + async _setDockTile(image: Buffer) { + await this._channel.setDockTile({ image }); + } +} + +export class BindingCall extends ChannelOwner { + static from(channel: channels.BindingCallChannel): BindingCall { + return (channel as any)._object; + } + + constructor( + parent: ChannelOwner, + type: string, + guid: string, + initializer: channels.BindingCallInitializer + ) { + super(parent, type, guid, initializer); + } + + async call(func: (source: structs.BindingSource, ...args: any[]) => any) { + try { + const frame = Frame.from(this._initializer.frame); + const source = { + context: frame._page!.context(), + page: frame._page!, + frame, + }; + let result: any; + if (this._initializer.handle) + result = await func(source, JSHandle.from(this._initializer.handle)); + else result = await func(source, ...this._initializer.args!.map(parseResult)); + this._channel.resolve({ result: serializeArgument(result) }).catch(() => {}); + } catch (e) { + this._channel.reject({ error: serializeError(e) }).catch(() => {}); + } + } +} + +function trimUrl(param: any): string | undefined { + if (isRegExp(param)) return `/${trimStringWithEllipsis(param.source, 50)}/${param.flags}`; + if (isString(param)) return `"${trimStringWithEllipsis(param, 50)}"`; +} diff --git a/daemon/src/sandbox/forked-client/src/client/platform.ts b/daemon/src/sandbox/forked-client/src/client/platform.ts new file mode 100644 index 00000000..9574e458 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/platform.ts @@ -0,0 +1,131 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { webColors } from "../utils/isomorphic/colors"; + +import type * as fs from "fs"; +import type * as path from "path"; +import type { Readable, Writable } from "stream"; +import type { Colors } from "../utils/isomorphic/colors"; +import type * as channels from "../protocol/channels"; + +export type Zone = { + push(data: unknown): Zone; + pop(): Zone; + run(func: () => R): R; + data(): T | undefined; +}; + +const noopZone: Zone = { + push: () => noopZone, + pop: () => noopZone, + run: (func) => func(), + data: () => undefined, +}; + +export type Platform = { + name: "node" | "web" | "empty"; + + boxedStackPrefixes: () => string[]; + calculateSha1: (text: string) => Promise; + colors: Colors; + coreDir?: string; + createGuid: () => string; + defaultMaxListeners: () => number; + env: Record; + fs: () => typeof fs; + inspectCustom: symbol | undefined; + isDebugMode: () => boolean; + isJSDebuggerAttached: () => boolean; + isLogEnabled: (name: "api" | "channel") => boolean; + isUnderTest: () => boolean; + log: (name: "api" | "channel", message: string | Error | object) => void; + path: () => typeof path; + pathSeparator: string; + showInternalStackFrames: () => boolean; + streamFile: (path: string, writable: Writable) => Promise; + streamReadable: (channel: channels.StreamChannel) => Readable; + streamWritable: (channel: channels.WritableStreamChannel) => Writable; + zodToJsonSchema: (schema: any) => any; + zones: { empty: Zone; current: () => Zone }; +}; + +export const emptyPlatform: Platform = { + name: "empty", + + boxedStackPrefixes: () => [], + + calculateSha1: async () => { + throw new Error("Not implemented"); + }, + + colors: webColors, + + createGuid: () => { + throw new Error("Not implemented"); + }, + + defaultMaxListeners: () => 10, + + env: {}, + + fs: () => { + throw new Error("Not implemented"); + }, + + inspectCustom: undefined, + + isDebugMode: () => false, + + isJSDebuggerAttached: () => false, + + isLogEnabled(name: "api" | "channel") { + return false; + }, + + isUnderTest: () => false, + + log(name: "api" | "channel", message: string | Error | object) {}, + + path: () => { + throw new Error("Function not implemented."); + }, + + pathSeparator: "/", + + showInternalStackFrames: () => false, + + streamFile(path: string, writable: Writable): Promise { + throw new Error("Streams are not available"); + }, + + streamReadable: (channel: channels.StreamChannel) => { + throw new Error("Streams are not available"); + }, + + streamWritable: (channel: channels.WritableStreamChannel) => { + throw new Error("Streams are not available"); + }, + + zodToJsonSchema: (schema: any) => { + throw new Error("Zod is not available"); + }, + + zones: { empty: noopZone, current: () => noopZone }, +}; + +export { quickjsPlatform } from "../../quickjs-platform"; diff --git a/daemon/src/sandbox/forked-client/src/client/playwright.ts b/daemon/src/sandbox/forked-client/src/client/playwright.ts new file mode 100644 index 00000000..b1e37f43 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/playwright.ts @@ -0,0 +1,94 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Android } from "./android"; +import { Browser } from "./browser"; +import { BrowserType } from "./browserType"; +import { ChannelOwner } from "./channelOwner"; +import { Electron } from "./electron"; +import { TimeoutError } from "./errors"; +import { APIRequest } from "./fetch"; +import { Selectors } from "./selectors"; + +import type * as channels from "../protocol/channels"; +import type { LaunchOptions } from "../../types/types"; + +export class Playwright extends ChannelOwner { + readonly _android: Android; + readonly _electron: Electron; + readonly chromium: BrowserType; + readonly firefox: BrowserType; + readonly webkit: BrowserType; + readonly devices: any; + selectors: Selectors; + readonly request: APIRequest; + readonly errors: { TimeoutError: typeof TimeoutError }; + + // Instrumentation. + _defaultLaunchOptions?: LaunchOptions; + _defaultContextTimeout?: number; + _defaultContextNavigationTimeout?: number; + + constructor( + parent: ChannelOwner, + type: string, + guid: string, + initializer: channels.PlaywrightInitializer + ) { + super(parent, type, guid, initializer); + this.request = new APIRequest(this); + this.chromium = BrowserType.from(initializer.chromium); + this.chromium._playwright = this; + this.firefox = BrowserType.from(initializer.firefox); + this.firefox._playwright = this; + this.webkit = BrowserType.from(initializer.webkit); + this.webkit._playwright = this; + this._android = Android.from(initializer.android); + this._android._playwright = this; + this._electron = Electron.from(initializer.electron); + this._electron._playwright = this; + this.devices = this._connection.localUtils()?.devices ?? {}; + this.selectors = new Selectors(this._connection._platform); + this.errors = { TimeoutError }; + } + + static from(channel: channels.PlaywrightChannel): Playwright { + return (channel as any)._object; + } + + private _browserTypes(): BrowserType[] { + return [this.chromium, this.firefox, this.webkit]; + } + + _preLaunchedBrowser(): Browser { + const browser = Browser.from(this._initializer.preLaunchedBrowser!); + browser._connectToBrowserType( + this[browser._name as "chromium" | "firefox" | "webkit"], + {}, + undefined + ); + return browser; + } + + _allContexts() { + return this._browserTypes().flatMap((type) => [...type._contexts]); + } + + _allPages() { + return this._allContexts().flatMap((context) => context.pages()); + } +} diff --git a/daemon/src/sandbox/forked-client/src/client/screencast.ts b/daemon/src/sandbox/forked-client/src/client/screencast.ts new file mode 100644 index 00000000..dd3ad2a9 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/screencast.ts @@ -0,0 +1,12 @@ +// @ts-nocheck +export class Screencast { + constructor(page) { + this._page = page; + } + + async start() { + throw new Error("Screencast is not available in the QuickJS sandbox"); + } + + async stop() {} +} diff --git a/daemon/src/sandbox/forked-client/src/client/selectors.ts b/daemon/src/sandbox/forked-client/src/client/selectors.ts new file mode 100644 index 00000000..400de9e1 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/selectors.ts @@ -0,0 +1,70 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { evaluationScript } from "./clientHelper"; +import { setTestIdAttribute } from "./locator"; + +import type { SelectorEngine } from "./types"; +import type * as api from "../../types/types"; +import type * as channels from "../protocol/channels"; +import type { BrowserContext } from "./browserContext"; +import type { Platform } from "./platform"; + +export class Selectors implements api.Selectors { + private _platform: Platform; + private _selectorEngines: channels.SelectorEngine[] = []; + private _testIdAttributeName: string | undefined; + readonly _contextsForSelectors = new Set(); + + constructor(platform: Platform) { + this._platform = platform; + } + + async register( + name: string, + script: string | (() => SelectorEngine) | { path?: string; content?: string }, + options: { contentScript?: boolean } = {} + ): Promise { + if (this._selectorEngines.some((engine) => engine.name === name)) + throw new Error(`selectors.register: "${name}" selector engine has been already registered`); + + const source = await evaluationScript(this._platform, script, undefined, false); + const selectorEngine: channels.SelectorEngine = { ...options, name, source }; + for (const context of this._contextsForSelectors) + await context._channel.registerSelectorEngine({ selectorEngine }); + this._selectorEngines.push(selectorEngine); + } + + setTestIdAttribute(attributeName: string) { + this._testIdAttributeName = attributeName; + setTestIdAttribute(attributeName); + for (const context of this._contextsForSelectors) { + context._options.testIdAttributeName = attributeName; + context._channel + .setTestIdAttributeName({ testIdAttributeName: attributeName }) + .catch(() => {}); + } + } + + _withSelectorOptions(options: T) { + return { + ...options, + selectorEngines: this._selectorEngines, + testIdAttributeName: this._testIdAttributeName, + }; + } +} diff --git a/daemon/src/sandbox/forked-client/src/client/stream.ts b/daemon/src/sandbox/forked-client/src/client/stream.ts new file mode 100644 index 00000000..c0ec339c --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/stream.ts @@ -0,0 +1,12 @@ +// @ts-nocheck +import { ChannelOwner } from "./channelOwner"; + +export class Stream extends ChannelOwner { + static from(channel) { + return channel?._object; + } + + stream() { + throw new Error("Readable streams are not available in the QuickJS sandbox"); + } +} diff --git a/daemon/src/sandbox/forked-client/src/client/timeoutSettings.ts b/daemon/src/sandbox/forked-client/src/client/timeoutSettings.ts new file mode 100644 index 00000000..b6881059 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/timeoutSettings.ts @@ -0,0 +1,76 @@ +// @ts-nocheck +/** + * Copyright 2019 Google Inc. All rights reserved. + * Modifications copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + DEFAULT_PLAYWRIGHT_LAUNCH_TIMEOUT, + DEFAULT_PLAYWRIGHT_TIMEOUT, +} from "../utils/isomorphic/time"; + +import type { Platform } from "./platform"; + +export class TimeoutSettings { + private _parent: TimeoutSettings | undefined; + private _defaultTimeout: number | undefined; + private _defaultNavigationTimeout: number | undefined; + private _platform: Platform; + + constructor(platform: Platform, parent?: TimeoutSettings) { + this._parent = parent; + this._platform = platform; + } + + setDefaultTimeout(timeout: number | undefined) { + this._defaultTimeout = timeout; + } + + setDefaultNavigationTimeout(timeout: number | undefined) { + this._defaultNavigationTimeout = timeout; + } + + defaultNavigationTimeout() { + return this._defaultNavigationTimeout; + } + + defaultTimeout() { + return this._defaultTimeout; + } + + navigationTimeout(options: { timeout?: number }): number { + if (typeof options.timeout === "number") return options.timeout; + if (this._defaultNavigationTimeout !== undefined) return this._defaultNavigationTimeout; + if (this._platform.isDebugMode()) return 0; + if (this._defaultTimeout !== undefined) return this._defaultTimeout; + if (this._parent) return this._parent.navigationTimeout(options); + return DEFAULT_PLAYWRIGHT_TIMEOUT; + } + + timeout(options: { timeout?: number }): number { + if (typeof options.timeout === "number") return options.timeout; + if (this._platform.isDebugMode()) return 0; + if (this._defaultTimeout !== undefined) return this._defaultTimeout; + if (this._parent) return this._parent.timeout(options); + return DEFAULT_PLAYWRIGHT_TIMEOUT; + } + + launchTimeout(options: { timeout?: number }): number { + if (typeof options.timeout === "number") return options.timeout; + if (this._platform.isDebugMode()) return 0; + if (this._parent) return this._parent.launchTimeout(options); + return DEFAULT_PLAYWRIGHT_LAUNCH_TIMEOUT; + } +} diff --git a/daemon/src/sandbox/forked-client/src/client/tracing.ts b/daemon/src/sandbox/forked-client/src/client/tracing.ts new file mode 100644 index 00000000..af9e21c6 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/tracing.ts @@ -0,0 +1,22 @@ +// @ts-nocheck +import { ChannelOwner } from "./channelOwner"; + +export class Tracing extends ChannelOwner { + _tracesDir; + + static from(channel) { + return channel?._object; + } + + async start() {} + + async startChunk() {} + + async stop() {} + + async stopChunk() {} + + async group() {} + + async groupEnd() {} +} diff --git a/daemon/src/sandbox/forked-client/src/client/types.ts b/daemon/src/sandbox/forked-client/src/client/types.ts new file mode 100644 index 00000000..675f2ca0 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/types.ts @@ -0,0 +1,186 @@ +// @ts-nocheck +/** + * Copyright 2018 Google Inc. All rights reserved. + * Modifications copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { Size } from "../utils/isomorphic/types"; +import type * as channels from "../protocol/channels"; +export type { HeadersArray, Point, Quad, Rect, Size } from "../utils/isomorphic/types"; + +type LoggerSeverity = "verbose" | "info" | "warning" | "error"; +export interface Logger { + isEnabled(name: string, severity: LoggerSeverity): boolean; + log( + name: string, + severity: LoggerSeverity, + message: string | Error, + args: any[], + hints: { color?: string } + ): void; +} + +export type TimeoutOptions = { timeout?: number }; +export type StrictOptions = { strict?: boolean }; +export type Headers = { [key: string]: string }; + +export type WaitForEventOptions = Function | (TimeoutOptions & { predicate?: Function }); +export type WaitForFunctionOptions = TimeoutOptions & { polling?: "raf" | number }; + +export type SelectOption = { + value?: string; + label?: string; + index?: number; + valueOrLabel?: string; +}; +export type SelectOptionOptions = TimeoutOptions & { force?: boolean }; +export type FilePayload = { name: string; mimeType: string; buffer: Buffer }; +export type StorageState = { + cookies: channels.NetworkCookie[]; + origins: Omit[]; +}; +export type SetStorageState = { + cookies?: channels.SetNetworkCookie[]; + origins?: (Omit & { indexedDB?: unknown[] })[]; +}; + +export type LifecycleEvent = channels.LifecycleEvent; +export const kLifecycleEvents: Set = new Set([ + "load", + "domcontentloaded", + "networkidle", + "commit", +]); + +export type ClientCertificate = { + origin: string; + cert?: Buffer; + certPath?: string; + key?: Buffer; + keyPath?: string; + pfx?: Buffer; + pfxPath?: string; + passphrase?: string; +}; + +export type BrowserContextOptions = Omit< + channels.BrowserNewContextOptions, + | "viewport" + | "noDefaultViewport" + | "extraHTTPHeaders" + | "clientCertificates" + | "storageState" + | "recordHar" + | "colorScheme" + | "reducedMotion" + | "forcedColors" + | "acceptDownloads" + | "contrast" + | "agent" +> & { + viewport?: Size | null; + extraHTTPHeaders?: Headers; + logger?: Logger; + videosPath?: string; + videoSize?: Size; + storageState?: string | SetStorageState; + har?: { + path: string; + fallback?: "abort" | "continue"; + urlFilter?: string | RegExp; + }; + recordHar?: { + path: string; + omitContent?: boolean; + content?: "omit" | "embed" | "attach"; + mode?: "full" | "minimal"; + urlFilter?: string | RegExp; + }; + colorScheme?: "dark" | "light" | "no-preference" | null; + reducedMotion?: "reduce" | "no-preference" | null; + forcedColors?: "active" | "none" | null; + contrast?: "more" | "no-preference" | null; + acceptDownloads?: boolean; + clientCertificates?: ClientCertificate[]; +}; + +type LaunchOverrides = { + ignoreDefaultArgs?: boolean | string[]; + env?: NodeJS.ProcessEnv; + logger?: Logger; + firefoxUserPrefs?: { [key: string]: string | number | boolean }; +} & TimeoutOptions; + +export type LaunchOptions = Omit< + channels.BrowserTypeLaunchOptions, + "ignoreAllDefaultArgs" | "ignoreDefaultArgs" | "env" | "firefoxUserPrefs" +> & + LaunchOverrides; +export type LaunchPersistentContextOptions = Omit< + LaunchOptions & BrowserContextOptions, + "storageState" +>; + +export type ConnectOptions = { + endpoint: string; + browserName?: string; + headers?: { [key: string]: string }; + exposeNetwork?: string; + slowMo?: number; + timeout?: number; + logger?: Logger; +}; +export type LaunchServerOptions = LaunchOptions & { + host?: string; + port?: number; + wsPath?: string; +}; + +export type LaunchAndroidServerOptions = { + deviceSerialNumber?: string; + adbHost?: string; + adbPort?: number; + omitDriverInstall?: boolean; + host?: string; + port?: number; + wsPath?: string; +}; + +export type StartServerOptions = { + host?: string; + port?: number; + wsPath?: string; + workspaceDir?: string; + metadata?: Record; +}; + +export type SelectorEngine = { + /** + * Returns the first element matching given selector in the root's subtree. + */ + query(root: HTMLElement, selector: string): HTMLElement | null; + /** + * Returns all elements matching given selector in the root's subtree. + */ + queryAll(root: HTMLElement, selector: string): HTMLElement[]; +}; + +export type RemoteAddr = channels.RemoteAddr; +export type SecurityDetails = channels.SecurityDetails; + +export type FrameExpectParams = Omit< + channels.FrameExpectParams, + "selector" | "expression" | "expectedValue" +> & { expectedValue?: any }; diff --git a/daemon/src/sandbox/forked-client/src/client/video.ts b/daemon/src/sandbox/forked-client/src/client/video.ts new file mode 100644 index 00000000..446c17d7 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/video.ts @@ -0,0 +1,29 @@ +// @ts-nocheck +import { EventEmitter } from "./eventEmitter"; + +export class Video extends EventEmitter { + constructor(page, connection, artifact) { + super(page._platform); + this._page = page; + this._connection = connection; + this._artifact = artifact; + } + + async start() { + throw new Error("Video capture is not available in the QuickJS sandbox"); + } + + async stop() {} + + async path() { + throw new Error("Video capture is not available in the QuickJS sandbox"); + } + + async saveAs() { + throw new Error("Video capture is not available in the QuickJS sandbox"); + } + + async delete() { + await this._artifact?.delete?.(); + } +} diff --git a/daemon/src/sandbox/forked-client/src/client/waiter.ts b/daemon/src/sandbox/forked-client/src/client/waiter.ts new file mode 100644 index 00000000..b9c385af --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/waiter.ts @@ -0,0 +1,181 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { TimeoutError } from "./errors"; +import { rewriteErrorMessage } from "../utils/isomorphic/stackTrace"; + +import type { ChannelOwner } from "./channelOwner"; +import type * as channels from "../protocol/channels"; +import type { EventEmitter } from "events"; +import type { Zone } from "./platform"; + +export class Waiter { + private _dispose: (() => void)[]; + private _failures: Promise[] = []; + private _immediateError?: Error; + private _logs: string[] = []; + private _channelOwner: ChannelOwner; + private _waitId: string; + private _error: string | undefined; + private _savedZone: Zone; + + constructor(channelOwner: ChannelOwner, event: string) { + this._waitId = channelOwner._platform.createGuid(); + this._channelOwner = channelOwner; + this._savedZone = channelOwner._platform.zones.current().pop(); + + this._channelOwner._channel + .waitForEventInfo({ info: { waitId: this._waitId, phase: "before", event } }) + .catch(() => {}); + this._dispose = [ + () => + this._channelOwner + ._wrapApiCall( + async () => { + await this._channelOwner._channel.waitForEventInfo({ + info: { waitId: this._waitId, phase: "after", error: this._error }, + }); + }, + { internal: true } + ) + .catch(() => {}), + ]; + } + + static createForEvent(channelOwner: ChannelOwner, event: string) { + return new Waiter(channelOwner, event); + } + + async waitForEvent( + emitter: EventEmitter, + event: string, + predicate?: (arg: T) => boolean | Promise + ): Promise { + const { promise, dispose } = waitForEvent(emitter, event, this._savedZone, predicate); + return await this.waitForPromise(promise, dispose); + } + + rejectOnEvent( + emitter: EventEmitter, + event: string, + error: Error | (() => Error), + predicate?: (arg: T) => boolean | Promise + ) { + const { promise, dispose } = waitForEvent(emitter, event, this._savedZone, predicate); + this._rejectOn( + promise.then(() => { + throw typeof error === "function" ? error() : error; + }), + dispose + ); + } + + rejectOnTimeout(timeout: number, message: string) { + if (!timeout) return; + const { promise, dispose } = waitForTimeout(timeout); + this._rejectOn( + promise.then(() => { + throw new TimeoutError(message); + }), + dispose + ); + } + + rejectImmediately(error: Error) { + this._immediateError = error; + } + + dispose() { + for (const dispose of this._dispose) dispose(); + } + + async waitForPromise(promise: Promise, dispose?: () => void): Promise { + try { + if (this._immediateError) throw this._immediateError; + const result = await Promise.race([promise, ...this._failures]); + if (dispose) dispose(); + return result; + } catch (e) { + if (dispose) dispose(); + this._error = e.message; + this.dispose(); + rewriteErrorMessage(e, e.message + formatLogRecording(this._logs)); + throw e; + } + } + + log(s: string) { + this._logs.push(s); + this._channelOwner + ._wrapApiCall( + async () => { + await this._channelOwner._channel.waitForEventInfo({ + info: { waitId: this._waitId, phase: "log", message: s }, + }); + }, + { internal: true } + ) + .catch(() => {}); + } + + private _rejectOn(promise: Promise, dispose?: () => void) { + this._failures.push(promise); + if (dispose) this._dispose.push(dispose); + } +} + +function waitForEvent( + emitter: EventEmitter, + event: string, + savedZone: Zone, + predicate?: (arg: T) => boolean | Promise +): { promise: Promise; dispose: () => void } { + let listener: (eventArg: any) => void; + const promise = new Promise((resolve, reject) => { + listener = async (eventArg: any) => { + await savedZone.run(async () => { + try { + if (predicate && !(await predicate(eventArg))) return; + emitter.removeListener(event, listener); + resolve(eventArg); + } catch (e) { + emitter.removeListener(event, listener); + reject(e); + } + }); + }; + emitter.addListener(event, listener); + }); + const dispose = () => emitter.removeListener(event, listener); + return { promise, dispose }; +} + +function waitForTimeout(timeout: number): { promise: Promise; dispose: () => void } { + let timeoutId: any; + const promise = new Promise((resolve) => (timeoutId = setTimeout(resolve, timeout))); + const dispose = () => clearTimeout(timeoutId); + return { promise, dispose }; +} + +function formatLogRecording(log: string[]): string { + if (!log.length) return ""; + const header = ` logs `; + const headerLength = 60; + const leftLength = (headerLength - header.length) / 2; + const rightLength = headerLength - header.length - leftLength; + return `\n${"=".repeat(leftLength)}${header}${"=".repeat(rightLength)}\n${log.join("\n")}\n${"=".repeat(headerLength)}`; +} diff --git a/daemon/src/sandbox/forked-client/src/client/webError.ts b/daemon/src/sandbox/forked-client/src/client/webError.ts new file mode 100644 index 00000000..c1c891fd --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/webError.ts @@ -0,0 +1,37 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { Page } from "./page"; +import type * as api from "../../types/types"; + +export class WebError implements api.WebError { + private _page: Page | null; + private _error: Error; + + constructor(page: Page | null, error: Error) { + this._page = page; + this._error = error; + } + + page() { + return this._page; + } + + error() { + return this._error; + } +} diff --git a/daemon/src/sandbox/forked-client/src/client/worker.ts b/daemon/src/sandbox/forked-client/src/client/worker.ts new file mode 100644 index 00000000..9f5e275c --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/worker.ts @@ -0,0 +1,120 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ChannelOwner } from "./channelOwner"; +import { TargetClosedError } from "./errors"; +import { Events } from "./events"; +import { JSHandle, assertMaxArguments, parseResult, serializeArgument } from "./jsHandle"; +import { LongStandingScope } from "../utils/isomorphic/manualPromise"; +import { TimeoutSettings } from "./timeoutSettings"; +import { Waiter } from "./waiter"; + +import type { BrowserContext } from "./browserContext"; +import type { Page } from "./page"; +import type * as structs from "../../types/structs"; +import type * as api from "../../types/types"; +import type * as channels from "../protocol/channels"; +import type { WaitForEventOptions } from "./types"; + +export class Worker extends ChannelOwner implements api.Worker { + _page: Page | undefined; // Set for web workers. + _context: BrowserContext | undefined; // Set for service workers. + readonly _closedScope = new LongStandingScope(); + + static fromNullable(worker: channels.WorkerChannel | undefined): Worker | null { + return worker ? Worker.from(worker) : null; + } + + static from(worker: channels.WorkerChannel): Worker { + return (worker as any)._object; + } + + constructor( + parent: ChannelOwner, + type: string, + guid: string, + initializer: channels.WorkerInitializer + ) { + super(parent, type, guid, initializer); + this._setEventToSubscriptionMapping( + new Map([ + [Events.Worker.Console, "console"], + ]) + ); + this._channel.on("close", () => { + if (this._page) this._page._workers.delete(this); + if (this._context) this._context._serviceWorkers.delete(this); + this.emit(Events.Worker.Close, this); + }); + this.once(Events.Worker.Close, () => + this._closedScope.close(this._page?._closeErrorWithReason() || new TargetClosedError()) + ); + } + + url(): string { + return this._initializer.url; + } + + async evaluate(pageFunction: structs.PageFunction, arg?: Arg): Promise { + assertMaxArguments(arguments.length, 2); + const result = await this._channel.evaluateExpression({ + expression: String(pageFunction), + isFunction: typeof pageFunction === "function", + arg: serializeArgument(arg), + }); + return parseResult(result.value); + } + + async evaluateHandle( + pageFunction: structs.PageFunction, + arg?: Arg + ): Promise> { + assertMaxArguments(arguments.length, 2); + const result = await this._channel.evaluateExpressionHandle({ + expression: String(pageFunction), + isFunction: typeof pageFunction === "function", + arg: serializeArgument(arg), + }); + return JSHandle.from(result.handle) as any as structs.SmartHandle; + } + + async waitForEvent(event: string, optionsOrPredicate: WaitForEventOptions = {}): Promise { + return await this._wrapApiCall(async () => { + const timeoutSettings = + this._page?._timeoutSettings ?? + this._context?._timeoutSettings ?? + new TimeoutSettings(this._platform); + const timeout = timeoutSettings.timeout( + typeof optionsOrPredicate === "function" ? {} : optionsOrPredicate + ); + const predicate = + typeof optionsOrPredicate === "function" + ? optionsOrPredicate + : optionsOrPredicate.predicate; + const waiter = Waiter.createForEvent(this, event); + waiter.rejectOnTimeout( + timeout, + `Timeout ${timeout}ms exceeded while waiting for event "${event}"` + ); + if (event !== Events.Worker.Close) + waiter.rejectOnEvent(this, Events.Worker.Close, () => new TargetClosedError()); + const result = await waiter.waitForEvent(this, event, predicate as any); + waiter.dispose(); + return result; + }); + } +} diff --git a/daemon/src/sandbox/forked-client/src/client/writableStream.ts b/daemon/src/sandbox/forked-client/src/client/writableStream.ts new file mode 100644 index 00000000..2e4c5537 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/writableStream.ts @@ -0,0 +1,12 @@ +// @ts-nocheck +import { ChannelOwner } from "./channelOwner"; + +export class WritableStream extends ChannelOwner { + static from(channel) { + return channel?._object; + } + + stream() { + throw new Error("Writable streams are not available in the QuickJS sandbox"); + } +} diff --git a/daemon/src/sandbox/forked-client/src/protocol/channels.d.ts b/daemon/src/sandbox/forked-client/src/protocol/channels.d.ts new file mode 100644 index 00000000..edb26f81 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/protocol/channels.d.ts @@ -0,0 +1,175 @@ +// @ts-nocheck +// Generated for the QuickJS Playwright fork. +// The upstream generated channels.ts type file is not present in the scratch source tree. + +export type APIRequestContextChannel = any; +export type APIRequestContextInitializer = any; +export type APIResponse = any; +export type AndroidChannel = any; +export type AndroidDeviceChannel = any; +export type AndroidDeviceInitializer = any; +export type AndroidInitializer = any; +export type AndroidSelector = any; +export type AndroidSocketChannel = any; +export type AndroidSocketInitializer = any; +export type AndroidWebView = any; +export type ArtifactChannel = any; +export type BindingCallChannel = any; +export type BindingCallInitializer = any; +export type BrowserChannel = any; +export type BrowserContextChannel = any; +export type BrowserContextConsoleEvent = any; +export type BrowserContextEnableRecorderParams = any; +export type BrowserContextInitializer = any; +export type BrowserContextRequestFinishedEvent = any; +export type BrowserContextSetNetworkInterceptionPatternsParams = any; +export type BrowserContextSetWebSocketInterceptionPatternsParams = any; +export type BrowserContextUpdateSubscriptionParams = any; +export type BrowserInitializer = any; +export type BrowserNewContextOptions = any; +export type BrowserNewContextParams = any; +export type BrowserTypeChannel = any; +export type BrowserTypeLaunchOptions = any; +export type BrowserTypeLaunchParams = any; +export type CDPSessionChannel = any; +export type CDPSessionInitializer = any; +export type Channel = any; +export type DebuggerChannel = any; +export type DebuggerInitializer = any; +export type DialogChannel = any; +export type DialogInitializer = any; +export type DisposableChannel = any; +export type ElectronApplicationChannel = any; +export type ElectronApplicationConsoleEvent = any; +export type ElectronApplicationInitializer = any; +export type ElectronApplicationUpdateSubscriptionParams = any; +export type ElectronChannel = any; +export type ElectronInitializer = any; +export type ElectronLaunchOptions = any; +export type ElectronLaunchParams = any; +export type ElementHandleChannel = any; +export type ElementHandleCheckOptions = any; +export type ElementHandleClickOptions = any; +export type ElementHandleDblclickOptions = any; +export type ElementHandleFillOptions = any; +export type ElementHandleHoverOptions = any; +export type ElementHandlePressOptions = any; +export type ElementHandleScreenshotOptions = any; +export type ElementHandleScreenshotParams = any; +export type ElementHandleScrollIntoViewIfNeededOptions = any; +export type ElementHandleSelectTextOptions = any; +export type ElementHandleSetInputFilesOptions = any; +export type ElementHandleSetInputFilesParams = any; +export type ElementHandleTapOptions = any; +export type ElementHandleTypeOptions = any; +export type ElementHandleUncheckOptions = any; +export type ElementHandleWaitForSelectorOptions = any; +export type EventTargetChannel = any; +export type FormField = any; +export type FrameChannel = any; +export type FrameCheckOptions = any; +export type FrameClickOptions = any; +export type FrameDblclickOptions = any; +export type FrameDispatchEventOptions = any; +export type FrameDragAndDropOptions = any; +export type FrameExpectParams = any; +export type FrameFillOptions = any; +export type FrameFocusOptions = any; +export type FrameGetAttributeOptions = any; +export type FrameGotoOptions = any; +export type FrameHoverOptions = any; +export type FrameInitializer = any; +export type FrameInnerHTMLOptions = any; +export type FrameInnerTextOptions = any; +export type FrameInputValueOptions = any; +export type FrameIsCheckedOptions = any; +export type FrameIsDisabledOptions = any; +export type FrameIsEditableOptions = any; +export type FrameIsEnabledOptions = any; +export type FrameIsHiddenOptions = any; +export type FrameIsVisibleOptions = any; +export type FrameNavigatedEvent = any; +export type FramePressOptions = any; +export type FrameSetContentOptions = any; +export type FrameSetInputFilesOptions = any; +export type FrameTapOptions = any; +export type FrameTextContentOptions = any; +export type FrameTypeOptions = any; +export type FrameUncheckOptions = any; +export type FrameWaitForSelectorOptions = any; +export type InitializerTraits = any; +export type JSHandleChannel = any; +export type JSHandleInitializer = any; +export type JsonPipeChannel = any; +export type JsonPipeInitializer = any; +export type LifecycleEvent = any; +export type LocalUtilsAddStackToTracingNoReplyParams = any; +export type LocalUtilsChannel = any; +export type LocalUtilsConnectParams = any; +export type LocalUtilsHarCloseParams = any; +export type LocalUtilsHarLookupParams = any; +export type LocalUtilsHarLookupResult = any; +export type LocalUtilsHarOpenParams = any; +export type LocalUtilsHarOpenResult = any; +export type LocalUtilsHarUnzipParams = any; +export type LocalUtilsInitializer = any; +export type LocalUtilsTraceDiscardedParams = any; +export type LocalUtilsTracingStartedParams = any; +export type LocalUtilsTracingStartedResult = any; +export type LocalUtilsZipParams = any; +export type Metadata = any; +export type NameValue = any; +export type NetworkCookie = any; +export type OriginStorage = any; +export type PageChannel = any; +export type PageExpectScreenshotOptions = any; +export type PageGoBackOptions = any; +export type PageGoForwardOptions = any; +export type PageInitializer = any; +export type PageKeyboardPressOptions = any; +export type PageKeyboardTypeOptions = any; +export type PageMouseClickOptions = any; +export type PageMouseDownOptions = any; +export type PageMouseUpOptions = any; +export type PagePdfParams = any; +export type PageReloadOptions = any; +export type PageScreenshotOptions = any; +export type PageScreenshotParams = any; +export type PageStartCSSCoverageOptions = any; +export type PageStartJSCoverageOptions = any; +export type PageStopCSSCoverageResult = any; +export type PageStopJSCoverageResult = any; +export type PageUpdateSubscriptionParams = any; +export type PlaywrightChannel = any; +export type PlaywrightInitializer = any; +export type PlaywrightNewRequestOptions = any; +export type PlaywrightNewRequestParams = any; +export type RemoteAddr = any; +export type RequestChannel = any; +export type RequestInitializer = any; +export type ResponseChannel = any; +export type ResponseInitializer = any; +export type RootChannel = any; +export type RouteChannel = any; +export type RouteInitializer = any; +export type SecurityDetails = any; +export type SelectorEngine = any; +export type SerializedArgument = any; +export type SerializedValue = any; +export type SetNetworkCookie = any; +export type SetOriginStorage = any; +export type StackFrame = any; +export type StreamChannel = any; +export type StreamInitializer = any; +export type TracingChannel = any; +export type TracingInitializer = any; +export type WebSocketChannel = any; +export type WebSocketInitializer = any; +export type WebSocketRouteChannel = any; +export type WebSocketRouteInitializer = any; +export type WorkerChannel = any; +export type WorkerInitializer = any; +export type WorkerUpdateSubscriptionParams = any; +export type WritableStreamChannel = any; +export type WritableStreamInitializer = any; +export type js = any; diff --git a/daemon/src/sandbox/forked-client/src/protocol/serializers.ts b/daemon/src/sandbox/forked-client/src/protocol/serializers.ts new file mode 100644 index 00000000..bbc145a2 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/protocol/serializers.ts @@ -0,0 +1,205 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { SerializedValue } from "./channels"; + +export function parseSerializedValue(value: SerializedValue, handles: any[] | undefined): any { + return innerParseSerializedValue(value, handles, new Map(), []); +} + +function innerParseSerializedValue( + value: SerializedValue, + handles: any[] | undefined, + refs: Map, + accessChain: Array +): any { + if (value.ref !== undefined) return refs.get(value.ref); + if (value.n !== undefined) return value.n; + if (value.s !== undefined) return value.s; + if (value.b !== undefined) return value.b; + if (value.v !== undefined) { + if (value.v === "undefined") return undefined; + if (value.v === "null") return null; + if (value.v === "NaN") return NaN; + if (value.v === "Infinity") return Infinity; + if (value.v === "-Infinity") return -Infinity; + if (value.v === "-0") return -0; + } + if (value.d !== undefined) return new Date(value.d); + if (value.u !== undefined) return new URL(value.u); + if (value.bi !== undefined) return BigInt(value.bi); + if (value.e !== undefined) { + const error = new Error(value.e.m); + error.name = value.e.n; + error.stack = value.e.s; + return error; + } + if (value.r !== undefined) return new RegExp(value.r.p, value.r.f); + if (value.ta !== undefined) { + const ctor = typedArrayKindToConstructor[value.ta.k] as any; + return new ctor( + value.ta.b.buffer, + value.ta.b.byteOffset, + value.ta.b.length / ctor.BYTES_PER_ELEMENT + ); + } + + if (value.a !== undefined) { + const result: any[] = []; + refs.set(value.id!, result); + for (let i = 0; i < value.a.length; i++) + result.push(innerParseSerializedValue(value.a[i], handles, refs, [...accessChain, i])); + return result; + } + if (value.o !== undefined) { + const result: any = {}; + refs.set(value.id!, result); + for (const { k, v } of value.o) + result[k] = innerParseSerializedValue(v, handles, refs, [...accessChain, k]); + return result; + } + if (value.h !== undefined) { + if (handles === undefined) throw new Error("Unexpected handle"); + return handles[value.h]; + } + throw new Error( + `Attempting to deserialize unexpected value${accessChainToDisplayString(accessChain)}: ${value}` + ); +} + +export type HandleOrValue = { h: number } | { fallThrough: any }; +type VisitorInfo = { + visited: Map; + lastId: number; +}; + +export function serializeValue( + value: any, + handleSerializer: (value: any) => HandleOrValue +): SerializedValue { + return innerSerializeValue(value, handleSerializer, { lastId: 0, visited: new Map() }, []); +} + +export function serializePlainValue(arg: any): SerializedValue { + return serializeValue(arg, (value) => ({ fallThrough: value })); +} + +function innerSerializeValue( + value: any, + handleSerializer: (value: any) => HandleOrValue, + visitorInfo: VisitorInfo, + accessChain: Array +): SerializedValue { + const handle = handleSerializer(value); + if ("fallThrough" in handle) value = handle.fallThrough; + else return handle; + + if (typeof value === "symbol") return { v: "undefined" }; + if (Object.is(value, undefined)) return { v: "undefined" }; + if (Object.is(value, null)) return { v: "null" }; + if (Object.is(value, NaN)) return { v: "NaN" }; + if (Object.is(value, Infinity)) return { v: "Infinity" }; + if (Object.is(value, -Infinity)) return { v: "-Infinity" }; + if (Object.is(value, -0)) return { v: "-0" }; + if (typeof value === "boolean") return { b: value }; + if (typeof value === "number") return { n: value }; + if (typeof value === "string") return { s: value }; + if (typeof value === "bigint") return { bi: value.toString() }; + if (isError(value)) return { e: { n: value.name, m: value.message, s: value.stack || "" } }; + if (isDate(value)) return { d: value.toJSON() }; + if (isURL(value)) return { u: value.toJSON() }; + if (isRegExp(value)) return { r: { p: value.source, f: value.flags } }; + + const typedArrayKind = constructorToTypedArrayKind.get(value.constructor); + if (typedArrayKind) + return { + ta: { b: Buffer.from(value.buffer, value.byteOffset, value.byteLength), k: typedArrayKind }, + }; + + const id = visitorInfo.visited.get(value); + if (id) return { ref: id }; + + if (Array.isArray(value)) { + const a = []; + const id = ++visitorInfo.lastId; + visitorInfo.visited.set(value, id); + for (let i = 0; i < value.length; ++i) + a.push(innerSerializeValue(value[i], handleSerializer, visitorInfo, [...accessChain, i])); + return { a, id }; + } + if (typeof value === "object") { + const o: { k: string; v: SerializedValue }[] = []; + const id = ++visitorInfo.lastId; + visitorInfo.visited.set(value, id); + for (const name of Object.keys(value)) + o.push({ + k: name, + v: innerSerializeValue(value[name], handleSerializer, visitorInfo, [...accessChain, name]), + }); + return { o, id }; + } + // Likely only functions can reach here. + throw new Error( + `Attempting to serialize unexpected value${accessChainToDisplayString(accessChain)}: ${value}` + ); +} + +function accessChainToDisplayString(accessChain: Array): string { + const chainString = accessChain + .map((accessor, i) => { + if (typeof accessor === "string") return i ? `.${accessor}` : accessor; + return `[${accessor}]`; + }) + .join(""); + + return chainString.length > 0 ? ` at position "${chainString}"` : ""; +} + +function isRegExp(obj: any): obj is RegExp { + return obj instanceof RegExp || Object.prototype.toString.call(obj) === "[object RegExp]"; +} + +function isDate(obj: any): obj is Date { + return obj instanceof Date || Object.prototype.toString.call(obj) === "[object Date]"; +} + +function isURL(obj: any): obj is URL { + return obj instanceof URL || Object.prototype.toString.call(obj) === "[object URL]"; +} + +function isError(obj: any): obj is Error { + const proto = obj ? Object.getPrototypeOf(obj) : null; + return obj instanceof Error || proto?.name === "Error" || (proto && isError(proto)); +} + +type TypedArrayKind = NonNullable["k"]; +const typedArrayKindToConstructor: Record = { + i8: Int8Array, + ui8: Uint8Array, + ui8c: Uint8ClampedArray, + i16: Int16Array, + ui16: Uint16Array, + i32: Int32Array, + ui32: Uint32Array, + f32: Float32Array, + f64: Float64Array, + bi64: BigInt64Array, + bui64: BigUint64Array, +}; +const constructorToTypedArrayKind: Map = new Map( + Object.entries(typedArrayKindToConstructor).map(([k, v]) => [v, k as TypedArrayKind]) +); diff --git a/daemon/src/sandbox/forked-client/src/protocol/validator.ts b/daemon/src/sandbox/forked-client/src/protocol/validator.ts new file mode 100644 index 00000000..e873f0a3 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/protocol/validator.ts @@ -0,0 +1,3256 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// This file is generated by generate_channels.js, do not edit manually. + +import { + scheme, + tOptional, + tObject, + tBoolean, + tInt, + tFloat, + tString, + tAny, + tEnum, + tArray, + tBinary, + tChannel, + tType, +} from "./validatorPrimitives"; +export type { Validator, ValidatorContext } from "./validatorPrimitives"; +export { + ValidationError, + findValidator, + maybeFindValidator, + createMetadataValidator, +} from "./validatorPrimitives"; + +scheme.StackFrame = tObject({ + file: tString, + line: tInt, + column: tInt, + function: tOptional(tString), +}); +scheme.Metadata = tObject({ + location: tOptional( + tObject({ + file: tString, + line: tOptional(tInt), + column: tOptional(tInt), + }) + ), + title: tOptional(tString), + internal: tOptional(tBoolean), + stepId: tOptional(tString), +}); +scheme.ClientSideCallMetadata = tObject({ + id: tInt, + stack: tOptional(tArray(tType("StackFrame"))), +}); +scheme.Point = tObject({ + x: tFloat, + y: tFloat, +}); +scheme.Rect = tObject({ + x: tFloat, + y: tFloat, + width: tFloat, + height: tFloat, +}); +scheme.SerializedValue = tObject({ + n: tOptional(tFloat), + b: tOptional(tBoolean), + s: tOptional(tString), + v: tOptional(tEnum(["null", "undefined", "NaN", "Infinity", "-Infinity", "-0"])), + d: tOptional(tString), + u: tOptional(tString), + bi: tOptional(tString), + ta: tOptional( + tObject({ + b: tBinary, + k: tEnum(["i8", "ui8", "ui8c", "i16", "ui16", "i32", "ui32", "f32", "f64", "bi64", "bui64"]), + }) + ), + e: tOptional( + tObject({ + m: tString, + n: tString, + s: tString, + }) + ), + r: tOptional( + tObject({ + p: tString, + f: tString, + }) + ), + a: tOptional(tArray(tType("SerializedValue"))), + o: tOptional( + tArray( + tObject({ + k: tString, + v: tType("SerializedValue"), + }) + ) + ), + h: tOptional(tInt), + id: tOptional(tInt), + ref: tOptional(tInt), +}); +scheme.SerializedArgument = tObject({ + value: tType("SerializedValue"), + handles: tArray(tChannel("*")), +}); +scheme.ExpectedTextValue = tObject({ + string: tOptional(tString), + regexSource: tOptional(tString), + regexFlags: tOptional(tString), + matchSubstring: tOptional(tBoolean), + ignoreCase: tOptional(tBoolean), + normalizeWhiteSpace: tOptional(tBoolean), +}); +scheme.SelectorEngine = tObject({ + name: tString, + source: tString, + contentScript: tOptional(tBoolean), +}); +scheme.URLPattern = tObject({ + hash: tString, + hostname: tString, + password: tString, + pathname: tString, + port: tString, + protocol: tString, + search: tString, + username: tString, +}); +scheme.SetNetworkCookie = tObject({ + name: tString, + value: tString, + url: tOptional(tString), + domain: tOptional(tString), + path: tOptional(tString), + expires: tOptional(tFloat), + httpOnly: tOptional(tBoolean), + secure: tOptional(tBoolean), + sameSite: tOptional(tEnum(["Strict", "Lax", "None"])), + partitionKey: tOptional(tString), + _crHasCrossSiteAncestor: tOptional(tBoolean), +}); +scheme.NetworkCookie = tObject({ + name: tString, + value: tString, + domain: tString, + path: tString, + expires: tFloat, + httpOnly: tBoolean, + secure: tBoolean, + sameSite: tEnum(["Strict", "Lax", "None"]), + partitionKey: tOptional(tString), + _crHasCrossSiteAncestor: tOptional(tBoolean), +}); +scheme.NameValue = tObject({ + name: tString, + value: tString, +}); +scheme.IndexedDBDatabase = tObject({ + name: tString, + version: tInt, + stores: tArray( + tObject({ + name: tString, + autoIncrement: tBoolean, + keyPath: tOptional(tString), + keyPathArray: tOptional(tArray(tString)), + records: tArray( + tObject({ + key: tOptional(tAny), + keyEncoded: tOptional(tAny), + value: tOptional(tAny), + valueEncoded: tOptional(tAny), + }) + ), + indexes: tArray( + tObject({ + name: tString, + keyPath: tOptional(tString), + keyPathArray: tOptional(tArray(tString)), + multiEntry: tBoolean, + unique: tBoolean, + }) + ), + }) + ), +}); +scheme.SetOriginStorage = tObject({ + origin: tString, + localStorage: tArray(tType("NameValue")), + indexedDB: tOptional(tArray(tType("IndexedDBDatabase"))), +}); +scheme.OriginStorage = tObject({ + origin: tString, + localStorage: tArray(tType("NameValue")), + indexedDB: tOptional(tArray(tType("IndexedDBDatabase"))), +}); +scheme.SerializedError = tObject({ + error: tOptional( + tObject({ + message: tString, + name: tString, + stack: tOptional(tString), + }) + ), + value: tOptional(tType("SerializedValue")), +}); +scheme.RecordHarOptions = tObject({ + zip: tOptional(tBoolean), + content: tOptional(tEnum(["embed", "attach", "omit"])), + mode: tOptional(tEnum(["full", "minimal"])), + urlGlob: tOptional(tString), + urlRegexSource: tOptional(tString), + urlRegexFlags: tOptional(tString), +}); +scheme.FormField = tObject({ + name: tString, + value: tOptional(tString), + file: tOptional( + tObject({ + name: tString, + mimeType: tOptional(tString), + buffer: tBinary, + }) + ), +}); +scheme.SDKLanguage = tEnum(["javascript", "python", "java", "csharp"]); +scheme.APIRequestContextInitializer = tObject({ + tracing: tChannel(["Tracing"]), +}); +scheme.APIRequestContextFetchParams = tObject({ + url: tString, + encodedParams: tOptional(tString), + params: tOptional(tArray(tType("NameValue"))), + method: tOptional(tString), + headers: tOptional(tArray(tType("NameValue"))), + postData: tOptional(tBinary), + jsonData: tOptional(tString), + formData: tOptional(tArray(tType("NameValue"))), + multipartData: tOptional(tArray(tType("FormField"))), + timeout: tFloat, + failOnStatusCode: tOptional(tBoolean), + ignoreHTTPSErrors: tOptional(tBoolean), + maxRedirects: tOptional(tInt), + maxRetries: tOptional(tInt), +}); +scheme.APIRequestContextFetchResult = tObject({ + response: tType("APIResponse"), +}); +scheme.APIRequestContextFetchResponseBodyParams = tObject({ + fetchUid: tString, +}); +scheme.APIRequestContextFetchResponseBodyResult = tObject({ + binary: tOptional(tBinary), +}); +scheme.APIRequestContextFetchLogParams = tObject({ + fetchUid: tString, +}); +scheme.APIRequestContextFetchLogResult = tObject({ + log: tArray(tString), +}); +scheme.APIRequestContextStorageStateParams = tObject({ + indexedDB: tOptional(tBoolean), +}); +scheme.APIRequestContextStorageStateResult = tObject({ + cookies: tArray(tType("NetworkCookie")), + origins: tArray(tType("OriginStorage")), +}); +scheme.APIRequestContextDisposeAPIResponseParams = tObject({ + fetchUid: tString, +}); +scheme.APIRequestContextDisposeAPIResponseResult = tOptional(tObject({})); +scheme.APIRequestContextDisposeParams = tObject({ + reason: tOptional(tString), +}); +scheme.APIRequestContextDisposeResult = tOptional(tObject({})); +scheme.APIResponse = tObject({ + fetchUid: tString, + url: tString, + status: tInt, + statusText: tString, + headers: tArray(tType("NameValue")), +}); +scheme.LifecycleEvent = tEnum(["load", "domcontentloaded", "networkidle", "commit"]); +scheme.ConsoleMessagesFilter = tEnum(["all", "sinceNavigation"]); +scheme.LocalUtilsInitializer = tObject({ + deviceDescriptors: tArray( + tObject({ + name: tString, + descriptor: tObject({ + userAgent: tString, + viewport: tObject({ + width: tInt, + height: tInt, + }), + screen: tOptional( + tObject({ + width: tInt, + height: tInt, + }) + ), + deviceScaleFactor: tFloat, + isMobile: tBoolean, + hasTouch: tBoolean, + defaultBrowserType: tEnum(["chromium", "firefox", "webkit"]), + }), + }) + ), +}); +scheme.LocalUtilsZipParams = tObject({ + zipFile: tString, + entries: tArray(tType("NameValue")), + stacksId: tOptional(tString), + mode: tEnum(["write", "append"]), + includeSources: tBoolean, + additionalSources: tOptional(tArray(tString)), +}); +scheme.LocalUtilsZipResult = tOptional(tObject({})); +scheme.LocalUtilsHarOpenParams = tObject({ + file: tString, +}); +scheme.LocalUtilsHarOpenResult = tObject({ + harId: tOptional(tString), + error: tOptional(tString), +}); +scheme.LocalUtilsHarLookupParams = tObject({ + harId: tString, + url: tString, + method: tString, + headers: tArray(tType("NameValue")), + postData: tOptional(tBinary), + isNavigationRequest: tBoolean, +}); +scheme.LocalUtilsHarLookupResult = tObject({ + action: tEnum(["error", "redirect", "fulfill", "noentry"]), + message: tOptional(tString), + redirectURL: tOptional(tString), + status: tOptional(tInt), + headers: tOptional(tArray(tType("NameValue"))), + body: tOptional(tBinary), +}); +scheme.LocalUtilsHarCloseParams = tObject({ + harId: tString, +}); +scheme.LocalUtilsHarCloseResult = tOptional(tObject({})); +scheme.LocalUtilsHarUnzipParams = tObject({ + zipFile: tString, + harFile: tString, +}); +scheme.LocalUtilsHarUnzipResult = tOptional(tObject({})); +scheme.LocalUtilsConnectParams = tObject({ + endpoint: tString, + headers: tOptional(tAny), + exposeNetwork: tOptional(tString), + slowMo: tOptional(tFloat), + timeout: tFloat, + socksProxyRedirectPortForTest: tOptional(tInt), +}); +scheme.LocalUtilsConnectResult = tObject({ + pipe: tChannel(["JsonPipe"]), + headers: tArray(tType("NameValue")), +}); +scheme.LocalUtilsTracingStartedParams = tObject({ + tracesDir: tOptional(tString), + traceName: tString, + live: tOptional(tBoolean), +}); +scheme.LocalUtilsTracingStartedResult = tObject({ + stacksId: tString, +}); +scheme.LocalUtilsAddStackToTracingNoReplyParams = tObject({ + callData: tType("ClientSideCallMetadata"), +}); +scheme.LocalUtilsAddStackToTracingNoReplyResult = tOptional(tObject({})); +scheme.LocalUtilsTraceDiscardedParams = tObject({ + stacksId: tString, +}); +scheme.LocalUtilsTraceDiscardedResult = tOptional(tObject({})); +scheme.LocalUtilsGlobToRegexParams = tObject({ + glob: tString, + baseURL: tOptional(tString), + webSocketUrl: tOptional(tBoolean), +}); +scheme.LocalUtilsGlobToRegexResult = tObject({ + regex: tString, +}); +scheme.RootInitializer = tOptional(tObject({})); +scheme.RootInitializeParams = tObject({ + sdkLanguage: tType("SDKLanguage"), +}); +scheme.RootInitializeResult = tObject({ + playwright: tChannel(["Playwright"]), +}); +scheme.PlaywrightInitializer = tObject({ + chromium: tChannel(["BrowserType"]), + firefox: tChannel(["BrowserType"]), + webkit: tChannel(["BrowserType"]), + android: tChannel(["Android"]), + electron: tChannel(["Electron"]), + utils: tOptional(tChannel(["LocalUtils"])), + preLaunchedBrowser: tOptional(tChannel(["Browser"])), + preConnectedAndroidDevice: tOptional(tChannel(["AndroidDevice"])), + socksSupport: tOptional(tChannel(["SocksSupport"])), +}); +scheme.PlaywrightNewRequestParams = tObject({ + baseURL: tOptional(tString), + userAgent: tOptional(tString), + ignoreHTTPSErrors: tOptional(tBoolean), + extraHTTPHeaders: tOptional(tArray(tType("NameValue"))), + failOnStatusCode: tOptional(tBoolean), + clientCertificates: tOptional( + tArray( + tObject({ + origin: tString, + cert: tOptional(tBinary), + key: tOptional(tBinary), + passphrase: tOptional(tString), + pfx: tOptional(tBinary), + }) + ) + ), + maxRedirects: tOptional(tInt), + httpCredentials: tOptional( + tObject({ + username: tString, + password: tString, + origin: tOptional(tString), + send: tOptional(tEnum(["always", "unauthorized"])), + }) + ), + proxy: tOptional( + tObject({ + server: tString, + bypass: tOptional(tString), + username: tOptional(tString), + password: tOptional(tString), + }) + ), + storageState: tOptional( + tObject({ + cookies: tOptional(tArray(tType("NetworkCookie"))), + origins: tOptional(tArray(tType("SetOriginStorage"))), + }) + ), + tracesDir: tOptional(tString), +}); +scheme.PlaywrightNewRequestResult = tObject({ + request: tChannel(["APIRequestContext"]), +}); +scheme.RecorderSource = tObject({ + isRecorded: tBoolean, + id: tString, + label: tString, + text: tString, + language: tString, + highlight: tArray( + tObject({ + line: tInt, + type: tString, + }) + ), + revealLine: tOptional(tInt), + group: tOptional(tString), +}); +scheme.DebugControllerInitializer = tOptional(tObject({})); +scheme.DebugControllerInspectRequestedEvent = tObject({ + selector: tString, + locator: tString, + ariaSnapshot: tString, +}); +scheme.DebugControllerSetModeRequestedEvent = tObject({ + mode: tString, +}); +scheme.DebugControllerStateChangedEvent = tObject({ + pageCount: tInt, +}); +scheme.DebugControllerSourceChangedEvent = tObject({ + text: tString, + header: tOptional(tString), + footer: tOptional(tString), + actions: tOptional(tArray(tString)), +}); +scheme.DebugControllerPausedEvent = tObject({ + paused: tBoolean, +}); +scheme.DebugControllerInitializeParams = tObject({ + codegenId: tString, + sdkLanguage: tType("SDKLanguage"), +}); +scheme.DebugControllerInitializeResult = tOptional(tObject({})); +scheme.DebugControllerSetReportStateChangedParams = tObject({ + enabled: tBoolean, +}); +scheme.DebugControllerSetReportStateChangedResult = tOptional(tObject({})); +scheme.DebugControllerSetRecorderModeParams = tObject({ + mode: tEnum(["inspecting", "recording", "none"]), + testIdAttributeName: tOptional(tString), + generateAutoExpect: tOptional(tBoolean), +}); +scheme.DebugControllerSetRecorderModeResult = tOptional(tObject({})); +scheme.DebugControllerHighlightParams = tObject({ + selector: tOptional(tString), + ariaTemplate: tOptional(tString), +}); +scheme.DebugControllerHighlightResult = tOptional(tObject({})); +scheme.DebugControllerHideHighlightParams = tOptional(tObject({})); +scheme.DebugControllerHideHighlightResult = tOptional(tObject({})); +scheme.DebugControllerResumeParams = tOptional(tObject({})); +scheme.DebugControllerResumeResult = tOptional(tObject({})); +scheme.DebugControllerKillParams = tOptional(tObject({})); +scheme.DebugControllerKillResult = tOptional(tObject({})); +scheme.SocksSupportInitializer = tOptional(tObject({})); +scheme.SocksSupportSocksRequestedEvent = tObject({ + uid: tString, + host: tString, + port: tInt, +}); +scheme.SocksSupportSocksDataEvent = tObject({ + uid: tString, + data: tBinary, +}); +scheme.SocksSupportSocksClosedEvent = tObject({ + uid: tString, +}); +scheme.SocksSupportSocksConnectedParams = tObject({ + uid: tString, + host: tString, + port: tInt, +}); +scheme.SocksSupportSocksConnectedResult = tOptional(tObject({})); +scheme.SocksSupportSocksFailedParams = tObject({ + uid: tString, + errorCode: tString, +}); +scheme.SocksSupportSocksFailedResult = tOptional(tObject({})); +scheme.SocksSupportSocksDataParams = tObject({ + uid: tString, + data: tBinary, +}); +scheme.SocksSupportSocksDataResult = tOptional(tObject({})); +scheme.SocksSupportSocksErrorParams = tObject({ + uid: tString, + error: tString, +}); +scheme.SocksSupportSocksErrorResult = tOptional(tObject({})); +scheme.SocksSupportSocksEndParams = tObject({ + uid: tString, +}); +scheme.SocksSupportSocksEndResult = tOptional(tObject({})); +scheme.BrowserTypeInitializer = tObject({ + executablePath: tString, + name: tString, +}); +scheme.BrowserTypeLaunchParams = tObject({ + channel: tOptional(tString), + executablePath: tOptional(tString), + args: tOptional(tArray(tString)), + ignoreAllDefaultArgs: tOptional(tBoolean), + ignoreDefaultArgs: tOptional(tArray(tString)), + assistantMode: tOptional(tBoolean), + handleSIGINT: tOptional(tBoolean), + handleSIGTERM: tOptional(tBoolean), + handleSIGHUP: tOptional(tBoolean), + timeout: tFloat, + env: tOptional(tArray(tType("NameValue"))), + headless: tOptional(tBoolean), + proxy: tOptional( + tObject({ + server: tString, + bypass: tOptional(tString), + username: tOptional(tString), + password: tOptional(tString), + }) + ), + downloadsPath: tOptional(tString), + tracesDir: tOptional(tString), + artifactsDir: tOptional(tString), + chromiumSandbox: tOptional(tBoolean), + firefoxUserPrefs: tOptional(tAny), + cdpPort: tOptional(tInt), + slowMo: tOptional(tFloat), +}); +scheme.BrowserTypeLaunchResult = tObject({ + browser: tChannel(["Browser"]), +}); +scheme.BrowserTypeLaunchPersistentContextParams = tObject({ + channel: tOptional(tString), + executablePath: tOptional(tString), + args: tOptional(tArray(tString)), + ignoreAllDefaultArgs: tOptional(tBoolean), + ignoreDefaultArgs: tOptional(tArray(tString)), + assistantMode: tOptional(tBoolean), + handleSIGINT: tOptional(tBoolean), + handleSIGTERM: tOptional(tBoolean), + handleSIGHUP: tOptional(tBoolean), + timeout: tFloat, + env: tOptional(tArray(tType("NameValue"))), + headless: tOptional(tBoolean), + proxy: tOptional( + tObject({ + server: tString, + bypass: tOptional(tString), + username: tOptional(tString), + password: tOptional(tString), + }) + ), + downloadsPath: tOptional(tString), + tracesDir: tOptional(tString), + artifactsDir: tOptional(tString), + chromiumSandbox: tOptional(tBoolean), + firefoxUserPrefs: tOptional(tAny), + cdpPort: tOptional(tInt), + noDefaultViewport: tOptional(tBoolean), + viewport: tOptional( + tObject({ + width: tInt, + height: tInt, + }) + ), + screen: tOptional( + tObject({ + width: tInt, + height: tInt, + }) + ), + ignoreHTTPSErrors: tOptional(tBoolean), + clientCertificates: tOptional( + tArray( + tObject({ + origin: tString, + cert: tOptional(tBinary), + key: tOptional(tBinary), + passphrase: tOptional(tString), + pfx: tOptional(tBinary), + }) + ) + ), + javaScriptEnabled: tOptional(tBoolean), + bypassCSP: tOptional(tBoolean), + userAgent: tOptional(tString), + locale: tOptional(tString), + timezoneId: tOptional(tString), + geolocation: tOptional( + tObject({ + longitude: tFloat, + latitude: tFloat, + accuracy: tOptional(tFloat), + }) + ), + permissions: tOptional(tArray(tString)), + extraHTTPHeaders: tOptional(tArray(tType("NameValue"))), + offline: tOptional(tBoolean), + httpCredentials: tOptional( + tObject({ + username: tString, + password: tString, + origin: tOptional(tString), + send: tOptional(tEnum(["always", "unauthorized"])), + }) + ), + deviceScaleFactor: tOptional(tFloat), + isMobile: tOptional(tBoolean), + hasTouch: tOptional(tBoolean), + colorScheme: tOptional(tEnum(["dark", "light", "no-preference", "no-override"])), + reducedMotion: tOptional(tEnum(["reduce", "no-preference", "no-override"])), + forcedColors: tOptional(tEnum(["active", "none", "no-override"])), + acceptDownloads: tOptional(tEnum(["accept", "deny", "internal-browser-default"])), + contrast: tOptional(tEnum(["no-preference", "more", "no-override"])), + baseURL: tOptional(tString), + recordVideo: tOptional( + tObject({ + dir: tString, + size: tOptional( + tObject({ + width: tInt, + height: tInt, + }) + ), + }) + ), + strictSelectors: tOptional(tBoolean), + serviceWorkers: tOptional(tEnum(["allow", "block"])), + selectorEngines: tOptional(tArray(tType("SelectorEngine"))), + testIdAttributeName: tOptional(tString), + userDataDir: tString, + slowMo: tOptional(tFloat), +}); +scheme.BrowserTypeLaunchPersistentContextResult = tObject({ + browser: tChannel(["Browser"]), + context: tChannel(["BrowserContext"]), +}); +scheme.BrowserTypeConnectOverCDPParams = tObject({ + endpointURL: tString, + headers: tOptional(tArray(tType("NameValue"))), + slowMo: tOptional(tFloat), + timeout: tFloat, + isLocal: tOptional(tBoolean), +}); +scheme.BrowserTypeConnectOverCDPResult = tObject({ + browser: tChannel(["Browser"]), + defaultContext: tOptional(tChannel(["BrowserContext"])), +}); +scheme.BrowserTypeConnectOverCDPTransportParams = tObject({ + transport: tBinary, +}); +scheme.BrowserTypeConnectOverCDPTransportResult = tObject({ + browser: tChannel(["Browser"]), + defaultContext: tOptional(tChannel(["BrowserContext"])), +}); +scheme.BrowserInitializer = tObject({ + version: tString, + name: tString, +}); +scheme.BrowserContextEvent = tObject({ + context: tChannel(["BrowserContext"]), +}); +scheme.BrowserCloseEvent = tOptional(tObject({})); +scheme.BrowserStartServerParams = tObject({ + title: tString, + workspaceDir: tOptional(tString), + metadata: tOptional(tAny), +}); +scheme.BrowserStartServerResult = tObject({ + pipeName: tString, +}); +scheme.BrowserStopServerParams = tOptional(tObject({})); +scheme.BrowserStopServerResult = tOptional(tObject({})); +scheme.BrowserCloseParams = tObject({ + reason: tOptional(tString), +}); +scheme.BrowserCloseResult = tOptional(tObject({})); +scheme.BrowserKillForTestsParams = tOptional(tObject({})); +scheme.BrowserKillForTestsResult = tOptional(tObject({})); +scheme.BrowserDefaultUserAgentForTestParams = tOptional(tObject({})); +scheme.BrowserDefaultUserAgentForTestResult = tObject({ + userAgent: tString, +}); +scheme.BrowserNewContextParams = tObject({ + noDefaultViewport: tOptional(tBoolean), + viewport: tOptional( + tObject({ + width: tInt, + height: tInt, + }) + ), + screen: tOptional( + tObject({ + width: tInt, + height: tInt, + }) + ), + ignoreHTTPSErrors: tOptional(tBoolean), + clientCertificates: tOptional( + tArray( + tObject({ + origin: tString, + cert: tOptional(tBinary), + key: tOptional(tBinary), + passphrase: tOptional(tString), + pfx: tOptional(tBinary), + }) + ) + ), + javaScriptEnabled: tOptional(tBoolean), + bypassCSP: tOptional(tBoolean), + userAgent: tOptional(tString), + locale: tOptional(tString), + timezoneId: tOptional(tString), + geolocation: tOptional( + tObject({ + longitude: tFloat, + latitude: tFloat, + accuracy: tOptional(tFloat), + }) + ), + permissions: tOptional(tArray(tString)), + extraHTTPHeaders: tOptional(tArray(tType("NameValue"))), + offline: tOptional(tBoolean), + httpCredentials: tOptional( + tObject({ + username: tString, + password: tString, + origin: tOptional(tString), + send: tOptional(tEnum(["always", "unauthorized"])), + }) + ), + deviceScaleFactor: tOptional(tFloat), + isMobile: tOptional(tBoolean), + hasTouch: tOptional(tBoolean), + colorScheme: tOptional(tEnum(["dark", "light", "no-preference", "no-override"])), + reducedMotion: tOptional(tEnum(["reduce", "no-preference", "no-override"])), + forcedColors: tOptional(tEnum(["active", "none", "no-override"])), + acceptDownloads: tOptional(tEnum(["accept", "deny", "internal-browser-default"])), + contrast: tOptional(tEnum(["no-preference", "more", "no-override"])), + baseURL: tOptional(tString), + recordVideo: tOptional( + tObject({ + dir: tString, + size: tOptional( + tObject({ + width: tInt, + height: tInt, + }) + ), + }) + ), + strictSelectors: tOptional(tBoolean), + serviceWorkers: tOptional(tEnum(["allow", "block"])), + selectorEngines: tOptional(tArray(tType("SelectorEngine"))), + testIdAttributeName: tOptional(tString), + proxy: tOptional( + tObject({ + server: tString, + bypass: tOptional(tString), + username: tOptional(tString), + password: tOptional(tString), + }) + ), + storageState: tOptional( + tObject({ + cookies: tOptional(tArray(tType("SetNetworkCookie"))), + origins: tOptional(tArray(tType("SetOriginStorage"))), + }) + ), +}); +scheme.BrowserNewContextResult = tObject({ + context: tChannel(["BrowserContext"]), +}); +scheme.BrowserNewContextForReuseParams = tObject({ + noDefaultViewport: tOptional(tBoolean), + viewport: tOptional( + tObject({ + width: tInt, + height: tInt, + }) + ), + screen: tOptional( + tObject({ + width: tInt, + height: tInt, + }) + ), + ignoreHTTPSErrors: tOptional(tBoolean), + clientCertificates: tOptional( + tArray( + tObject({ + origin: tString, + cert: tOptional(tBinary), + key: tOptional(tBinary), + passphrase: tOptional(tString), + pfx: tOptional(tBinary), + }) + ) + ), + javaScriptEnabled: tOptional(tBoolean), + bypassCSP: tOptional(tBoolean), + userAgent: tOptional(tString), + locale: tOptional(tString), + timezoneId: tOptional(tString), + geolocation: tOptional( + tObject({ + longitude: tFloat, + latitude: tFloat, + accuracy: tOptional(tFloat), + }) + ), + permissions: tOptional(tArray(tString)), + extraHTTPHeaders: tOptional(tArray(tType("NameValue"))), + offline: tOptional(tBoolean), + httpCredentials: tOptional( + tObject({ + username: tString, + password: tString, + origin: tOptional(tString), + send: tOptional(tEnum(["always", "unauthorized"])), + }) + ), + deviceScaleFactor: tOptional(tFloat), + isMobile: tOptional(tBoolean), + hasTouch: tOptional(tBoolean), + colorScheme: tOptional(tEnum(["dark", "light", "no-preference", "no-override"])), + reducedMotion: tOptional(tEnum(["reduce", "no-preference", "no-override"])), + forcedColors: tOptional(tEnum(["active", "none", "no-override"])), + acceptDownloads: tOptional(tEnum(["accept", "deny", "internal-browser-default"])), + contrast: tOptional(tEnum(["no-preference", "more", "no-override"])), + baseURL: tOptional(tString), + recordVideo: tOptional( + tObject({ + dir: tString, + size: tOptional( + tObject({ + width: tInt, + height: tInt, + }) + ), + }) + ), + strictSelectors: tOptional(tBoolean), + serviceWorkers: tOptional(tEnum(["allow", "block"])), + selectorEngines: tOptional(tArray(tType("SelectorEngine"))), + testIdAttributeName: tOptional(tString), + proxy: tOptional( + tObject({ + server: tString, + bypass: tOptional(tString), + username: tOptional(tString), + password: tOptional(tString), + }) + ), + storageState: tOptional( + tObject({ + cookies: tOptional(tArray(tType("SetNetworkCookie"))), + origins: tOptional(tArray(tType("SetOriginStorage"))), + }) + ), +}); +scheme.BrowserNewContextForReuseResult = tObject({ + context: tChannel(["BrowserContext"]), +}); +scheme.BrowserDisconnectFromReusedContextParams = tObject({ + reason: tString, +}); +scheme.BrowserDisconnectFromReusedContextResult = tOptional(tObject({})); +scheme.BrowserNewBrowserCDPSessionParams = tOptional(tObject({})); +scheme.BrowserNewBrowserCDPSessionResult = tObject({ + session: tChannel(["CDPSession"]), +}); +scheme.BrowserStartTracingParams = tObject({ + page: tOptional(tChannel(["Page"])), + screenshots: tOptional(tBoolean), + categories: tOptional(tArray(tString)), +}); +scheme.BrowserStartTracingResult = tOptional(tObject({})); +scheme.BrowserStopTracingParams = tOptional(tObject({})); +scheme.BrowserStopTracingResult = tObject({ + artifact: tChannel(["Artifact"]), +}); +scheme.EventTargetInitializer = tOptional(tObject({})); +scheme.EventTargetWaitForEventInfoParams = tObject({ + info: tObject({ + waitId: tString, + phase: tEnum(["before", "after", "log"]), + event: tOptional(tString), + message: tOptional(tString), + error: tOptional(tString), + }), +}); +scheme.BrowserContextWaitForEventInfoParams = tType("EventTargetWaitForEventInfoParams"); +scheme.PageWaitForEventInfoParams = tType("EventTargetWaitForEventInfoParams"); +scheme.WorkerWaitForEventInfoParams = tType("EventTargetWaitForEventInfoParams"); +scheme.WebSocketWaitForEventInfoParams = tType("EventTargetWaitForEventInfoParams"); +scheme.DebuggerWaitForEventInfoParams = tType("EventTargetWaitForEventInfoParams"); +scheme.ElectronApplicationWaitForEventInfoParams = tType("EventTargetWaitForEventInfoParams"); +scheme.AndroidDeviceWaitForEventInfoParams = tType("EventTargetWaitForEventInfoParams"); +scheme.EventTargetWaitForEventInfoResult = tOptional(tObject({})); +scheme.BrowserContextWaitForEventInfoResult = tType("EventTargetWaitForEventInfoResult"); +scheme.PageWaitForEventInfoResult = tType("EventTargetWaitForEventInfoResult"); +scheme.WorkerWaitForEventInfoResult = tType("EventTargetWaitForEventInfoResult"); +scheme.WebSocketWaitForEventInfoResult = tType("EventTargetWaitForEventInfoResult"); +scheme.DebuggerWaitForEventInfoResult = tType("EventTargetWaitForEventInfoResult"); +scheme.ElectronApplicationWaitForEventInfoResult = tType("EventTargetWaitForEventInfoResult"); +scheme.AndroidDeviceWaitForEventInfoResult = tType("EventTargetWaitForEventInfoResult"); +scheme.BrowserContextInitializer = tObject({ + requestContext: tChannel(["APIRequestContext"]), + tracing: tChannel(["Tracing"]), + options: tObject({ + noDefaultViewport: tOptional(tBoolean), + viewport: tOptional( + tObject({ + width: tInt, + height: tInt, + }) + ), + screen: tOptional( + tObject({ + width: tInt, + height: tInt, + }) + ), + ignoreHTTPSErrors: tOptional(tBoolean), + clientCertificates: tOptional( + tArray( + tObject({ + origin: tString, + cert: tOptional(tBinary), + key: tOptional(tBinary), + passphrase: tOptional(tString), + pfx: tOptional(tBinary), + }) + ) + ), + javaScriptEnabled: tOptional(tBoolean), + bypassCSP: tOptional(tBoolean), + userAgent: tOptional(tString), + locale: tOptional(tString), + timezoneId: tOptional(tString), + geolocation: tOptional( + tObject({ + longitude: tFloat, + latitude: tFloat, + accuracy: tOptional(tFloat), + }) + ), + permissions: tOptional(tArray(tString)), + extraHTTPHeaders: tOptional(tArray(tType("NameValue"))), + offline: tOptional(tBoolean), + httpCredentials: tOptional( + tObject({ + username: tString, + password: tString, + origin: tOptional(tString), + send: tOptional(tEnum(["always", "unauthorized"])), + }) + ), + deviceScaleFactor: tOptional(tFloat), + isMobile: tOptional(tBoolean), + hasTouch: tOptional(tBoolean), + colorScheme: tOptional(tEnum(["dark", "light", "no-preference", "no-override"])), + reducedMotion: tOptional(tEnum(["reduce", "no-preference", "no-override"])), + forcedColors: tOptional(tEnum(["active", "none", "no-override"])), + acceptDownloads: tOptional(tEnum(["accept", "deny", "internal-browser-default"])), + contrast: tOptional(tEnum(["no-preference", "more", "no-override"])), + baseURL: tOptional(tString), + recordVideo: tOptional( + tObject({ + dir: tString, + size: tOptional( + tObject({ + width: tInt, + height: tInt, + }) + ), + }) + ), + strictSelectors: tOptional(tBoolean), + serviceWorkers: tOptional(tEnum(["allow", "block"])), + selectorEngines: tOptional(tArray(tType("SelectorEngine"))), + testIdAttributeName: tOptional(tString), + }), +}); +scheme.BrowserContextBindingCallEvent = tObject({ + binding: tChannel(["BindingCall"]), +}); +scheme.BrowserContextConsoleEvent = tObject({ + type: tString, + text: tString, + args: tArray(tChannel(["ElementHandle", "JSHandle"])), + location: tObject({ + url: tString, + lineNumber: tInt, + columnNumber: tInt, + }), + timestamp: tOptional(tFloat), + page: tOptional(tChannel(["Page"])), + worker: tOptional(tChannel(["Worker"])), +}); +scheme.BrowserContextCloseEvent = tOptional(tObject({})); +scheme.BrowserContextDialogEvent = tObject({ + dialog: tChannel(["Dialog"]), +}); +scheme.BrowserContextPageEvent = tObject({ + page: tChannel(["Page"]), +}); +scheme.BrowserContextPageErrorEvent = tObject({ + error: tType("SerializedError"), + page: tChannel(["Page"]), +}); +scheme.BrowserContextRouteEvent = tObject({ + route: tChannel(["Route"]), +}); +scheme.BrowserContextWebSocketRouteEvent = tObject({ + webSocketRoute: tChannel(["WebSocketRoute"]), +}); +scheme.BrowserContextServiceWorkerEvent = tObject({ + worker: tChannel(["Worker"]), +}); +scheme.BrowserContextRequestEvent = tObject({ + request: tChannel(["Request"]), + page: tOptional(tChannel(["Page"])), +}); +scheme.BrowserContextRequestFailedEvent = tObject({ + request: tChannel(["Request"]), + failureText: tOptional(tString), + responseEndTiming: tFloat, + page: tOptional(tChannel(["Page"])), +}); +scheme.BrowserContextRequestFinishedEvent = tObject({ + request: tChannel(["Request"]), + response: tOptional(tChannel(["Response"])), + responseEndTiming: tFloat, + page: tOptional(tChannel(["Page"])), +}); +scheme.BrowserContextResponseEvent = tObject({ + response: tChannel(["Response"]), + page: tOptional(tChannel(["Page"])), +}); +scheme.BrowserContextRecorderEventEvent = tObject({ + event: tEnum(["actionAdded", "actionUpdated", "signalAdded"]), + data: tAny, + page: tChannel(["Page"]), + code: tString, +}); +scheme.BrowserContextAddCookiesParams = tObject({ + cookies: tArray(tType("SetNetworkCookie")), +}); +scheme.BrowserContextAddCookiesResult = tOptional(tObject({})); +scheme.BrowserContextAddInitScriptParams = tObject({ + source: tString, +}); +scheme.BrowserContextAddInitScriptResult = tObject({ + disposable: tChannel(["Disposable"]), +}); +scheme.BrowserContextClearCookiesParams = tObject({ + name: tOptional(tString), + nameRegexSource: tOptional(tString), + nameRegexFlags: tOptional(tString), + domain: tOptional(tString), + domainRegexSource: tOptional(tString), + domainRegexFlags: tOptional(tString), + path: tOptional(tString), + pathRegexSource: tOptional(tString), + pathRegexFlags: tOptional(tString), +}); +scheme.BrowserContextClearCookiesResult = tOptional(tObject({})); +scheme.BrowserContextClearPermissionsParams = tOptional(tObject({})); +scheme.BrowserContextClearPermissionsResult = tOptional(tObject({})); +scheme.BrowserContextCloseParams = tObject({ + reason: tOptional(tString), +}); +scheme.BrowserContextCloseResult = tOptional(tObject({})); +scheme.BrowserContextCookiesParams = tObject({ + urls: tArray(tString), +}); +scheme.BrowserContextCookiesResult = tObject({ + cookies: tArray(tType("NetworkCookie")), +}); +scheme.BrowserContextExposeBindingParams = tObject({ + name: tString, + needsHandle: tOptional(tBoolean), +}); +scheme.BrowserContextExposeBindingResult = tObject({ + disposable: tChannel(["Disposable"]), +}); +scheme.BrowserContextGrantPermissionsParams = tObject({ + permissions: tArray(tString), + origin: tOptional(tString), +}); +scheme.BrowserContextGrantPermissionsResult = tOptional(tObject({})); +scheme.BrowserContextNewPageParams = tOptional(tObject({})); +scheme.BrowserContextNewPageResult = tObject({ + page: tChannel(["Page"]), +}); +scheme.BrowserContextRegisterSelectorEngineParams = tObject({ + selectorEngine: tType("SelectorEngine"), +}); +scheme.BrowserContextRegisterSelectorEngineResult = tOptional(tObject({})); +scheme.BrowserContextSetTestIdAttributeNameParams = tObject({ + testIdAttributeName: tString, +}); +scheme.BrowserContextSetTestIdAttributeNameResult = tOptional(tObject({})); +scheme.BrowserContextSetExtraHTTPHeadersParams = tObject({ + headers: tArray(tType("NameValue")), +}); +scheme.BrowserContextSetExtraHTTPHeadersResult = tOptional(tObject({})); +scheme.BrowserContextSetGeolocationParams = tObject({ + geolocation: tOptional( + tObject({ + longitude: tFloat, + latitude: tFloat, + accuracy: tOptional(tFloat), + }) + ), +}); +scheme.BrowserContextSetGeolocationResult = tOptional(tObject({})); +scheme.BrowserContextSetHTTPCredentialsParams = tObject({ + httpCredentials: tOptional( + tObject({ + username: tString, + password: tString, + origin: tOptional(tString), + }) + ), +}); +scheme.BrowserContextSetHTTPCredentialsResult = tOptional(tObject({})); +scheme.BrowserContextSetNetworkInterceptionPatternsParams = tObject({ + patterns: tArray( + tObject({ + glob: tOptional(tString), + regexSource: tOptional(tString), + regexFlags: tOptional(tString), + urlPattern: tOptional(tType("URLPattern")), + }) + ), +}); +scheme.BrowserContextSetNetworkInterceptionPatternsResult = tOptional(tObject({})); +scheme.BrowserContextSetWebSocketInterceptionPatternsParams = tObject({ + patterns: tArray( + tObject({ + glob: tOptional(tString), + regexSource: tOptional(tString), + regexFlags: tOptional(tString), + urlPattern: tOptional(tType("URLPattern")), + }) + ), +}); +scheme.BrowserContextSetWebSocketInterceptionPatternsResult = tOptional(tObject({})); +scheme.BrowserContextSetOfflineParams = tObject({ + offline: tBoolean, +}); +scheme.BrowserContextSetOfflineResult = tOptional(tObject({})); +scheme.BrowserContextStorageStateParams = tObject({ + indexedDB: tOptional(tBoolean), +}); +scheme.BrowserContextStorageStateResult = tObject({ + cookies: tArray(tType("NetworkCookie")), + origins: tArray(tType("OriginStorage")), +}); +scheme.BrowserContextSetStorageStateParams = tObject({ + storageState: tOptional( + tObject({ + cookies: tOptional(tArray(tType("SetNetworkCookie"))), + origins: tOptional(tArray(tType("SetOriginStorage"))), + }) + ), +}); +scheme.BrowserContextSetStorageStateResult = tOptional(tObject({})); +scheme.BrowserContextPauseParams = tOptional(tObject({})); +scheme.BrowserContextPauseResult = tOptional(tObject({})); +scheme.BrowserContextEnableRecorderParams = tObject({ + language: tOptional(tString), + mode: tOptional(tEnum(["inspecting", "recording"])), + recorderMode: tOptional(tEnum(["default", "api"])), + pauseOnNextStatement: tOptional(tBoolean), + testIdAttributeName: tOptional(tString), + launchOptions: tOptional(tAny), + contextOptions: tOptional(tAny), + device: tOptional(tString), + saveStorage: tOptional(tString), + outputFile: tOptional(tString), + handleSIGINT: tOptional(tBoolean), + omitCallTracking: tOptional(tBoolean), +}); +scheme.BrowserContextEnableRecorderResult = tOptional(tObject({})); +scheme.BrowserContextDisableRecorderParams = tOptional(tObject({})); +scheme.BrowserContextDisableRecorderResult = tOptional(tObject({})); +scheme.BrowserContextExposeConsoleApiParams = tOptional(tObject({})); +scheme.BrowserContextExposeConsoleApiResult = tOptional(tObject({})); +scheme.BrowserContextNewCDPSessionParams = tObject({ + page: tOptional(tChannel(["Page"])), + frame: tOptional(tChannel(["Frame"])), +}); +scheme.BrowserContextNewCDPSessionResult = tObject({ + session: tChannel(["CDPSession"]), +}); +scheme.BrowserContextHarStartParams = tObject({ + page: tOptional(tChannel(["Page"])), + options: tType("RecordHarOptions"), +}); +scheme.BrowserContextHarStartResult = tObject({ + harId: tString, +}); +scheme.BrowserContextHarExportParams = tObject({ + harId: tOptional(tString), +}); +scheme.BrowserContextHarExportResult = tObject({ + artifact: tChannel(["Artifact"]), +}); +scheme.BrowserContextCreateTempFilesParams = tObject({ + rootDirName: tOptional(tString), + items: tArray( + tObject({ + name: tString, + lastModifiedMs: tOptional(tFloat), + }) + ), +}); +scheme.BrowserContextCreateTempFilesResult = tObject({ + rootDir: tOptional(tChannel(["WritableStream"])), + writableStreams: tArray(tChannel(["WritableStream"])), +}); +scheme.BrowserContextUpdateSubscriptionParams = tObject({ + event: tEnum(["console", "dialog", "request", "response", "requestFinished", "requestFailed"]), + enabled: tBoolean, +}); +scheme.BrowserContextUpdateSubscriptionResult = tOptional(tObject({})); +scheme.BrowserContextClockFastForwardParams = tObject({ + ticksNumber: tOptional(tFloat), + ticksString: tOptional(tString), +}); +scheme.BrowserContextClockFastForwardResult = tOptional(tObject({})); +scheme.BrowserContextClockInstallParams = tObject({ + timeNumber: tOptional(tFloat), + timeString: tOptional(tString), +}); +scheme.BrowserContextClockInstallResult = tOptional(tObject({})); +scheme.BrowserContextClockPauseAtParams = tObject({ + timeNumber: tOptional(tFloat), + timeString: tOptional(tString), +}); +scheme.BrowserContextClockPauseAtResult = tOptional(tObject({})); +scheme.BrowserContextClockResumeParams = tOptional(tObject({})); +scheme.BrowserContextClockResumeResult = tOptional(tObject({})); +scheme.BrowserContextClockRunForParams = tObject({ + ticksNumber: tOptional(tFloat), + ticksString: tOptional(tString), +}); +scheme.BrowserContextClockRunForResult = tOptional(tObject({})); +scheme.BrowserContextClockSetFixedTimeParams = tObject({ + timeNumber: tOptional(tFloat), + timeString: tOptional(tString), +}); +scheme.BrowserContextClockSetFixedTimeResult = tOptional(tObject({})); +scheme.BrowserContextClockSetSystemTimeParams = tObject({ + timeNumber: tOptional(tFloat), + timeString: tOptional(tString), +}); +scheme.BrowserContextClockSetSystemTimeResult = tOptional(tObject({})); +scheme.PageInitializer = tObject({ + mainFrame: tChannel(["Frame"]), + viewportSize: tOptional( + tObject({ + width: tInt, + height: tInt, + }) + ), + isClosed: tBoolean, + opener: tOptional(tChannel(["Page"])), + video: tOptional(tChannel(["Artifact"])), +}); +scheme.PageBindingCallEvent = tObject({ + binding: tChannel(["BindingCall"]), +}); +scheme.PageCloseEvent = tOptional(tObject({})); +scheme.PageCrashEvent = tOptional(tObject({})); +scheme.PageDownloadEvent = tObject({ + url: tString, + suggestedFilename: tString, + artifact: tChannel(["Artifact"]), +}); +scheme.PageViewportSizeChangedEvent = tObject({ + viewportSize: tOptional( + tObject({ + width: tInt, + height: tInt, + }) + ), +}); +scheme.PageFileChooserEvent = tObject({ + element: tChannel(["ElementHandle"]), + isMultiple: tBoolean, +}); +scheme.PageFrameAttachedEvent = tObject({ + frame: tChannel(["Frame"]), +}); +scheme.PageFrameDetachedEvent = tObject({ + frame: tChannel(["Frame"]), +}); +scheme.PageLocatorHandlerTriggeredEvent = tObject({ + uid: tInt, +}); +scheme.PageRouteEvent = tObject({ + route: tChannel(["Route"]), +}); +scheme.PageScreencastFrameEvent = tObject({ + data: tBinary, +}); +scheme.PageWebSocketRouteEvent = tObject({ + webSocketRoute: tChannel(["WebSocketRoute"]), +}); +scheme.PageWebSocketEvent = tObject({ + webSocket: tChannel(["WebSocket"]), +}); +scheme.PageWorkerEvent = tObject({ + worker: tChannel(["Worker"]), +}); +scheme.PageAddInitScriptParams = tObject({ + source: tString, +}); +scheme.PageAddInitScriptResult = tObject({ + disposable: tChannel(["Disposable"]), +}); +scheme.PageCloseParams = tObject({ + runBeforeUnload: tOptional(tBoolean), + reason: tOptional(tString), +}); +scheme.PageCloseResult = tOptional(tObject({})); +scheme.PageClearConsoleMessagesParams = tOptional(tObject({})); +scheme.PageClearConsoleMessagesResult = tOptional(tObject({})); +scheme.PageConsoleMessagesParams = tObject({ + filter: tOptional(tType("ConsoleMessagesFilter")), +}); +scheme.PageConsoleMessagesResult = tObject({ + messages: tArray( + tObject({ + type: tString, + text: tString, + args: tArray(tChannel(["ElementHandle", "JSHandle"])), + location: tObject({ + url: tString, + lineNumber: tInt, + columnNumber: tInt, + }), + timestamp: tFloat, + }) + ), +}); +scheme.PageEmulateMediaParams = tObject({ + media: tOptional(tEnum(["screen", "print", "no-override"])), + colorScheme: tOptional(tEnum(["dark", "light", "no-preference", "no-override"])), + reducedMotion: tOptional(tEnum(["reduce", "no-preference", "no-override"])), + forcedColors: tOptional(tEnum(["active", "none", "no-override"])), + contrast: tOptional(tEnum(["no-preference", "more", "no-override"])), +}); +scheme.PageEmulateMediaResult = tOptional(tObject({})); +scheme.PageExposeBindingParams = tObject({ + name: tString, + needsHandle: tOptional(tBoolean), +}); +scheme.PageExposeBindingResult = tObject({ + disposable: tChannel(["Disposable"]), +}); +scheme.PageGoBackParams = tObject({ + timeout: tFloat, + waitUntil: tOptional(tType("LifecycleEvent")), +}); +scheme.PageGoBackResult = tObject({ + response: tOptional(tChannel(["Response"])), +}); +scheme.PageGoForwardParams = tObject({ + timeout: tFloat, + waitUntil: tOptional(tType("LifecycleEvent")), +}); +scheme.PageGoForwardResult = tObject({ + response: tOptional(tChannel(["Response"])), +}); +scheme.PageRequestGCParams = tOptional(tObject({})); +scheme.PageRequestGCResult = tOptional(tObject({})); +scheme.PageRegisterLocatorHandlerParams = tObject({ + selector: tString, + noWaitAfter: tOptional(tBoolean), +}); +scheme.PageRegisterLocatorHandlerResult = tObject({ + uid: tInt, +}); +scheme.PageResolveLocatorHandlerNoReplyParams = tObject({ + uid: tInt, + remove: tOptional(tBoolean), +}); +scheme.PageResolveLocatorHandlerNoReplyResult = tOptional(tObject({})); +scheme.PageUnregisterLocatorHandlerParams = tObject({ + uid: tInt, +}); +scheme.PageUnregisterLocatorHandlerResult = tOptional(tObject({})); +scheme.PageReloadParams = tObject({ + timeout: tFloat, + waitUntil: tOptional(tType("LifecycleEvent")), +}); +scheme.PageReloadResult = tObject({ + response: tOptional(tChannel(["Response"])), +}); +scheme.PageExpectScreenshotParams = tObject({ + expected: tOptional(tBinary), + timeout: tFloat, + isNot: tBoolean, + locator: tOptional( + tObject({ + frame: tChannel(["Frame"]), + selector: tString, + }) + ), + comparator: tOptional(tString), + maxDiffPixels: tOptional(tInt), + maxDiffPixelRatio: tOptional(tFloat), + threshold: tOptional(tFloat), + fullPage: tOptional(tBoolean), + clip: tOptional(tType("Rect")), + omitBackground: tOptional(tBoolean), + caret: tOptional(tEnum(["hide", "initial"])), + animations: tOptional(tEnum(["disabled", "allow"])), + scale: tOptional(tEnum(["css", "device"])), + mask: tOptional( + tArray( + tObject({ + frame: tChannel(["Frame"]), + selector: tString, + }) + ) + ), + maskColor: tOptional(tString), + style: tOptional(tString), +}); +scheme.PageExpectScreenshotResult = tObject({ + diff: tOptional(tBinary), + errorMessage: tOptional(tString), + actual: tOptional(tBinary), + previous: tOptional(tBinary), + timedOut: tOptional(tBoolean), + log: tOptional(tArray(tString)), +}); +scheme.PageScreenshotParams = tObject({ + timeout: tFloat, + type: tOptional(tEnum(["png", "jpeg"])), + quality: tOptional(tInt), + fullPage: tOptional(tBoolean), + clip: tOptional(tType("Rect")), + omitBackground: tOptional(tBoolean), + caret: tOptional(tEnum(["hide", "initial"])), + animations: tOptional(tEnum(["disabled", "allow"])), + scale: tOptional(tEnum(["css", "device"])), + mask: tOptional( + tArray( + tObject({ + frame: tChannel(["Frame"]), + selector: tString, + }) + ) + ), + maskColor: tOptional(tString), + style: tOptional(tString), +}); +scheme.PageScreenshotResult = tObject({ + binary: tBinary, +}); +scheme.PageSetExtraHTTPHeadersParams = tObject({ + headers: tArray(tType("NameValue")), +}); +scheme.PageSetExtraHTTPHeadersResult = tOptional(tObject({})); +scheme.PageSetNetworkInterceptionPatternsParams = tObject({ + patterns: tArray( + tObject({ + glob: tOptional(tString), + regexSource: tOptional(tString), + regexFlags: tOptional(tString), + urlPattern: tOptional(tType("URLPattern")), + }) + ), +}); +scheme.PageSetNetworkInterceptionPatternsResult = tOptional(tObject({})); +scheme.PageSetWebSocketInterceptionPatternsParams = tObject({ + patterns: tArray( + tObject({ + glob: tOptional(tString), + regexSource: tOptional(tString), + regexFlags: tOptional(tString), + urlPattern: tOptional(tType("URLPattern")), + }) + ), +}); +scheme.PageSetWebSocketInterceptionPatternsResult = tOptional(tObject({})); +scheme.PageSetViewportSizeParams = tObject({ + viewportSize: tObject({ + width: tInt, + height: tInt, + }), +}); +scheme.PageSetViewportSizeResult = tOptional(tObject({})); +scheme.PageKeyboardDownParams = tObject({ + key: tString, +}); +scheme.PageKeyboardDownResult = tOptional(tObject({})); +scheme.PageKeyboardUpParams = tObject({ + key: tString, +}); +scheme.PageKeyboardUpResult = tOptional(tObject({})); +scheme.PageKeyboardInsertTextParams = tObject({ + text: tString, +}); +scheme.PageKeyboardInsertTextResult = tOptional(tObject({})); +scheme.PageKeyboardTypeParams = tObject({ + text: tString, + delay: tOptional(tFloat), +}); +scheme.PageKeyboardTypeResult = tOptional(tObject({})); +scheme.PageKeyboardPressParams = tObject({ + key: tString, + delay: tOptional(tFloat), +}); +scheme.PageKeyboardPressResult = tOptional(tObject({})); +scheme.PageMouseMoveParams = tObject({ + x: tFloat, + y: tFloat, + steps: tOptional(tInt), +}); +scheme.PageMouseMoveResult = tOptional(tObject({})); +scheme.PageMouseDownParams = tObject({ + button: tOptional(tEnum(["left", "right", "middle"])), + clickCount: tOptional(tInt), +}); +scheme.PageMouseDownResult = tOptional(tObject({})); +scheme.PageMouseUpParams = tObject({ + button: tOptional(tEnum(["left", "right", "middle"])), + clickCount: tOptional(tInt), +}); +scheme.PageMouseUpResult = tOptional(tObject({})); +scheme.PageMouseClickParams = tObject({ + x: tFloat, + y: tFloat, + delay: tOptional(tFloat), + button: tOptional(tEnum(["left", "right", "middle"])), + clickCount: tOptional(tInt), +}); +scheme.PageMouseClickResult = tOptional(tObject({})); +scheme.PageMouseWheelParams = tObject({ + deltaX: tFloat, + deltaY: tFloat, +}); +scheme.PageMouseWheelResult = tOptional(tObject({})); +scheme.PageTouchscreenTapParams = tObject({ + x: tFloat, + y: tFloat, +}); +scheme.PageTouchscreenTapResult = tOptional(tObject({})); +scheme.PageClearPageErrorsParams = tOptional(tObject({})); +scheme.PageClearPageErrorsResult = tOptional(tObject({})); +scheme.PagePageErrorsParams = tObject({ + filter: tOptional(tType("ConsoleMessagesFilter")), +}); +scheme.PagePageErrorsResult = tObject({ + errors: tArray(tType("SerializedError")), +}); +scheme.PagePdfParams = tObject({ + scale: tOptional(tFloat), + displayHeaderFooter: tOptional(tBoolean), + headerTemplate: tOptional(tString), + footerTemplate: tOptional(tString), + printBackground: tOptional(tBoolean), + landscape: tOptional(tBoolean), + pageRanges: tOptional(tString), + format: tOptional(tString), + width: tOptional(tString), + height: tOptional(tString), + preferCSSPageSize: tOptional(tBoolean), + margin: tOptional( + tObject({ + top: tOptional(tString), + bottom: tOptional(tString), + left: tOptional(tString), + right: tOptional(tString), + }) + ), + tagged: tOptional(tBoolean), + outline: tOptional(tBoolean), +}); +scheme.PagePdfResult = tObject({ + pdf: tBinary, +}); +scheme.PageRequestsParams = tOptional(tObject({})); +scheme.PageRequestsResult = tObject({ + requests: tArray(tChannel(["Request"])), +}); +scheme.PageSnapshotForAIParams = tObject({ + track: tOptional(tString), + selector: tOptional(tString), + depth: tOptional(tInt), + timeout: tFloat, +}); +scheme.PageSnapshotForAIResult = tObject({ + full: tString, + incremental: tOptional(tString), +}); +scheme.PageStartJSCoverageParams = tObject({ + resetOnNavigation: tOptional(tBoolean), + reportAnonymousScripts: tOptional(tBoolean), +}); +scheme.PageStartJSCoverageResult = tOptional(tObject({})); +scheme.PageStopJSCoverageParams = tOptional(tObject({})); +scheme.PageStopJSCoverageResult = tObject({ + entries: tArray( + tObject({ + url: tString, + scriptId: tString, + source: tOptional(tString), + functions: tArray( + tObject({ + functionName: tString, + isBlockCoverage: tBoolean, + ranges: tArray( + tObject({ + startOffset: tInt, + endOffset: tInt, + count: tInt, + }) + ), + }) + ), + }) + ), +}); +scheme.PageStartCSSCoverageParams = tObject({ + resetOnNavigation: tOptional(tBoolean), +}); +scheme.PageStartCSSCoverageResult = tOptional(tObject({})); +scheme.PageStopCSSCoverageParams = tOptional(tObject({})); +scheme.PageStopCSSCoverageResult = tObject({ + entries: tArray( + tObject({ + url: tString, + text: tOptional(tString), + ranges: tArray( + tObject({ + start: tInt, + end: tInt, + }) + ), + }) + ), +}); +scheme.PageBringToFrontParams = tOptional(tObject({})); +scheme.PageBringToFrontResult = tOptional(tObject({})); +scheme.PagePickLocatorParams = tOptional(tObject({})); +scheme.PagePickLocatorResult = tObject({ + selector: tString, +}); +scheme.PageCancelPickLocatorParams = tOptional(tObject({})); +scheme.PageCancelPickLocatorResult = tOptional(tObject({})); +scheme.PageStartScreencastParams = tObject({ + maxSize: tOptional( + tObject({ + width: tInt, + height: tInt, + }) + ), +}); +scheme.PageStartScreencastResult = tOptional(tObject({})); +scheme.PageStopScreencastParams = tOptional(tObject({})); +scheme.PageStopScreencastResult = tOptional(tObject({})); +scheme.PageVideoStartParams = tObject({ + size: tOptional( + tObject({ + width: tInt, + height: tInt, + }) + ), +}); +scheme.PageVideoStartResult = tObject({ + artifact: tChannel(["Artifact"]), +}); +scheme.PageVideoStopParams = tOptional(tObject({})); +scheme.PageVideoStopResult = tOptional(tObject({})); +scheme.PageUpdateSubscriptionParams = tObject({ + event: tEnum([ + "console", + "dialog", + "fileChooser", + "request", + "response", + "requestFinished", + "requestFailed", + ]), + enabled: tBoolean, +}); +scheme.PageUpdateSubscriptionResult = tOptional(tObject({})); +scheme.PageSetDockTileParams = tObject({ + image: tBinary, +}); +scheme.PageSetDockTileResult = tOptional(tObject({})); +scheme.FrameInitializer = tObject({ + url: tString, + name: tString, + parentFrame: tOptional(tChannel(["Frame"])), + loadStates: tArray(tType("LifecycleEvent")), +}); +scheme.FrameLoadstateEvent = tObject({ + add: tOptional(tType("LifecycleEvent")), + remove: tOptional(tType("LifecycleEvent")), +}); +scheme.FrameNavigatedEvent = tObject({ + url: tString, + name: tString, + newDocument: tOptional( + tObject({ + request: tOptional(tChannel(["Request"])), + }) + ), + error: tOptional(tString), +}); +scheme.FrameEvalOnSelectorParams = tObject({ + selector: tString, + strict: tOptional(tBoolean), + expression: tString, + isFunction: tOptional(tBoolean), + arg: tType("SerializedArgument"), +}); +scheme.FrameEvalOnSelectorResult = tObject({ + value: tType("SerializedValue"), +}); +scheme.FrameEvalOnSelectorAllParams = tObject({ + selector: tString, + expression: tString, + isFunction: tOptional(tBoolean), + arg: tType("SerializedArgument"), +}); +scheme.FrameEvalOnSelectorAllResult = tObject({ + value: tType("SerializedValue"), +}); +scheme.FrameAddScriptTagParams = tObject({ + url: tOptional(tString), + content: tOptional(tString), + type: tOptional(tString), +}); +scheme.FrameAddScriptTagResult = tObject({ + element: tChannel(["ElementHandle"]), +}); +scheme.FrameAddStyleTagParams = tObject({ + url: tOptional(tString), + content: tOptional(tString), +}); +scheme.FrameAddStyleTagResult = tObject({ + element: tChannel(["ElementHandle"]), +}); +scheme.FrameAriaSnapshotParams = tObject({ + selector: tString, + timeout: tFloat, +}); +scheme.FrameAriaSnapshotResult = tObject({ + snapshot: tString, +}); +scheme.FrameBlurParams = tObject({ + selector: tString, + strict: tOptional(tBoolean), + timeout: tFloat, +}); +scheme.FrameBlurResult = tOptional(tObject({})); +scheme.FrameCheckParams = tObject({ + selector: tString, + strict: tOptional(tBoolean), + force: tOptional(tBoolean), + position: tOptional(tType("Point")), + timeout: tFloat, + trial: tOptional(tBoolean), +}); +scheme.FrameCheckResult = tOptional(tObject({})); +scheme.FrameClickParams = tObject({ + selector: tString, + strict: tOptional(tBoolean), + force: tOptional(tBoolean), + noWaitAfter: tOptional(tBoolean), + modifiers: tOptional(tArray(tEnum(["Alt", "Control", "ControlOrMeta", "Meta", "Shift"]))), + position: tOptional(tType("Point")), + delay: tOptional(tFloat), + button: tOptional(tEnum(["left", "right", "middle"])), + clickCount: tOptional(tInt), + timeout: tFloat, + trial: tOptional(tBoolean), + steps: tOptional(tInt), +}); +scheme.FrameClickResult = tOptional(tObject({})); +scheme.FrameContentParams = tOptional(tObject({})); +scheme.FrameContentResult = tObject({ + value: tString, +}); +scheme.FrameDragAndDropParams = tObject({ + source: tString, + target: tString, + force: tOptional(tBoolean), + timeout: tFloat, + trial: tOptional(tBoolean), + sourcePosition: tOptional(tType("Point")), + targetPosition: tOptional(tType("Point")), + strict: tOptional(tBoolean), + steps: tOptional(tInt), +}); +scheme.FrameDragAndDropResult = tOptional(tObject({})); +scheme.FrameDblclickParams = tObject({ + selector: tString, + strict: tOptional(tBoolean), + force: tOptional(tBoolean), + modifiers: tOptional(tArray(tEnum(["Alt", "Control", "ControlOrMeta", "Meta", "Shift"]))), + position: tOptional(tType("Point")), + delay: tOptional(tFloat), + button: tOptional(tEnum(["left", "right", "middle"])), + timeout: tFloat, + trial: tOptional(tBoolean), + steps: tOptional(tInt), +}); +scheme.FrameDblclickResult = tOptional(tObject({})); +scheme.FrameDispatchEventParams = tObject({ + selector: tString, + strict: tOptional(tBoolean), + type: tString, + eventInit: tType("SerializedArgument"), + timeout: tFloat, +}); +scheme.FrameDispatchEventResult = tOptional(tObject({})); +scheme.FrameEvaluateExpressionParams = tObject({ + expression: tString, + isFunction: tOptional(tBoolean), + arg: tType("SerializedArgument"), +}); +scheme.FrameEvaluateExpressionResult = tObject({ + value: tType("SerializedValue"), +}); +scheme.FrameEvaluateExpressionHandleParams = tObject({ + expression: tString, + isFunction: tOptional(tBoolean), + arg: tType("SerializedArgument"), +}); +scheme.FrameEvaluateExpressionHandleResult = tObject({ + handle: tChannel(["ElementHandle", "JSHandle"]), +}); +scheme.FrameFillParams = tObject({ + selector: tString, + strict: tOptional(tBoolean), + value: tString, + force: tOptional(tBoolean), + timeout: tFloat, +}); +scheme.FrameFillResult = tOptional(tObject({})); +scheme.FrameFocusParams = tObject({ + selector: tString, + strict: tOptional(tBoolean), + timeout: tFloat, +}); +scheme.FrameFocusResult = tOptional(tObject({})); +scheme.FrameFrameElementParams = tOptional(tObject({})); +scheme.FrameFrameElementResult = tObject({ + element: tChannel(["ElementHandle"]), +}); +scheme.FrameResolveSelectorParams = tObject({ + selector: tString, +}); +scheme.FrameResolveSelectorResult = tObject({ + resolvedSelector: tString, +}); +scheme.FrameHighlightParams = tObject({ + selector: tString, +}); +scheme.FrameHighlightResult = tOptional(tObject({})); +scheme.FrameGetAttributeParams = tObject({ + selector: tString, + strict: tOptional(tBoolean), + name: tString, + timeout: tFloat, +}); +scheme.FrameGetAttributeResult = tObject({ + value: tOptional(tString), +}); +scheme.FrameGotoParams = tObject({ + url: tString, + timeout: tFloat, + waitUntil: tOptional(tType("LifecycleEvent")), + referer: tOptional(tString), +}); +scheme.FrameGotoResult = tObject({ + response: tOptional(tChannel(["Response"])), +}); +scheme.FrameHoverParams = tObject({ + selector: tString, + strict: tOptional(tBoolean), + force: tOptional(tBoolean), + modifiers: tOptional(tArray(tEnum(["Alt", "Control", "ControlOrMeta", "Meta", "Shift"]))), + position: tOptional(tType("Point")), + timeout: tFloat, + trial: tOptional(tBoolean), +}); +scheme.FrameHoverResult = tOptional(tObject({})); +scheme.FrameInnerHTMLParams = tObject({ + selector: tString, + strict: tOptional(tBoolean), + timeout: tFloat, +}); +scheme.FrameInnerHTMLResult = tObject({ + value: tString, +}); +scheme.FrameInnerTextParams = tObject({ + selector: tString, + strict: tOptional(tBoolean), + timeout: tFloat, +}); +scheme.FrameInnerTextResult = tObject({ + value: tString, +}); +scheme.FrameInputValueParams = tObject({ + selector: tString, + strict: tOptional(tBoolean), + timeout: tFloat, +}); +scheme.FrameInputValueResult = tObject({ + value: tString, +}); +scheme.FrameIsCheckedParams = tObject({ + selector: tString, + strict: tOptional(tBoolean), + timeout: tFloat, +}); +scheme.FrameIsCheckedResult = tObject({ + value: tBoolean, +}); +scheme.FrameIsDisabledParams = tObject({ + selector: tString, + strict: tOptional(tBoolean), + timeout: tFloat, +}); +scheme.FrameIsDisabledResult = tObject({ + value: tBoolean, +}); +scheme.FrameIsEnabledParams = tObject({ + selector: tString, + strict: tOptional(tBoolean), + timeout: tFloat, +}); +scheme.FrameIsEnabledResult = tObject({ + value: tBoolean, +}); +scheme.FrameIsHiddenParams = tObject({ + selector: tString, + strict: tOptional(tBoolean), +}); +scheme.FrameIsHiddenResult = tObject({ + value: tBoolean, +}); +scheme.FrameIsVisibleParams = tObject({ + selector: tString, + strict: tOptional(tBoolean), +}); +scheme.FrameIsVisibleResult = tObject({ + value: tBoolean, +}); +scheme.FrameIsEditableParams = tObject({ + selector: tString, + strict: tOptional(tBoolean), + timeout: tFloat, +}); +scheme.FrameIsEditableResult = tObject({ + value: tBoolean, +}); +scheme.FramePressParams = tObject({ + selector: tString, + strict: tOptional(tBoolean), + key: tString, + delay: tOptional(tFloat), + noWaitAfter: tOptional(tBoolean), + timeout: tFloat, +}); +scheme.FramePressResult = tOptional(tObject({})); +scheme.FrameQuerySelectorParams = tObject({ + selector: tString, + strict: tOptional(tBoolean), +}); +scheme.FrameQuerySelectorResult = tObject({ + element: tOptional(tChannel(["ElementHandle"])), +}); +scheme.FrameQuerySelectorAllParams = tObject({ + selector: tString, +}); +scheme.FrameQuerySelectorAllResult = tObject({ + elements: tArray(tChannel(["ElementHandle"])), +}); +scheme.FrameQueryCountParams = tObject({ + selector: tString, +}); +scheme.FrameQueryCountResult = tObject({ + value: tInt, +}); +scheme.FrameSelectOptionParams = tObject({ + selector: tString, + strict: tOptional(tBoolean), + elements: tOptional(tArray(tChannel(["ElementHandle"]))), + options: tOptional( + tArray( + tObject({ + valueOrLabel: tOptional(tString), + value: tOptional(tString), + label: tOptional(tString), + index: tOptional(tInt), + }) + ) + ), + force: tOptional(tBoolean), + timeout: tFloat, +}); +scheme.FrameSelectOptionResult = tObject({ + values: tArray(tString), +}); +scheme.FrameSetContentParams = tObject({ + html: tString, + timeout: tFloat, + waitUntil: tOptional(tType("LifecycleEvent")), +}); +scheme.FrameSetContentResult = tOptional(tObject({})); +scheme.FrameSetInputFilesParams = tObject({ + selector: tString, + strict: tOptional(tBoolean), + payloads: tOptional( + tArray( + tObject({ + name: tString, + mimeType: tOptional(tString), + buffer: tBinary, + }) + ) + ), + localDirectory: tOptional(tString), + directoryStream: tOptional(tChannel(["WritableStream"])), + localPaths: tOptional(tArray(tString)), + streams: tOptional(tArray(tChannel(["WritableStream"]))), + timeout: tFloat, +}); +scheme.FrameSetInputFilesResult = tOptional(tObject({})); +scheme.FrameTapParams = tObject({ + selector: tString, + strict: tOptional(tBoolean), + force: tOptional(tBoolean), + modifiers: tOptional(tArray(tEnum(["Alt", "Control", "ControlOrMeta", "Meta", "Shift"]))), + position: tOptional(tType("Point")), + timeout: tFloat, + trial: tOptional(tBoolean), +}); +scheme.FrameTapResult = tOptional(tObject({})); +scheme.FrameTextContentParams = tObject({ + selector: tString, + strict: tOptional(tBoolean), + timeout: tFloat, +}); +scheme.FrameTextContentResult = tObject({ + value: tOptional(tString), +}); +scheme.FrameTitleParams = tOptional(tObject({})); +scheme.FrameTitleResult = tObject({ + value: tString, +}); +scheme.FrameTypeParams = tObject({ + selector: tString, + strict: tOptional(tBoolean), + text: tString, + delay: tOptional(tFloat), + timeout: tFloat, +}); +scheme.FrameTypeResult = tOptional(tObject({})); +scheme.FrameUncheckParams = tObject({ + selector: tString, + strict: tOptional(tBoolean), + force: tOptional(tBoolean), + position: tOptional(tType("Point")), + timeout: tFloat, + trial: tOptional(tBoolean), +}); +scheme.FrameUncheckResult = tOptional(tObject({})); +scheme.FrameWaitForTimeoutParams = tObject({ + waitTimeout: tFloat, +}); +scheme.FrameWaitForTimeoutResult = tOptional(tObject({})); +scheme.FrameWaitForFunctionParams = tObject({ + expression: tString, + isFunction: tOptional(tBoolean), + arg: tType("SerializedArgument"), + timeout: tFloat, + pollingInterval: tOptional(tFloat), +}); +scheme.FrameWaitForFunctionResult = tObject({ + handle: tChannel(["ElementHandle", "JSHandle"]), +}); +scheme.FrameWaitForSelectorParams = tObject({ + selector: tString, + strict: tOptional(tBoolean), + timeout: tFloat, + state: tOptional(tEnum(["attached", "detached", "visible", "hidden"])), + omitReturnValue: tOptional(tBoolean), +}); +scheme.FrameWaitForSelectorResult = tObject({ + element: tOptional(tChannel(["ElementHandle"])), +}); +scheme.FrameExpectParams = tObject({ + selector: tOptional(tString), + expression: tString, + expressionArg: tOptional(tAny), + expectedText: tOptional(tArray(tType("ExpectedTextValue"))), + expectedNumber: tOptional(tFloat), + expectedValue: tOptional(tType("SerializedArgument")), + useInnerText: tOptional(tBoolean), + isNot: tBoolean, + timeout: tFloat, +}); +scheme.FrameExpectResult = tObject({ + matches: tBoolean, + received: tOptional(tType("SerializedValue")), + timedOut: tOptional(tBoolean), + errorMessage: tOptional(tString), + log: tOptional(tArray(tString)), +}); +scheme.WorkerInitializer = tObject({ + url: tString, +}); +scheme.WorkerCloseEvent = tOptional(tObject({})); +scheme.WorkerEvaluateExpressionParams = tObject({ + expression: tString, + isFunction: tOptional(tBoolean), + arg: tType("SerializedArgument"), +}); +scheme.WorkerEvaluateExpressionResult = tObject({ + value: tType("SerializedValue"), +}); +scheme.WorkerEvaluateExpressionHandleParams = tObject({ + expression: tString, + isFunction: tOptional(tBoolean), + arg: tType("SerializedArgument"), +}); +scheme.WorkerEvaluateExpressionHandleResult = tObject({ + handle: tChannel(["ElementHandle", "JSHandle"]), +}); +scheme.WorkerUpdateSubscriptionParams = tObject({ + event: tEnum(["console"]), + enabled: tBoolean, +}); +scheme.WorkerUpdateSubscriptionResult = tOptional(tObject({})); +scheme.DisposableInitializer = tOptional(tObject({})); +scheme.DisposableDisposeParams = tOptional(tObject({})); +scheme.DisposableDisposeResult = tOptional(tObject({})); +scheme.JSHandleInitializer = tObject({ + preview: tString, +}); +scheme.JSHandlePreviewUpdatedEvent = tObject({ + preview: tString, +}); +scheme.ElementHandlePreviewUpdatedEvent = tType("JSHandlePreviewUpdatedEvent"); +scheme.JSHandleDisposeParams = tOptional(tObject({})); +scheme.ElementHandleDisposeParams = tType("JSHandleDisposeParams"); +scheme.JSHandleDisposeResult = tOptional(tObject({})); +scheme.ElementHandleDisposeResult = tType("JSHandleDisposeResult"); +scheme.JSHandleEvaluateExpressionParams = tObject({ + expression: tString, + isFunction: tOptional(tBoolean), + arg: tType("SerializedArgument"), +}); +scheme.ElementHandleEvaluateExpressionParams = tType("JSHandleEvaluateExpressionParams"); +scheme.JSHandleEvaluateExpressionResult = tObject({ + value: tType("SerializedValue"), +}); +scheme.ElementHandleEvaluateExpressionResult = tType("JSHandleEvaluateExpressionResult"); +scheme.JSHandleEvaluateExpressionHandleParams = tObject({ + expression: tString, + isFunction: tOptional(tBoolean), + arg: tType("SerializedArgument"), +}); +scheme.ElementHandleEvaluateExpressionHandleParams = tType( + "JSHandleEvaluateExpressionHandleParams" +); +scheme.JSHandleEvaluateExpressionHandleResult = tObject({ + handle: tChannel(["ElementHandle", "JSHandle"]), +}); +scheme.ElementHandleEvaluateExpressionHandleResult = tType( + "JSHandleEvaluateExpressionHandleResult" +); +scheme.JSHandleGetPropertyListParams = tOptional(tObject({})); +scheme.ElementHandleGetPropertyListParams = tType("JSHandleGetPropertyListParams"); +scheme.JSHandleGetPropertyListResult = tObject({ + properties: tArray( + tObject({ + name: tString, + value: tChannel(["ElementHandle", "JSHandle"]), + }) + ), +}); +scheme.ElementHandleGetPropertyListResult = tType("JSHandleGetPropertyListResult"); +scheme.JSHandleGetPropertyParams = tObject({ + name: tString, +}); +scheme.ElementHandleGetPropertyParams = tType("JSHandleGetPropertyParams"); +scheme.JSHandleGetPropertyResult = tObject({ + handle: tChannel(["ElementHandle", "JSHandle"]), +}); +scheme.ElementHandleGetPropertyResult = tType("JSHandleGetPropertyResult"); +scheme.JSHandleJsonValueParams = tOptional(tObject({})); +scheme.ElementHandleJsonValueParams = tType("JSHandleJsonValueParams"); +scheme.JSHandleJsonValueResult = tObject({ + value: tType("SerializedValue"), +}); +scheme.ElementHandleJsonValueResult = tType("JSHandleJsonValueResult"); +scheme.ElementHandleInitializer = tObject({ + preview: tString, +}); +scheme.ElementHandleEvalOnSelectorParams = tObject({ + selector: tString, + strict: tOptional(tBoolean), + expression: tString, + isFunction: tOptional(tBoolean), + arg: tType("SerializedArgument"), +}); +scheme.ElementHandleEvalOnSelectorResult = tObject({ + value: tType("SerializedValue"), +}); +scheme.ElementHandleEvalOnSelectorAllParams = tObject({ + selector: tString, + expression: tString, + isFunction: tOptional(tBoolean), + arg: tType("SerializedArgument"), +}); +scheme.ElementHandleEvalOnSelectorAllResult = tObject({ + value: tType("SerializedValue"), +}); +scheme.ElementHandleBoundingBoxParams = tOptional(tObject({})); +scheme.ElementHandleBoundingBoxResult = tObject({ + value: tOptional(tType("Rect")), +}); +scheme.ElementHandleCheckParams = tObject({ + force: tOptional(tBoolean), + position: tOptional(tType("Point")), + timeout: tFloat, + trial: tOptional(tBoolean), +}); +scheme.ElementHandleCheckResult = tOptional(tObject({})); +scheme.ElementHandleClickParams = tObject({ + force: tOptional(tBoolean), + noWaitAfter: tOptional(tBoolean), + modifiers: tOptional(tArray(tEnum(["Alt", "Control", "ControlOrMeta", "Meta", "Shift"]))), + position: tOptional(tType("Point")), + delay: tOptional(tFloat), + button: tOptional(tEnum(["left", "right", "middle"])), + clickCount: tOptional(tInt), + timeout: tFloat, + trial: tOptional(tBoolean), + steps: tOptional(tInt), +}); +scheme.ElementHandleClickResult = tOptional(tObject({})); +scheme.ElementHandleContentFrameParams = tOptional(tObject({})); +scheme.ElementHandleContentFrameResult = tObject({ + frame: tOptional(tChannel(["Frame"])), +}); +scheme.ElementHandleDblclickParams = tObject({ + force: tOptional(tBoolean), + modifiers: tOptional(tArray(tEnum(["Alt", "Control", "ControlOrMeta", "Meta", "Shift"]))), + position: tOptional(tType("Point")), + delay: tOptional(tFloat), + button: tOptional(tEnum(["left", "right", "middle"])), + timeout: tFloat, + trial: tOptional(tBoolean), + steps: tOptional(tInt), +}); +scheme.ElementHandleDblclickResult = tOptional(tObject({})); +scheme.ElementHandleDispatchEventParams = tObject({ + type: tString, + eventInit: tType("SerializedArgument"), +}); +scheme.ElementHandleDispatchEventResult = tOptional(tObject({})); +scheme.ElementHandleFillParams = tObject({ + value: tString, + force: tOptional(tBoolean), + timeout: tFloat, +}); +scheme.ElementHandleFillResult = tOptional(tObject({})); +scheme.ElementHandleFocusParams = tOptional(tObject({})); +scheme.ElementHandleFocusResult = tOptional(tObject({})); +scheme.ElementHandleGetAttributeParams = tObject({ + name: tString, +}); +scheme.ElementHandleGetAttributeResult = tObject({ + value: tOptional(tString), +}); +scheme.ElementHandleHoverParams = tObject({ + force: tOptional(tBoolean), + modifiers: tOptional(tArray(tEnum(["Alt", "Control", "ControlOrMeta", "Meta", "Shift"]))), + position: tOptional(tType("Point")), + timeout: tFloat, + trial: tOptional(tBoolean), +}); +scheme.ElementHandleHoverResult = tOptional(tObject({})); +scheme.ElementHandleInnerHTMLParams = tOptional(tObject({})); +scheme.ElementHandleInnerHTMLResult = tObject({ + value: tString, +}); +scheme.ElementHandleInnerTextParams = tOptional(tObject({})); +scheme.ElementHandleInnerTextResult = tObject({ + value: tString, +}); +scheme.ElementHandleInputValueParams = tOptional(tObject({})); +scheme.ElementHandleInputValueResult = tObject({ + value: tString, +}); +scheme.ElementHandleIsCheckedParams = tOptional(tObject({})); +scheme.ElementHandleIsCheckedResult = tObject({ + value: tBoolean, +}); +scheme.ElementHandleIsDisabledParams = tOptional(tObject({})); +scheme.ElementHandleIsDisabledResult = tObject({ + value: tBoolean, +}); +scheme.ElementHandleIsEditableParams = tOptional(tObject({})); +scheme.ElementHandleIsEditableResult = tObject({ + value: tBoolean, +}); +scheme.ElementHandleIsEnabledParams = tOptional(tObject({})); +scheme.ElementHandleIsEnabledResult = tObject({ + value: tBoolean, +}); +scheme.ElementHandleIsHiddenParams = tOptional(tObject({})); +scheme.ElementHandleIsHiddenResult = tObject({ + value: tBoolean, +}); +scheme.ElementHandleIsVisibleParams = tOptional(tObject({})); +scheme.ElementHandleIsVisibleResult = tObject({ + value: tBoolean, +}); +scheme.ElementHandleOwnerFrameParams = tOptional(tObject({})); +scheme.ElementHandleOwnerFrameResult = tObject({ + frame: tOptional(tChannel(["Frame"])), +}); +scheme.ElementHandlePressParams = tObject({ + key: tString, + delay: tOptional(tFloat), + timeout: tFloat, + noWaitAfter: tOptional(tBoolean), +}); +scheme.ElementHandlePressResult = tOptional(tObject({})); +scheme.ElementHandleQuerySelectorParams = tObject({ + selector: tString, + strict: tOptional(tBoolean), +}); +scheme.ElementHandleQuerySelectorResult = tObject({ + element: tOptional(tChannel(["ElementHandle"])), +}); +scheme.ElementHandleQuerySelectorAllParams = tObject({ + selector: tString, +}); +scheme.ElementHandleQuerySelectorAllResult = tObject({ + elements: tArray(tChannel(["ElementHandle"])), +}); +scheme.ElementHandleScreenshotParams = tObject({ + timeout: tFloat, + type: tOptional(tEnum(["png", "jpeg"])), + quality: tOptional(tInt), + omitBackground: tOptional(tBoolean), + caret: tOptional(tEnum(["hide", "initial"])), + animations: tOptional(tEnum(["disabled", "allow"])), + scale: tOptional(tEnum(["css", "device"])), + mask: tOptional( + tArray( + tObject({ + frame: tChannel(["Frame"]), + selector: tString, + }) + ) + ), + maskColor: tOptional(tString), + style: tOptional(tString), +}); +scheme.ElementHandleScreenshotResult = tObject({ + binary: tBinary, +}); +scheme.ElementHandleScrollIntoViewIfNeededParams = tObject({ + timeout: tFloat, +}); +scheme.ElementHandleScrollIntoViewIfNeededResult = tOptional(tObject({})); +scheme.ElementHandleSelectOptionParams = tObject({ + elements: tOptional(tArray(tChannel(["ElementHandle"]))), + options: tOptional( + tArray( + tObject({ + valueOrLabel: tOptional(tString), + value: tOptional(tString), + label: tOptional(tString), + index: tOptional(tInt), + }) + ) + ), + force: tOptional(tBoolean), + timeout: tFloat, +}); +scheme.ElementHandleSelectOptionResult = tObject({ + values: tArray(tString), +}); +scheme.ElementHandleSelectTextParams = tObject({ + force: tOptional(tBoolean), + timeout: tFloat, +}); +scheme.ElementHandleSelectTextResult = tOptional(tObject({})); +scheme.ElementHandleSetInputFilesParams = tObject({ + payloads: tOptional( + tArray( + tObject({ + name: tString, + mimeType: tOptional(tString), + buffer: tBinary, + }) + ) + ), + localDirectory: tOptional(tString), + directoryStream: tOptional(tChannel(["WritableStream"])), + localPaths: tOptional(tArray(tString)), + streams: tOptional(tArray(tChannel(["WritableStream"]))), + timeout: tFloat, +}); +scheme.ElementHandleSetInputFilesResult = tOptional(tObject({})); +scheme.ElementHandleTapParams = tObject({ + force: tOptional(tBoolean), + modifiers: tOptional(tArray(tEnum(["Alt", "Control", "ControlOrMeta", "Meta", "Shift"]))), + position: tOptional(tType("Point")), + timeout: tFloat, + trial: tOptional(tBoolean), +}); +scheme.ElementHandleTapResult = tOptional(tObject({})); +scheme.ElementHandleTextContentParams = tOptional(tObject({})); +scheme.ElementHandleTextContentResult = tObject({ + value: tOptional(tString), +}); +scheme.ElementHandleTypeParams = tObject({ + text: tString, + delay: tOptional(tFloat), + timeout: tFloat, +}); +scheme.ElementHandleTypeResult = tOptional(tObject({})); +scheme.ElementHandleUncheckParams = tObject({ + force: tOptional(tBoolean), + position: tOptional(tType("Point")), + timeout: tFloat, + trial: tOptional(tBoolean), +}); +scheme.ElementHandleUncheckResult = tOptional(tObject({})); +scheme.ElementHandleWaitForElementStateParams = tObject({ + state: tEnum(["visible", "hidden", "stable", "enabled", "disabled", "editable"]), + timeout: tFloat, +}); +scheme.ElementHandleWaitForElementStateResult = tOptional(tObject({})); +scheme.ElementHandleWaitForSelectorParams = tObject({ + selector: tString, + strict: tOptional(tBoolean), + timeout: tFloat, + state: tOptional(tEnum(["attached", "detached", "visible", "hidden"])), +}); +scheme.ElementHandleWaitForSelectorResult = tObject({ + element: tOptional(tChannel(["ElementHandle"])), +}); +scheme.RequestInitializer = tObject({ + frame: tOptional(tChannel(["Frame"])), + serviceWorker: tOptional(tChannel(["Worker"])), + url: tString, + resourceType: tString, + method: tString, + postData: tOptional(tBinary), + headers: tArray(tType("NameValue")), + isNavigationRequest: tBoolean, + redirectedFrom: tOptional(tChannel(["Request"])), + hasResponse: tBoolean, +}); +scheme.RequestResponseEvent = tOptional(tObject({})); +scheme.RequestResponseParams = tOptional(tObject({})); +scheme.RequestResponseResult = tObject({ + response: tOptional(tChannel(["Response"])), +}); +scheme.RequestRawRequestHeadersParams = tOptional(tObject({})); +scheme.RequestRawRequestHeadersResult = tObject({ + headers: tArray(tType("NameValue")), +}); +scheme.RouteInitializer = tObject({ + request: tChannel(["Request"]), +}); +scheme.RouteRedirectNavigationRequestParams = tObject({ + url: tString, +}); +scheme.RouteRedirectNavigationRequestResult = tOptional(tObject({})); +scheme.RouteAbortParams = tObject({ + errorCode: tOptional(tString), +}); +scheme.RouteAbortResult = tOptional(tObject({})); +scheme.RouteContinueParams = tObject({ + url: tOptional(tString), + method: tOptional(tString), + headers: tOptional(tArray(tType("NameValue"))), + postData: tOptional(tBinary), + isFallback: tBoolean, +}); +scheme.RouteContinueResult = tOptional(tObject({})); +scheme.RouteFulfillParams = tObject({ + status: tOptional(tInt), + headers: tOptional(tArray(tType("NameValue"))), + body: tOptional(tString), + isBase64: tOptional(tBoolean), + fetchResponseUid: tOptional(tString), +}); +scheme.RouteFulfillResult = tOptional(tObject({})); +scheme.WebSocketRouteInitializer = tObject({ + url: tString, +}); +scheme.WebSocketRouteMessageFromPageEvent = tObject({ + message: tString, + isBase64: tBoolean, +}); +scheme.WebSocketRouteMessageFromServerEvent = tObject({ + message: tString, + isBase64: tBoolean, +}); +scheme.WebSocketRouteClosePageEvent = tObject({ + code: tOptional(tInt), + reason: tOptional(tString), + wasClean: tBoolean, +}); +scheme.WebSocketRouteCloseServerEvent = tObject({ + code: tOptional(tInt), + reason: tOptional(tString), + wasClean: tBoolean, +}); +scheme.WebSocketRouteConnectParams = tOptional(tObject({})); +scheme.WebSocketRouteConnectResult = tOptional(tObject({})); +scheme.WebSocketRouteEnsureOpenedParams = tOptional(tObject({})); +scheme.WebSocketRouteEnsureOpenedResult = tOptional(tObject({})); +scheme.WebSocketRouteSendToPageParams = tObject({ + message: tString, + isBase64: tBoolean, +}); +scheme.WebSocketRouteSendToPageResult = tOptional(tObject({})); +scheme.WebSocketRouteSendToServerParams = tObject({ + message: tString, + isBase64: tBoolean, +}); +scheme.WebSocketRouteSendToServerResult = tOptional(tObject({})); +scheme.WebSocketRouteClosePageParams = tObject({ + code: tOptional(tInt), + reason: tOptional(tString), + wasClean: tBoolean, +}); +scheme.WebSocketRouteClosePageResult = tOptional(tObject({})); +scheme.WebSocketRouteCloseServerParams = tObject({ + code: tOptional(tInt), + reason: tOptional(tString), + wasClean: tBoolean, +}); +scheme.WebSocketRouteCloseServerResult = tOptional(tObject({})); +scheme.ResourceTiming = tObject({ + startTime: tFloat, + domainLookupStart: tFloat, + domainLookupEnd: tFloat, + connectStart: tFloat, + secureConnectionStart: tFloat, + connectEnd: tFloat, + requestStart: tFloat, + responseStart: tFloat, +}); +scheme.ResponseInitializer = tObject({ + request: tChannel(["Request"]), + url: tString, + status: tInt, + statusText: tString, + headers: tArray(tType("NameValue")), + timing: tType("ResourceTiming"), + fromServiceWorker: tBoolean, +}); +scheme.ResponseBodyParams = tOptional(tObject({})); +scheme.ResponseBodyResult = tObject({ + binary: tBinary, +}); +scheme.ResponseSecurityDetailsParams = tOptional(tObject({})); +scheme.ResponseSecurityDetailsResult = tObject({ + value: tOptional(tType("SecurityDetails")), +}); +scheme.ResponseServerAddrParams = tOptional(tObject({})); +scheme.ResponseServerAddrResult = tObject({ + value: tOptional(tType("RemoteAddr")), +}); +scheme.ResponseRawResponseHeadersParams = tOptional(tObject({})); +scheme.ResponseRawResponseHeadersResult = tObject({ + headers: tArray(tType("NameValue")), +}); +scheme.ResponseHttpVersionParams = tOptional(tObject({})); +scheme.ResponseHttpVersionResult = tObject({ + value: tString, +}); +scheme.ResponseSizesParams = tOptional(tObject({})); +scheme.ResponseSizesResult = tObject({ + sizes: tType("RequestSizes"), +}); +scheme.SecurityDetails = tObject({ + issuer: tOptional(tString), + protocol: tOptional(tString), + subjectName: tOptional(tString), + validFrom: tOptional(tFloat), + validTo: tOptional(tFloat), +}); +scheme.RequestSizes = tObject({ + requestBodySize: tInt, + requestHeadersSize: tInt, + responseBodySize: tInt, + responseHeadersSize: tInt, +}); +scheme.RemoteAddr = tObject({ + ipAddress: tString, + port: tInt, +}); +scheme.WebSocketInitializer = tObject({ + url: tString, +}); +scheme.WebSocketOpenEvent = tOptional(tObject({})); +scheme.WebSocketFrameSentEvent = tObject({ + opcode: tInt, + data: tString, +}); +scheme.WebSocketFrameReceivedEvent = tObject({ + opcode: tInt, + data: tString, +}); +scheme.WebSocketSocketErrorEvent = tObject({ + error: tString, +}); +scheme.WebSocketCloseEvent = tOptional(tObject({})); +scheme.BindingCallInitializer = tObject({ + frame: tChannel(["Frame"]), + name: tString, + args: tOptional(tArray(tType("SerializedValue"))), + handle: tOptional(tChannel(["ElementHandle", "JSHandle"])), +}); +scheme.BindingCallRejectParams = tObject({ + error: tType("SerializedError"), +}); +scheme.BindingCallRejectResult = tOptional(tObject({})); +scheme.BindingCallResolveParams = tObject({ + result: tType("SerializedArgument"), +}); +scheme.BindingCallResolveResult = tOptional(tObject({})); +scheme.DebuggerInitializer = tOptional(tObject({})); +scheme.DebuggerPausedStateChangedEvent = tObject({ + pausedDetails: tArray( + tObject({ + location: tObject({ + file: tString, + line: tOptional(tInt), + column: tOptional(tInt), + }), + title: tString, + }) + ), +}); +scheme.DebuggerPauseParams = tOptional(tObject({})); +scheme.DebuggerPauseResult = tOptional(tObject({})); +scheme.DebuggerResumeParams = tOptional(tObject({})); +scheme.DebuggerResumeResult = tOptional(tObject({})); +scheme.DebuggerNextParams = tOptional(tObject({})); +scheme.DebuggerNextResult = tOptional(tObject({})); +scheme.DebuggerRunToParams = tObject({ + location: tObject({ + file: tString, + line: tOptional(tInt), + column: tOptional(tInt), + }), +}); +scheme.DebuggerRunToResult = tOptional(tObject({})); +scheme.DialogInitializer = tObject({ + page: tOptional(tChannel(["Page"])), + type: tString, + message: tString, + defaultValue: tString, +}); +scheme.DialogAcceptParams = tObject({ + promptText: tOptional(tString), +}); +scheme.DialogAcceptResult = tOptional(tObject({})); +scheme.DialogDismissParams = tOptional(tObject({})); +scheme.DialogDismissResult = tOptional(tObject({})); +scheme.TracingInitializer = tOptional(tObject({})); +scheme.TracingTracingStartParams = tObject({ + name: tOptional(tString), + snapshots: tOptional(tBoolean), + screenshots: tOptional(tBoolean), + live: tOptional(tBoolean), +}); +scheme.TracingTracingStartResult = tOptional(tObject({})); +scheme.TracingTracingStartChunkParams = tObject({ + name: tOptional(tString), + title: tOptional(tString), +}); +scheme.TracingTracingStartChunkResult = tObject({ + traceName: tString, +}); +scheme.TracingTracingGroupParams = tObject({ + name: tString, + location: tOptional( + tObject({ + file: tString, + line: tOptional(tInt), + column: tOptional(tInt), + }) + ), +}); +scheme.TracingTracingGroupResult = tOptional(tObject({})); +scheme.TracingTracingGroupEndParams = tOptional(tObject({})); +scheme.TracingTracingGroupEndResult = tOptional(tObject({})); +scheme.TracingTracingStopChunkParams = tObject({ + mode: tEnum(["archive", "discard", "entries"]), +}); +scheme.TracingTracingStopChunkResult = tObject({ + artifact: tOptional(tChannel(["Artifact"])), + entries: tOptional(tArray(tType("NameValue"))), +}); +scheme.TracingTracingStopParams = tOptional(tObject({})); +scheme.TracingTracingStopResult = tOptional(tObject({})); +scheme.ArtifactInitializer = tObject({ + absolutePath: tString, +}); +scheme.ArtifactPathAfterFinishedParams = tOptional(tObject({})); +scheme.ArtifactPathAfterFinishedResult = tObject({ + value: tString, +}); +scheme.ArtifactSaveAsParams = tObject({ + path: tString, +}); +scheme.ArtifactSaveAsResult = tOptional(tObject({})); +scheme.ArtifactSaveAsStreamParams = tOptional(tObject({})); +scheme.ArtifactSaveAsStreamResult = tObject({ + stream: tChannel(["Stream"]), +}); +scheme.ArtifactFailureParams = tOptional(tObject({})); +scheme.ArtifactFailureResult = tObject({ + error: tOptional(tString), +}); +scheme.ArtifactStreamParams = tOptional(tObject({})); +scheme.ArtifactStreamResult = tObject({ + stream: tChannel(["Stream"]), +}); +scheme.ArtifactCancelParams = tOptional(tObject({})); +scheme.ArtifactCancelResult = tOptional(tObject({})); +scheme.ArtifactDeleteParams = tOptional(tObject({})); +scheme.ArtifactDeleteResult = tOptional(tObject({})); +scheme.StreamInitializer = tOptional(tObject({})); +scheme.StreamReadParams = tObject({ + size: tOptional(tInt), +}); +scheme.StreamReadResult = tObject({ + binary: tBinary, +}); +scheme.StreamCloseParams = tOptional(tObject({})); +scheme.StreamCloseResult = tOptional(tObject({})); +scheme.WritableStreamInitializer = tOptional(tObject({})); +scheme.WritableStreamWriteParams = tObject({ + binary: tBinary, +}); +scheme.WritableStreamWriteResult = tOptional(tObject({})); +scheme.WritableStreamCloseParams = tOptional(tObject({})); +scheme.WritableStreamCloseResult = tOptional(tObject({})); +scheme.CDPSessionInitializer = tOptional(tObject({})); +scheme.CDPSessionEventEvent = tObject({ + method: tString, + params: tOptional(tAny), +}); +scheme.CDPSessionCloseEvent = tOptional(tObject({})); +scheme.CDPSessionSendParams = tObject({ + method: tString, + params: tOptional(tAny), +}); +scheme.CDPSessionSendResult = tObject({ + result: tAny, +}); +scheme.CDPSessionDetachParams = tOptional(tObject({})); +scheme.CDPSessionDetachResult = tOptional(tObject({})); +scheme.ElectronInitializer = tOptional(tObject({})); +scheme.ElectronLaunchParams = tObject({ + executablePath: tOptional(tString), + args: tOptional(tArray(tString)), + chromiumSandbox: tOptional(tBoolean), + cwd: tOptional(tString), + env: tOptional(tArray(tType("NameValue"))), + timeout: tFloat, + acceptDownloads: tOptional(tEnum(["accept", "deny", "internal-browser-default"])), + bypassCSP: tOptional(tBoolean), + colorScheme: tOptional(tEnum(["dark", "light", "no-preference", "no-override"])), + extraHTTPHeaders: tOptional(tArray(tType("NameValue"))), + geolocation: tOptional( + tObject({ + longitude: tFloat, + latitude: tFloat, + accuracy: tOptional(tFloat), + }) + ), + httpCredentials: tOptional( + tObject({ + username: tString, + password: tString, + origin: tOptional(tString), + }) + ), + ignoreHTTPSErrors: tOptional(tBoolean), + locale: tOptional(tString), + offline: tOptional(tBoolean), + recordVideo: tOptional( + tObject({ + dir: tString, + size: tOptional( + tObject({ + width: tInt, + height: tInt, + }) + ), + }) + ), + strictSelectors: tOptional(tBoolean), + timezoneId: tOptional(tString), + tracesDir: tOptional(tString), + selectorEngines: tOptional(tArray(tType("SelectorEngine"))), + testIdAttributeName: tOptional(tString), +}); +scheme.ElectronLaunchResult = tObject({ + electronApplication: tChannel(["ElectronApplication"]), +}); +scheme.ElectronApplicationInitializer = tObject({ + context: tChannel(["BrowserContext"]), +}); +scheme.ElectronApplicationCloseEvent = tOptional(tObject({})); +scheme.ElectronApplicationConsoleEvent = tObject({ + type: tString, + text: tString, + args: tArray(tChannel(["ElementHandle", "JSHandle"])), + location: tObject({ + url: tString, + lineNumber: tInt, + columnNumber: tInt, + }), + timestamp: tFloat, +}); +scheme.ElectronApplicationBrowserWindowParams = tObject({ + page: tChannel(["Page"]), +}); +scheme.ElectronApplicationBrowserWindowResult = tObject({ + handle: tChannel(["ElementHandle", "JSHandle"]), +}); +scheme.ElectronApplicationEvaluateExpressionParams = tObject({ + expression: tString, + isFunction: tOptional(tBoolean), + arg: tType("SerializedArgument"), +}); +scheme.ElectronApplicationEvaluateExpressionResult = tObject({ + value: tType("SerializedValue"), +}); +scheme.ElectronApplicationEvaluateExpressionHandleParams = tObject({ + expression: tString, + isFunction: tOptional(tBoolean), + arg: tType("SerializedArgument"), +}); +scheme.ElectronApplicationEvaluateExpressionHandleResult = tObject({ + handle: tChannel(["ElementHandle", "JSHandle"]), +}); +scheme.ElectronApplicationUpdateSubscriptionParams = tObject({ + event: tEnum(["console"]), + enabled: tBoolean, +}); +scheme.ElectronApplicationUpdateSubscriptionResult = tOptional(tObject({})); +scheme.AndroidInitializer = tOptional(tObject({})); +scheme.AndroidDevicesParams = tObject({ + host: tOptional(tString), + port: tOptional(tInt), + omitDriverInstall: tOptional(tBoolean), +}); +scheme.AndroidDevicesResult = tObject({ + devices: tArray(tChannel(["AndroidDevice"])), +}); +scheme.AndroidSocketInitializer = tOptional(tObject({})); +scheme.AndroidSocketDataEvent = tObject({ + data: tBinary, +}); +scheme.AndroidSocketCloseEvent = tOptional(tObject({})); +scheme.AndroidSocketWriteParams = tObject({ + data: tBinary, +}); +scheme.AndroidSocketWriteResult = tOptional(tObject({})); +scheme.AndroidSocketCloseParams = tOptional(tObject({})); +scheme.AndroidSocketCloseResult = tOptional(tObject({})); +scheme.AndroidDeviceInitializer = tObject({ + model: tString, + serial: tString, +}); +scheme.AndroidDeviceCloseEvent = tOptional(tObject({})); +scheme.AndroidDeviceWebViewAddedEvent = tObject({ + webView: tType("AndroidWebView"), +}); +scheme.AndroidDeviceWebViewRemovedEvent = tObject({ + socketName: tString, +}); +scheme.AndroidDeviceWaitParams = tObject({ + androidSelector: tType("AndroidSelector"), + state: tOptional(tEnum(["gone"])), + timeout: tFloat, +}); +scheme.AndroidDeviceWaitResult = tOptional(tObject({})); +scheme.AndroidDeviceFillParams = tObject({ + androidSelector: tType("AndroidSelector"), + text: tString, + timeout: tFloat, +}); +scheme.AndroidDeviceFillResult = tOptional(tObject({})); +scheme.AndroidDeviceTapParams = tObject({ + androidSelector: tType("AndroidSelector"), + duration: tOptional(tFloat), + timeout: tFloat, +}); +scheme.AndroidDeviceTapResult = tOptional(tObject({})); +scheme.AndroidDeviceDragParams = tObject({ + androidSelector: tType("AndroidSelector"), + dest: tType("Point"), + speed: tOptional(tFloat), + timeout: tFloat, +}); +scheme.AndroidDeviceDragResult = tOptional(tObject({})); +scheme.AndroidDeviceFlingParams = tObject({ + androidSelector: tType("AndroidSelector"), + direction: tEnum(["up", "down", "left", "right"]), + speed: tOptional(tFloat), + timeout: tFloat, +}); +scheme.AndroidDeviceFlingResult = tOptional(tObject({})); +scheme.AndroidDeviceLongTapParams = tObject({ + androidSelector: tType("AndroidSelector"), + timeout: tFloat, +}); +scheme.AndroidDeviceLongTapResult = tOptional(tObject({})); +scheme.AndroidDevicePinchCloseParams = tObject({ + androidSelector: tType("AndroidSelector"), + percent: tFloat, + speed: tOptional(tFloat), + timeout: tFloat, +}); +scheme.AndroidDevicePinchCloseResult = tOptional(tObject({})); +scheme.AndroidDevicePinchOpenParams = tObject({ + androidSelector: tType("AndroidSelector"), + percent: tFloat, + speed: tOptional(tFloat), + timeout: tFloat, +}); +scheme.AndroidDevicePinchOpenResult = tOptional(tObject({})); +scheme.AndroidDeviceScrollParams = tObject({ + androidSelector: tType("AndroidSelector"), + direction: tEnum(["up", "down", "left", "right"]), + percent: tFloat, + speed: tOptional(tFloat), + timeout: tFloat, +}); +scheme.AndroidDeviceScrollResult = tOptional(tObject({})); +scheme.AndroidDeviceSwipeParams = tObject({ + androidSelector: tType("AndroidSelector"), + direction: tEnum(["up", "down", "left", "right"]), + percent: tFloat, + speed: tOptional(tFloat), + timeout: tFloat, +}); +scheme.AndroidDeviceSwipeResult = tOptional(tObject({})); +scheme.AndroidDeviceInfoParams = tObject({ + androidSelector: tType("AndroidSelector"), +}); +scheme.AndroidDeviceInfoResult = tObject({ + info: tType("AndroidElementInfo"), +}); +scheme.AndroidDeviceScreenshotParams = tOptional(tObject({})); +scheme.AndroidDeviceScreenshotResult = tObject({ + binary: tBinary, +}); +scheme.AndroidDeviceInputTypeParams = tObject({ + text: tString, +}); +scheme.AndroidDeviceInputTypeResult = tOptional(tObject({})); +scheme.AndroidDeviceInputPressParams = tObject({ + key: tString, +}); +scheme.AndroidDeviceInputPressResult = tOptional(tObject({})); +scheme.AndroidDeviceInputTapParams = tObject({ + point: tType("Point"), +}); +scheme.AndroidDeviceInputTapResult = tOptional(tObject({})); +scheme.AndroidDeviceInputSwipeParams = tObject({ + segments: tArray(tType("Point")), + steps: tInt, +}); +scheme.AndroidDeviceInputSwipeResult = tOptional(tObject({})); +scheme.AndroidDeviceInputDragParams = tObject({ + from: tType("Point"), + to: tType("Point"), + steps: tInt, +}); +scheme.AndroidDeviceInputDragResult = tOptional(tObject({})); +scheme.AndroidDeviceLaunchBrowserParams = tObject({ + noDefaultViewport: tOptional(tBoolean), + viewport: tOptional( + tObject({ + width: tInt, + height: tInt, + }) + ), + screen: tOptional( + tObject({ + width: tInt, + height: tInt, + }) + ), + ignoreHTTPSErrors: tOptional(tBoolean), + clientCertificates: tOptional( + tArray( + tObject({ + origin: tString, + cert: tOptional(tBinary), + key: tOptional(tBinary), + passphrase: tOptional(tString), + pfx: tOptional(tBinary), + }) + ) + ), + javaScriptEnabled: tOptional(tBoolean), + bypassCSP: tOptional(tBoolean), + userAgent: tOptional(tString), + locale: tOptional(tString), + timezoneId: tOptional(tString), + geolocation: tOptional( + tObject({ + longitude: tFloat, + latitude: tFloat, + accuracy: tOptional(tFloat), + }) + ), + permissions: tOptional(tArray(tString)), + extraHTTPHeaders: tOptional(tArray(tType("NameValue"))), + offline: tOptional(tBoolean), + httpCredentials: tOptional( + tObject({ + username: tString, + password: tString, + origin: tOptional(tString), + send: tOptional(tEnum(["always", "unauthorized"])), + }) + ), + deviceScaleFactor: tOptional(tFloat), + isMobile: tOptional(tBoolean), + hasTouch: tOptional(tBoolean), + colorScheme: tOptional(tEnum(["dark", "light", "no-preference", "no-override"])), + reducedMotion: tOptional(tEnum(["reduce", "no-preference", "no-override"])), + forcedColors: tOptional(tEnum(["active", "none", "no-override"])), + acceptDownloads: tOptional(tEnum(["accept", "deny", "internal-browser-default"])), + contrast: tOptional(tEnum(["no-preference", "more", "no-override"])), + baseURL: tOptional(tString), + recordVideo: tOptional( + tObject({ + dir: tString, + size: tOptional( + tObject({ + width: tInt, + height: tInt, + }) + ), + }) + ), + strictSelectors: tOptional(tBoolean), + serviceWorkers: tOptional(tEnum(["allow", "block"])), + selectorEngines: tOptional(tArray(tType("SelectorEngine"))), + testIdAttributeName: tOptional(tString), + pkg: tOptional(tString), + args: tOptional(tArray(tString)), + proxy: tOptional( + tObject({ + server: tString, + bypass: tOptional(tString), + username: tOptional(tString), + password: tOptional(tString), + }) + ), +}); +scheme.AndroidDeviceLaunchBrowserResult = tObject({ + context: tChannel(["BrowserContext"]), +}); +scheme.AndroidDeviceOpenParams = tObject({ + command: tString, +}); +scheme.AndroidDeviceOpenResult = tObject({ + socket: tChannel(["AndroidSocket"]), +}); +scheme.AndroidDeviceShellParams = tObject({ + command: tString, +}); +scheme.AndroidDeviceShellResult = tObject({ + result: tBinary, +}); +scheme.AndroidDeviceInstallApkParams = tObject({ + file: tBinary, + args: tOptional(tArray(tString)), +}); +scheme.AndroidDeviceInstallApkResult = tOptional(tObject({})); +scheme.AndroidDevicePushParams = tObject({ + file: tBinary, + path: tString, + mode: tOptional(tInt), +}); +scheme.AndroidDevicePushResult = tOptional(tObject({})); +scheme.AndroidDeviceConnectToWebViewParams = tObject({ + socketName: tString, +}); +scheme.AndroidDeviceConnectToWebViewResult = tObject({ + context: tChannel(["BrowserContext"]), +}); +scheme.AndroidDeviceCloseParams = tOptional(tObject({})); +scheme.AndroidDeviceCloseResult = tOptional(tObject({})); +scheme.AndroidWebView = tObject({ + pid: tInt, + pkg: tString, + socketName: tString, +}); +scheme.AndroidSelector = tObject({ + checkable: tOptional(tBoolean), + checked: tOptional(tBoolean), + clazz: tOptional(tString), + clickable: tOptional(tBoolean), + depth: tOptional(tInt), + desc: tOptional(tString), + enabled: tOptional(tBoolean), + focusable: tOptional(tBoolean), + focused: tOptional(tBoolean), + hasChild: tOptional( + tObject({ + androidSelector: tType("AndroidSelector"), + }) + ), + hasDescendant: tOptional( + tObject({ + androidSelector: tType("AndroidSelector"), + maxDepth: tOptional(tInt), + }) + ), + longClickable: tOptional(tBoolean), + pkg: tOptional(tString), + res: tOptional(tString), + scrollable: tOptional(tBoolean), + selected: tOptional(tBoolean), + text: tOptional(tString), +}); +scheme.AndroidElementInfo = tObject({ + children: tOptional(tArray(tType("AndroidElementInfo"))), + clazz: tString, + desc: tString, + res: tString, + pkg: tString, + text: tString, + bounds: tType("Rect"), + checkable: tBoolean, + checked: tBoolean, + clickable: tBoolean, + enabled: tBoolean, + focusable: tBoolean, + focused: tBoolean, + longClickable: tBoolean, + scrollable: tBoolean, + selected: tBoolean, +}); +scheme.JsonPipeInitializer = tOptional(tObject({})); +scheme.JsonPipeMessageEvent = tObject({ + message: tAny, +}); +scheme.JsonPipeClosedEvent = tObject({ + reason: tOptional(tString), +}); +scheme.JsonPipeSendParams = tObject({ + message: tAny, +}); +scheme.JsonPipeSendResult = tOptional(tObject({})); +scheme.JsonPipeCloseParams = tOptional(tObject({})); +scheme.JsonPipeCloseResult = tOptional(tObject({})); diff --git a/daemon/src/sandbox/forked-client/src/protocol/validatorPrimitives.ts b/daemon/src/sandbox/forked-client/src/protocol/validatorPrimitives.ts new file mode 100644 index 00000000..537995d8 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/protocol/validatorPrimitives.ts @@ -0,0 +1,147 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export class ValidationError extends Error {} +export type Validator = (arg: any, path: string, context: ValidatorContext) => any; +export type ValidatorContext = { + tChannelImpl: (names: "*" | string[], arg: any, path: string, context: ValidatorContext) => any; + binary: "toBase64" | "fromBase64" | "buffer"; + isUnderTest: () => boolean; +}; +export const scheme: { [key: string]: Validator } = {}; + +export function findValidator( + type: string, + method: string, + kind: "Initializer" | "Event" | "Params" | "Result" +): Validator { + const validator = maybeFindValidator(type, method, kind); + if (!validator) throw new ValidationError(`Unknown scheme for ${kind}: ${type}.${method}`); + return validator; +} +export function maybeFindValidator( + type: string, + method: string, + kind: "Initializer" | "Event" | "Params" | "Result" +): Validator | undefined { + const schemeName = + type + (kind === "Initializer" ? "" : method[0].toUpperCase() + method.substring(1)) + kind; + return scheme[schemeName]; +} +export function createMetadataValidator(): Validator { + return tOptional(scheme["Metadata"]); +} + +export const tFloat: Validator = (arg: any, path: string, context: ValidatorContext) => { + if (arg instanceof Number) return arg.valueOf(); + if (typeof arg === "number") return arg; + throw new ValidationError(`${path}: expected float, got ${typeof arg}`); +}; +export const tInt: Validator = (arg: any, path: string, context: ValidatorContext) => { + let value: number; + if (arg instanceof Number) value = arg.valueOf(); + else if (typeof arg === "number") value = arg; + else throw new ValidationError(`${path}: expected integer, got ${typeof arg}`); + if (!Number.isInteger(value)) + throw new ValidationError(`${path}: expected integer, got float ${value}`); + return value; +}; +export const tBoolean: Validator = (arg: any, path: string, context: ValidatorContext) => { + if (arg instanceof Boolean) return arg.valueOf(); + if (typeof arg === "boolean") return arg; + throw new ValidationError(`${path}: expected boolean, got ${typeof arg}`); +}; +export const tString: Validator = (arg: any, path: string, context: ValidatorContext) => { + if (arg instanceof String) return arg.valueOf(); + if (typeof arg === "string") return arg; + throw new ValidationError(`${path}: expected string, got ${typeof arg}`); +}; +export const tBinary: Validator = (arg: any, path: string, context: ValidatorContext) => { + if (context.binary === "fromBase64") { + if (arg instanceof String) return Buffer.from(arg.valueOf(), "base64"); + if (typeof arg === "string") return Buffer.from(arg, "base64"); + throw new ValidationError(`${path}: expected base64-encoded buffer, got ${typeof arg}`); + } + if (context.binary === "toBase64") { + if (!(arg instanceof Buffer)) + throw new ValidationError(`${path}: expected Buffer, got ${typeof arg}`); + return (arg as Buffer).toString("base64"); + } + if (context.binary === "buffer") { + // TODO: support custom binary types. + if (!(arg instanceof Buffer) && !(arg instanceof Object)) + throw new ValidationError(`${path}: expected Buffer, got ${typeof arg}`); + return arg; + } + throw new ValidationError(`Unsupported binary behavior "${context.binary}"`); +}; +export const tUndefined: Validator = (arg: any, path: string, context: ValidatorContext) => { + if (Object.is(arg, undefined)) return arg; + throw new ValidationError(`${path}: expected undefined, got ${typeof arg}`); +}; +export const tAny: Validator = (arg: any, path: string, context: ValidatorContext) => { + return arg; +}; +export const tOptional = (v: Validator): Validator => { + return (arg: any, path: string, context: ValidatorContext) => { + if (Object.is(arg, undefined)) return arg; + return v(arg, path, context); + }; +}; +export const tArray = (v: Validator): Validator => { + return (arg: any, path: string, context: ValidatorContext) => { + if (!Array.isArray(arg)) + throw new ValidationError(`${path}: expected array, got ${typeof arg}`); + return arg.map((x, index) => v(x, path + "[" + index + "]", context)); + }; +}; +export const tObject = (s: { [key: string]: Validator }): Validator => { + return (arg: any, path: string, context: ValidatorContext) => { + if (Object.is(arg, null)) throw new ValidationError(`${path}: expected object, got null`); + if (typeof arg !== "object") + throw new ValidationError(`${path}: expected object, got ${typeof arg}`); + const result: any = {}; + for (const [key, v] of Object.entries(s)) { + const value = v(arg[key], path ? path + "." + key : key, context); + if (!Object.is(value, undefined)) result[key] = value; + } + if (context.isUnderTest()) { + for (const [key, value] of Object.entries(arg)) { + if (key.startsWith("__testHook")) result[key] = value; + } + } + return result; + }; +}; +export const tEnum = (e: string[]): Validator => { + return (arg: any, path: string, context: ValidatorContext) => { + if (!e.includes(arg)) throw new ValidationError(`${path}: expected one of (${e.join("|")})`); + return arg; + }; +}; +export const tChannel = (names: "*" | string[]): Validator => { + return (arg: any, path: string, context: ValidatorContext) => { + return context.tChannelImpl(names, arg, path, context); + }; +}; +export const tType = (name: string): Validator => { + return (arg: any, path: string, context: ValidatorContext) => { + const v = scheme[name]; + if (!v) throw new ValidationError(path + ': unknown type "' + name + '"'); + return v(arg, path, context); + }; +}; diff --git a/daemon/src/sandbox/forked-client/src/utils/isomorphic/ariaSnapshot.ts b/daemon/src/sandbox/forked-client/src/utils/isomorphic/ariaSnapshot.ts new file mode 100644 index 00000000..c44e124e --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/utils/isomorphic/ariaSnapshot.ts @@ -0,0 +1,717 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// https://www.w3.org/TR/wai-aria-1.2/#role_definitions + +export type AriaRole = + | "alert" + | "alertdialog" + | "application" + | "article" + | "banner" + | "blockquote" + | "button" + | "caption" + | "cell" + | "checkbox" + | "code" + | "columnheader" + | "combobox" + | "complementary" + | "contentinfo" + | "definition" + | "deletion" + | "dialog" + | "directory" + | "document" + | "emphasis" + | "feed" + | "figure" + | "form" + | "generic" + | "grid" + | "gridcell" + | "group" + | "heading" + | "img" + | "insertion" + | "link" + | "list" + | "listbox" + | "listitem" + | "log" + | "main" + | "mark" + | "marquee" + | "math" + | "meter" + | "menu" + | "menubar" + | "menuitem" + | "menuitemcheckbox" + | "menuitemradio" + | "navigation" + | "none" + | "note" + | "option" + | "paragraph" + | "presentation" + | "progressbar" + | "radio" + | "radiogroup" + | "region" + | "row" + | "rowgroup" + | "rowheader" + | "scrollbar" + | "search" + | "searchbox" + | "separator" + | "slider" + | "spinbutton" + | "status" + | "strong" + | "subscript" + | "superscript" + | "switch" + | "tab" + | "table" + | "tablist" + | "tabpanel" + | "term" + | "textbox" + | "time" + | "timer" + | "toolbar" + | "tooltip" + | "tree" + | "treegrid" + | "treeitem"; + +// Note: please keep in sync with ariaPropsEqual() below. +export type AriaProps = { + checked?: boolean | "mixed"; + disabled?: boolean; + expanded?: boolean; + active?: boolean; + level?: number; + pressed?: boolean | "mixed"; + selected?: boolean; +}; + +export type AriaBox = { + visible: boolean; + inline: boolean; + cursor?: string; +}; + +// Note: please keep in sync with ariaNodesEqual() below. +export type AriaNode = AriaProps & { + role: AriaRole | "fragment" | "iframe"; + name: string; + ref?: string; + children: (AriaNode | string)[]; + box: AriaBox; + receivesPointerEvents: boolean; + props: Record; +}; + +export function ariaNodesEqual(a: AriaNode, b: AriaNode): boolean { + if (a.role !== b.role || a.name !== b.name) return false; + if (!ariaPropsEqual(a, b) || hasPointerCursor(a) !== hasPointerCursor(b)) return false; + const aKeys = Object.keys(a.props); + const bKeys = Object.keys(b.props); + return aKeys.length === bKeys.length && aKeys.every((k) => a.props[k] === b.props[k]); +} + +export function hasPointerCursor(ariaNode: AriaNode): boolean { + return ariaNode.box.cursor === "pointer"; +} + +function ariaPropsEqual(a: AriaProps, b: AriaProps): boolean { + return ( + a.active === b.active && + a.checked === b.checked && + a.disabled === b.disabled && + a.expanded === b.expanded && + a.selected === b.selected && + a.level === b.level && + a.pressed === b.pressed + ); +} + +// We pass parsed template between worlds using JSON, make it easy. +export type AriaRegex = { pattern: string }; + +// We can't tell apart pattern and text, so we pass both. +export type AriaTextValue = { + raw: string; + normalized: string; +}; + +export type AriaTemplateTextNode = { + kind: "text"; + text: AriaTextValue; +}; + +export type AriaTemplateRoleNode = AriaProps & { + kind: "role"; + role: AriaRole | "fragment"; + name?: AriaRegex | string; + children?: AriaTemplateNode[]; + props?: Record; + containerMode?: "contain" | "equal" | "deep-equal"; +}; + +export type AriaTemplateNode = AriaTemplateRoleNode | AriaTemplateTextNode; + +import type * as yamlTypes from "yaml"; + +type YamlLibrary = { + parseDocument: typeof yamlTypes.parseDocument; + Scalar: typeof yamlTypes.Scalar; + YAMLMap: typeof yamlTypes.YAMLMap; + YAMLSeq: typeof yamlTypes.YAMLSeq; + LineCounter: typeof yamlTypes.LineCounter; +}; + +type ParsedYamlPosition = { line: number; col: number }; +type ParsingOptions = yamlTypes.ParseOptions; + +export type ParsedYamlError = { + message: string; + range: [ParsedYamlPosition, ParsedYamlPosition]; +}; + +export function parseAriaSnapshotUnsafe( + yaml: YamlLibrary, + text: string, + options: ParsingOptions = {} +): AriaTemplateNode { + const result = parseAriaSnapshot(yaml, text, options); + if (result.errors.length) throw new Error(result.errors[0].message); + return result.fragment; +} + +export function parseAriaSnapshot( + yaml: YamlLibrary, + text: string, + options: ParsingOptions = {} +): { fragment: AriaTemplateNode; errors: ParsedYamlError[] } { + const lineCounter = new yaml.LineCounter(); + const parseOptions: ParsingOptions = { + keepSourceTokens: true, + lineCounter, + ...options, + }; + const yamlDoc = yaml.parseDocument(text, parseOptions); + const errors: ParsedYamlError[] = []; + + const convertRange = ( + range: [number, number] | yamlTypes.Range + ): [ParsedYamlPosition, ParsedYamlPosition] => { + return [lineCounter.linePos(range[0]), lineCounter.linePos(range[1])]; + }; + + const addError = (error: yamlTypes.YAMLError) => { + errors.push({ + message: error.message, + range: [lineCounter.linePos(error.pos[0]), lineCounter.linePos(error.pos[1])], + }); + }; + + const convertSeq = (container: AriaTemplateRoleNode, seq: yamlTypes.YAMLSeq) => { + for (const item of seq.items) { + const itemIsString = item instanceof yaml.Scalar && typeof item.value === "string"; + if (itemIsString) { + const childNode = KeyParser.parse(item, parseOptions, errors); + if (childNode) { + container.children = container.children || []; + container.children.push(childNode); + } + continue; + } + const itemIsMap = item instanceof yaml.YAMLMap; + if (itemIsMap) { + convertMap(container, item); + continue; + } + errors.push({ + message: "Sequence items should be strings or maps", + range: convertRange((item as any).range || seq.range), + }); + } + }; + + const convertMap = (container: AriaTemplateRoleNode, map: yamlTypes.YAMLMap) => { + for (const entry of map.items) { + container.children = container.children || []; + // Key must by a string + const keyIsString = entry.key instanceof yaml.Scalar && typeof entry.key.value === "string"; + if (!keyIsString) { + errors.push({ + message: "Only string keys are supported", + range: convertRange((entry.key as any).range || map.range), + }); + continue; + } + + const key: yamlTypes.Scalar = entry.key as yamlTypes.Scalar; + const value = entry.value; + + // - text: "text" + if (key.value === "text") { + const valueIsString = value instanceof yaml.Scalar && typeof value.value === "string"; + if (!valueIsString) { + errors.push({ + message: "Text value should be a string", + range: convertRange((entry.value as any).range || map.range), + }); + continue; + } + container.children.push({ + kind: "text", + text: textValue(value.value), + }); + continue; + } + + // - /children: equal + if (key.value === "/children") { + const valueIsString = value instanceof yaml.Scalar && typeof value.value === "string"; + if ( + !valueIsString || + (value.value !== "contain" && value.value !== "equal" && value.value !== "deep-equal") + ) { + errors.push({ + message: 'Strict value should be "contain", "equal" or "deep-equal"', + range: convertRange((entry.value as any).range || map.range), + }); + continue; + } + container.containerMode = value.value; + continue; + } + + // - /url: "about:blank" + if (key.value.startsWith("/")) { + const valueIsString = value instanceof yaml.Scalar && typeof value.value === "string"; + if (!valueIsString) { + errors.push({ + message: "Property value should be a string", + range: convertRange((entry.value as any).range || map.range), + }); + continue; + } + container.props = container.props ?? {}; + container.props[key.value.slice(1)] = textValue(value.value); + continue; + } + + // role "name": ... + const childNode = KeyParser.parse(key, parseOptions, errors); + if (!childNode) continue; + + // - role "name": "text" + const valueIsScalar = value instanceof yaml.Scalar; + if (valueIsScalar) { + const type = typeof value.value; + if (type !== "string" && type !== "number" && type !== "boolean") { + errors.push({ + message: "Node value should be a string or a sequence", + range: convertRange((entry.value as any).range || map.range), + }); + continue; + } + + container.children.push({ + ...childNode, + children: [ + { + kind: "text", + text: textValue(String(value.value)), + }, + ], + }); + continue; + } + + // - role "name": + // - child + const valueIsSequence = value instanceof yaml.YAMLSeq; + if (valueIsSequence) { + container.children.push(childNode); + convertSeq(childNode, value as yamlTypes.YAMLSeq); + continue; + } + + errors.push({ + message: "Map values should be strings or sequences", + range: convertRange((entry.value as any).range || map.range), + }); + } + }; + + const fragment: AriaTemplateNode = { kind: "role", role: "fragment" }; + + yamlDoc.errors.forEach(addError); + if (errors.length) return { errors, fragment }; + + if (!(yamlDoc.contents instanceof yaml.YAMLSeq)) { + errors.push({ + message: 'Aria snapshot must be a YAML sequence, elements starting with " -"', + range: yamlDoc.contents + ? convertRange(yamlDoc.contents!.range) + : [ + { line: 0, col: 0 }, + { line: 0, col: 0 }, + ], + }); + } + if (errors.length) return { errors, fragment }; + + convertSeq(fragment, yamlDoc.contents as yamlTypes.YAMLSeq); + if (errors.length) return { errors, fragment: emptyFragment }; + // `- button` should target the button, not its parent. + if ( + fragment.children?.length === 1 && + (!fragment.containerMode || fragment.containerMode === "contain") + ) + return { fragment: fragment.children[0], errors: [] }; + return { fragment, errors: [] }; +} + +const emptyFragment: AriaTemplateRoleNode = { kind: "role", role: "fragment" }; + +function normalizeWhitespace(text: string) { + // TODO: why is this different from normalizeWhitespace in stringUtils.ts? + return text + .replace(/[\u200b\u00ad]/g, "") + .replace(/[\r\n\s\t]+/g, " ") + .trim(); +} + +export function textValue(value: string): AriaTextValue { + return { + raw: value, + normalized: normalizeWhitespace(value), + }; +} + +export class KeyParser { + private _input: string; + private _pos: number; + private _length: number; + + static parse( + text: yamlTypes.Scalar, + options: ParsingOptions, + errors: ParsedYamlError[] + ): AriaTemplateRoleNode | null { + try { + return new KeyParser(text.value)._parse(); + } catch (e) { + if (e instanceof ParserError) { + const message = + options.prettyErrors === false + ? e.message + : e.message + ":\n\n" + text.value + "\n" + " ".repeat(e.pos) + "^\n"; + errors.push({ + message, + range: [ + options.lineCounter!.linePos(text.range![0]), + options.lineCounter!.linePos(text.range![0] + e.pos), + ], + }); + return null; + } + throw e; + } + } + + constructor(input: string) { + this._input = input; + this._pos = 0; + this._length = input.length; + } + + private _peek() { + return this._input[this._pos] || ""; + } + + private _next() { + if (this._pos < this._length) return this._input[this._pos++]; + return null; + } + + private _eof() { + return this._pos >= this._length; + } + + private _isWhitespace() { + return !this._eof() && /\s/.test(this._peek()); + } + + private _skipWhitespace() { + while (this._isWhitespace()) this._pos++; + } + + private _readIdentifier(type: "role" | "attribute"): string { + if (this._eof()) this._throwError(`Unexpected end of input when expecting ${type}`); + const start = this._pos; + while (!this._eof() && /[a-zA-Z]/.test(this._peek())) this._pos++; + return this._input.slice(start, this._pos); + } + + private _readString(): string { + let result = ""; + let escaped = false; + while (!this._eof()) { + const ch = this._next(); + if (escaped) { + result += ch; + escaped = false; + } else if (ch === "\\") { + escaped = true; + } else if (ch === '"') { + return result; + } else { + result += ch; + } + } + this._throwError("Unterminated string"); + } + + private _throwError(message: string, offset: number = 0): never { + throw new ParserError(message, offset || this._pos); + } + + private _readRegex(): AriaRegex { + let result = ""; + let escaped = false; + let insideClass = false; + while (!this._eof()) { + const ch = this._next(); + if (escaped) { + result += ch; + escaped = false; + } else if (ch === "\\") { + escaped = true; + result += ch; + } else if (ch === "/" && !insideClass) { + return { pattern: result }; + } else if (ch === "[") { + insideClass = true; + result += ch; + } else if (ch === "]" && insideClass) { + result += ch; + insideClass = false; + } else { + result += ch; + } + } + this._throwError("Unterminated regex"); + } + + private _readStringOrRegex(): string | AriaRegex | null { + const ch = this._peek(); + if (ch === '"') { + this._next(); + return normalizeWhitespace(this._readString()); + } + + if (ch === "/") { + this._next(); + return this._readRegex(); + } + + return null; + } + + private _readAttributes(result: AriaTemplateRoleNode) { + let errorPos = this._pos; + while (true) { + this._skipWhitespace(); + if (this._peek() === "[") { + this._next(); + this._skipWhitespace(); + errorPos = this._pos; + const flagName = this._readIdentifier("attribute"); + this._skipWhitespace(); + let flagValue = ""; + if (this._peek() === "=") { + this._next(); + this._skipWhitespace(); + errorPos = this._pos; + while (this._peek() !== "]" && !this._isWhitespace() && !this._eof()) + flagValue += this._next(); + } + this._skipWhitespace(); + if (this._peek() !== "]") this._throwError("Expected ]"); + + this._next(); // Consume ']' + this._applyAttribute(result, flagName, flagValue || "true", errorPos); + } else { + break; + } + } + } + + _parse(): AriaTemplateRoleNode { + this._skipWhitespace(); + + const role = this._readIdentifier("role") as AriaTemplateRoleNode["role"]; + this._skipWhitespace(); + const name = this._readStringOrRegex() || ""; + const result: AriaTemplateRoleNode = { kind: "role", role, name }; + this._readAttributes(result); + this._skipWhitespace(); + if (!this._eof()) this._throwError("Unexpected input"); + return result; + } + + private _applyAttribute( + node: AriaTemplateRoleNode, + key: string, + value: string, + errorPos: number + ) { + if (key === "checked") { + this._assert( + value === "true" || value === "false" || value === "mixed", + 'Value of "checked\" attribute must be a boolean or "mixed"', + errorPos + ); + node.checked = value === "true" ? true : value === "false" ? false : "mixed"; + return; + } + if (key === "disabled") { + this._assert( + value === "true" || value === "false", + 'Value of "disabled" attribute must be a boolean', + errorPos + ); + node.disabled = value === "true"; + return; + } + if (key === "expanded") { + this._assert( + value === "true" || value === "false", + 'Value of "expanded" attribute must be a boolean', + errorPos + ); + node.expanded = value === "true"; + return; + } + if (key === "active") { + this._assert( + value === "true" || value === "false", + 'Value of "active" attribute must be a boolean', + errorPos + ); + node.active = value === "true"; + return; + } + if (key === "level") { + this._assert(!isNaN(Number(value)), 'Value of "level" attribute must be a number', errorPos); + node.level = Number(value); + return; + } + if (key === "pressed") { + this._assert( + value === "true" || value === "false" || value === "mixed", + 'Value of "pressed" attribute must be a boolean or "mixed"', + errorPos + ); + node.pressed = value === "true" ? true : value === "false" ? false : "mixed"; + return; + } + if (key === "selected") { + this._assert( + value === "true" || value === "false", + 'Value of "selected" attribute must be a boolean', + errorPos + ); + node.selected = value === "true"; + return; + } + this._assert(false, `Unsupported attribute [${key}]`, errorPos); + } + + private _assert(value: any, message: string, valuePos: number): asserts value { + if (!value) this._throwError(message || "Assertion error", valuePos); + } +} + +export class ParserError extends Error { + readonly pos: number; + + constructor(message: string, pos: number) { + super(message); + this.pos = pos; + } +} + +export function findNewNode(from: AriaNode | undefined, to: AriaNode): AriaNode | undefined { + type ByRoleAndName = Map>; + + function fillMap(root: AriaNode, map: ByRoleAndName, position: number) { + let size = 1; + let childPosition = position + size; + for (const child of root.children || []) { + if (typeof child === "string") { + size++; + childPosition++; + } else { + size += fillMap(child, map, childPosition); + childPosition += size; + } + } + if ( + !["none", "presentation", "fragment", "iframe", "generic"].includes(root.role) && + root.name + ) { + let byRole = map.get(root.role); + if (!byRole) { + byRole = new Map(); + map.set(root.role, byRole); + } + const existing = byRole.get(root.name); + // This heuristic prioritizes elements at the top of the page, even if somewhat smaller. + const sizeAndPosition = size * 100 - position; + if (!existing || existing.sizeAndPosition < sizeAndPosition) + byRole.set(root.name, { node: root, sizeAndPosition }); + } + return size; + } + + const fromMap: ByRoleAndName = new Map(); + if (from) fillMap(from, fromMap, 0); + + const toMap: ByRoleAndName = new Map(); + fillMap(to, toMap, 0); + + const result: { node: AriaNode; sizeAndPosition: number }[] = []; + for (const [role, byRole] of toMap) { + for (const [name, byName] of byRole) { + const inFrom = fromMap.get(role)?.get(name); + if (!inFrom) result.push(byName); + } + } + result.sort((a, b) => b.sizeAndPosition - a.sizeAndPosition); + return result[0]?.node; +} diff --git a/daemon/src/sandbox/forked-client/src/utils/isomorphic/assert.ts b/daemon/src/sandbox/forked-client/src/utils/isomorphic/assert.ts new file mode 100644 index 00000000..56ee86d9 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/utils/isomorphic/assert.ts @@ -0,0 +1,20 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export function assert(value: any, message?: string): asserts value { + if (!value) throw new Error(message || "Assertion error"); +} diff --git a/daemon/src/sandbox/forked-client/src/utils/isomorphic/colors.ts b/daemon/src/sandbox/forked-client/src/utils/isomorphic/colors.ts new file mode 100644 index 00000000..3f02f808 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/utils/isomorphic/colors.ts @@ -0,0 +1,67 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export const webColors = { + enabled: true, + reset: (text: string) => applyStyle(0, 0, text), + + bold: (text: string) => applyStyle(1, 22, text), + dim: (text: string) => applyStyle(2, 22, text), + italic: (text: string) => applyStyle(3, 23, text), + underline: (text: string) => applyStyle(4, 24, text), + inverse: (text: string) => applyStyle(7, 27, text), + hidden: (text: string) => applyStyle(8, 28, text), + strikethrough: (text: string) => applyStyle(9, 29, text), + + black: (text: string) => applyStyle(30, 39, text), + red: (text: string) => applyStyle(31, 39, text), + green: (text: string) => applyStyle(32, 39, text), + yellow: (text: string) => applyStyle(33, 39, text), + blue: (text: string) => applyStyle(34, 39, text), + magenta: (text: string) => applyStyle(35, 39, text), + cyan: (text: string) => applyStyle(36, 39, text), + white: (text: string) => applyStyle(37, 39, text), + gray: (text: string) => applyStyle(90, 39, text), + grey: (text: string) => applyStyle(90, 39, text), +}; + +export type Colors = typeof webColors; + +export const noColors: Colors = { + enabled: false, + reset: (t) => t, + bold: (t) => t, + dim: (t) => t, + italic: (t) => t, + underline: (t) => t, + inverse: (t) => t, + hidden: (t) => t, + strikethrough: (t) => t, + black: (t) => t, + red: (t) => t, + green: (t) => t, + yellow: (t) => t, + blue: (t) => t, + magenta: (t) => t, + cyan: (t) => t, + white: (t) => t, + gray: (t) => t, + grey: (t) => t, +}; + +const applyStyle = (open: number, close: number, text: string) => + `\u001b[${open}m${text}\u001b[${close}m`; diff --git a/daemon/src/sandbox/forked-client/src/utils/isomorphic/cssParser.ts b/daemon/src/sandbox/forked-client/src/utils/isomorphic/cssParser.ts new file mode 100644 index 00000000..abfa08b0 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/utils/isomorphic/cssParser.ts @@ -0,0 +1,285 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as css from "./cssTokenizer"; + +export class InvalidSelectorError extends Error {} + +export function isInvalidSelectorError(error: Error) { + return error instanceof InvalidSelectorError; +} + +// Note: '>=' is used internally for text engine to preserve backwards compatibility. +type ClauseCombinator = "" | ">" | "+" | "~" | ">="; +// TODO: consider +// - key=value +// - operators like `=`, `|=`, `~=`, `*=`, `/` +// - ~=value +// - argument modes: "parse all", "parse commas", "just a string" +export type CSSFunctionArgument = CSSComplexSelector | number | string; +export type CSSFunction = { name: string; args: CSSFunctionArgument[] }; +export type CSSSimpleSelector = { css?: string; functions: CSSFunction[] }; +export type CSSComplexSelector = { + simples: { selector: CSSSimpleSelector; combinator: ClauseCombinator }[]; +}; +export type CSSComplexSelectorList = CSSComplexSelector[]; + +export function parseCSS( + selector: string, + customNames: Set +): { selector: CSSComplexSelectorList; names: string[] } { + let tokens: css.CSSTokenInterface[]; + try { + tokens = css.tokenize(selector); + if (!(tokens[tokens.length - 1] instanceof css.EOFToken)) tokens.push(new css.EOFToken()); + } catch (e) { + const newMessage = + e.message + ` while parsing css selector "${selector}". Did you mean to CSS.escape it?`; + const index = (e.stack || "").indexOf(e.message); + if (index !== -1) + e.stack = + e.stack.substring(0, index) + newMessage + e.stack.substring(index + e.message.length); + e.message = newMessage; + throw e; + } + const unsupportedToken = tokens.find((token) => { + return ( + token instanceof css.AtKeywordToken || + token instanceof css.BadStringToken || + token instanceof css.BadURLToken || + token instanceof css.ColumnToken || + token instanceof css.CDOToken || + token instanceof css.CDCToken || + token instanceof css.SemicolonToken || + // TODO: Consider using these for something, e.g. to escape complex strings. + // For example :xpath{ (//div/bar[@attr="foo"])[2]/baz } + // Or this way :xpath( {complex-xpath-goes-here("hello")} ) + token instanceof css.OpenCurlyToken || + token instanceof css.CloseCurlyToken || + // TODO: Consider treating these as strings? + token instanceof css.URLToken || + token instanceof css.PercentageToken + ); + }); + if (unsupportedToken) + throw new InvalidSelectorError( + `Unsupported token "${unsupportedToken.toSource()}" while parsing css selector "${selector}". Did you mean to CSS.escape it?` + ); + + let pos = 0; + const names = new Set(); + + function unexpected() { + return new InvalidSelectorError( + `Unexpected token "${tokens[pos].toSource()}" while parsing css selector "${selector}". Did you mean to CSS.escape it?` + ); + } + + function skipWhitespace() { + while (tokens[pos] instanceof css.WhitespaceToken) pos++; + } + + function isIdent(p = pos) { + return tokens[p] instanceof css.IdentToken; + } + + function isString(p = pos) { + return tokens[p] instanceof css.StringToken; + } + + function isNumber(p = pos) { + return tokens[p] instanceof css.NumberToken; + } + + function isComma(p = pos) { + return tokens[p] instanceof css.CommaToken; + } + + function isOpenParen(p = pos) { + return tokens[p] instanceof css.OpenParenToken; + } + + function isCloseParen(p = pos) { + return tokens[p] instanceof css.CloseParenToken; + } + + function isFunction(p = pos) { + return tokens[p] instanceof css.FunctionToken; + } + + function isStar(p = pos) { + return tokens[p] instanceof css.DelimToken && tokens[p].value === "*"; + } + + function isEOF(p = pos) { + return tokens[p] instanceof css.EOFToken; + } + + function isClauseCombinator(p = pos) { + return ( + tokens[p] instanceof css.DelimToken && [">", "+", "~"].includes(tokens[p].value as string) + ); + } + + function isSelectorClauseEnd(p = pos) { + return ( + isComma(p) || + isCloseParen(p) || + isEOF(p) || + isClauseCombinator(p) || + tokens[p] instanceof css.WhitespaceToken + ); + } + + function consumeFunctionArguments(): CSSFunctionArgument[] { + const result = [consumeArgument()]; + while (true) { + skipWhitespace(); + if (!isComma()) break; + pos++; + result.push(consumeArgument()); + } + return result; + } + + function consumeArgument(): CSSFunctionArgument { + skipWhitespace(); + if (isNumber()) return tokens[pos++].value!; + if (isString()) return tokens[pos++].value!; + return consumeComplexSelector(); + } + + function consumeComplexSelector(): CSSComplexSelector { + const result: CSSComplexSelector = { simples: [] }; + skipWhitespace(); + if (isClauseCombinator()) { + // Put implicit ":scope" at the start. https://drafts.csswg.org/selectors-4/#relative + result.simples.push({ + selector: { functions: [{ name: "scope", args: [] }] }, + combinator: "", + }); + } else { + result.simples.push({ selector: consumeSimpleSelector(), combinator: "" }); + } + while (true) { + skipWhitespace(); + if (isClauseCombinator()) { + result.simples[result.simples.length - 1].combinator = tokens[pos++] + .value as ClauseCombinator; + skipWhitespace(); + } else if (isSelectorClauseEnd()) { + break; + } + result.simples.push({ combinator: "", selector: consumeSimpleSelector() }); + } + return result; + } + + function consumeSimpleSelector(): CSSSimpleSelector { + let rawCSSString = ""; + const functions: CSSFunction[] = []; + + while (!isSelectorClauseEnd()) { + if (isIdent() || isStar()) { + rawCSSString += tokens[pos++].toSource(); + } else if (tokens[pos] instanceof css.HashToken) { + rawCSSString += tokens[pos++].toSource(); + } else if (tokens[pos] instanceof css.DelimToken && tokens[pos].value === ".") { + pos++; + if (isIdent()) rawCSSString += "." + tokens[pos++].toSource(); + else throw unexpected(); + } else if (tokens[pos] instanceof css.ColonToken) { + pos++; + if (isIdent()) { + if (!customNames.has((tokens[pos].value as string).toLowerCase())) { + rawCSSString += ":" + tokens[pos++].toSource(); + } else { + const name = (tokens[pos++].value as string).toLowerCase(); + functions.push({ name, args: [] }); + names.add(name); + } + } else if (isFunction()) { + const name = (tokens[pos++].value as string).toLowerCase(); + if (!customNames.has(name)) { + rawCSSString += `:${name}(${consumeBuiltinFunctionArguments()})`; + } else { + functions.push({ name, args: consumeFunctionArguments() }); + names.add(name); + } + skipWhitespace(); + if (!isCloseParen()) throw unexpected(); + pos++; + } else { + throw unexpected(); + } + } else if (tokens[pos] instanceof css.OpenSquareToken) { + rawCSSString += "["; + pos++; + while (!(tokens[pos] instanceof css.CloseSquareToken) && !isEOF()) + rawCSSString += tokens[pos++].toSource(); + if (!(tokens[pos] instanceof css.CloseSquareToken)) throw unexpected(); + rawCSSString += "]"; + pos++; + } else { + throw unexpected(); + } + } + if (!rawCSSString && !functions.length) throw unexpected(); + return { css: rawCSSString || undefined, functions }; + } + + function consumeBuiltinFunctionArguments(): string { + let s = ""; + let balance = 1; // First open paren is a part of a function token. + while (!isEOF()) { + if (isOpenParen() || isFunction()) balance++; + if (isCloseParen()) balance--; + if (!balance) break; + s += tokens[pos++].toSource(); + } + return s; + } + + const result = consumeFunctionArguments(); + if (!isEOF()) throw unexpected(); + if (result.some((arg) => typeof arg !== "object" || !("simples" in arg))) + throw new InvalidSelectorError( + `Error while parsing css selector "${selector}". Did you mean to CSS.escape it?` + ); + return { selector: result as CSSComplexSelector[], names: Array.from(names) }; +} + +export function serializeSelector(args: CSSFunctionArgument[]) { + return args + .map((arg) => { + if (typeof arg === "string") return `"${arg}"`; + if (typeof arg === "number") return String(arg); + return arg.simples + .map(({ selector, combinator }) => { + let s = selector.css || ""; + s = + s + + selector.functions + .map((func) => `:${func.name}(${serializeSelector(func.args)})`) + .join(""); + if (combinator) s += " " + combinator; + return s; + }) + .join(" "); + }) + .join(", "); +} diff --git a/daemon/src/sandbox/forked-client/src/utils/isomorphic/cssTokenizer.ts b/daemon/src/sandbox/forked-client/src/utils/isomorphic/cssTokenizer.ts new file mode 100644 index 00000000..bf7394e6 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/utils/isomorphic/cssTokenizer.ts @@ -0,0 +1,1011 @@ +// @ts-nocheck +/* eslint-disable notice/notice */ + +/* + * The code in this file is licensed under the CC0 license. + * http://creativecommons.org/publicdomain/zero/1.0/ + * It is free to use for any purpose. No attribution, permission, or reproduction of this license is required. + */ + +// Original at https://github.com/tabatkins/parse-css +// Changes: +// - JS is replaced with TS. +// - Universal Module Definition wrapper is removed. +// - Everything not related to tokenizing - below the first exports block - is removed. + +export interface CSSTokenInterface { + toSource(): string; + value: string | number | undefined; +} + +const between = function (num: number, first: number, last: number) { + return num >= first && num <= last; +}; +function digit(code: number) { + return between(code, 0x30, 0x39); +} +function hexdigit(code: number) { + return digit(code) || between(code, 0x41, 0x46) || between(code, 0x61, 0x66); +} +function uppercaseletter(code: number) { + return between(code, 0x41, 0x5a); +} +function lowercaseletter(code: number) { + return between(code, 0x61, 0x7a); +} +function letter(code: number) { + return uppercaseletter(code) || lowercaseletter(code); +} +function nonascii(code: number) { + return code >= 0x80; +} +function namestartchar(code: number) { + return letter(code) || nonascii(code) || code === 0x5f; +} +function namechar(code: number) { + return namestartchar(code) || digit(code) || code === 0x2d; +} +function nonprintable(code: number) { + return between(code, 0, 8) || code === 0xb || between(code, 0xe, 0x1f) || code === 0x7f; +} +function newline(code: number) { + return code === 0xa; +} +function whitespace(code: number) { + return newline(code) || code === 9 || code === 0x20; +} + +const maximumallowedcodepoint = 0x10ffff; + +export class InvalidCharacterError extends Error { + constructor(message: string) { + super(message); + this.name = "InvalidCharacterError"; + } +} + +function preprocess(str: string): number[] { + // Turn a string into an array of code points, + // following the preprocessing cleanup rules. + const codepoints = []; + for (let i = 0; i < str.length; i++) { + let code = str.charCodeAt(i); + if (code === 0xd && str.charCodeAt(i + 1) === 0xa) { + code = 0xa; + i++; + } + if (code === 0xd || code === 0xc) code = 0xa; + if (code === 0x0) code = 0xfffd; + if (between(code, 0xd800, 0xdbff) && between(str.charCodeAt(i + 1), 0xdc00, 0xdfff)) { + // Decode a surrogate pair into an astral codepoint. + const lead = code - 0xd800; + const trail = str.charCodeAt(i + 1) - 0xdc00; + code = Math.pow(2, 16) + lead * Math.pow(2, 10) + trail; + i++; + } + codepoints.push(code); + } + return codepoints; +} + +function stringFromCode(code: number) { + if (code <= 0xffff) return String.fromCharCode(code); + // Otherwise, encode astral char as surrogate pair. + code -= Math.pow(2, 16); + const lead = Math.floor(code / Math.pow(2, 10)) + 0xd800; + const trail = (code % Math.pow(2, 10)) + 0xdc00; + return String.fromCharCode(lead) + String.fromCharCode(trail); +} + +export function tokenize(str1: string): CSSTokenInterface[] { + const str = preprocess(str1); + let i = -1; + const tokens: CSSTokenInterface[] = []; + let code: number; + + // Line number information. + let line = 0; + let column = 0; + // The only use of lastLineLength is in reconsume(). + let lastLineLength = 0; + const incrLineno = function () { + line += 1; + lastLineLength = column; + column = 0; + }; + const locStart = { line: line, column: column }; + + const codepoint = function (i: number): number { + if (i >= str.length) return -1; + + return str[i]; + }; + const next = function (num?: number) { + if (num === undefined) num = 1; + if (num > 3) throw "Spec Error: no more than three codepoints of lookahead."; + return codepoint(i + num); + }; + const consume = function (num?: number): boolean { + if (num === undefined) num = 1; + i += num; + code = codepoint(i); + if (newline(code)) incrLineno(); + else column += num; + // console.log('Consume '+i+' '+String.fromCharCode(code) + ' 0x' + code.toString(16)); + return true; + }; + const reconsume = function () { + i -= 1; + if (newline(code)) { + line -= 1; + column = lastLineLength; + } else { + column -= 1; + } + locStart.line = line; + locStart.column = column; + return true; + }; + const eof = function (codepoint?: number): boolean { + if (codepoint === undefined) codepoint = code; + return codepoint === -1; + }; + const donothing = function () {}; + const parseerror = function () { + // Language bindings don't like writing to stdout! + // console.log('Parse error at index ' + i + ', processing codepoint 0x' + code.toString(16) + '.'); return true; + }; + + const consumeAToken = function (): CSSTokenInterface { + consumeComments(); + consume(); + if (whitespace(code)) { + while (whitespace(next())) consume(); + return new WhitespaceToken(); + } else if (code === 0x22) { + return consumeAStringToken(); + } else if (code === 0x23) { + if (namechar(next()) || areAValidEscape(next(1), next(2))) { + const token = new HashToken(""); + if (wouldStartAnIdentifier(next(1), next(2), next(3))) token.type = "id"; + token.value = consumeAName(); + return token; + } else { + return new DelimToken(code); + } + } else if (code === 0x24) { + if (next() === 0x3d) { + consume(); + return new SuffixMatchToken(); + } else { + return new DelimToken(code); + } + } else if (code === 0x27) { + return consumeAStringToken(); + } else if (code === 0x28) { + return new OpenParenToken(); + } else if (code === 0x29) { + return new CloseParenToken(); + } else if (code === 0x2a) { + if (next() === 0x3d) { + consume(); + return new SubstringMatchToken(); + } else { + return new DelimToken(code); + } + } else if (code === 0x2b) { + if (startsWithANumber()) { + reconsume(); + return consumeANumericToken(); + } else { + return new DelimToken(code); + } + } else if (code === 0x2c) { + return new CommaToken(); + } else if (code === 0x2d) { + if (startsWithANumber()) { + reconsume(); + return consumeANumericToken(); + } else if (next(1) === 0x2d && next(2) === 0x3e) { + consume(2); + return new CDCToken(); + } else if (startsWithAnIdentifier()) { + reconsume(); + return consumeAnIdentlikeToken(); + } else { + return new DelimToken(code); + } + } else if (code === 0x2e) { + if (startsWithANumber()) { + reconsume(); + return consumeANumericToken(); + } else { + return new DelimToken(code); + } + } else if (code === 0x3a) { + return new ColonToken(); + } else if (code === 0x3b) { + return new SemicolonToken(); + } else if (code === 0x3c) { + if (next(1) === 0x21 && next(2) === 0x2d && next(3) === 0x2d) { + consume(3); + return new CDOToken(); + } else { + return new DelimToken(code); + } + } else if (code === 0x40) { + if (wouldStartAnIdentifier(next(1), next(2), next(3))) + return new AtKeywordToken(consumeAName()); + else return new DelimToken(code); + } else if (code === 0x5b) { + return new OpenSquareToken(); + } else if (code === 0x5c) { + if (startsWithAValidEscape()) { + reconsume(); + return consumeAnIdentlikeToken(); + } else { + parseerror(); + return new DelimToken(code); + } + } else if (code === 0x5d) { + return new CloseSquareToken(); + } else if (code === 0x5e) { + if (next() === 0x3d) { + consume(); + return new PrefixMatchToken(); + } else { + return new DelimToken(code); + } + } else if (code === 0x7b) { + return new OpenCurlyToken(); + } else if (code === 0x7c) { + if (next() === 0x3d) { + consume(); + return new DashMatchToken(); + } else if (next() === 0x7c) { + consume(); + return new ColumnToken(); + } else { + return new DelimToken(code); + } + } else if (code === 0x7d) { + return new CloseCurlyToken(); + } else if (code === 0x7e) { + if (next() === 0x3d) { + consume(); + return new IncludeMatchToken(); + } else { + return new DelimToken(code); + } + } else if (digit(code)) { + reconsume(); + return consumeANumericToken(); + } else if (namestartchar(code)) { + reconsume(); + return consumeAnIdentlikeToken(); + } else if (eof()) { + return new EOFToken(); + } else { + return new DelimToken(code); + } + }; + + const consumeComments = function () { + while (next(1) === 0x2f && next(2) === 0x2a) { + consume(2); + while (true) { + consume(); + if (code === 0x2a && next() === 0x2f) { + consume(); + break; + } else if (eof()) { + parseerror(); + return; + } + } + } + }; + + const consumeANumericToken = function () { + const num = consumeANumber(); + if (wouldStartAnIdentifier(next(1), next(2), next(3))) { + const token = new DimensionToken(); + token.value = num.value; + token.repr = num.repr; + token.type = num.type; + token.unit = consumeAName(); + return token; + } else if (next() === 0x25) { + consume(); + const token = new PercentageToken(); + token.value = num.value; + token.repr = num.repr; + return token; + } else { + const token = new NumberToken(); + token.value = num.value; + token.repr = num.repr; + token.type = num.type; + return token; + } + }; + + const consumeAnIdentlikeToken = function (): CSSTokenInterface { + const str = consumeAName(); + if (str.toLowerCase() === "url" && next() === 0x28) { + consume(); + while (whitespace(next(1)) && whitespace(next(2))) consume(); + if (next() === 0x22 || next() === 0x27) return new FunctionToken(str); + else if (whitespace(next()) && (next(2) === 0x22 || next(2) === 0x27)) + return new FunctionToken(str); + else return consumeAURLToken(); + } else if (next() === 0x28) { + consume(); + return new FunctionToken(str); + } else { + return new IdentToken(str); + } + }; + + const consumeAStringToken = function (endingCodePoint?: number): CSSParserToken { + if (endingCodePoint === undefined) endingCodePoint = code; + let string = ""; + while (consume()) { + if (code === endingCodePoint || eof()) { + return new StringToken(string); + } else if (newline(code)) { + parseerror(); + reconsume(); + return new BadStringToken(); + } else if (code === 0x5c) { + if (eof(next())) donothing(); + else if (newline(next())) consume(); + else string += stringFromCode(consumeEscape()); + } else { + string += stringFromCode(code); + } + } + throw new Error("Internal error"); + }; + + const consumeAURLToken = function (): CSSTokenInterface { + const token = new URLToken(""); + while (whitespace(next())) consume(); + if (eof(next())) return token; + while (consume()) { + if (code === 0x29 || eof()) { + return token; + } else if (whitespace(code)) { + while (whitespace(next())) consume(); + if (next() === 0x29 || eof(next())) { + consume(); + return token; + } else { + consumeTheRemnantsOfABadURL(); + return new BadURLToken(); + } + } else if (code === 0x22 || code === 0x27 || code === 0x28 || nonprintable(code)) { + parseerror(); + consumeTheRemnantsOfABadURL(); + return new BadURLToken(); + } else if (code === 0x5c) { + if (startsWithAValidEscape()) { + token.value += stringFromCode(consumeEscape()); + } else { + parseerror(); + consumeTheRemnantsOfABadURL(); + return new BadURLToken(); + } + } else { + token.value += stringFromCode(code); + } + } + throw new Error("Internal error"); + }; + + const consumeEscape = function () { + // Assume the current character is the \ + // and the next code point is not a newline. + consume(); + if (hexdigit(code)) { + // Consume 1-6 hex digits + const digits = [code]; + for (let total = 0; total < 5; total++) { + if (hexdigit(next())) { + consume(); + digits.push(code); + } else { + break; + } + } + if (whitespace(next())) consume(); + let value = parseInt( + digits + .map(function (x) { + return String.fromCharCode(x); + }) + .join(""), + 16 + ); + if (value > maximumallowedcodepoint) value = 0xfffd; + return value; + } else if (eof()) { + return 0xfffd; + } else { + return code; + } + }; + + const areAValidEscape = function (c1: number, c2: number) { + if (c1 !== 0x5c) return false; + if (newline(c2)) return false; + return true; + }; + const startsWithAValidEscape = function () { + return areAValidEscape(code, next()); + }; + + const wouldStartAnIdentifier = function (c1: number, c2: number, c3: number) { + if (c1 === 0x2d) return namestartchar(c2) || c2 === 0x2d || areAValidEscape(c2, c3); + else if (namestartchar(c1)) return true; + else if (c1 === 0x5c) return areAValidEscape(c1, c2); + else return false; + }; + const startsWithAnIdentifier = function () { + return wouldStartAnIdentifier(code, next(1), next(2)); + }; + + const wouldStartANumber = function (c1: number, c2: number, c3: number) { + if (c1 === 0x2b || c1 === 0x2d) { + if (digit(c2)) return true; + if (c2 === 0x2e && digit(c3)) return true; + return false; + } else if (c1 === 0x2e) { + if (digit(c2)) return true; + return false; + } else if (digit(c1)) { + return true; + } else { + return false; + } + }; + const startsWithANumber = function () { + return wouldStartANumber(code, next(1), next(2)); + }; + + const consumeAName = function (): string { + let result = ""; + while (consume()) { + if (namechar(code)) { + result += stringFromCode(code); + } else if (startsWithAValidEscape()) { + result += stringFromCode(consumeEscape()); + } else { + reconsume(); + return result; + } + } + throw new Error("Internal parse error"); + }; + + const consumeANumber = function () { + let repr = ""; + let type = "integer"; + if (next() === 0x2b || next() === 0x2d) { + consume(); + repr += stringFromCode(code); + } + while (digit(next())) { + consume(); + repr += stringFromCode(code); + } + if (next(1) === 0x2e && digit(next(2))) { + consume(); + repr += stringFromCode(code); + consume(); + repr += stringFromCode(code); + type = "number"; + while (digit(next())) { + consume(); + repr += stringFromCode(code); + } + } + const c1 = next(1), + c2 = next(2), + c3 = next(3); + if ((c1 === 0x45 || c1 === 0x65) && digit(c2)) { + consume(); + repr += stringFromCode(code); + consume(); + repr += stringFromCode(code); + type = "number"; + while (digit(next())) { + consume(); + repr += stringFromCode(code); + } + } else if ((c1 === 0x45 || c1 === 0x65) && (c2 === 0x2b || c2 === 0x2d) && digit(c3)) { + consume(); + repr += stringFromCode(code); + consume(); + repr += stringFromCode(code); + consume(); + repr += stringFromCode(code); + type = "number"; + while (digit(next())) { + consume(); + repr += stringFromCode(code); + } + } + const value = convertAStringToANumber(repr); + return { type: type, value: value, repr: repr }; + }; + + const convertAStringToANumber = function (string: string): number { + // CSS's number rules are identical to JS, afaik. + return +string; + }; + + const consumeTheRemnantsOfABadURL = function () { + while (consume()) { + if (code === 0x29 || eof()) { + return; + } else if (startsWithAValidEscape()) { + consumeEscape(); + donothing(); + } else { + donothing(); + } + } + }; + + let iterationCount = 0; + while (!eof(next())) { + tokens.push(consumeAToken()); + iterationCount++; + if (iterationCount > str.length * 2) throw new Error("I'm infinite-looping!"); + } + return tokens; +} + +export class CSSParserToken implements CSSTokenInterface { + tokenType = ""; + value: string | number | undefined; + toJSON(): any { + return { token: this.tokenType }; + } + toString() { + return this.tokenType; + } + toSource() { + return "" + this; + } +} + +export class BadStringToken extends CSSParserToken { + override tokenType = "BADSTRING"; +} + +export class BadURLToken extends CSSParserToken { + override tokenType = "BADURL"; +} + +export class WhitespaceToken extends CSSParserToken { + override tokenType = "WHITESPACE"; + override toString() { + return "WS"; + } + override toSource() { + return " "; + } +} + +export class CDOToken extends CSSParserToken { + override tokenType = "CDO"; + override toSource() { + return ""; + } +} + +export class ColonToken extends CSSParserToken { + override tokenType = ":"; +} + +export class SemicolonToken extends CSSParserToken { + override tokenType = ";"; +} + +export class CommaToken extends CSSParserToken { + override tokenType = ","; +} + +export class GroupingToken extends CSSParserToken { + override value = ""; + mirror = ""; +} + +export class OpenCurlyToken extends GroupingToken { + override tokenType = "{"; + constructor() { + super(); + this.value = "{"; + this.mirror = "}"; + } +} + +export class CloseCurlyToken extends GroupingToken { + override tokenType = "}"; + constructor() { + super(); + this.value = "}"; + this.mirror = "{"; + } +} + +export class OpenSquareToken extends GroupingToken { + override tokenType = "["; + constructor() { + super(); + this.value = "["; + this.mirror = "]"; + } +} + +export class CloseSquareToken extends GroupingToken { + override tokenType = "]"; + constructor() { + super(); + this.value = "]"; + this.mirror = "["; + } +} + +export class OpenParenToken extends GroupingToken { + override tokenType = "("; + constructor() { + super(); + this.value = "("; + this.mirror = ")"; + } +} + +export class CloseParenToken extends GroupingToken { + override tokenType = ")"; + constructor() { + super(); + this.value = ")"; + this.mirror = "("; + } +} + +export class IncludeMatchToken extends CSSParserToken { + override tokenType = "~="; +} + +export class DashMatchToken extends CSSParserToken { + override tokenType = "|="; +} + +export class PrefixMatchToken extends CSSParserToken { + override tokenType = "^="; +} + +export class SuffixMatchToken extends CSSParserToken { + override tokenType = "$="; +} + +export class SubstringMatchToken extends CSSParserToken { + override tokenType = "*="; +} + +export class ColumnToken extends CSSParserToken { + override tokenType = "||"; +} + +export class EOFToken extends CSSParserToken { + override tokenType = "EOF"; + override toSource() { + return ""; + } +} + +export class DelimToken extends CSSParserToken { + override tokenType = "DELIM"; + override value: string = ""; + + constructor(code: number) { + super(); + this.value = stringFromCode(code); + } + + override toString() { + return "DELIM(" + this.value + ")"; + } + + override toJSON() { + const json = this.constructor.prototype.constructor.prototype.toJSON.call(this); + json.value = this.value; + return json; + } + + override toSource() { + if (this.value === "\\") return "\\\n"; + else return this.value; + } +} + +export abstract class StringValuedToken extends CSSParserToken { + override value: string = ""; + ASCIIMatch(str: string) { + return this.value.toLowerCase() === str.toLowerCase(); + } + + override toJSON() { + const json = this.constructor.prototype.constructor.prototype.toJSON.call(this); + json.value = this.value; + return json; + } +} + +export class IdentToken extends StringValuedToken { + constructor(val: string) { + super(); + this.value = val; + } + + override tokenType = "IDENT"; + override toString() { + return "IDENT(" + this.value + ")"; + } + override toSource() { + return escapeIdent(this.value); + } +} + +export class FunctionToken extends StringValuedToken { + override tokenType = "FUNCTION"; + mirror: string; + constructor(val: string) { + super(); + this.value = val; + this.mirror = ")"; + } + + override toString() { + return "FUNCTION(" + this.value + ")"; + } + + override toSource() { + return escapeIdent(this.value) + "("; + } +} + +export class AtKeywordToken extends StringValuedToken { + override tokenType = "AT-KEYWORD"; + constructor(val: string) { + super(); + this.value = val; + } + override toString() { + return "AT(" + this.value + ")"; + } + override toSource() { + return "@" + escapeIdent(this.value); + } +} + +export class HashToken extends StringValuedToken { + override tokenType = "HASH"; + type: string; + constructor(val: string) { + super(); + this.value = val; + this.type = "unrestricted"; + } + + override toString() { + return "HASH(" + this.value + ")"; + } + + override toJSON() { + const json = this.constructor.prototype.constructor.prototype.toJSON.call(this); + json.value = this.value; + json.type = this.type; + return json; + } + + override toSource() { + if (this.type === "id") return "#" + escapeIdent(this.value); + else return "#" + escapeHash(this.value); + } +} + +export class StringToken extends StringValuedToken { + override tokenType = "STRING"; + constructor(val: string) { + super(); + this.value = val; + } + + override toString() { + return '"' + escapeString(this.value) + '"'; + } +} + +export class URLToken extends StringValuedToken { + override tokenType = "URL"; + constructor(val: string) { + super(); + this.value = val; + } + override toString() { + return "URL(" + this.value + ")"; + } + override toSource() { + return 'url("' + escapeString(this.value) + '")'; + } +} + +export class NumberToken extends CSSParserToken { + override tokenType = "NUMBER"; + type: string; + repr: string; + + constructor() { + super(); + this.type = "integer"; + this.repr = ""; + } + + override toString() { + if (this.type === "integer") return "INT(" + this.value + ")"; + return "NUMBER(" + this.value + ")"; + } + override toJSON() { + const json = super.toJSON(); + json.value = this.value; + json.type = this.type; + json.repr = this.repr; + return json; + } + override toSource() { + return this.repr; + } +} + +export class PercentageToken extends CSSParserToken { + override tokenType = "PERCENTAGE"; + repr: string; + constructor() { + super(); + this.repr = ""; + } + override toString() { + return "PERCENTAGE(" + this.value + ")"; + } + override toJSON() { + const json = this.constructor.prototype.constructor.prototype.toJSON.call(this); + json.value = this.value; + json.repr = this.repr; + return json; + } + override toSource() { + return this.repr + "%"; + } +} + +export class DimensionToken extends CSSParserToken { + override tokenType = "DIMENSION"; + type: string; + repr: string; + unit: string; + + constructor() { + super(); + this.type = "integer"; + this.repr = ""; + this.unit = ""; + } + + override toString() { + return "DIM(" + this.value + "," + this.unit + ")"; + } + override toJSON() { + const json = this.constructor.prototype.constructor.prototype.toJSON.call(this); + json.value = this.value; + json.type = this.type; + json.repr = this.repr; + json.unit = this.unit; + return json; + } + override toSource() { + const source = this.repr; + let unit = escapeIdent(this.unit); + if ( + unit[0].toLowerCase() === "e" && + (unit[1] === "-" || between(unit.charCodeAt(1), 0x30, 0x39)) + ) { + // Unit is ambiguous with scinot + // Remove the leading "e", replace with escape. + unit = "\\65 " + unit.slice(1, unit.length); + } + return source + unit; + } +} + +function escapeIdent(string: string) { + string = "" + string; + let result = ""; + const firstcode = string.charCodeAt(0); + for (let i = 0; i < string.length; i++) { + const code = string.charCodeAt(i); + if (code === 0x0) + throw new InvalidCharacterError("Invalid character: the input contains U+0000."); + + if ( + between(code, 0x1, 0x1f) || + code === 0x7f || + (i === 0 && between(code, 0x30, 0x39)) || + (i === 1 && between(code, 0x30, 0x39) && firstcode === 0x2d) + ) + result += "\\" + code.toString(16) + " "; + else if ( + code >= 0x80 || + code === 0x2d || + code === 0x5f || + between(code, 0x30, 0x39) || + between(code, 0x41, 0x5a) || + between(code, 0x61, 0x7a) + ) + result += string[i]; + else result += "\\" + string[i]; + } + return result; +} + +function escapeHash(string: string) { + // Escapes the contents of "unrestricted"-type hash tokens. + // Won't preserve the ID-ness of "id"-type hash tokens; + // use escapeIdent() for that. + string = "" + string; + let result = ""; + for (let i = 0; i < string.length; i++) { + const code = string.charCodeAt(i); + if (code === 0x0) + throw new InvalidCharacterError("Invalid character: the input contains U+0000."); + + if ( + code >= 0x80 || + code === 0x2d || + code === 0x5f || + between(code, 0x30, 0x39) || + between(code, 0x41, 0x5a) || + between(code, 0x61, 0x7a) + ) + result += string[i]; + else result += "\\" + code.toString(16) + " "; + } + return result; +} + +function escapeString(string: string) { + string = "" + string; + let result = ""; + for (let i = 0; i < string.length; i++) { + const code = string.charCodeAt(i); + + if (code === 0x0) + throw new InvalidCharacterError("Invalid character: the input contains U+0000."); + + if (between(code, 0x1, 0x1f) || code === 0x7f) result += "\\" + code.toString(16) + " "; + else if (code === 0x22 || code === 0x5c) result += "\\" + string[i]; + else result += string[i]; + } + return result; +} diff --git a/daemon/src/sandbox/forked-client/src/utils/isomorphic/headers.ts b/daemon/src/sandbox/forked-client/src/utils/isomorphic/headers.ts new file mode 100644 index 00000000..cbb26c71 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/utils/isomorphic/headers.ts @@ -0,0 +1,45 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +type HeadersArray = { name: string; value: string }[]; +type HeadersObject = { [key: string]: string }; + +export function headersObjectToArray( + headers: HeadersObject, + separator?: string, + setCookieSeparator?: string +): HeadersArray { + if (!setCookieSeparator) setCookieSeparator = separator; + const result: HeadersArray = []; + for (const name in headers) { + const values = headers[name]; + if (values === undefined) continue; + if (separator) { + const sep = name.toLowerCase() === "set-cookie" ? setCookieSeparator : separator; + for (const value of values.split(sep!)) result.push({ name, value: value.trim() }); + } else { + result.push({ name, value: values }); + } + } + return result; +} + +export function headersArrayToObject(headers: HeadersArray, lowerCase: boolean): HeadersObject { + const result: HeadersObject = {}; + for (const { name, value } of headers) result[lowerCase ? name.toLowerCase() : name] = value; + return result; +} diff --git a/daemon/src/sandbox/forked-client/src/utils/isomorphic/imageUtils.ts b/daemon/src/sandbox/forked-client/src/utils/isomorphic/imageUtils.ts new file mode 100644 index 00000000..5f63a89d --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/utils/isomorphic/imageUtils.ts @@ -0,0 +1,180 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export type ImageData = { width: number; height: number; data: Buffer }; + +export function padImageToSize( + image: ImageData, + size: { width: number; height: number } +): ImageData { + if (image.width === size.width && image.height === size.height) return image; + const buffer = new Uint8Array(size.width * size.height * 4); + for (let y = 0; y < size.height; y++) { + for (let x = 0; x < size.width; x++) { + const to = (y * size.width + x) * 4; + if (y < image.height && x < image.width) { + const from = (y * image.width + x) * 4; + buffer[to] = image.data[from]; + buffer[to + 1] = image.data[from + 1]; + buffer[to + 2] = image.data[from + 2]; + buffer[to + 3] = image.data[from + 3]; + } else { + buffer[to] = 0; + buffer[to + 1] = 0; + buffer[to + 2] = 0; + buffer[to + 3] = 0; + } + } + } + return { data: Buffer.from(buffer), width: size.width, height: size.height }; +} + +export function scaleImageToSize( + image: ImageData, + size: { width: number; height: number } +): ImageData { + const { data: src, width: w1, height: h1 } = image; + const w2 = Math.max(1, Math.floor(size.width)); + const h2 = Math.max(1, Math.floor(size.height)); + + if (w1 === w2 && h1 === h2) return image; + + if (w1 <= 0 || h1 <= 0) throw new Error("Invalid input image"); + if (size.width <= 0 || size.height <= 0 || !isFinite(size.width) || !isFinite(size.height)) + throw new Error("Invalid output dimensions"); + + const clamp = (v: number, lo: number, hi: number) => (v < lo ? lo : v > hi ? hi : v); + + // Catmull–Rom weights + const weights = (t: number, o: Float32Array) => { + const t2 = t * t, + t3 = t2 * t; + o[0] = -0.5 * t + 1.0 * t2 - 0.5 * t3; + o[1] = 1.0 - 2.5 * t2 + 1.5 * t3; + o[2] = 0.5 * t + 2.0 * t2 - 1.5 * t3; + o[3] = -0.5 * t2 + 0.5 * t3; + }; + + const srcRowStride = w1 * 4; + const dstRowStride = w2 * 4; + + // Precompute X: indices, weights, and byte offsets (idx*4) + const xOff = new Int32Array(w2 * 4); // byte offsets = xIdx*4 + const xW = new Float32Array(w2 * 4); + const wx = new Float32Array(4); + const xScale = w1 / w2; + for (let x = 0; x < w2; x++) { + const sx = (x + 0.5) * xScale - 0.5; + const sxi = Math.floor(sx); + const t = sx - sxi; + weights(t, wx); + const b = x * 4; + const i0 = clamp(sxi - 1, 0, w1 - 1); + const i1 = clamp(sxi + 0, 0, w1 - 1); + const i2 = clamp(sxi + 1, 0, w1 - 1); + const i3 = clamp(sxi + 2, 0, w1 - 1); + xOff[b + 0] = i0 << 2; + xOff[b + 1] = i1 << 2; + xOff[b + 2] = i2 << 2; + xOff[b + 3] = i3 << 2; + xW[b + 0] = wx[0]; + xW[b + 1] = wx[1]; + xW[b + 2] = wx[2]; + xW[b + 3] = wx[3]; + } + + // Precompute Y: indices, weights, and row-base byte offsets (y*rowStride) + const yRow = new Int32Array(h2 * 4); // row base in bytes + const yW = new Float32Array(h2 * 4); + const wy = new Float32Array(4); + const yScale = h1 / h2; + for (let y = 0; y < h2; y++) { + const sy = (y + 0.5) * yScale - 0.5; + const syi = Math.floor(sy); + const t = sy - syi; + weights(t, wy); + const b = y * 4; + const j0 = clamp(syi - 1, 0, h1 - 1); + const j1 = clamp(syi + 0, 0, h1 - 1); + const j2 = clamp(syi + 1, 0, h1 - 1); + const j3 = clamp(syi + 2, 0, h1 - 1); + yRow[b + 0] = j0 * srcRowStride; + yRow[b + 1] = j1 * srcRowStride; + yRow[b + 2] = j2 * srcRowStride; + yRow[b + 3] = j3 * srcRowStride; + yW[b + 0] = wy[0]; + yW[b + 1] = wy[1]; + yW[b + 2] = wy[2]; + yW[b + 3] = wy[3]; + } + + const dst = new Uint8Array(w2 * h2 * 4); + + for (let y = 0; y < h2; y++) { + const yb = y * 4; + const rb0 = yRow[yb + 0], + rb1 = yRow[yb + 1], + rb2 = yRow[yb + 2], + rb3 = yRow[yb + 3]; + const wy0 = yW[yb + 0], + wy1 = yW[yb + 1], + wy2 = yW[yb + 2], + wy3 = yW[yb + 3]; + const dstBase = y * dstRowStride; + + for (let x = 0; x < w2; x++) { + const xb = x * 4; + const xo0 = xOff[xb + 0], + xo1 = xOff[xb + 1], + xo2 = xOff[xb + 2], + xo3 = xOff[xb + 3]; + const wx0 = xW[xb + 0], + wx1 = xW[xb + 1], + wx2 = xW[xb + 2], + wx3 = xW[xb + 3]; + const di = dstBase + (x << 2); + + // unrolled RGBA + for (let c = 0; c < 4; c++) { + const r0 = + src[rb0 + xo0 + c] * wx0 + + src[rb0 + xo1 + c] * wx1 + + src[rb0 + xo2 + c] * wx2 + + src[rb0 + xo3 + c] * wx3; + const r1 = + src[rb1 + xo0 + c] * wx0 + + src[rb1 + xo1 + c] * wx1 + + src[rb1 + xo2 + c] * wx2 + + src[rb1 + xo3 + c] * wx3; + const r2 = + src[rb2 + xo0 + c] * wx0 + + src[rb2 + xo1 + c] * wx1 + + src[rb2 + xo2 + c] * wx2 + + src[rb2 + xo3 + c] * wx3; + const r3 = + src[rb3 + xo0 + c] * wx0 + + src[rb3 + xo1 + c] * wx1 + + src[rb3 + xo2 + c] * wx2 + + src[rb3 + xo3 + c] * wx3; + const v = r0 * wy0 + r1 * wy1 + r2 * wy2 + r3 * wy3; + dst[di + c] = v < 0 ? 0 : v > 255 ? 255 : v | 0; + } + } + } + + return { data: Buffer.from(dst.buffer), width: w2, height: h2 }; +} diff --git a/daemon/src/sandbox/forked-client/src/utils/isomorphic/locatorGenerators.ts b/daemon/src/sandbox/forked-client/src/utils/isomorphic/locatorGenerators.ts new file mode 100644 index 00000000..5b6a5a37 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/utils/isomorphic/locatorGenerators.ts @@ -0,0 +1,844 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { parseAttributeSelector, parseSelector, stringifySelector } from "./selectorParser"; +import { + escapeWithQuotes, + normalizeEscapedRegexQuotes, + toSnakeCase, + toTitleCase, +} from "./stringUtils"; + +import type { NestedSelectorBody } from "./selectorParser"; +import type { ParsedSelector } from "./selectorParser"; + +export type Language = "javascript" | "python" | "java" | "csharp" | "jsonl"; +export type LocatorType = + | "default" + | "role" + | "text" + | "label" + | "placeholder" + | "alt" + | "title" + | "test-id" + | "nth" + | "first" + | "last" + | "visible" + | "has-text" + | "has-not-text" + | "has" + | "hasNot" + | "frame" + | "frame-locator" + | "and" + | "or" + | "chain"; +export type LocatorBase = "page" | "locator" | "frame-locator"; +export type Quote = "'" | '"' | "`"; + +type LocatorOptions = { + attrs?: { name: string; value: string | boolean | number }[]; + exact?: boolean; + name?: string | RegExp; + hasText?: string | RegExp; + hasNotText?: string | RegExp; +}; +export interface LocatorFactory { + generateLocator( + base: LocatorBase, + kind: LocatorType, + body: string | RegExp, + options?: LocatorOptions + ): string; + chainLocators(locators: string[]): string; +} + +export function asLocatorDescription(lang: Language, selector: string): string { + try { + const parsed = parseSelector(selector); + const customDescription = parseCustomDescription(parsed); + if (customDescription) return customDescription; + return innerAsLocators(new generators[lang](), parsed, false, 1)[0]; + } catch (e) { + // Tolerate invalid input. + return selector; + } +} + +export function locatorCustomDescription(selector: string): string | undefined { + try { + const parsed = parseSelector(selector); + return parseCustomDescription(parsed); + } catch (e) { + return undefined; + } +} + +function parseCustomDescription(parsed: ParsedSelector): string | undefined { + const lastPart = parsed.parts[parsed.parts.length - 1]; + if (lastPart?.name === "internal:describe") { + const description = JSON.parse(lastPart.body as string); + if (typeof description === "string") return description; + } + return undefined; +} + +export function asLocator( + lang: Language, + selector: string, + isFrameLocator: boolean = false +): string { + return asLocators(lang, selector, isFrameLocator, 1)[0]; +} + +export function asLocators( + lang: Language, + selector: string, + isFrameLocator: boolean = false, + maxOutputSize = 20, + preferredQuote?: Quote +): string[] { + try { + return innerAsLocators( + new generators[lang](preferredQuote), + parseSelector(selector), + isFrameLocator, + maxOutputSize + ); + } catch (e) { + // Tolerate invalid input. + return [selector]; + } +} + +function innerAsLocators( + factory: LocatorFactory, + parsed: ParsedSelector, + isFrameLocator: boolean = false, + maxOutputSize = 20 +): string[] { + const parts = [...parsed.parts]; + const tokens: string[][] = []; + let nextBase: LocatorBase = isFrameLocator ? "frame-locator" : "page"; + for (let index = 0; index < parts.length; index++) { + const part = parts[index]; + const base = nextBase; + nextBase = "locator"; + + if (part.name === "internal:describe") continue; + if (part.name === "nth") { + if (part.body === "0") + tokens.push([ + factory.generateLocator(base, "first", ""), + factory.generateLocator(base, "nth", "0"), + ]); + else if (part.body === "-1") + tokens.push([ + factory.generateLocator(base, "last", ""), + factory.generateLocator(base, "nth", "-1"), + ]); + else tokens.push([factory.generateLocator(base, "nth", part.body as string)]); + continue; + } + if (part.name === "visible") { + tokens.push([ + factory.generateLocator(base, "visible", part.body as string), + factory.generateLocator(base, "default", `visible=${part.body}`), + ]); + continue; + } + if (part.name === "internal:text") { + const { exact, text } = detectExact(part.body as string); + tokens.push([factory.generateLocator(base, "text", text, { exact })]); + continue; + } + if (part.name === "internal:has-text") { + const { exact, text } = detectExact(part.body as string); + // There is no locator equivalent for strict has-text, leave it as is. + if (!exact) { + tokens.push([factory.generateLocator(base, "has-text", text, { exact })]); + continue; + } + } + if (part.name === "internal:has-not-text") { + const { exact, text } = detectExact(part.body as string); + // There is no locator equivalent for strict has-not-text, leave it as is. + if (!exact) { + tokens.push([factory.generateLocator(base, "has-not-text", text, { exact })]); + continue; + } + } + if (part.name === "internal:has") { + const inners = innerAsLocators( + factory, + (part.body as NestedSelectorBody).parsed, + false, + maxOutputSize + ); + tokens.push(inners.map((inner) => factory.generateLocator(base, "has", inner))); + continue; + } + if (part.name === "internal:has-not") { + const inners = innerAsLocators( + factory, + (part.body as NestedSelectorBody).parsed, + false, + maxOutputSize + ); + tokens.push(inners.map((inner) => factory.generateLocator(base, "hasNot", inner))); + continue; + } + if (part.name === "internal:and") { + const inners = innerAsLocators( + factory, + (part.body as NestedSelectorBody).parsed, + false, + maxOutputSize + ); + tokens.push(inners.map((inner) => factory.generateLocator(base, "and", inner))); + continue; + } + if (part.name === "internal:or") { + const inners = innerAsLocators( + factory, + (part.body as NestedSelectorBody).parsed, + false, + maxOutputSize + ); + tokens.push(inners.map((inner) => factory.generateLocator(base, "or", inner))); + continue; + } + if (part.name === "internal:chain") { + const inners = innerAsLocators( + factory, + (part.body as NestedSelectorBody).parsed, + false, + maxOutputSize + ); + tokens.push(inners.map((inner) => factory.generateLocator(base, "chain", inner))); + continue; + } + if (part.name === "internal:label") { + const { exact, text } = detectExact(part.body as string); + tokens.push([factory.generateLocator(base, "label", text, { exact })]); + continue; + } + if (part.name === "internal:role") { + const attrSelector = parseAttributeSelector(part.body as string, true); + const options: LocatorOptions = { attrs: [] }; + for (const attr of attrSelector.attributes) { + if (attr.name === "name") { + options.exact = attr.caseSensitive; + options.name = attr.value; + } else { + if (attr.name === "level" && typeof attr.value === "string") attr.value = +attr.value; + options.attrs!.push({ + name: attr.name === "include-hidden" ? "includeHidden" : attr.name, + value: attr.value, + }); + } + } + tokens.push([factory.generateLocator(base, "role", attrSelector.name, options)]); + continue; + } + if (part.name === "internal:testid") { + const attrSelector = parseAttributeSelector(part.body as string, true); + const { value } = attrSelector.attributes[0]; + tokens.push([factory.generateLocator(base, "test-id", value)]); + continue; + } + if (part.name === "internal:attr") { + const attrSelector = parseAttributeSelector(part.body as string, true); + const { name, value, caseSensitive } = attrSelector.attributes[0]; + const text = value as string | RegExp; + const exact = !!caseSensitive; + if (name === "placeholder") { + tokens.push([factory.generateLocator(base, "placeholder", text, { exact })]); + continue; + } + if (name === "alt") { + tokens.push([factory.generateLocator(base, "alt", text, { exact })]); + continue; + } + if (name === "title") { + tokens.push([factory.generateLocator(base, "title", text, { exact })]); + continue; + } + } + if (part.name === "internal:control" && (part.body as string) === "enter-frame") { + // transform last tokens from `${selector}` into `${selector}.contentFrame()` and `frameLocator(${selector})` + const lastTokens = tokens[tokens.length - 1]; + const lastPart = parts[index - 1]; + + const transformed = lastTokens.map((token) => + factory.chainLocators([token, factory.generateLocator(base, "frame", "")]) + ); + if (["xpath", "css"].includes(lastPart.name)) { + transformed.push( + factory.generateLocator(base, "frame-locator", stringifySelector({ parts: [lastPart] })), + factory.generateLocator( + base, + "frame-locator", + stringifySelector({ parts: [lastPart] }, true) + ) + ); + } + + lastTokens.splice(0, lastTokens.length, ...transformed); + nextBase = "frame-locator"; + continue; + } + + const nextPart = parts[index + 1]; + + const selectorPart = stringifySelector({ parts: [part] }); + const locatorPart = factory.generateLocator(base, "default", selectorPart); + + if (nextPart && ["internal:has-text", "internal:has-not-text"].includes(nextPart.name)) { + const { exact, text } = detectExact(nextPart.body as string); + // There is no locator equivalent for strict has-text and has-not-text, leave it as is. + if (!exact) { + const nextLocatorPart = factory.generateLocator( + "locator", + nextPart.name === "internal:has-text" ? "has-text" : "has-not-text", + text, + { exact } + ); + const options: LocatorOptions = {}; + if (nextPart.name === "internal:has-text") options.hasText = text; + else options.hasNotText = text; + const combinedPart = factory.generateLocator(base, "default", selectorPart, options); + // Two options: + // - locator('div').filter({ hasText: 'foo' }) + // - locator('div', { hasText: 'foo' }) + tokens.push([factory.chainLocators([locatorPart, nextLocatorPart]), combinedPart]); + index++; + continue; + } + } + + // Selectors can be prefixed with engine name, e.g. xpath=//foo + let locatorPartWithEngine: string | undefined; + if (["xpath", "css"].includes(part.name)) { + const selectorPart = stringifySelector({ parts: [part] }, /* forceEngineName */ true); + locatorPartWithEngine = factory.generateLocator(base, "default", selectorPart); + } + + tokens.push([locatorPart, locatorPartWithEngine].filter(Boolean) as string[]); + } + + return combineTokens(factory, tokens, maxOutputSize); +} + +function combineTokens( + factory: LocatorFactory, + tokens: string[][], + maxOutputSize: number +): string[] { + const currentTokens = tokens.map(() => ""); + const result: string[] = []; + + const visit = (index: number) => { + if (index === tokens.length) { + result.push(factory.chainLocators(currentTokens)); + return result.length < maxOutputSize; + } + for (const taken of tokens[index]) { + currentTokens[index] = taken; + if (!visit(index + 1)) return false; + } + return true; + }; + + visit(0); + return result; +} + +function detectExact(text: string): { exact?: boolean; text: string | RegExp } { + let exact = false; + const match = text.match(/^\/(.*)\/([igm]*)$/); + if (match) return { text: new RegExp(match[1], match[2]) }; + if (text.endsWith('"')) { + text = JSON.parse(text); + exact = true; + } else if (text.endsWith('"s')) { + text = JSON.parse(text.substring(0, text.length - 1)); + exact = true; + } else if (text.endsWith('"i')) { + text = JSON.parse(text.substring(0, text.length - 1)); + exact = false; + } + return { exact, text }; +} + +export class JavaScriptLocatorFactory implements LocatorFactory { + constructor(private preferredQuote?: Quote) {} + + generateLocator( + base: LocatorBase, + kind: LocatorType, + body: string | RegExp, + options: LocatorOptions = {} + ): string { + switch (kind) { + case "default": + if (options.hasText !== undefined) + return `locator(${this.quote(body as string)}, { hasText: ${this.toHasText(options.hasText)} })`; + if (options.hasNotText !== undefined) + return `locator(${this.quote(body as string)}, { hasNotText: ${this.toHasText(options.hasNotText)} })`; + return `locator(${this.quote(body as string)})`; + case "frame-locator": + return `frameLocator(${this.quote(body as string)})`; + case "frame": + return `contentFrame()`; + case "nth": + return `nth(${body})`; + case "first": + return `first()`; + case "last": + return `last()`; + case "visible": + return `filter({ visible: ${body === "true" ? "true" : "false"} })`; + case "role": + const attrs: string[] = []; + if (isRegExp(options.name)) { + attrs.push(`name: ${this.regexToSourceString(options.name)}`); + } else if (typeof options.name === "string") { + attrs.push(`name: ${this.quote(options.name)}`); + if (options.exact) attrs.push(`exact: true`); + } + for (const { name, value } of options.attrs!) + attrs.push(`${name}: ${typeof value === "string" ? this.quote(value) : value}`); + const attrString = attrs.length ? `, { ${attrs.join(", ")} }` : ""; + return `getByRole(${this.quote(body as string)}${attrString})`; + case "has-text": + return `filter({ hasText: ${this.toHasText(body)} })`; + case "has-not-text": + return `filter({ hasNotText: ${this.toHasText(body)} })`; + case "has": + return `filter({ has: ${body} })`; + case "hasNot": + return `filter({ hasNot: ${body} })`; + case "and": + return `and(${body})`; + case "or": + return `or(${body})`; + case "chain": + return `locator(${body})`; + case "test-id": + return `getByTestId(${this.toTestIdValue(body)})`; + case "text": + return this.toCallWithExact("getByText", body, !!options.exact); + case "alt": + return this.toCallWithExact("getByAltText", body, !!options.exact); + case "placeholder": + return this.toCallWithExact("getByPlaceholder", body, !!options.exact); + case "label": + return this.toCallWithExact("getByLabel", body, !!options.exact); + case "title": + return this.toCallWithExact("getByTitle", body, !!options.exact); + default: + throw new Error("Unknown selector kind " + kind); + } + } + + chainLocators(locators: string[]): string { + return locators.join("."); + } + + private regexToSourceString(re: RegExp) { + return normalizeEscapedRegexQuotes(String(re)); + } + + private toCallWithExact(method: string, body: string | RegExp, exact?: boolean) { + if (isRegExp(body)) return `${method}(${this.regexToSourceString(body)})`; + return exact + ? `${method}(${this.quote(body)}, { exact: true })` + : `${method}(${this.quote(body)})`; + } + + private toHasText(body: string | RegExp) { + if (isRegExp(body)) return this.regexToSourceString(body); + return this.quote(body); + } + + private toTestIdValue(value: string | RegExp): string { + if (isRegExp(value)) return this.regexToSourceString(value); + return this.quote(value); + } + + private quote(text: string) { + return escapeWithQuotes(text, this.preferredQuote ?? "'"); + } +} + +export class PythonLocatorFactory implements LocatorFactory { + generateLocator( + base: LocatorBase, + kind: LocatorType, + body: string | RegExp, + options: LocatorOptions = {} + ): string { + switch (kind) { + case "default": + if (options.hasText !== undefined) + return `locator(${this.quote(body as string)}, has_text=${this.toHasText(options.hasText)})`; + if (options.hasNotText !== undefined) + return `locator(${this.quote(body as string)}, has_not_text=${this.toHasText(options.hasNotText)})`; + return `locator(${this.quote(body as string)})`; + case "frame-locator": + return `frame_locator(${this.quote(body as string)})`; + case "frame": + return `content_frame`; + case "nth": + return `nth(${body})`; + case "first": + return `first`; + case "last": + return `last`; + case "visible": + return `filter(visible=${body === "true" ? "True" : "False"})`; + case "role": + const attrs: string[] = []; + if (isRegExp(options.name)) { + attrs.push(`name=${this.regexToString(options.name)}`); + } else if (typeof options.name === "string") { + attrs.push(`name=${this.quote(options.name)}`); + if (options.exact) attrs.push(`exact=True`); + } + for (const { name, value } of options.attrs!) { + let valueString = typeof value === "string" ? this.quote(value) : value; + if (typeof value === "boolean") valueString = value ? "True" : "False"; + attrs.push(`${toSnakeCase(name)}=${valueString}`); + } + const attrString = attrs.length ? `, ${attrs.join(", ")}` : ""; + return `get_by_role(${this.quote(body as string)}${attrString})`; + case "has-text": + return `filter(has_text=${this.toHasText(body)})`; + case "has-not-text": + return `filter(has_not_text=${this.toHasText(body)})`; + case "has": + return `filter(has=${body})`; + case "hasNot": + return `filter(has_not=${body})`; + case "and": + return `and_(${body})`; + case "or": + return `or_(${body})`; + case "chain": + return `locator(${body})`; + case "test-id": + return `get_by_test_id(${this.toTestIdValue(body)})`; + case "text": + return this.toCallWithExact("get_by_text", body, !!options.exact); + case "alt": + return this.toCallWithExact("get_by_alt_text", body, !!options.exact); + case "placeholder": + return this.toCallWithExact("get_by_placeholder", body, !!options.exact); + case "label": + return this.toCallWithExact("get_by_label", body, !!options.exact); + case "title": + return this.toCallWithExact("get_by_title", body, !!options.exact); + default: + throw new Error("Unknown selector kind " + kind); + } + } + + chainLocators(locators: string[]): string { + return locators.join("."); + } + + private regexToString(body: RegExp) { + const suffix = body.flags.includes("i") ? ", re.IGNORECASE" : ""; + return `re.compile(r"${normalizeEscapedRegexQuotes(body.source).replace(/\\\//, "/").replace(/"/g, '\\"')}"${suffix})`; + } + + private toCallWithExact(method: string, body: string | RegExp, exact: boolean) { + if (isRegExp(body)) return `${method}(${this.regexToString(body)})`; + if (exact) return `${method}(${this.quote(body)}, exact=True)`; + return `${method}(${this.quote(body)})`; + } + + private toHasText(body: string | RegExp) { + if (isRegExp(body)) return this.regexToString(body); + return `${this.quote(body)}`; + } + + private toTestIdValue(value: string | RegExp) { + if (isRegExp(value)) return this.regexToString(value); + return this.quote(value); + } + + private quote(text: string) { + return escapeWithQuotes(text, '\"'); + } +} + +export class JavaLocatorFactory implements LocatorFactory { + generateLocator( + base: LocatorBase, + kind: LocatorType, + body: string | RegExp, + options: LocatorOptions = {} + ): string { + let clazz: string; + switch (base) { + case "page": + clazz = "Page"; + break; + case "frame-locator": + clazz = "FrameLocator"; + break; + case "locator": + clazz = "Locator"; + break; + } + switch (kind) { + case "default": + if (options.hasText !== undefined) + return `locator(${this.quote(body as string)}, new ${clazz}.LocatorOptions().setHasText(${this.toHasText(options.hasText)}))`; + if (options.hasNotText !== undefined) + return `locator(${this.quote(body as string)}, new ${clazz}.LocatorOptions().setHasNotText(${this.toHasText(options.hasNotText)}))`; + return `locator(${this.quote(body as string)})`; + case "frame-locator": + return `frameLocator(${this.quote(body as string)})`; + case "frame": + return `contentFrame()`; + case "nth": + return `nth(${body})`; + case "first": + return `first()`; + case "last": + return `last()`; + case "visible": + return `filter(new ${clazz}.FilterOptions().setVisible(${body === "true" ? "true" : "false"}))`; + case "role": + const attrs: string[] = []; + if (isRegExp(options.name)) { + attrs.push(`.setName(${this.regexToString(options.name)})`); + } else if (typeof options.name === "string") { + attrs.push(`.setName(${this.quote(options.name)})`); + if (options.exact) attrs.push(`.setExact(true)`); + } + for (const { name, value } of options.attrs!) + attrs.push( + `.set${toTitleCase(name)}(${typeof value === "string" ? this.quote(value) : value})` + ); + const attrString = attrs.length ? `, new ${clazz}.GetByRoleOptions()${attrs.join("")}` : ""; + return `getByRole(AriaRole.${toSnakeCase(body as string).toUpperCase()}${attrString})`; + case "has-text": + return `filter(new ${clazz}.FilterOptions().setHasText(${this.toHasText(body)}))`; + case "has-not-text": + return `filter(new ${clazz}.FilterOptions().setHasNotText(${this.toHasText(body)}))`; + case "has": + return `filter(new ${clazz}.FilterOptions().setHas(${body}))`; + case "hasNot": + return `filter(new ${clazz}.FilterOptions().setHasNot(${body}))`; + case "and": + return `and(${body})`; + case "or": + return `or(${body})`; + case "chain": + return `locator(${body})`; + case "test-id": + return `getByTestId(${this.toTestIdValue(body)})`; + case "text": + return this.toCallWithExact(clazz, "getByText", body, !!options.exact); + case "alt": + return this.toCallWithExact(clazz, "getByAltText", body, !!options.exact); + case "placeholder": + return this.toCallWithExact(clazz, "getByPlaceholder", body, !!options.exact); + case "label": + return this.toCallWithExact(clazz, "getByLabel", body, !!options.exact); + case "title": + return this.toCallWithExact(clazz, "getByTitle", body, !!options.exact); + default: + throw new Error("Unknown selector kind " + kind); + } + } + + chainLocators(locators: string[]): string { + return locators.join("."); + } + + private regexToString(body: RegExp) { + const suffix = body.flags.includes("i") ? ", Pattern.CASE_INSENSITIVE" : ""; + return `Pattern.compile(${this.quote(normalizeEscapedRegexQuotes(body.source))}${suffix})`; + } + + private toCallWithExact(clazz: string, method: string, body: string | RegExp, exact: boolean) { + if (isRegExp(body)) return `${method}(${this.regexToString(body)})`; + if (exact) + return `${method}(${this.quote(body)}, new ${clazz}.${toTitleCase(method)}Options().setExact(true))`; + return `${method}(${this.quote(body)})`; + } + + private toHasText(body: string | RegExp) { + if (isRegExp(body)) return this.regexToString(body); + return this.quote(body); + } + + private toTestIdValue(value: string | RegExp) { + if (isRegExp(value)) return this.regexToString(value); + return this.quote(value); + } + + private quote(text: string) { + return escapeWithQuotes(text, '\"'); + } +} + +export class CSharpLocatorFactory implements LocatorFactory { + generateLocator( + base: LocatorBase, + kind: LocatorType, + body: string | RegExp, + options: LocatorOptions = {} + ): string { + switch (kind) { + case "default": + if (options.hasText !== undefined) + return `Locator(${this.quote(body as string)}, new() { ${this.toHasText(options.hasText)} })`; + if (options.hasNotText !== undefined) + return `Locator(${this.quote(body as string)}, new() { ${this.toHasNotText(options.hasNotText)} })`; + return `Locator(${this.quote(body as string)})`; + case "frame-locator": + return `FrameLocator(${this.quote(body as string)})`; + case "frame": + return `ContentFrame`; + case "nth": + return `Nth(${body})`; + case "first": + return `First`; + case "last": + return `Last`; + case "visible": + return `Filter(new() { Visible = ${body === "true" ? "true" : "false"} })`; + case "role": + const attrs: string[] = []; + if (isRegExp(options.name)) { + attrs.push(`NameRegex = ${this.regexToString(options.name)}`); + } else if (typeof options.name === "string") { + attrs.push(`Name = ${this.quote(options.name)}`); + if (options.exact) attrs.push(`Exact = true`); + } + for (const { name, value } of options.attrs!) + attrs.push( + `${toTitleCase(name)} = ${typeof value === "string" ? this.quote(value) : value}` + ); + const attrString = attrs.length ? `, new() { ${attrs.join(", ")} }` : ""; + return `GetByRole(AriaRole.${toTitleCase(body as string)}${attrString})`; + case "has-text": + return `Filter(new() { ${this.toHasText(body)} })`; + case "has-not-text": + return `Filter(new() { ${this.toHasNotText(body)} })`; + case "has": + return `Filter(new() { Has = ${body} })`; + case "hasNot": + return `Filter(new() { HasNot = ${body} })`; + case "and": + return `And(${body})`; + case "or": + return `Or(${body})`; + case "chain": + return `Locator(${body})`; + case "test-id": + return `GetByTestId(${this.toTestIdValue(body)})`; + case "text": + return this.toCallWithExact("GetByText", body, !!options.exact); + case "alt": + return this.toCallWithExact("GetByAltText", body, !!options.exact); + case "placeholder": + return this.toCallWithExact("GetByPlaceholder", body, !!options.exact); + case "label": + return this.toCallWithExact("GetByLabel", body, !!options.exact); + case "title": + return this.toCallWithExact("GetByTitle", body, !!options.exact); + default: + throw new Error("Unknown selector kind " + kind); + } + } + + chainLocators(locators: string[]): string { + return locators.join("."); + } + + private regexToString(body: RegExp): string { + const suffix = body.flags.includes("i") ? ", RegexOptions.IgnoreCase" : ""; + return `new Regex(${this.quote(normalizeEscapedRegexQuotes(body.source))}${suffix})`; + } + + private toCallWithExact(method: string, body: string | RegExp, exact: boolean) { + if (isRegExp(body)) return `${method}(${this.regexToString(body)})`; + if (exact) return `${method}(${this.quote(body)}, new() { Exact = true })`; + return `${method}(${this.quote(body)})`; + } + + private toHasText(body: string | RegExp) { + if (isRegExp(body)) return `HasTextRegex = ${this.regexToString(body)}`; + return `HasText = ${this.quote(body)}`; + } + + private toTestIdValue(value: string | RegExp) { + if (isRegExp(value)) return this.regexToString(value); + return this.quote(value); + } + + private toHasNotText(body: string | RegExp) { + if (isRegExp(body)) return `HasNotTextRegex = ${this.regexToString(body)}`; + return `HasNotText = ${this.quote(body)}`; + } + + private quote(text: string) { + return escapeWithQuotes(text, '\"'); + } +} + +export class JsonlLocatorFactory implements LocatorFactory { + generateLocator( + base: LocatorBase, + kind: LocatorType, + body: string | RegExp, + options: LocatorOptions = {} + ): string { + return JSON.stringify({ + kind, + body, + options, + }); + } + + chainLocators(locators: string[]): string { + const objects = locators.map((l) => JSON.parse(l)); + for (let i = 0; i < objects.length - 1; ++i) objects[i].next = objects[i + 1]; + return JSON.stringify(objects[0]); + } +} + +const generators: Record LocatorFactory> = { + javascript: JavaScriptLocatorFactory, + python: PythonLocatorFactory, + java: JavaLocatorFactory, + csharp: CSharpLocatorFactory, + jsonl: JsonlLocatorFactory, +}; + +function isRegExp(obj: any): obj is RegExp { + return obj instanceof RegExp; +} diff --git a/daemon/src/sandbox/forked-client/src/utils/isomorphic/locatorParser.ts b/daemon/src/sandbox/forked-client/src/utils/isomorphic/locatorParser.ts new file mode 100644 index 00000000..bad30c2a --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/utils/isomorphic/locatorParser.ts @@ -0,0 +1,268 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { asLocators } from "./locatorGenerators"; +import { parseSelector } from "./selectorParser"; +import { escapeForAttributeSelector, escapeForTextSelector } from "./stringUtils"; + +import type { Language, Quote } from "./locatorGenerators"; + +type TemplateParams = { quote: string; text: string }[]; +function parseLocator( + locator: string, + testIdAttributeName: string +): { selector: string; preferredQuote: Quote | undefined } { + locator = locator + .replace(/AriaRole\s*\.\s*([\w]+)/g, (_, group) => group.toLowerCase()) + .replace( + /(get_by_role|getByRole)\s*\(\s*(?:["'`])([^'"`]+)['"`]/g, + (_, group1, group2) => `${group1}(${group2.toLowerCase()}` + ); + const params: TemplateParams = []; + let template = ""; + for (let i = 0; i < locator.length; ++i) { + const quote = locator[i]; + if (quote !== '"' && quote !== "'" && quote !== "`" && quote !== "/") { + template += quote; + continue; + } + const isRegexEscaping = locator[i - 1] === "r" || locator[i] === "/"; + ++i; + let text = ""; + while (i < locator.length) { + if (locator[i] === "\\") { + if (isRegexEscaping) { + if (locator[i + 1] !== quote) text += locator[i]; + ++i; + text += locator[i]; + } else { + ++i; + if (locator[i] === "n") text += "\n"; + else if (locator[i] === "r") text += "\r"; + else if (locator[i] === "t") text += "\t"; + else text += locator[i]; + } + ++i; + continue; + } + if (locator[i] !== quote) { + text += locator[i++]; + continue; + } + break; + } + params.push({ quote, text }); + template += (quote === "/" ? "r" : "") + "$" + params.length; + } + + // Equalize languages. + template = template + .toLowerCase() + .replace(/get_by_alt_text/g, "getbyalttext") + .replace(/get_by_test_id/g, "getbytestid") + .replace(/get_by_([\w]+)/g, "getby$1") + .replace(/has_not_text/g, "hasnottext") + .replace(/has_text/g, "hastext") + .replace(/has_not/g, "hasnot") + .replace(/frame_locator/g, "framelocator") + .replace(/content_frame/g, "contentframe") + .replace(/[{}\s]/g, "") + .replace(/new\(\)/g, "") + .replace(/new[\w]+\.[\w]+options\(\)/g, "") + .replace(/\.set/g, ",set") + .replace(/\.or_\(/g, "or(") // Python has "or_" instead of "or". + .replace(/\.and_\(/g, "and(") // Python has "and_" instead of "and". + .replace(/:/g, "=") + .replace(/,re\.ignorecase/g, "i") + .replace(/,pattern.case_insensitive/g, "i") + .replace(/,regexoptions.ignorecase/g, "i") + .replace(/re.compile\(([^)]+)\)/g, "$1") // Python has regex strings as r"foo" + .replace(/pattern.compile\(([^)]+)\)/g, "r$1") + .replace(/newregex\(([^)]+)\)/g, "r$1") + .replace(/string=/g, "=") + .replace(/regex=/g, "=") + .replace(/,,/g, ",") + .replace(/,\)/g, ")"); + + const preferredQuote = params.map((p) => p.quote).filter((quote) => "'\"`".includes(quote))[0] as + | Quote + | undefined; + return { selector: transform(template, params, testIdAttributeName), preferredQuote }; +} + +function countParams(template: string) { + return [...template.matchAll(/\$\d+/g)].length; +} + +function shiftParams(template: string, sub: number) { + return template.replace(/\$(\d+)/g, (_, ordinal) => `$${ordinal - sub}`); +} + +function transform(template: string, params: TemplateParams, testIdAttributeName: string): string { + // Recursively handle filter(has=, hasnot=, sethas(), sethasnot()). + // TODO: handle and(locator), or(locator), locator(locator), locator(has=, hasnot=, sethas(), sethasnot()). + while (true) { + const hasMatch = template.match(/filter\(,?(has=|hasnot=|sethas\(|sethasnot\()/); + if (!hasMatch) break; + + // Extract inner locator based on balanced parens. + const start = hasMatch.index! + hasMatch[0].length; + let balance = 0; + let end = start; + for (; end < template.length; end++) { + if (template[end] === "(") balance++; + else if (template[end] === ")") balance--; + if (balance < 0) break; + } + + // Replace Java sethas(...) and sethasnot(...) with has=... and hasnot=... + let prefix = template.substring(0, start); + let extraSymbol = 0; + if (["sethas(", "sethasnot("].includes(hasMatch[1])) { + // Eat extra ) symbol at the end of sethas(...) + extraSymbol = 1; + prefix = prefix.replace(/sethas\($/, "has=").replace(/sethasnot\($/, "hasnot="); + } + + const paramsCountBeforeHas = countParams(template.substring(0, start)); + const hasTemplate = shiftParams(template.substring(start, end), paramsCountBeforeHas); + const paramsCountInHas = countParams(hasTemplate); + const hasParams = params.slice(paramsCountBeforeHas, paramsCountBeforeHas + paramsCountInHas); + const hasSelector = JSON.stringify(transform(hasTemplate, hasParams, testIdAttributeName)); + + // Replace filter(has=...) with filter(has2=$5). Use has2 to avoid matching the same filter again. + // Replace filter(hasnot=...) with filter(hasnot2=$5). Use hasnot2 to avoid matching the same filter again. + template = + prefix.replace(/=$/, "2=") + + `$${paramsCountBeforeHas + 1}` + + shiftParams(template.substring(end + extraSymbol), paramsCountInHas - 1); + + // Replace inner params with $5 value. + const paramsBeforeHas = params.slice(0, paramsCountBeforeHas); + const paramsAfterHas = params.slice(paramsCountBeforeHas + paramsCountInHas); + params = paramsBeforeHas.concat([{ quote: '"', text: hasSelector }]).concat(paramsAfterHas); + } + + // Transform to selector engines. + template = template + .replace( + /\,set([\w]+)\(([^)]+)\)/g, + (_, group1, group2) => "," + group1.toLowerCase() + "=" + group2.toLowerCase() + ) + .replace(/framelocator\(([^)]+)\)/g, "$1.internal:control=enter-frame") + .replace(/contentframe(\(\))?/g, "internal:control=enter-frame") + .replace(/locator\(([^)]+),hastext=([^),]+)\)/g, "locator($1).internal:has-text=$2") + .replace(/locator\(([^)]+),hasnottext=([^),]+)\)/g, "locator($1).internal:has-not-text=$2") + .replace(/locator\(([^)]+),hastext=([^),]+)\)/g, "locator($1).internal:has-text=$2") + .replace(/locator\(([^)]+)\)/g, "$1") + .replace(/getbyrole\(([^)]+)\)/g, "internal:role=$1") + .replace(/getbytext\(([^)]+)\)/g, "internal:text=$1") + .replace(/getbylabel\(([^)]+)\)/g, "internal:label=$1") + .replace(/getbytestid\(([^)]+)\)/g, `internal:testid=[${testIdAttributeName}=$1]`) + .replace(/getby(placeholder|alt|title)(?:text)?\(([^)]+)\)/g, "internal:attr=[$1=$2]") + .replace(/first(\(\))?/g, "nth=0") + .replace(/last(\(\))?/g, "nth=-1") + .replace(/nth\(([^)]+)\)/g, "nth=$1") + .replace(/filter\(,?visible=true\)/g, "visible=true") + .replace(/filter\(,?visible=false\)/g, "visible=false") + .replace(/filter\(,?hastext=([^)]+)\)/g, "internal:has-text=$1") + .replace(/filter\(,?hasnottext=([^)]+)\)/g, "internal:has-not-text=$1") + .replace(/filter\(,?has2=([^)]+)\)/g, "internal:has=$1") + .replace(/filter\(,?hasnot2=([^)]+)\)/g, "internal:has-not=$1") + .replace(/,exact=false/g, "") + .replace(/,exact=true/g, "s") + .replace(/,includehidden=/g, ",include-hidden=") + .replace(/\,/g, "]["); + + const parts = template.split("."); + // Turn "internal:control=enter-frame >> nth=0" into "nth=0 >> internal:control=enter-frame" + // because these are swapped in locators vs selectors. + for (let index = 0; index < parts.length - 1; index++) { + if (parts[index] === "internal:control=enter-frame" && parts[index + 1].startsWith("nth=")) { + // Swap nth and enter-frame. + const [nth] = parts.splice(index, 1); + parts.splice(index + 1, 0, nth); + } + } + + // Substitute params. + return parts + .map((t) => { + if (!t.startsWith("internal:") || t === "internal:control") + return t.replace(/\$(\d+)/g, (_, ordinal) => { + const param = params[+ordinal - 1]; + return param.text; + }); + t = t.includes("[") ? t.replace(/\]/, "") + "]" : t; + t = t + .replace(/(?:r)\$(\d+)(i)?/g, (_, ordinal, suffix) => { + const param = params[+ordinal - 1]; + if ( + t.startsWith("internal:attr") || + t.startsWith("internal:testid") || + t.startsWith("internal:role") + ) + return escapeForAttributeSelector(new RegExp(param.text), false) + (suffix || ""); + return escapeForTextSelector(new RegExp(param.text, suffix), false); + }) + .replace(/\$(\d+)(i|s)?/g, (_, ordinal, suffix) => { + const param = params[+ordinal - 1]; + if (t.startsWith("internal:has=") || t.startsWith("internal:has-not=")) return param.text; + if (t.startsWith("internal:testid")) return escapeForAttributeSelector(param.text, true); + if (t.startsWith("internal:attr") || t.startsWith("internal:role")) + return escapeForAttributeSelector(param.text, suffix === "s"); + return escapeForTextSelector(param.text, suffix === "s"); + }); + return t; + }) + .join(" >> "); +} + +export function locatorOrSelectorAsSelector( + language: Language, + locator: string, + testIdAttributeName: string +): string { + try { + return unsafeLocatorOrSelectorAsSelector(language, locator, testIdAttributeName); + } catch (e) { + return ""; + } +} + +export function unsafeLocatorOrSelectorAsSelector( + language: Language, + locator: string, + testIdAttributeName: string +): string { + try { + parseSelector(locator); + return locator; + } catch (e) {} + const { selector, preferredQuote } = parseLocator(locator, testIdAttributeName); + const locators = asLocators(language, selector, undefined, undefined, preferredQuote); + const digest = digestForComparison(language, locator); + if (locators.some((candidate) => digestForComparison(language, candidate) === digest)) + return selector; + return ""; +} + +function digestForComparison(language: Language, locator: string) { + locator = locator.replace(/\s/g, ""); + if (language === "javascript") locator = locator.replace(/\\?["`]/g, "'").replace(/,{}/g, ""); + return locator; +} diff --git a/daemon/src/sandbox/forked-client/src/utils/isomorphic/locatorUtils.ts b/daemon/src/sandbox/forked-client/src/utils/isomorphic/locatorUtils.ts new file mode 100644 index 00000000..eaf8cdd1 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/utils/isomorphic/locatorUtils.ts @@ -0,0 +1,80 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { escapeForAttributeSelector, escapeForTextSelector } from "./stringUtils"; + +export type ByRoleOptions = { + checked?: boolean; + disabled?: boolean; + exact?: boolean; + expanded?: boolean; + includeHidden?: boolean; + level?: number; + name?: string | RegExp; + pressed?: boolean; + selected?: boolean; +}; + +function getByAttributeTextSelector( + attrName: string, + text: string | RegExp, + options?: { exact?: boolean } +): string { + return `internal:attr=[${attrName}=${escapeForAttributeSelector(text, options?.exact || false)}]`; +} + +export function getByTestIdSelector(testIdAttributeName: string, testId: string | RegExp): string { + return `internal:testid=[${testIdAttributeName}=${escapeForAttributeSelector(testId, true)}]`; +} + +export function getByLabelSelector(text: string | RegExp, options?: { exact?: boolean }): string { + return "internal:label=" + escapeForTextSelector(text, !!options?.exact); +} + +export function getByAltTextSelector(text: string | RegExp, options?: { exact?: boolean }): string { + return getByAttributeTextSelector("alt", text, options); +} + +export function getByTitleSelector(text: string | RegExp, options?: { exact?: boolean }): string { + return getByAttributeTextSelector("title", text, options); +} + +export function getByPlaceholderSelector( + text: string | RegExp, + options?: { exact?: boolean } +): string { + return getByAttributeTextSelector("placeholder", text, options); +} + +export function getByTextSelector(text: string | RegExp, options?: { exact?: boolean }): string { + return "internal:text=" + escapeForTextSelector(text, !!options?.exact); +} + +export function getByRoleSelector(role: string, options: ByRoleOptions = {}): string { + const props: string[][] = []; + if (options.checked !== undefined) props.push(["checked", String(options.checked)]); + if (options.disabled !== undefined) props.push(["disabled", String(options.disabled)]); + if (options.selected !== undefined) props.push(["selected", String(options.selected)]); + if (options.expanded !== undefined) props.push(["expanded", String(options.expanded)]); + if (options.includeHidden !== undefined) + props.push(["include-hidden", String(options.includeHidden)]); + if (options.level !== undefined) props.push(["level", String(options.level)]); + if (options.name !== undefined) + props.push(["name", escapeForAttributeSelector(options.name, !!options.exact)]); + if (options.pressed !== undefined) props.push(["pressed", String(options.pressed)]); + return `internal:role=${role}${props.map(([n, v]) => `[${n}=${v}]`).join("")}`; +} diff --git a/daemon/src/sandbox/forked-client/src/utils/isomorphic/lruCache.ts b/daemon/src/sandbox/forked-client/src/utils/isomorphic/lruCache.ts new file mode 100644 index 00000000..830aadb4 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/utils/isomorphic/lruCache.ts @@ -0,0 +1,50 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export class LRUCache { + private _maxSize: number; + private _map: Map; + private _size: number; + + constructor(maxSize: number) { + this._maxSize = maxSize; + this._map = new Map(); + this._size = 0; + } + + getOrCompute(key: K, compute: () => { value: V; size: number }): V { + if (this._map.has(key)) { + const result = this._map.get(key)!; + // reinserting makes this the least recently used entry + this._map.delete(key); + this._map.set(key, result); + return result.value; + } + + const result = compute(); + + while (this._map.size && this._size + result.size > this._maxSize) { + const [firstKey, firstValue] = this._map.entries().next().value!; + this._size -= firstValue.size; + this._map.delete(firstKey); + } + + this._map.set(key, result); + this._size += result.size; + return result.value; + } +} diff --git a/daemon/src/sandbox/forked-client/src/utils/isomorphic/manualPromise.ts b/daemon/src/sandbox/forked-client/src/utils/isomorphic/manualPromise.ts new file mode 100644 index 00000000..efdb248c --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/utils/isomorphic/manualPromise.ts @@ -0,0 +1,119 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { captureRawStack } from "./stackTrace"; + +export class ManualPromise extends Promise { + private _resolve!: (t: T) => void; + private _reject!: (e: Error) => void; + private _isDone: boolean; + + constructor() { + let resolve: (t: T) => void; + let reject: (e: Error) => void; + super((f, r) => { + resolve = f; + reject = r; + }); + this._isDone = false; + this._resolve = resolve!; + this._reject = reject!; + } + + isDone() { + return this._isDone; + } + + resolve(t: T) { + this._isDone = true; + this._resolve(t); + } + + reject(e: Error) { + this._isDone = true; + this._reject(e); + } + + static override get [Symbol.species]() { + return Promise; + } + + override get [Symbol.toStringTag]() { + return "ManualPromise"; + } +} + +export class LongStandingScope { + private _terminateError: Error | undefined; + private _closeError: Error | undefined; + private _terminatePromises = new Map, string[]>(); + private _isClosed = false; + + reject(error: Error) { + this._isClosed = true; + this._terminateError = error; + for (const p of this._terminatePromises.keys()) p.resolve(error); + } + + close(error: Error) { + this._isClosed = true; + this._closeError = error; + for (const [p, frames] of this._terminatePromises) p.resolve(cloneError(error, frames)); + } + + isClosed() { + return this._isClosed; + } + + static async raceMultiple(scopes: LongStandingScope[], promise: Promise): Promise { + return Promise.race(scopes.map((s) => s.race(promise))); + } + + async race(promise: Promise | Promise[]): Promise { + return this._race(Array.isArray(promise) ? promise : [promise], false) as Promise; + } + + async safeRace(promise: Promise, defaultValue: T): Promise; + async safeRace(promise: Promise): Promise; + async safeRace(promise: Promise, defaultValue?: T): Promise { + return this._race([promise], true, defaultValue); + } + + private async _race(promises: Promise[], safe: boolean, defaultValue?: any): Promise { + const terminatePromise = new ManualPromise(); + const frames = captureRawStack(); + if (this._terminateError) terminatePromise.resolve(this._terminateError); + if (this._closeError) terminatePromise.resolve(cloneError(this._closeError, frames)); + this._terminatePromises.set(terminatePromise, frames); + try { + return await Promise.race([ + terminatePromise.then((e) => (safe ? defaultValue : Promise.reject(e))), + ...promises, + ]); + } finally { + this._terminatePromises.delete(terminatePromise); + } + } +} + +function cloneError(error: Error, frames: string[]) { + const clone = new Error(); + clone.name = error.name; + clone.message = error.message; + clone.stack = [error.name + ":" + error.message, ...frames].join("\n"); + return clone; +} diff --git a/daemon/src/sandbox/forked-client/src/utils/isomorphic/mimeType.ts b/daemon/src/sandbox/forked-client/src/utils/isomorphic/mimeType.ts new file mode 100644 index 00000000..e571c4b2 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/utils/isomorphic/mimeType.ts @@ -0,0 +1,454 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the 'License'); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an 'AS IS' BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export function isJsonMimeType(mimeType: string) { + return !!mimeType.match( + /^(application\/json|application\/.*?\+json|text\/(x-)?json)(;\s*charset=.*)?$/ + ); +} + +export function isXmlMimeType(mimeType: string) { + return !!mimeType.match(/^(application\/xml|application\/.*?\+xml|text\/xml)(;\s*charset=.*)?$/); +} + +export function isTextualMimeType(mimeType: string) { + return !!mimeType.match( + /^(text\/.*?|application\/(json|(x-)?javascript|xml.*?|ecmascript|graphql|x-www-form-urlencoded)|image\/svg(\+xml)?|application\/.*?(\+json|\+xml))(;\s*charset=.*)?$/ + ); +} +export function getMimeTypeForPath(path: string): string | null { + const dotIndex = path.lastIndexOf("."); + if (dotIndex === -1) return null; + const extension = path.substring(dotIndex + 1); + return types.get(extension) || null; +} + +const types: Map = new Map([ + ["ez", "application/andrew-inset"], + ["aw", "application/applixware"], + ["atom", "application/atom+xml"], + ["atomcat", "application/atomcat+xml"], + ["atomdeleted", "application/atomdeleted+xml"], + ["atomsvc", "application/atomsvc+xml"], + ["dwd", "application/atsc-dwd+xml"], + ["held", "application/atsc-held+xml"], + ["rsat", "application/atsc-rsat+xml"], + ["bdoc", "application/bdoc"], + ["xcs", "application/calendar+xml"], + ["ccxml", "application/ccxml+xml"], + ["cdfx", "application/cdfx+xml"], + ["cdmia", "application/cdmi-capability"], + ["cdmic", "application/cdmi-container"], + ["cdmid", "application/cdmi-domain"], + ["cdmio", "application/cdmi-object"], + ["cdmiq", "application/cdmi-queue"], + ["cu", "application/cu-seeme"], + ["mpd", "application/dash+xml"], + ["davmount", "application/davmount+xml"], + ["dbk", "application/docbook+xml"], + ["dssc", "application/dssc+der"], + ["xdssc", "application/dssc+xml"], + ["ecma", "application/ecmascript"], + ["es", "application/ecmascript"], + ["emma", "application/emma+xml"], + ["emotionml", "application/emotionml+xml"], + ["epub", "application/epub+zip"], + ["exi", "application/exi"], + ["exp", "application/express"], + ["fdt", "application/fdt+xml"], + ["pfr", "application/font-tdpfr"], + ["geojson", "application/geo+json"], + ["gml", "application/gml+xml"], + ["gpx", "application/gpx+xml"], + ["gxf", "application/gxf"], + ["gz", "application/gzip"], + ["hjson", "application/hjson"], + ["stk", "application/hyperstudio"], + ["ink", "application/inkml+xml"], + ["inkml", "application/inkml+xml"], + ["ipfix", "application/ipfix"], + ["its", "application/its+xml"], + ["ear", "application/java-archive"], + ["jar", "application/java-archive"], + ["war", "application/java-archive"], + ["ser", "application/java-serialized-object"], + ["class", "application/java-vm"], + ["js", "application/javascript"], + ["mjs", "application/javascript"], + ["json", "application/json"], + ["map", "application/json"], + ["json5", "application/json5"], + ["jsonml", "application/jsonml+json"], + ["jsonld", "application/ld+json"], + ["lgr", "application/lgr+xml"], + ["lostxml", "application/lost+xml"], + ["hqx", "application/mac-binhex40"], + ["cpt", "application/mac-compactpro"], + ["mads", "application/mads+xml"], + ["webmanifest", "application/manifest+json"], + ["mrc", "application/marc"], + ["mrcx", "application/marcxml+xml"], + ["ma", "application/mathematica"], + ["mb", "application/mathematica"], + ["nb", "application/mathematica"], + ["mathml", "application/mathml+xml"], + ["mbox", "application/mbox"], + ["mscml", "application/mediaservercontrol+xml"], + ["metalink", "application/metalink+xml"], + ["meta4", "application/metalink4+xml"], + ["mets", "application/mets+xml"], + ["maei", "application/mmt-aei+xml"], + ["musd", "application/mmt-usd+xml"], + ["mods", "application/mods+xml"], + ["m21", "application/mp21"], + ["mp21", "application/mp21"], + ["m4p", "application/mp4"], + ["mp4s", "application/mp4"], + ["doc", "application/msword"], + ["dot", "application/msword"], + ["mxf", "application/mxf"], + ["nq", "application/n-quads"], + ["nt", "application/n-triples"], + ["cjs", "application/node"], + ["bin", "application/octet-stream"], + ["bpk", "application/octet-stream"], + ["buffer", "application/octet-stream"], + ["deb", "application/octet-stream"], + ["deploy", "application/octet-stream"], + ["dist", "application/octet-stream"], + ["distz", "application/octet-stream"], + ["dll", "application/octet-stream"], + ["dmg", "application/octet-stream"], + ["dms", "application/octet-stream"], + ["dump", "application/octet-stream"], + ["elc", "application/octet-stream"], + ["exe", "application/octet-stream"], + ["img", "application/octet-stream"], + ["iso", "application/octet-stream"], + ["lrf", "application/octet-stream"], + ["mar", "application/octet-stream"], + ["msi", "application/octet-stream"], + ["msm", "application/octet-stream"], + ["msp", "application/octet-stream"], + ["pkg", "application/octet-stream"], + ["so", "application/octet-stream"], + ["oda", "application/oda"], + ["opf", "application/oebps-package+xml"], + ["ogx", "application/ogg"], + ["omdoc", "application/omdoc+xml"], + ["onepkg", "application/onenote"], + ["onetmp", "application/onenote"], + ["onetoc", "application/onenote"], + ["onetoc2", "application/onenote"], + ["oxps", "application/oxps"], + ["relo", "application/p2p-overlay+xml"], + ["xer", "application/patch-ops-error+xml"], + ["pdf", "application/pdf"], + ["pgp", "application/pgp-encrypted"], + ["asc", "application/pgp-signature"], + ["sig", "application/pgp-signature"], + ["prf", "application/pics-rules"], + ["p10", "application/pkcs10"], + ["p7c", "application/pkcs7-mime"], + ["p7m", "application/pkcs7-mime"], + ["p7s", "application/pkcs7-signature"], + ["p8", "application/pkcs8"], + ["ac", "application/pkix-attr-cert"], + ["cer", "application/pkix-cert"], + ["crl", "application/pkix-crl"], + ["pkipath", "application/pkix-pkipath"], + ["pki", "application/pkixcmp"], + ["pls", "application/pls+xml"], + ["ai", "application/postscript"], + ["eps", "application/postscript"], + ["ps", "application/postscript"], + ["provx", "application/provenance+xml"], + ["pskcxml", "application/pskc+xml"], + ["raml", "application/raml+yaml"], + ["owl", "application/rdf+xml"], + ["rdf", "application/rdf+xml"], + ["rif", "application/reginfo+xml"], + ["rnc", "application/relax-ng-compact-syntax"], + ["rl", "application/resource-lists+xml"], + ["rld", "application/resource-lists-diff+xml"], + ["rs", "application/rls-services+xml"], + ["rapd", "application/route-apd+xml"], + ["sls", "application/route-s-tsid+xml"], + ["rusd", "application/route-usd+xml"], + ["gbr", "application/rpki-ghostbusters"], + ["mft", "application/rpki-manifest"], + ["roa", "application/rpki-roa"], + ["rsd", "application/rsd+xml"], + ["rss", "application/rss+xml"], + ["rtf", "application/rtf"], + ["sbml", "application/sbml+xml"], + ["scq", "application/scvp-cv-request"], + ["scs", "application/scvp-cv-response"], + ["spq", "application/scvp-vp-request"], + ["spp", "application/scvp-vp-response"], + ["sdp", "application/sdp"], + ["senmlx", "application/senml+xml"], + ["sensmlx", "application/sensml+xml"], + ["setpay", "application/set-payment-initiation"], + ["setreg", "application/set-registration-initiation"], + ["shf", "application/shf+xml"], + ["sieve", "application/sieve"], + ["siv", "application/sieve"], + ["smi", "application/smil+xml"], + ["smil", "application/smil+xml"], + ["rq", "application/sparql-query"], + ["srx", "application/sparql-results+xml"], + ["gram", "application/srgs"], + ["grxml", "application/srgs+xml"], + ["sru", "application/sru+xml"], + ["ssdl", "application/ssdl+xml"], + ["ssml", "application/ssml+xml"], + ["swidtag", "application/swid+xml"], + ["tei", "application/tei+xml"], + ["teicorpus", "application/tei+xml"], + ["tfi", "application/thraud+xml"], + ["tsd", "application/timestamped-data"], + ["toml", "application/toml"], + ["trig", "application/trig"], + ["ttml", "application/ttml+xml"], + ["ubj", "application/ubjson"], + ["rsheet", "application/urc-ressheet+xml"], + ["td", "application/urc-targetdesc+xml"], + ["vxml", "application/voicexml+xml"], + ["wasm", "application/wasm"], + ["wgt", "application/widget"], + ["hlp", "application/winhlp"], + ["wsdl", "application/wsdl+xml"], + ["wspolicy", "application/wspolicy+xml"], + ["xaml", "application/xaml+xml"], + ["xav", "application/xcap-att+xml"], + ["xca", "application/xcap-caps+xml"], + ["xdf", "application/xcap-diff+xml"], + ["xel", "application/xcap-el+xml"], + ["xns", "application/xcap-ns+xml"], + ["xenc", "application/xenc+xml"], + ["xht", "application/xhtml+xml"], + ["xhtml", "application/xhtml+xml"], + ["xlf", "application/xliff+xml"], + ["rng", "application/xml"], + ["xml", "application/xml"], + ["xsd", "application/xml"], + ["xsl", "application/xml"], + ["dtd", "application/xml-dtd"], + ["xop", "application/xop+xml"], + ["xpl", "application/xproc+xml"], + ["*xsl", "application/xslt+xml"], + ["xslt", "application/xslt+xml"], + ["xspf", "application/xspf+xml"], + ["mxml", "application/xv+xml"], + ["xhvml", "application/xv+xml"], + ["xvm", "application/xv+xml"], + ["xvml", "application/xv+xml"], + ["yang", "application/yang"], + ["yin", "application/yin+xml"], + ["zip", "application/zip"], + ["*3gpp", "audio/3gpp"], + ["adp", "audio/adpcm"], + ["amr", "audio/amr"], + ["au", "audio/basic"], + ["snd", "audio/basic"], + ["kar", "audio/midi"], + ["mid", "audio/midi"], + ["midi", "audio/midi"], + ["rmi", "audio/midi"], + ["mxmf", "audio/mobile-xmf"], + ["*mp3", "audio/mp3"], + ["m4a", "audio/mp4"], + ["mp4a", "audio/mp4"], + ["m2a", "audio/mpeg"], + ["m3a", "audio/mpeg"], + ["mp2", "audio/mpeg"], + ["mp2a", "audio/mpeg"], + ["mp3", "audio/mpeg"], + ["mpga", "audio/mpeg"], + ["oga", "audio/ogg"], + ["ogg", "audio/ogg"], + ["opus", "audio/ogg"], + ["spx", "audio/ogg"], + ["s3m", "audio/s3m"], + ["sil", "audio/silk"], + ["wav", "audio/wav"], + ["*wav", "audio/wave"], + ["weba", "audio/webm"], + ["xm", "audio/xm"], + ["ttc", "font/collection"], + ["otf", "font/otf"], + ["ttf", "font/ttf"], + ["woff", "font/woff"], + ["woff2", "font/woff2"], + ["exr", "image/aces"], + ["apng", "image/apng"], + ["avif", "image/avif"], + ["bmp", "image/bmp"], + ["cgm", "image/cgm"], + ["drle", "image/dicom-rle"], + ["emf", "image/emf"], + ["fits", "image/fits"], + ["g3", "image/g3fax"], + ["gif", "image/gif"], + ["heic", "image/heic"], + ["heics", "image/heic-sequence"], + ["heif", "image/heif"], + ["heifs", "image/heif-sequence"], + ["hej2", "image/hej2k"], + ["hsj2", "image/hsj2"], + ["ief", "image/ief"], + ["jls", "image/jls"], + ["jp2", "image/jp2"], + ["jpg2", "image/jp2"], + ["jpe", "image/jpeg"], + ["jpeg", "image/jpeg"], + ["jpg", "image/jpeg"], + ["jph", "image/jph"], + ["jhc", "image/jphc"], + ["jpm", "image/jpm"], + ["jpf", "image/jpx"], + ["jpx", "image/jpx"], + ["jxr", "image/jxr"], + ["jxra", "image/jxra"], + ["jxrs", "image/jxrs"], + ["jxs", "image/jxs"], + ["jxsc", "image/jxsc"], + ["jxsi", "image/jxsi"], + ["jxss", "image/jxss"], + ["ktx", "image/ktx"], + ["ktx2", "image/ktx2"], + ["png", "image/png"], + ["sgi", "image/sgi"], + ["svg", "image/svg+xml"], + ["svgz", "image/svg+xml"], + ["t38", "image/t38"], + ["tif", "image/tiff"], + ["tiff", "image/tiff"], + ["tfx", "image/tiff-fx"], + ["webp", "image/webp"], + ["wmf", "image/wmf"], + ["disposition-notification", "message/disposition-notification"], + ["u8msg", "message/global"], + ["u8dsn", "message/global-delivery-status"], + ["u8mdn", "message/global-disposition-notification"], + ["u8hdr", "message/global-headers"], + ["eml", "message/rfc822"], + ["mime", "message/rfc822"], + ["3mf", "model/3mf"], + ["gltf", "model/gltf+json"], + ["glb", "model/gltf-binary"], + ["iges", "model/iges"], + ["igs", "model/iges"], + ["mesh", "model/mesh"], + ["msh", "model/mesh"], + ["silo", "model/mesh"], + ["mtl", "model/mtl"], + ["obj", "model/obj"], + ["stpx", "model/step+xml"], + ["stpz", "model/step+zip"], + ["stpxz", "model/step-xml+zip"], + ["stl", "model/stl"], + ["vrml", "model/vrml"], + ["wrl", "model/vrml"], + ["*x3db", "model/x3d+binary"], + ["x3dbz", "model/x3d+binary"], + ["x3db", "model/x3d+fastinfoset"], + ["*x3dv", "model/x3d+vrml"], + ["x3dvz", "model/x3d+vrml"], + ["x3d", "model/x3d+xml"], + ["x3dz", "model/x3d+xml"], + ["x3dv", "model/x3d-vrml"], + ["appcache", "text/cache-manifest"], + ["manifest", "text/cache-manifest"], + ["ics", "text/calendar"], + ["ifb", "text/calendar"], + ["coffee", "text/coffeescript"], + ["litcoffee", "text/coffeescript"], + ["css", "text/css"], + ["csv", "text/csv"], + ["htm", "text/html"], + ["html", "text/html"], + ["shtml", "text/html"], + ["jade", "text/jade"], + ["jsx", "text/jsx"], + ["less", "text/less"], + ["markdown", "text/markdown"], + ["md", "text/markdown"], + ["mml", "text/mathml"], + ["mdx", "text/mdx"], + ["n3", "text/n3"], + ["conf", "text/plain"], + ["def", "text/plain"], + ["in", "text/plain"], + ["ini", "text/plain"], + ["list", "text/plain"], + ["log", "text/plain"], + ["text", "text/plain"], + ["txt", "text/plain"], + ["rtx", "text/richtext"], + ["*rtf", "text/rtf"], + ["sgm", "text/sgml"], + ["sgml", "text/sgml"], + ["shex", "text/shex"], + ["slim", "text/slim"], + ["slm", "text/slim"], + ["spdx", "text/spdx"], + ["styl", "text/stylus"], + ["stylus", "text/stylus"], + ["tsv", "text/tab-separated-values"], + ["man", "text/troff"], + ["me", "text/troff"], + ["ms", "text/troff"], + ["roff", "text/troff"], + ["t", "text/troff"], + ["tr", "text/troff"], + ["ttl", "text/turtle"], + ["uri", "text/uri-list"], + ["uris", "text/uri-list"], + ["urls", "text/uri-list"], + ["vcard", "text/vcard"], + ["vtt", "text/vtt"], + ["*xml", "text/xml"], + ["yaml", "text/yaml"], + ["yml", "text/yaml"], + ["3gp", "video/3gpp"], + ["3gpp", "video/3gpp"], + ["3g2", "video/3gpp2"], + ["h261", "video/h261"], + ["h263", "video/h263"], + ["h264", "video/h264"], + ["m4s", "video/iso.segment"], + ["jpgv", "video/jpeg"], + ["jpm", "video/jpm"], + ["jpgm", "video/jpm"], + ["mj2", "video/mj2"], + ["mjp2", "video/mj2"], + ["ts", "application/typescript"], + ["mp4", "video/mp4"], + ["mp4v", "video/mp4"], + ["mpg4", "video/mp4"], + ["m1v", "video/mpeg"], + ["m2v", "video/mpeg"], + ["mpe", "video/mpeg"], + ["mpeg", "video/mpeg"], + ["mpg", "video/mpeg"], + ["ogv", "video/ogg"], + ["mov", "video/quicktime"], + ["qt", "video/quicktime"], + ["webm", "video/webm"], +]); diff --git a/daemon/src/sandbox/forked-client/src/utils/isomorphic/multimap.ts b/daemon/src/sandbox/forked-client/src/utils/isomorphic/multimap.ts new file mode 100644 index 00000000..3607288b --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/utils/isomorphic/multimap.ts @@ -0,0 +1,83 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export class MultiMap { + private _map: Map; + + constructor() { + this._map = new Map(); + } + + set(key: K, value: V) { + let values = this._map.get(key); + if (!values) { + values = []; + this._map.set(key, values); + } + values.push(value); + } + + get(key: K): V[] { + return this._map.get(key) || []; + } + + has(key: K): boolean { + return this._map.has(key); + } + + delete(key: K, value: V) { + const values = this._map.get(key); + if (!values) return; + if (values.includes(value)) + this._map.set( + key, + values.filter((v) => value !== v) + ); + } + + deleteAll(key: K) { + this._map.delete(key); + } + + hasValue(key: K, value: V): boolean { + const values = this._map.get(key); + if (!values) return false; + return values.includes(value); + } + + get size(): number { + return this._map.size; + } + + [Symbol.iterator](): Iterator<[K, V[]]> { + return this._map[Symbol.iterator](); + } + + keys(): IterableIterator { + return this._map.keys(); + } + + values(): Iterable { + const result: V[] = []; + for (const key of this.keys()) result.push(...this.get(key)); + return result; + } + + clear() { + this._map.clear(); + } +} diff --git a/daemon/src/sandbox/forked-client/src/utils/isomorphic/protocolFormatter.ts b/daemon/src/sandbox/forked-client/src/utils/isomorphic/protocolFormatter.ts new file mode 100644 index 00000000..595c2893 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/utils/isomorphic/protocolFormatter.ts @@ -0,0 +1,86 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { methodMetainfo } from "./protocolMetainfo"; + +export function formatProtocolParam( + params: Record | undefined, + alternatives: string +): string | undefined { + return _formatProtocolParam(params, alternatives)?.replaceAll("\n", "\\n"); +} + +function _formatProtocolParam( + params: Record | undefined, + alternatives: string +): string | undefined { + if (!params) return undefined; + + for (const name of alternatives.split("|")) { + if (name === "url") { + try { + const urlObject = new URL(params[name]); + if (urlObject.protocol === "data:") return urlObject.protocol; + if (urlObject.protocol === "about:") return params[name]; + return urlObject.pathname + urlObject.search; + } catch (error) { + if (params[name] !== undefined) return params[name]; + } + } + if (name === "timeNumber" && params[name] !== undefined) { + // eslint-disable-next-line no-restricted-globals + return new Date(params[name]).toString(); + } + + const value = deepParam(params, name); + if (value !== undefined) return value; + } +} + +function deepParam(params: Record, name: string): string | undefined { + const tokens = name.split("."); + let current = params; + for (const token of tokens) { + if (typeof current !== "object" || current === null) return undefined; + current = current[token]; + } + if (current === undefined) return undefined; + return String(current); +} + +export function renderTitleForCall(metadata: { + title?: string; + type: string; + method: string; + params: Record | undefined; +}) { + const titleFormat = + metadata.title ?? + methodMetainfo.get(metadata.type + "." + metadata.method)?.title ?? + metadata.method; + return titleFormat.replace(/\{([^}]+)\}/g, (fullMatch, p1) => { + return formatProtocolParam(metadata.params, p1) ?? fullMatch; + }); +} + +export type ActionGroup = "configuration" | "route" | "getter"; + +export function getActionGroup(metadata: { type: string; method: string }) { + return methodMetainfo.get(metadata.type + "." + metadata.method)?.group as + | undefined + | ActionGroup; +} diff --git a/daemon/src/sandbox/forked-client/src/utils/isomorphic/protocolMetainfo.ts b/daemon/src/sandbox/forked-client/src/utils/isomorphic/protocolMetainfo.ts new file mode 100644 index 00000000..f7f0879b --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/utils/isomorphic/protocolMetainfo.ts @@ -0,0 +1,541 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// This file is generated by generate_channels.js, do not edit manually. + +export const methodMetainfo = new Map< + string, + { + internal?: boolean; + title?: string; + slowMo?: boolean; + snapshot?: boolean; + pausesBeforeInput?: boolean; + pausesBeforeAction?: boolean; + group?: string; + } +>([ + ["APIRequestContext.fetch", { title: '{method} "{url}"' }], + ["APIRequestContext.fetchResponseBody", { title: "Get response body", group: "getter" }], + ["APIRequestContext.fetchLog", { internal: true }], + ["APIRequestContext.storageState", { title: "Get storage state", group: "configuration" }], + ["APIRequestContext.disposeAPIResponse", { internal: true }], + ["APIRequestContext.dispose", { internal: true }], + ["LocalUtils.zip", { internal: true }], + ["LocalUtils.harOpen", { internal: true }], + ["LocalUtils.harLookup", { internal: true }], + ["LocalUtils.harClose", { internal: true }], + ["LocalUtils.harUnzip", { internal: true }], + ["LocalUtils.connect", { internal: true }], + ["LocalUtils.tracingStarted", { internal: true }], + ["LocalUtils.addStackToTracingNoReply", { internal: true }], + ["LocalUtils.traceDiscarded", { internal: true }], + ["LocalUtils.globToRegex", { internal: true }], + ["Root.initialize", { internal: true }], + ["Playwright.newRequest", { title: "Create request context" }], + ["DebugController.initialize", { internal: true }], + ["DebugController.setReportStateChanged", { internal: true }], + ["DebugController.setRecorderMode", { internal: true }], + ["DebugController.highlight", { internal: true }], + ["DebugController.hideHighlight", { internal: true }], + ["DebugController.resume", { internal: true }], + ["DebugController.kill", { internal: true }], + ["SocksSupport.socksConnected", { internal: true }], + ["SocksSupport.socksFailed", { internal: true }], + ["SocksSupport.socksData", { internal: true }], + ["SocksSupport.socksError", { internal: true }], + ["SocksSupport.socksEnd", { internal: true }], + ["BrowserType.launch", { title: "Launch browser" }], + ["BrowserType.launchPersistentContext", { title: "Launch persistent context" }], + ["BrowserType.connectOverCDP", { title: "Connect over CDP" }], + ["BrowserType.connectOverCDPTransport", { title: "Connect over CDP transport" }], + ["Browser.startServer", { title: "Start server" }], + ["Browser.stopServer", { title: "Stop server" }], + ["Browser.close", { title: "Close browser", pausesBeforeAction: true }], + ["Browser.killForTests", { internal: true }], + ["Browser.defaultUserAgentForTest", { internal: true }], + ["Browser.newContext", { title: "Create context" }], + ["Browser.newContextForReuse", { internal: true }], + ["Browser.disconnectFromReusedContext", { internal: true }], + ["Browser.newBrowserCDPSession", { title: "Create CDP session", group: "configuration" }], + ["Browser.startTracing", { title: "Start browser tracing", group: "configuration" }], + ["Browser.stopTracing", { title: "Stop browser tracing", group: "configuration" }], + ["EventTarget.waitForEventInfo", { title: 'Wait for event "{info.event}"', snapshot: true }], + ["BrowserContext.waitForEventInfo", { title: 'Wait for event "{info.event}"', snapshot: true }], + ["Page.waitForEventInfo", { title: 'Wait for event "{info.event}"', snapshot: true }], + ["Worker.waitForEventInfo", { title: 'Wait for event "{info.event}"', snapshot: true }], + ["WebSocket.waitForEventInfo", { title: 'Wait for event "{info.event}"', snapshot: true }], + ["Debugger.waitForEventInfo", { title: 'Wait for event "{info.event}"', snapshot: true }], + [ + "ElectronApplication.waitForEventInfo", + { title: 'Wait for event "{info.event}"', snapshot: true }, + ], + ["AndroidDevice.waitForEventInfo", { title: 'Wait for event "{info.event}"', snapshot: true }], + ["BrowserContext.addCookies", { title: "Add cookies", group: "configuration" }], + ["BrowserContext.addInitScript", { title: "Add init script", group: "configuration" }], + ["BrowserContext.clearCookies", { title: "Clear cookies", group: "configuration" }], + ["BrowserContext.clearPermissions", { title: "Clear permissions", group: "configuration" }], + ["BrowserContext.close", { title: "Close context", pausesBeforeAction: true }], + ["BrowserContext.cookies", { title: "Get cookies", group: "getter" }], + ["BrowserContext.exposeBinding", { title: "Expose binding", group: "configuration" }], + ["BrowserContext.grantPermissions", { title: "Grant permissions", group: "configuration" }], + ["BrowserContext.newPage", { title: "Create page" }], + ["BrowserContext.registerSelectorEngine", { internal: true }], + ["BrowserContext.setTestIdAttributeName", { internal: true }], + [ + "BrowserContext.setExtraHTTPHeaders", + { title: "Set extra HTTP headers", group: "configuration" }, + ], + ["BrowserContext.setGeolocation", { title: "Set geolocation", group: "configuration" }], + ["BrowserContext.setHTTPCredentials", { title: "Set HTTP credentials", group: "configuration" }], + ["BrowserContext.setNetworkInterceptionPatterns", { title: "Route requests", group: "route" }], + [ + "BrowserContext.setWebSocketInterceptionPatterns", + { title: "Route WebSockets", group: "route" }, + ], + ["BrowserContext.setOffline", { title: "Set offline mode" }], + ["BrowserContext.storageState", { title: "Get storage state", group: "configuration" }], + ["BrowserContext.setStorageState", { title: "Set storage state", group: "configuration" }], + ["BrowserContext.pause", { title: "Pause" }], + ["BrowserContext.enableRecorder", { internal: true }], + ["BrowserContext.disableRecorder", { internal: true }], + ["BrowserContext.exposeConsoleApi", { internal: true }], + ["BrowserContext.newCDPSession", { title: "Create CDP session", group: "configuration" }], + ["BrowserContext.harStart", { internal: true }], + ["BrowserContext.harExport", { internal: true }], + ["BrowserContext.createTempFiles", { internal: true }], + ["BrowserContext.updateSubscription", { internal: true }], + ["BrowserContext.clockFastForward", { title: 'Fast forward clock "{ticksNumber|ticksString}"' }], + ["BrowserContext.clockInstall", { title: 'Install clock "{timeNumber|timeString}"' }], + ["BrowserContext.clockPauseAt", { title: 'Pause clock "{timeNumber|timeString}"' }], + ["BrowserContext.clockResume", { title: "Resume clock" }], + ["BrowserContext.clockRunFor", { title: 'Run clock "{ticksNumber|ticksString}"' }], + ["BrowserContext.clockSetFixedTime", { title: 'Set fixed time "{timeNumber|timeString}"' }], + ["BrowserContext.clockSetSystemTime", { title: 'Set system time "{timeNumber|timeString}"' }], + ["Page.addInitScript", { title: "Add init script", group: "configuration" }], + ["Page.close", { title: "Close page", pausesBeforeAction: true }], + ["Page.clearConsoleMessages", { title: "Clear console messages" }], + ["Page.consoleMessages", { title: "Get console messages", group: "getter" }], + ["Page.emulateMedia", { title: "Emulate media", snapshot: true, pausesBeforeAction: true }], + ["Page.exposeBinding", { title: "Expose binding", group: "configuration" }], + ["Page.goBack", { title: "Go back", slowMo: true, snapshot: true, pausesBeforeAction: true }], + [ + "Page.goForward", + { title: "Go forward", slowMo: true, snapshot: true, pausesBeforeAction: true }, + ], + ["Page.requestGC", { title: "Request garbage collection", group: "configuration" }], + ["Page.registerLocatorHandler", { title: "Register locator handler" }], + ["Page.resolveLocatorHandlerNoReply", { internal: true }], + ["Page.unregisterLocatorHandler", { title: "Unregister locator handler" }], + ["Page.reload", { title: "Reload", slowMo: true, snapshot: true, pausesBeforeAction: true }], + [ + "Page.expectScreenshot", + { title: "Expect screenshot", snapshot: true, pausesBeforeAction: true }, + ], + ["Page.screenshot", { title: "Screenshot", snapshot: true, pausesBeforeAction: true }], + ["Page.setExtraHTTPHeaders", { title: "Set extra HTTP headers", group: "configuration" }], + ["Page.setNetworkInterceptionPatterns", { title: "Route requests", group: "route" }], + ["Page.setWebSocketInterceptionPatterns", { title: "Route WebSockets", group: "route" }], + [ + "Page.setViewportSize", + { title: "Set viewport size", snapshot: true, pausesBeforeAction: true }, + ], + [ + "Page.keyboardDown", + { title: 'Key down "{key}"', slowMo: true, snapshot: true, pausesBeforeAction: true }, + ], + [ + "Page.keyboardUp", + { title: 'Key up "{key}"', slowMo: true, snapshot: true, pausesBeforeAction: true }, + ], + [ + "Page.keyboardInsertText", + { title: 'Insert "{text}"', slowMo: true, snapshot: true, pausesBeforeAction: true }, + ], + [ + "Page.keyboardType", + { title: 'Type "{text}"', slowMo: true, snapshot: true, pausesBeforeAction: true }, + ], + [ + "Page.keyboardPress", + { title: 'Press "{key}"', slowMo: true, snapshot: true, pausesBeforeAction: true }, + ], + [ + "Page.mouseMove", + { title: "Mouse move", slowMo: true, snapshot: true, pausesBeforeAction: true }, + ], + [ + "Page.mouseDown", + { title: "Mouse down", slowMo: true, snapshot: true, pausesBeforeAction: true }, + ], + ["Page.mouseUp", { title: "Mouse up", slowMo: true, snapshot: true, pausesBeforeAction: true }], + ["Page.mouseClick", { title: "Click", slowMo: true, snapshot: true, pausesBeforeAction: true }], + [ + "Page.mouseWheel", + { title: "Mouse wheel", slowMo: true, snapshot: true, pausesBeforeAction: true }, + ], + ["Page.touchscreenTap", { title: "Tap", slowMo: true, snapshot: true, pausesBeforeAction: true }], + ["Page.clearPageErrors", { title: "Clear page errors" }], + ["Page.pageErrors", { title: "Get page errors", group: "getter" }], + ["Page.pdf", { title: "PDF" }], + ["Page.requests", { title: "Get network requests", group: "getter" }], + ["Page.snapshotForAI", { internal: true }], + ["Page.startJSCoverage", { title: "Start JS coverage", group: "configuration" }], + ["Page.stopJSCoverage", { title: "Stop JS coverage", group: "configuration" }], + ["Page.startCSSCoverage", { title: "Start CSS coverage", group: "configuration" }], + ["Page.stopCSSCoverage", { title: "Stop CSS coverage", group: "configuration" }], + ["Page.bringToFront", { title: "Bring to front" }], + ["Page.pickLocator", { title: "Pick locator", group: "configuration" }], + ["Page.cancelPickLocator", { title: "Cancel pick locator", group: "configuration" }], + ["Page.startScreencast", { title: "Start screencast", group: "configuration" }], + ["Page.stopScreencast", { title: "Stop screencast", group: "configuration" }], + ["Page.videoStart", { title: "Start video recording", group: "configuration" }], + ["Page.videoStop", { title: "Stop video recording", group: "configuration" }], + ["Page.updateSubscription", { internal: true }], + ["Page.setDockTile", { internal: true }], + ["Frame.evalOnSelector", { title: "Evaluate", snapshot: true, pausesBeforeAction: true }], + ["Frame.evalOnSelectorAll", { title: "Evaluate", snapshot: true, pausesBeforeAction: true }], + ["Frame.addScriptTag", { title: "Add script tag", snapshot: true, pausesBeforeAction: true }], + ["Frame.addStyleTag", { title: "Add style tag", snapshot: true, pausesBeforeAction: true }], + ["Frame.ariaSnapshot", { title: "Aria snapshot", snapshot: true, pausesBeforeAction: true }], + ["Frame.blur", { title: "Blur", slowMo: true, snapshot: true, pausesBeforeAction: true }], + ["Frame.check", { title: "Check", slowMo: true, snapshot: true, pausesBeforeInput: true }], + ["Frame.click", { title: "Click", slowMo: true, snapshot: true, pausesBeforeInput: true }], + ["Frame.content", { title: "Get content", snapshot: true, pausesBeforeAction: true }], + [ + "Frame.dragAndDrop", + { title: "Drag and drop", slowMo: true, snapshot: true, pausesBeforeInput: true }, + ], + [ + "Frame.dblclick", + { title: "Double click", slowMo: true, snapshot: true, pausesBeforeInput: true }, + ], + [ + "Frame.dispatchEvent", + { title: 'Dispatch "{type}"', slowMo: true, snapshot: true, pausesBeforeAction: true }, + ], + ["Frame.evaluateExpression", { title: "Evaluate", snapshot: true, pausesBeforeAction: true }], + [ + "Frame.evaluateExpressionHandle", + { title: "Evaluate", snapshot: true, pausesBeforeAction: true }, + ], + [ + "Frame.fill", + { title: 'Fill "{value}"', slowMo: true, snapshot: true, pausesBeforeInput: true }, + ], + ["Frame.focus", { title: "Focus", slowMo: true, snapshot: true, pausesBeforeAction: true }], + ["Frame.frameElement", { title: "Get frame element", group: "getter" }], + ["Frame.resolveSelector", { internal: true }], + ["Frame.highlight", { title: "Highlight element", group: "configuration" }], + [ + "Frame.getAttribute", + { title: 'Get attribute "{name}"', snapshot: true, pausesBeforeAction: true, group: "getter" }, + ], + [ + "Frame.goto", + { title: 'Navigate to "{url}"', slowMo: true, snapshot: true, pausesBeforeAction: true }, + ], + ["Frame.hover", { title: "Hover", slowMo: true, snapshot: true, pausesBeforeInput: true }], + [ + "Frame.innerHTML", + { title: "Get HTML", snapshot: true, pausesBeforeAction: true, group: "getter" }, + ], + [ + "Frame.innerText", + { title: "Get inner text", snapshot: true, pausesBeforeAction: true, group: "getter" }, + ], + [ + "Frame.inputValue", + { title: "Get input value", snapshot: true, pausesBeforeAction: true, group: "getter" }, + ], + [ + "Frame.isChecked", + { title: "Is checked", snapshot: true, pausesBeforeAction: true, group: "getter" }, + ], + [ + "Frame.isDisabled", + { title: "Is disabled", snapshot: true, pausesBeforeAction: true, group: "getter" }, + ], + [ + "Frame.isEnabled", + { title: "Is enabled", snapshot: true, pausesBeforeAction: true, group: "getter" }, + ], + [ + "Frame.isHidden", + { title: "Is hidden", snapshot: true, pausesBeforeAction: true, group: "getter" }, + ], + [ + "Frame.isVisible", + { title: "Is visible", snapshot: true, pausesBeforeAction: true, group: "getter" }, + ], + [ + "Frame.isEditable", + { title: "Is editable", snapshot: true, pausesBeforeAction: true, group: "getter" }, + ], + [ + "Frame.press", + { title: 'Press "{key}"', slowMo: true, snapshot: true, pausesBeforeInput: true }, + ], + ["Frame.querySelector", { title: "Query selector", snapshot: true }], + ["Frame.querySelectorAll", { title: "Query selector all", snapshot: true }], + ["Frame.queryCount", { title: "Query count", snapshot: true, pausesBeforeAction: true }], + [ + "Frame.selectOption", + { title: "Select option", slowMo: true, snapshot: true, pausesBeforeInput: true }, + ], + ["Frame.setContent", { title: "Set content", snapshot: true, pausesBeforeAction: true }], + [ + "Frame.setInputFiles", + { title: "Set input files", slowMo: true, snapshot: true, pausesBeforeInput: true }, + ], + ["Frame.tap", { title: "Tap", slowMo: true, snapshot: true, pausesBeforeInput: true }], + [ + "Frame.textContent", + { title: "Get text content", snapshot: true, pausesBeforeAction: true, group: "getter" }, + ], + ["Frame.title", { title: "Get page title", group: "getter" }], + ["Frame.type", { title: 'Type "{text}"', slowMo: true, snapshot: true, pausesBeforeInput: true }], + ["Frame.uncheck", { title: "Uncheck", slowMo: true, snapshot: true, pausesBeforeInput: true }], + ["Frame.waitForTimeout", { title: "Wait for timeout", snapshot: true }], + [ + "Frame.waitForFunction", + { title: "Wait for function", snapshot: true, pausesBeforeAction: true }, + ], + ["Frame.waitForSelector", { title: "Wait for selector", snapshot: true }], + ["Frame.expect", { title: 'Expect "{expression}"', snapshot: true, pausesBeforeAction: true }], + ["Worker.evaluateExpression", { title: "Evaluate" }], + ["Worker.evaluateExpressionHandle", { title: "Evaluate" }], + ["Worker.updateSubscription", { internal: true }], + ["Disposable.dispose", { internal: true }], + ["JSHandle.dispose", { internal: true }], + ["ElementHandle.dispose", { internal: true }], + ["JSHandle.evaluateExpression", { title: "Evaluate", snapshot: true, pausesBeforeAction: true }], + [ + "ElementHandle.evaluateExpression", + { title: "Evaluate", snapshot: true, pausesBeforeAction: true }, + ], + [ + "JSHandle.evaluateExpressionHandle", + { title: "Evaluate", snapshot: true, pausesBeforeAction: true }, + ], + [ + "ElementHandle.evaluateExpressionHandle", + { title: "Evaluate", snapshot: true, pausesBeforeAction: true }, + ], + ["JSHandle.getPropertyList", { title: "Get property list", group: "getter" }], + ["ElementHandle.getPropertyList", { title: "Get property list", group: "getter" }], + ["JSHandle.getProperty", { title: "Get JS property", group: "getter" }], + ["ElementHandle.getProperty", { title: "Get JS property", group: "getter" }], + ["JSHandle.jsonValue", { title: "Get JSON value", group: "getter" }], + ["ElementHandle.jsonValue", { title: "Get JSON value", group: "getter" }], + ["ElementHandle.evalOnSelector", { title: "Evaluate", snapshot: true, pausesBeforeAction: true }], + [ + "ElementHandle.evalOnSelectorAll", + { title: "Evaluate", snapshot: true, pausesBeforeAction: true }, + ], + [ + "ElementHandle.boundingBox", + { title: "Get bounding box", snapshot: true, pausesBeforeAction: true }, + ], + [ + "ElementHandle.check", + { title: "Check", slowMo: true, snapshot: true, pausesBeforeInput: true }, + ], + [ + "ElementHandle.click", + { title: "Click", slowMo: true, snapshot: true, pausesBeforeInput: true }, + ], + ["ElementHandle.contentFrame", { title: "Get content frame", group: "getter" }], + [ + "ElementHandle.dblclick", + { title: "Double click", slowMo: true, snapshot: true, pausesBeforeInput: true }, + ], + [ + "ElementHandle.dispatchEvent", + { title: "Dispatch event", slowMo: true, snapshot: true, pausesBeforeAction: true }, + ], + [ + "ElementHandle.fill", + { title: 'Fill "{value}"', slowMo: true, snapshot: true, pausesBeforeInput: true }, + ], + [ + "ElementHandle.focus", + { title: "Focus", slowMo: true, snapshot: true, pausesBeforeAction: true }, + ], + [ + "ElementHandle.getAttribute", + { title: "Get attribute", snapshot: true, pausesBeforeAction: true, group: "getter" }, + ], + [ + "ElementHandle.hover", + { title: "Hover", slowMo: true, snapshot: true, pausesBeforeInput: true }, + ], + [ + "ElementHandle.innerHTML", + { title: "Get HTML", snapshot: true, pausesBeforeAction: true, group: "getter" }, + ], + [ + "ElementHandle.innerText", + { title: "Get inner text", snapshot: true, pausesBeforeAction: true, group: "getter" }, + ], + [ + "ElementHandle.inputValue", + { title: "Get input value", snapshot: true, pausesBeforeAction: true, group: "getter" }, + ], + [ + "ElementHandle.isChecked", + { title: "Is checked", snapshot: true, pausesBeforeAction: true, group: "getter" }, + ], + [ + "ElementHandle.isDisabled", + { title: "Is disabled", snapshot: true, pausesBeforeAction: true, group: "getter" }, + ], + [ + "ElementHandle.isEditable", + { title: "Is editable", snapshot: true, pausesBeforeAction: true, group: "getter" }, + ], + [ + "ElementHandle.isEnabled", + { title: "Is enabled", snapshot: true, pausesBeforeAction: true, group: "getter" }, + ], + [ + "ElementHandle.isHidden", + { title: "Is hidden", snapshot: true, pausesBeforeAction: true, group: "getter" }, + ], + [ + "ElementHandle.isVisible", + { title: "Is visible", snapshot: true, pausesBeforeAction: true, group: "getter" }, + ], + ["ElementHandle.ownerFrame", { title: "Get owner frame", group: "getter" }], + [ + "ElementHandle.press", + { title: 'Press "{key}"', slowMo: true, snapshot: true, pausesBeforeInput: true }, + ], + ["ElementHandle.querySelector", { title: "Query selector", snapshot: true }], + ["ElementHandle.querySelectorAll", { title: "Query selector all", snapshot: true }], + ["ElementHandle.screenshot", { title: "Screenshot", snapshot: true, pausesBeforeAction: true }], + [ + "ElementHandle.scrollIntoViewIfNeeded", + { title: "Scroll into view", slowMo: true, snapshot: true, pausesBeforeAction: true }, + ], + [ + "ElementHandle.selectOption", + { title: "Select option", slowMo: true, snapshot: true, pausesBeforeInput: true }, + ], + [ + "ElementHandle.selectText", + { title: "Select text", slowMo: true, snapshot: true, pausesBeforeAction: true }, + ], + [ + "ElementHandle.setInputFiles", + { title: "Set input files", slowMo: true, snapshot: true, pausesBeforeInput: true }, + ], + ["ElementHandle.tap", { title: "Tap", slowMo: true, snapshot: true, pausesBeforeInput: true }], + [ + "ElementHandle.textContent", + { title: "Get text content", snapshot: true, pausesBeforeAction: true, group: "getter" }, + ], + ["ElementHandle.type", { title: "Type", slowMo: true, snapshot: true, pausesBeforeInput: true }], + [ + "ElementHandle.uncheck", + { title: "Uncheck", slowMo: true, snapshot: true, pausesBeforeInput: true }, + ], + [ + "ElementHandle.waitForElementState", + { title: "Wait for state", snapshot: true, pausesBeforeAction: true }, + ], + ["ElementHandle.waitForSelector", { title: "Wait for selector", snapshot: true }], + ["Request.response", { internal: true }], + ["Request.rawRequestHeaders", { internal: true }], + ["Route.redirectNavigationRequest", { internal: true }], + ["Route.abort", { title: "Abort request", group: "route" }], + ["Route.continue", { title: "Continue request", group: "route" }], + ["Route.fulfill", { title: "Fulfill request", group: "route" }], + ["WebSocketRoute.connect", { title: "Connect WebSocket to server", group: "route" }], + ["WebSocketRoute.ensureOpened", { internal: true }], + ["WebSocketRoute.sendToPage", { title: "Send WebSocket message", group: "route" }], + ["WebSocketRoute.sendToServer", { title: "Send WebSocket message", group: "route" }], + ["WebSocketRoute.closePage", { internal: true }], + ["WebSocketRoute.closeServer", { internal: true }], + ["Response.body", { title: "Get response body", group: "getter" }], + ["Response.securityDetails", { internal: true }], + ["Response.serverAddr", { internal: true }], + ["Response.rawResponseHeaders", { internal: true }], + ["Response.httpVersion", { internal: true }], + ["Response.sizes", { internal: true }], + ["BindingCall.reject", { internal: true }], + ["BindingCall.resolve", { internal: true }], + ["Debugger.pause", { title: "Pause on next call", group: "configuration" }], + ["Debugger.resume", { title: "Resume", group: "configuration" }], + ["Debugger.next", { title: "Step to next call", group: "configuration" }], + ["Debugger.runTo", { title: "Run to location", group: "configuration" }], + ["Dialog.accept", { title: "Accept dialog" }], + ["Dialog.dismiss", { title: "Dismiss dialog" }], + ["Tracing.tracingStart", { title: "Start tracing", group: "configuration" }], + ["Tracing.tracingStartChunk", { title: "Start tracing", group: "configuration" }], + ["Tracing.tracingGroup", { title: 'Trace "{name}"' }], + ["Tracing.tracingGroupEnd", { title: "Group end" }], + ["Tracing.tracingStopChunk", { title: "Stop tracing", group: "configuration" }], + ["Tracing.tracingStop", { title: "Stop tracing", group: "configuration" }], + ["Artifact.pathAfterFinished", { internal: true }], + ["Artifact.saveAs", { internal: true }], + ["Artifact.saveAsStream", { internal: true }], + ["Artifact.failure", { internal: true }], + ["Artifact.stream", { internal: true }], + ["Artifact.cancel", { internal: true }], + ["Artifact.delete", { internal: true }], + ["Stream.read", { internal: true }], + ["Stream.close", { internal: true }], + ["WritableStream.write", { internal: true }], + ["WritableStream.close", { internal: true }], + ["CDPSession.send", { title: "Send CDP command", group: "configuration" }], + ["CDPSession.detach", { title: "Detach CDP session", group: "configuration" }], + ["Electron.launch", { title: "Launch electron" }], + ["ElectronApplication.browserWindow", { internal: true }], + ["ElectronApplication.evaluateExpression", { title: "Evaluate" }], + ["ElectronApplication.evaluateExpressionHandle", { title: "Evaluate" }], + ["ElectronApplication.updateSubscription", { internal: true }], + ["Android.devices", { internal: true }], + ["AndroidSocket.write", { internal: true }], + ["AndroidSocket.close", { internal: true }], + ["AndroidDevice.wait", { title: "Wait" }], + ["AndroidDevice.fill", { title: 'Fill "{text}"' }], + ["AndroidDevice.tap", { title: "Tap" }], + ["AndroidDevice.drag", { title: "Drag" }], + ["AndroidDevice.fling", { title: "Fling" }], + ["AndroidDevice.longTap", { title: "Long tap" }], + ["AndroidDevice.pinchClose", { title: "Pinch close" }], + ["AndroidDevice.pinchOpen", { title: "Pinch open" }], + ["AndroidDevice.scroll", { title: "Scroll" }], + ["AndroidDevice.swipe", { title: "Swipe" }], + ["AndroidDevice.info", { internal: true }], + ["AndroidDevice.screenshot", { title: "Screenshot" }], + ["AndroidDevice.inputType", { title: "Type" }], + ["AndroidDevice.inputPress", { title: "Press" }], + ["AndroidDevice.inputTap", { title: "Tap" }], + ["AndroidDevice.inputSwipe", { title: "Swipe" }], + ["AndroidDevice.inputDrag", { title: "Drag" }], + ["AndroidDevice.launchBrowser", { title: "Launch browser" }], + ["AndroidDevice.open", { title: "Open app" }], + ["AndroidDevice.shell", { title: "Execute shell command", group: "configuration" }], + ["AndroidDevice.installApk", { title: "Install apk" }], + ["AndroidDevice.push", { title: "Push" }], + ["AndroidDevice.connectToWebView", { title: "Connect to Web View" }], + ["AndroidDevice.close", { internal: true }], + ["JsonPipe.send", { internal: true }], + ["JsonPipe.close", { internal: true }], +]); diff --git a/daemon/src/sandbox/forked-client/src/utils/isomorphic/rtti.ts b/daemon/src/sandbox/forked-client/src/utils/isomorphic/rtti.ts new file mode 100644 index 00000000..87021021 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/utils/isomorphic/rtti.ts @@ -0,0 +1,30 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { isString } from "./stringUtils"; + +export function isRegExp(obj: any): obj is RegExp { + return obj instanceof RegExp || Object.prototype.toString.call(obj) === "[object RegExp]"; +} + +export function isObject(obj: any): obj is NonNullable { + return typeof obj === "object" && obj !== null; +} + +export function isError(obj: any): obj is Error { + return obj instanceof Error || (obj && Object.getPrototypeOf(obj)?.name === "Error"); +} diff --git a/daemon/src/sandbox/forked-client/src/utils/isomorphic/selectorParser.ts b/daemon/src/sandbox/forked-client/src/utils/isomorphic/selectorParser.ts new file mode 100644 index 00000000..075f0bef --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/utils/isomorphic/selectorParser.ts @@ -0,0 +1,510 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { InvalidSelectorError, parseCSS } from "./cssParser"; + +import type { CSSComplexSelectorList } from "./cssParser"; +export { InvalidSelectorError, isInvalidSelectorError } from "./cssParser"; + +export type NestedSelectorBody = { parsed: ParsedSelector; distance?: number }; +const kNestedSelectorNames = new Set([ + "internal:has", + "internal:has-not", + "internal:and", + "internal:or", + "internal:chain", + "left-of", + "right-of", + "above", + "below", + "near", +]); +const kNestedSelectorNamesWithDistance = new Set(["left-of", "right-of", "above", "below", "near"]); + +export type ParsedSelectorPart = { + name: string; + body: string | CSSComplexSelectorList | NestedSelectorBody; + source: string; +}; + +export type ParsedSelector = { + parts: ParsedSelectorPart[]; + capture?: number; +}; + +type ParsedSelectorStrings = { + parts: { name: string; body: string }[]; + capture?: number; +}; + +export const customCSSNames = new Set([ + "not", + "is", + "where", + "has", + "scope", + "light", + "visible", + "text", + "text-matches", + "text-is", + "has-text", + "above", + "below", + "right-of", + "left-of", + "near", + "nth-match", +]); + +export function parseSelector(selector: string): ParsedSelector { + const parsedStrings = parseSelectorString(selector); + const parts: ParsedSelectorPart[] = []; + for (const part of parsedStrings.parts) { + if (part.name === "css" || part.name === "css:light") { + if (part.name === "css:light") part.body = ":light(" + part.body + ")"; + const parsedCSS = parseCSS(part.body, customCSSNames); + parts.push({ + name: "css", + body: parsedCSS.selector, + source: part.body, + }); + continue; + } + if (kNestedSelectorNames.has(part.name)) { + let innerSelector: string; + let distance: number | undefined; + try { + const unescaped = JSON.parse("[" + part.body + "]"); + if ( + !Array.isArray(unescaped) || + unescaped.length < 1 || + unescaped.length > 2 || + typeof unescaped[0] !== "string" + ) + throw new InvalidSelectorError(`Malformed selector: ${part.name}=` + part.body); + innerSelector = unescaped[0]; + if (unescaped.length === 2) { + if (typeof unescaped[1] !== "number" || !kNestedSelectorNamesWithDistance.has(part.name)) + throw new InvalidSelectorError(`Malformed selector: ${part.name}=` + part.body); + distance = unescaped[1]; + } + } catch (e) { + throw new InvalidSelectorError(`Malformed selector: ${part.name}=` + part.body); + } + const nested = { + name: part.name, + source: part.body, + body: { parsed: parseSelector(innerSelector), distance }, + }; + const lastFrame = [...nested.body.parsed.parts] + .reverse() + .find((part) => part.name === "internal:control" && part.body === "enter-frame"); + const lastFrameIndex = lastFrame ? nested.body.parsed.parts.indexOf(lastFrame) : -1; + // Allow nested selectors to start with the same frame selector. + if ( + lastFrameIndex !== -1 && + selectorPartsEqual( + nested.body.parsed.parts.slice(0, lastFrameIndex + 1), + parts.slice(0, lastFrameIndex + 1) + ) + ) + nested.body.parsed.parts.splice(0, lastFrameIndex + 1); + parts.push(nested); + continue; + } + parts.push({ ...part, source: part.body }); + } + if (kNestedSelectorNames.has(parts[0].name)) + throw new InvalidSelectorError(`"${parts[0].name}" selector cannot be first`); + return { + capture: parsedStrings.capture, + parts, + }; +} + +export function splitSelectorByFrame(selectorText: string): ParsedSelector[] { + const selector = parseSelector(selectorText); + const result: ParsedSelector[] = []; + let chunk: ParsedSelector = { + parts: [], + }; + let chunkStartIndex = 0; + for (let i = 0; i < selector.parts.length; ++i) { + const part = selector.parts[i]; + if (part.name === "internal:control" && part.body === "enter-frame") { + if (!chunk.parts.length) + throw new InvalidSelectorError( + "Selector cannot start with entering frame, select the iframe first" + ); + result.push(chunk); + chunk = { parts: [] }; + chunkStartIndex = i + 1; + continue; + } + if (selector.capture === i) chunk.capture = i - chunkStartIndex; + chunk.parts.push(part); + } + if (!chunk.parts.length) + throw new InvalidSelectorError( + `Selector cannot end with entering frame, while parsing selector ${selectorText}` + ); + result.push(chunk); + if (typeof selector.capture === "number" && typeof result[result.length - 1].capture !== "number") + throw new InvalidSelectorError( + `Can not capture the selector before diving into the frame. Only use * after the last frame has been selected` + ); + return result; +} + +function selectorPartsEqual(list1: ParsedSelectorPart[], list2: ParsedSelectorPart[]) { + return stringifySelector({ parts: list1 }) === stringifySelector({ parts: list2 }); +} + +export function stringifySelector( + selector: string | ParsedSelector, + forceEngineName?: boolean +): string { + if (typeof selector === "string") return selector; + return selector.parts + .map((p, i) => { + let includeEngine = true; + if (!forceEngineName && i !== selector.capture) { + if (p.name === "css") includeEngine = false; + else if ((p.name === "xpath" && p.source.startsWith("//")) || p.source.startsWith("..")) + includeEngine = false; + } + const prefix = includeEngine ? p.name + "=" : ""; + return `${i === selector.capture ? "*" : ""}${prefix}${p.source}`; + }) + .join(" >> "); +} + +export function visitAllSelectorParts( + selector: ParsedSelector, + visitor: (part: ParsedSelectorPart, nested: boolean) => void +) { + const visit = (selector: ParsedSelector, nested: boolean) => { + for (const part of selector.parts) { + visitor(part, nested); + if (kNestedSelectorNames.has(part.name)) + visit((part.body as NestedSelectorBody).parsed, true); + } + }; + visit(selector, false); +} + +function parseSelectorString(selector: string): ParsedSelectorStrings { + let index = 0; + let quote: string | undefined; + let start = 0; + const result: ParsedSelectorStrings = { parts: [] }; + const append = () => { + const part = selector.substring(start, index).trim(); + const eqIndex = part.indexOf("="); + let name: string; + let body: string; + if ( + eqIndex !== -1 && + part + .substring(0, eqIndex) + .trim() + .match(/^[a-zA-Z_0-9-+:*]+$/) + ) { + name = part.substring(0, eqIndex).trim(); + body = part.substring(eqIndex + 1); + } else if (part.length > 1 && part[0] === '"' && part[part.length - 1] === '"') { + name = "text"; + body = part; + } else if (part.length > 1 && part[0] === "'" && part[part.length - 1] === "'") { + name = "text"; + body = part; + } else if (/^\(*\/\//.test(part) || part.startsWith("..")) { + // If selector starts with '//' or '//' prefixed with multiple opening + // parenthesis, consider xpath. @see https://github.com/microsoft/playwright/issues/817 + // If selector starts with '..', consider xpath as well. + name = "xpath"; + body = part; + } else { + name = "css"; + body = part; + } + let capture = false; + if (name[0] === "*") { + capture = true; + name = name.substring(1); + } + result.parts.push({ name, body }); + if (capture) { + if (result.capture !== undefined) + throw new InvalidSelectorError(`Only one of the selectors can capture using * modifier`); + result.capture = result.parts.length - 1; + } + }; + + if (!selector.includes(">>")) { + index = selector.length; + append(); + return result; + } + + const shouldIgnoreTextSelectorQuote = () => { + const prefix = selector.substring(start, index); + const match = prefix.match(/^\s*text\s*=(.*)$/); + // Must be a text selector with some text before the quote. + return !!match && !!match[1]; + }; + + while (index < selector.length) { + const c = selector[index]; + if (c === "\\" && index + 1 < selector.length) { + index += 2; + } else if (c === quote) { + quote = undefined; + index++; + } else if ( + !quote && + (c === '"' || c === "'" || c === "`") && + !shouldIgnoreTextSelectorQuote() + ) { + quote = c; + index++; + } else if (!quote && c === ">" && selector[index + 1] === ">") { + append(); + index += 2; + start = index; + } else { + index++; + } + } + append(); + return result; +} + +export type AttributeSelectorOperator = "" | "=" | "*=" | "|=" | "^=" | "$=" | "~="; +export type AttributeSelectorPart = { + name: string; + jsonPath: string[]; + op: AttributeSelectorOperator; + value: any; + caseSensitive: boolean; +}; + +export type AttributeSelector = { + name: string; + attributes: AttributeSelectorPart[]; +}; + +export function parseAttributeSelector( + selector: string, + allowUnquotedStrings: boolean +): AttributeSelector { + let wp = 0; + let EOL = selector.length === 0; + + const next = () => selector[wp] || ""; + const eat1 = () => { + const result = next(); + ++wp; + EOL = wp >= selector.length; + return result; + }; + + const syntaxError = (stage: string | undefined) => { + if (EOL) + throw new InvalidSelectorError( + `Unexpected end of selector while parsing selector \`${selector}\`` + ); + throw new InvalidSelectorError( + `Error while parsing selector \`${selector}\` - unexpected symbol "${next()}" at position ${wp}` + + (stage ? " during " + stage : "") + ); + }; + + function skipSpaces() { + while (!EOL && /\s/.test(next())) eat1(); + } + + function isCSSNameChar(char: string) { + // https://www.w3.org/TR/css-syntax-3/#ident-token-diagram + return ( + char >= "\u0080" || // non-ascii + (char >= "\u0030" && char <= "\u0039") || // digit + (char >= "\u0041" && char <= "\u005a") || // uppercase letter + (char >= "\u0061" && char <= "\u007a") || // lowercase letter + (char >= "\u0030" && char <= "\u0039") || // digit + char === "\u005f" || // "_" + char === "\u002d" + ); // "-" + } + + function readIdentifier() { + let result = ""; + skipSpaces(); + while (!EOL && isCSSNameChar(next())) result += eat1(); + return result; + } + + function readQuotedString(quote: string) { + let result = eat1(); + if (result !== quote) syntaxError("parsing quoted string"); + while (!EOL && next() !== quote) { + if (next() === "\\") eat1(); + result += eat1(); + } + if (next() !== quote) syntaxError("parsing quoted string"); + result += eat1(); + return result; + } + + function readRegularExpression() { + if (eat1() !== "/") syntaxError("parsing regular expression"); + let source = ""; + let inClass = false; + // https://262.ecma-international.org/11.0/#sec-literals-regular-expression-literals + while (!EOL) { + if (next() === "\\") { + source += eat1(); + if (EOL) syntaxError("parsing regular expression"); + } else if (inClass && next() === "]") { + inClass = false; + } else if (!inClass && next() === "[") { + inClass = true; + } else if (!inClass && next() === "/") { + break; + } + source += eat1(); + } + if (eat1() !== "/") syntaxError("parsing regular expression"); + let flags = ""; + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions + while (!EOL && next().match(/[dgimsuy]/)) flags += eat1(); + try { + return new RegExp(source, flags); + } catch (e) { + throw new InvalidSelectorError(`Error while parsing selector \`${selector}\`: ${e.message}`); + } + } + + function readAttributeToken() { + let token = ""; + skipSpaces(); + if (next() === `'` || next() === `"`) token = readQuotedString(next()).slice(1, -1); + else token = readIdentifier(); + if (!token) syntaxError("parsing property path"); + return token; + } + + function readOperator(): AttributeSelectorOperator { + skipSpaces(); + let op = ""; + if (!EOL) op += eat1(); + if (!EOL && op !== "=") op += eat1(); + if (!["=", "*=", "^=", "$=", "|=", "~="].includes(op)) syntaxError("parsing operator"); + return op as AttributeSelectorOperator; + } + + function readAttribute(): AttributeSelectorPart { + // skip leading [ + eat1(); + + // read attribute name: + // foo.bar + // 'foo' . "ba zz" + const jsonPath = []; + jsonPath.push(readAttributeToken()); + skipSpaces(); + while (next() === ".") { + eat1(); + jsonPath.push(readAttributeToken()); + skipSpaces(); + } + // check property is truthy: [enabled] + if (next() === "]") { + eat1(); + return { + name: jsonPath.join("."), + jsonPath, + op: "", + value: null, + caseSensitive: false, + }; + } + + const operator = readOperator(); + + let value = undefined; + let caseSensitive = true; + skipSpaces(); + if (next() === "/") { + if (operator !== "=") + throw new InvalidSelectorError( + `Error while parsing selector \`${selector}\` - cannot use ${operator} in attribute with regular expression` + ); + value = readRegularExpression(); + } else if (next() === `'` || next() === `"`) { + value = readQuotedString(next()).slice(1, -1); + skipSpaces(); + if (next() === "i" || next() === "I") { + caseSensitive = false; + eat1(); + } else if (next() === "s" || next() === "S") { + caseSensitive = true; + eat1(); + } + } else { + value = ""; + while (!EOL && (isCSSNameChar(next()) || next() === "+" || next() === ".")) value += eat1(); + if (value === "true") { + value = true; + } else if (value === "false") { + value = false; + } else { + if (!allowUnquotedStrings) { + value = +value; + if (Number.isNaN(value)) syntaxError("parsing attribute value"); + } + } + } + skipSpaces(); + if (next() !== "]") syntaxError("parsing attribute value"); + + eat1(); + if (operator !== "=" && typeof value !== "string") + throw new InvalidSelectorError( + `Error while parsing selector \`${selector}\` - cannot use ${operator} in attribute with non-string matching value - ${value}` + ); + return { name: jsonPath.join("."), jsonPath, op: operator, value, caseSensitive }; + } + + const result: AttributeSelector = { + name: "", + attributes: [], + }; + result.name = readIdentifier(); + skipSpaces(); + while (next() === "[") { + result.attributes.push(readAttribute()); + skipSpaces(); + } + if (!EOL) syntaxError(undefined); + if (!result.name && !result.attributes.length) + throw new InvalidSelectorError( + `Error while parsing selector \`${selector}\` - selector cannot be empty` + ); + return result; +} diff --git a/daemon/src/sandbox/forked-client/src/utils/isomorphic/semaphore.ts b/daemon/src/sandbox/forked-client/src/utils/isomorphic/semaphore.ts new file mode 100644 index 00000000..baf04568 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/utils/isomorphic/semaphore.ts @@ -0,0 +1,51 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ManualPromise } from "./manualPromise"; + +export class Semaphore { + private _max: number; + private _acquired = 0; + private _queue: ManualPromise[] = []; + + constructor(max: number) { + this._max = max; + } + + setMax(max: number) { + this._max = max; + } + + acquire(): Promise { + const lock = new ManualPromise(); + this._queue.push(lock); + this._flush(); + return lock; + } + + release() { + --this._acquired; + this._flush(); + } + + private _flush() { + while (this._acquired < this._max && this._queue.length) { + ++this._acquired; + this._queue.shift()!.resolve(); + } + } +} diff --git a/daemon/src/sandbox/forked-client/src/utils/isomorphic/stackTrace.ts b/daemon/src/sandbox/forked-client/src/utils/isomorphic/stackTrace.ts new file mode 100644 index 00000000..847c5a69 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/utils/isomorphic/stackTrace.ts @@ -0,0 +1,200 @@ +// @ts-nocheck +/** + * The MIT License (MIT) + * Modifications copyright (c) Microsoft Corporation. + * + * Copyright (c) 2016-2023 Isaac Z. Schlueter i@izs.me, James Talmage james@talmage.io (github.com/jamestalmage), and + * Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated + * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the + * Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE + * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR + * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +export type RawStack = string[]; + +export type StackFrame = { + file: string; + line: number; + column: number; + function?: string; +}; + +export function captureRawStack(): RawStack { + const stackTraceLimit = Error.stackTraceLimit; + Error.stackTraceLimit = 50; + const error = new Error(); + const stack = error.stack || ""; + Error.stackTraceLimit = stackTraceLimit; + return stack.split("\n"); +} + +export function parseStackFrame( + text: string, + pathSeparator: string, + showInternalStackFrames: boolean +): StackFrame | null { + const match = text && text.match(re); + if (!match) return null; + + let fname = match[2]; + let file = match[7]; + if (!file) return null; + if (!showInternalStackFrames && (file.startsWith("internal") || file.startsWith("node:"))) + return null; + + const line = match[8]; + const column = match[9]; + const closeParen = match[11] === ")"; + + const frame: StackFrame = { + file: "", + line: 0, + column: 0, + }; + + if (line) frame.line = Number(line); + + if (column) frame.column = Number(column); + + if (closeParen && file) { + // make sure parens are balanced + // if we have a file like "asdf) [as foo] (xyz.js", then odds are + // that the fname should be += " (asdf) [as foo]" and the file + // should be just "xyz.js" + // walk backwards from the end to find the last unbalanced ( + let closes = 0; + for (let i = file.length - 1; i > 0; i--) { + if (file.charAt(i) === ")") { + closes++; + } else if (file.charAt(i) === "(" && file.charAt(i - 1) === " ") { + closes--; + if (closes === -1 && file.charAt(i - 1) === " ") { + const before = file.slice(0, i - 1); + const after = file.slice(i + 1); + file = after; + fname += ` (${before}`; + break; + } + } + } + } + + if (fname) { + const methodMatch = fname.match(methodRe); + if (methodMatch) fname = methodMatch[1]; + } + + if (file) { + if (file.startsWith("file://")) file = fileURLToPath(file, pathSeparator); + frame.file = file; + } + + if (fname) frame.function = fname; + + return frame; +} + +export function rewriteErrorMessage(e: E, newMessage: string): E { + const lines: string[] = (e.stack?.split("\n") || []).filter((l) => l.startsWith(" at ")); + e.message = newMessage; + const errorTitle = `${e.name}: ${e.message}`; + if (lines.length) e.stack = `${errorTitle}\n${lines.join("\n")}`; + return e; +} + +export function stringifyStackFrames(frames: StackFrame[]): string[] { + const stackLines: string[] = []; + for (const frame of frames) { + if (frame.function) + stackLines.push(` at ${frame.function} (${frame.file}:${frame.line}:${frame.column})`); + else stackLines.push(` at ${frame.file}:${frame.line}:${frame.column}`); + } + return stackLines; +} + +export function splitErrorMessage(message: string): { name: string; message: string } { + const separationIdx = message.indexOf(":"); + return { + name: separationIdx !== -1 ? message.slice(0, separationIdx) : "", + message: + separationIdx !== -1 && separationIdx + 2 <= message.length + ? message.substring(separationIdx + 2) + : message, + }; +} + +export function parseErrorStack( + stack: string, + pathSeparator: string, + showInternalStackFrames: boolean = false +): { + message: string; + stackLines: string[]; + location?: StackFrame; +} { + const lines = stack.split("\n"); + let firstStackLine = lines.findIndex((line) => line.startsWith(" at ")); + if (firstStackLine === -1) firstStackLine = lines.length; + const message = lines.slice(0, firstStackLine).join("\n"); + const stackLines = lines.slice(firstStackLine); + let location: StackFrame | undefined; + for (const line of stackLines) { + const frame = parseStackFrame(line, pathSeparator, showInternalStackFrames); + if (!frame || !frame.file) continue; + if (belongsToNodeModules(frame.file, pathSeparator)) continue; + location = { file: frame.file, column: frame.column || 0, line: frame.line || 0 }; + break; + } + return { message, stackLines, location }; +} + +function belongsToNodeModules(file: string, pathSeparator: string) { + return file.includes(`${pathSeparator}node_modules${pathSeparator}`); +} + +const re = new RegExp( + "^" + + // Sometimes we strip out the ' at' because it's noisy + "(?:\\s*at )?" + + // $1 = ctor if 'new' + "(?:(new) )?" + + // $2 = function name (can be literally anything) + // May contain method at the end as [as xyz] + "(?:(.*?) \\()?" + + // (eval at (file.js:1:1), + // $3 = eval origin + // $4:$5:$6 are eval file/line/col, but not normally reported + "(?:eval at ([^ ]+) \\((.+?):(\\d+):(\\d+)\\), )?" + + // file:line:col + // $7:$8:$9 + // $10 = 'native' if native + "(?:(.+?):(\\d+):(\\d+)|(native))" + + // maybe close the paren, then end + // if $11 is ), then we only allow balanced parens in the filename + // any imbalance is placed on the fname. This is a heuristic, and + // bound to be incorrect in some edge cases. The bet is that + // having weird characters in method names is more common than + // having weird characters in filenames, which seems reasonable. + "(\\)?)$" +); + +const methodRe = /^(.*?) \[as (.*?)\]$/; + +function fileURLToPath(fileUrl: string, pathSeparator: string): string { + if (!fileUrl.startsWith("file://")) return fileUrl; + + let path = decodeURIComponent(fileUrl.slice(7)); + if (path.startsWith("/") && /^[a-zA-Z]:/.test(path.slice(1))) path = path.slice(1); + + return path.replace(/\//g, pathSeparator); +} diff --git a/daemon/src/sandbox/forked-client/src/utils/isomorphic/stringUtils.ts b/daemon/src/sandbox/forked-client/src/utils/isomorphic/stringUtils.ts new file mode 100644 index 00000000..2f63eeab --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/utils/isomorphic/stringUtils.ts @@ -0,0 +1,196 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// NOTE: this function should not be used to escape any selectors. +export function escapeWithQuotes(text: string, char: string = "'") { + const stringified = JSON.stringify(text); + const escapedText = stringified.substring(1, stringified.length - 1).replace(/\\"/g, '"'); + if (char === "'") return char + escapedText.replace(/[']/g, "\\'") + char; + if (char === '"') return char + escapedText.replace(/["]/g, '\\"') + char; + if (char === "`") return char + escapedText.replace(/[`]/g, "\\`") + char; + throw new Error("Invalid escape char"); +} + +export function escapeTemplateString(text: string): string { + return text.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$\{/g, "\\${"); +} + +export function isString(obj: any): obj is string { + return typeof obj === "string" || obj instanceof String; +} + +export function toTitleCase(name: string) { + return name.charAt(0).toUpperCase() + name.substring(1); +} + +export function toSnakeCase(name: string): string { + // E.g. ignoreHTTPSErrors => ignore_https_errors. + return name + .replace(/([a-z0-9])([A-Z])/g, "$1_$2") + .replace(/([A-Z])([A-Z][a-z])/g, "$1_$2") + .toLowerCase(); +} + +export function formatObject( + value: any, + indent = " ", + mode: "multiline" | "oneline" = "multiline" +): string { + if (typeof value === "string") return escapeWithQuotes(value, "'"); + if (Array.isArray(value)) return `[${value.map((o) => formatObject(o)).join(", ")}]`; + if (typeof value === "object") { + const keys = Object.keys(value) + .filter((key) => key !== "timeout" && value[key] !== undefined) + .sort(); + if (!keys.length) return "{}"; + const tokens: string[] = []; + for (const key of keys) tokens.push(`${key}: ${formatObject(value[key])}`); + if (mode === "multiline") return `{\n${tokens.map((t) => indent + t).join(`,\n`)}\n}`; + return `{ ${tokens.join(", ")} }`; + } + return String(value); +} + +export function formatObjectOrVoid(value: any, indent = " "): string { + const result = formatObject(value, indent); + return result === "{}" ? "" : result; +} + +export function quoteCSSAttributeValue(text: string): string { + return `"${text.replace(/["\\]/g, (char) => "\\" + char)}"`; +} + +let normalizedWhitespaceCache: Map | undefined; + +export function cacheNormalizedWhitespaces() { + normalizedWhitespaceCache = new Map(); +} + +export function normalizeWhiteSpace(text: string): string { + let result = normalizedWhitespaceCache?.get(text); + if (result === undefined) { + result = text + .replace(/[\u200b\u00ad]/g, "") + .trim() + .replace(/\s+/g, " "); + normalizedWhitespaceCache?.set(text, result); + } + return result; +} + +export function normalizeEscapedRegexQuotes(source: string) { + // This function reverses the effect of escapeRegexForSelector below. + // Odd number of backslashes followed by the quote -> remove unneeded backslash. + return source.replace(/(^|[^\\])(\\\\)*\\(['"`])/g, "$1$2$3"); +} + +function escapeRegexForSelector(re: RegExp): string { + // Unicode mode does not allow "identity character escapes", so we do not escape and + // hope that it does not contain quotes and/or >> signs. + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Regular_expressions/Character_escape + // TODO: rework RE usages in internal selectors away from literal representation to json, e.g. {source,flags}. + if (re.unicode || (re as any).unicodeSets) return String(re); + // Even number of backslashes followed by the quote -> insert a backslash. + return String(re) + .replace(/(^|[^\\])(\\\\)*(["'`])/g, "$1$2\\$3") + .replace(/>>/g, "\\>\\>"); +} + +export function escapeForTextSelector(text: string | RegExp, exact: boolean): string { + if (typeof text !== "string") return escapeRegexForSelector(text); + return `${JSON.stringify(text)}${exact ? "s" : "i"}`; +} + +export function escapeForAttributeSelector(value: string | RegExp, exact: boolean): string { + if (typeof value !== "string") return escapeRegexForSelector(value); + // TODO: this should actually be + // cssEscape(value).replace(/\\ /g, ' ') + // However, our attribute selectors do not conform to CSS parsing spec, + // so we escape them differently. + return `"${value.replace(/\\/g, "\\\\").replace(/["]/g, '\\"')}"${exact ? "s" : "i"}`; +} + +export function trimString(input: string, cap: number, suffix: string = ""): string { + if (input.length <= cap) return input; + const chars = [...input]; + if (chars.length > cap) return chars.slice(0, cap - suffix.length).join("") + suffix; + return chars.join(""); +} + +export function trimStringWithEllipsis(input: string, cap: number): string { + return trimString(input, cap, "\u2026"); +} + +export function escapeRegExp(s: string) { + // From https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#escaping + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); // $& means the whole matched string +} + +const escaped = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }; +export function escapeHTMLAttribute(s: string): string { + return s.replace(/[&<>"']/gu, (char) => (escaped as any)[char]); +} +export function escapeHTML(s: string): string { + return s.replace(/[&<]/gu, (char) => (escaped as any)[char]); +} + +export function longestCommonSubstring(s1: string, s2: string): string { + const n = s1.length; + const m = s2.length; + let maxLen = 0; + let endingIndex = 0; + + // Initialize a 2D array with zeros + const dp = Array(n + 1) + .fill(null) + .map(() => Array(m + 1).fill(0)); + + // Build the dp table + for (let i = 1; i <= n; i++) { + for (let j = 1; j <= m; j++) { + if (s1[i - 1] === s2[j - 1]) { + dp[i][j] = dp[i - 1][j - 1] + 1; + + if (dp[i][j] > maxLen) { + maxLen = dp[i][j]; + endingIndex = i; + } + } + } + } + + // Extract the longest common substring + return s1.slice(endingIndex - maxLen, endingIndex); +} + +export function parseRegex(regex: string): RegExp { + if (regex[0] !== "/") throw new Error(`Invalid regex, must start with '/': ${regex}`); + const lastSlash = regex.lastIndexOf("/"); + if (lastSlash <= 0) + throw new Error(`Invalid regex, must end with '/' followed by optional flags: ${regex}`); + const source = regex.slice(1, lastSlash); + const flags = regex.slice(lastSlash + 1); + return new RegExp(source, flags); +} + +export const ansiRegex = new RegExp( + "([\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-ntqry=><~])))", + "g" +); +export function stripAnsiEscapes(str: string): string { + return str.replace(ansiRegex, ""); +} diff --git a/daemon/src/sandbox/forked-client/src/utils/isomorphic/time.ts b/daemon/src/sandbox/forked-client/src/utils/isomorphic/time.ts new file mode 100644 index 00000000..14397443 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/utils/isomorphic/time.ts @@ -0,0 +1,47 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Hopefully, this file is never used in injected sources, +// because it does not use `builtins.performance`, +// and can break when clock emulation is engaged. + +/* eslint-disable no-restricted-globals */ + +const fallbackTimeOrigin = Date.now(); +const performanceApi = globalThis.performance ?? { + timeOrigin: fallbackTimeOrigin, + now: () => Date.now() - fallbackTimeOrigin, +}; + +let _timeOrigin = performanceApi.timeOrigin; +let _timeShift = 0; + +export function setTimeOrigin(origin: number) { + _timeOrigin = origin; + _timeShift = performanceApi.timeOrigin - origin; +} + +export function timeOrigin(): number { + return _timeOrigin; +} + +export function monotonicTime(): number { + return Math.floor((performanceApi.now() + _timeShift) * 1000) / 1000; +} + +export const DEFAULT_PLAYWRIGHT_TIMEOUT = 30_000; +export const DEFAULT_PLAYWRIGHT_LAUNCH_TIMEOUT = 3 * 60 * 1000; // 3 minutes diff --git a/daemon/src/sandbox/forked-client/src/utils/isomorphic/timeoutRunner.ts b/daemon/src/sandbox/forked-client/src/utils/isomorphic/timeoutRunner.ts new file mode 100644 index 00000000..7c889256 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/utils/isomorphic/timeoutRunner.ts @@ -0,0 +1,64 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Hopefully, this file is never used in injected sources, +// because it does not use `builtins.setTimeout` and similar, +// and can break when clock emulation is engaged. + +/* eslint-disable no-restricted-globals */ + +import { monotonicTime } from "./time"; + +export async function raceAgainstDeadline( + cb: () => Promise, + deadline: number +): Promise<{ result: T; timedOut: false } | { timedOut: true }> { + let timer: NodeJS.Timeout | undefined; + return Promise.race([ + cb().then((result) => { + return { result, timedOut: false }; + }), + new Promise<{ timedOut: true }>((resolve) => { + if (!deadline) return; + timer = setTimeout(() => resolve({ timedOut: true }), deadline - monotonicTime()); + }), + ]).finally(() => { + clearTimeout(timer); + }); +} + +export async function pollAgainstDeadline( + callback: () => Promise<{ continuePolling: boolean; result: T }>, + deadline: number, + pollIntervals: number[] = [100, 250, 500, 1000] +): Promise<{ result?: T; timedOut: boolean }> { + const lastPollInterval = pollIntervals.pop() ?? 1000; + let lastResult: T | undefined; + const wrappedCallback = () => Promise.resolve().then(callback); + while (true) { + const time = monotonicTime(); + if (deadline && time >= deadline) break; + const received = await raceAgainstDeadline(wrappedCallback, deadline); + if (received.timedOut) break; + lastResult = (received as any).result.result; + if (!(received as any).result.continuePolling) return { result: lastResult, timedOut: false }; + const interval = pollIntervals!.shift() ?? lastPollInterval; + if (deadline && deadline <= monotonicTime() + interval) break; + await new Promise((x) => setTimeout(x, interval)); + } + return { timedOut: true, result: lastResult }; +} diff --git a/daemon/src/sandbox/forked-client/src/utils/isomorphic/types.ts b/daemon/src/sandbox/forked-client/src/utils/isomorphic/types.ts new file mode 100644 index 00000000..aab77db3 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/utils/isomorphic/types.ts @@ -0,0 +1,23 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export type Size = { width: number; height: number }; +export type Point = { x: number; y: number }; +export type Rect = Size & Point; +export type Quad = [Point, Point, Point, Point]; +export type NameValue = { name: string; value: string }; +export type HeadersArray = NameValue[]; diff --git a/daemon/src/sandbox/forked-client/src/utils/isomorphic/urlMatch.ts b/daemon/src/sandbox/forked-client/src/utils/isomorphic/urlMatch.ts new file mode 100644 index 00000000..bb41e4ea --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/utils/isomorphic/urlMatch.ts @@ -0,0 +1,299 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { isString } from "./stringUtils"; + +// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_expressions#escaping +const escapedChars = new Set([ + "$", + "^", + "+", + ".", + "*", + "(", + ")", + "|", + "\\", + "?", + "{", + "}", + "[", + "]", +]); + +export function globToRegexPattern(glob: string): string { + const tokens = ["^"]; + let inGroup = false; + for (let i = 0; i < glob.length; ++i) { + const c = glob[i]; + if (c === "\\" && i + 1 < glob.length) { + const char = glob[++i]; + tokens.push(escapedChars.has(char) ? "\\" + char : char); + continue; + } + if (c === "*") { + const charBefore = glob[i - 1]; + let starCount = 1; + while (glob[i + 1] === "*") { + starCount++; + i++; + } + if (starCount > 1) { + const charAfter = glob[i + 1]; + // Match either /..something../ or /. + if (charAfter === "/") { + if (charBefore === "/") tokens.push("((.+/)|)"); + else tokens.push("(.*/)"); + ++i; + } else { + tokens.push("(.*)"); + } + } else { + tokens.push("([^/]*)"); + } + continue; + } + + switch (c) { + case "{": + inGroup = true; + tokens.push("("); + break; + case "}": + inGroup = false; + tokens.push(")"); + break; + case ",": + if (inGroup) { + tokens.push("|"); + break; + } + tokens.push("\\" + c); + break; + default: + tokens.push(escapedChars.has(c) ? "\\" + c : c); + } + } + tokens.push("$"); + return tokens.join(""); +} + +function isRegExp(obj: any): obj is RegExp { + return obj instanceof RegExp || Object.prototype.toString.call(obj) === "[object RegExp]"; +} + +export type URLMatch = string | RegExp | ((url: URL) => boolean) | URLPattern; +// URLPattern is not in @types/node@18, so we polyfill it ourselves +export type URLPattern = { + test(input: string | URL): boolean; + hash: string; + hostname: string; + password: string; + pathname: string; + port: string; + protocol: string; + search: string; + username: string; +}; + +// @ts-expect-error URLPattern is not in @types/node yet +// eslint-disable-next-line no-restricted-globals +export const isURLPattern = (v: unknown): v is URLPattern => + typeof globalThis.URLPattern === "function" && v instanceof globalThis.URLPattern; + +export function serializeURLPattern(v: URLPattern) { + return { + hash: v.hash, + hostname: v.hostname, + password: v.password, + pathname: v.pathname, + port: v.port, + protocol: v.protocol, + search: v.search, + username: v.username, + }; +} + +export type SerializedURLMatch = { + glob?: string; + regexSource?: string; + regexFlags?: string; + urlPattern?: ReturnType; +}; + +export function serializeURLMatch(match: URLMatch): SerializedURLMatch | undefined { + if (isString(match)) return { glob: match }; + if (isRegExp(match)) return { regexSource: match.source, regexFlags: match.flags }; + if (isURLPattern(match)) return { urlPattern: serializeURLPattern(match) }; + // Functions cannot be serialized + return undefined; +} + +function deserializeURLPattern( + v: ReturnType +): URLPattern | ((url: URL) => boolean) { + // Client is on Node 24+ and can use URLPattern, Server is not. Let's match all URLs on the server, they'll be filtered again on the client. + // @ts-expect-error URLPattern is not in @types/node yet + // eslint-disable-next-line no-restricted-globals + if (typeof globalThis.URLPattern !== "function") return () => true; + + // @ts-expect-error URLPattern is not in @types/node yet + // eslint-disable-next-line no-restricted-globals + return new globalThis.URLPattern({ + hash: v.hash, + hostname: v.hostname, + password: v.password, + pathname: v.pathname, + port: v.port, + protocol: v.protocol, + search: v.search, + username: v.username, + }); +} + +export function deserializeURLMatch(match: { + glob?: string; + regexSource?: string; + regexFlags?: string; + urlPattern?: ReturnType; +}): URLMatch { + if (match.regexSource) return new RegExp(match.regexSource, match.regexFlags); + if (match.urlPattern) return deserializeURLPattern(match.urlPattern); + return match.glob!; +} + +export function urlMatchesEqual(match1: URLMatch, match2: URLMatch) { + if (isRegExp(match1) && isRegExp(match2)) + return match1.source === match2.source && match1.flags === match2.flags; + return match1 === match2; +} + +export function urlMatches( + baseURL: string | undefined, + urlString: string, + match: URLMatch | undefined, + webSocketUrl?: boolean +): boolean { + if (match === undefined || match === "") return true; + if (isString(match)) match = new RegExp(resolveGlobToRegexPattern(baseURL, match, webSocketUrl)); + if (isRegExp(match)) { + const r = match.test(urlString); + return r; + } + const url = parseURL(urlString); + if (!url) return false; + if (isURLPattern(match)) return match.test(url.href); + if (typeof match !== "function") + throw new Error("url parameter should be string, RegExp, URLPattern or function"); + return match(url); +} + +export function resolveGlobToRegexPattern( + baseURL: string | undefined, + glob: string, + webSocketUrl?: boolean +): string { + if (webSocketUrl) baseURL = toWebSocketBaseUrl(baseURL); + glob = resolveGlobBase(baseURL, glob); + return globToRegexPattern(glob); +} + +function toWebSocketBaseUrl(baseURL: string | undefined) { + // Allow http(s) baseURL to match ws(s) urls. + if (baseURL && /^https?:\/\//.test(baseURL)) baseURL = baseURL.replace(/^http/, "ws"); + return baseURL; +} + +function resolveGlobBase(baseURL: string | undefined, match: string): string { + if (!match.startsWith("*")) { + const tokenMap = new Map(); + function mapToken(original: string, replacement: string) { + if (original.length === 0) return ""; + tokenMap.set(replacement, original); + return replacement; + } + // Escaped `\\?` behaves the same as `?` in our glob patterns. + match = match.replaceAll(/\\\\\?/g, "?"); + // Special case about: URLs as they are not relative to baseURL + if ( + match.startsWith("about:") || + match.startsWith("data:") || + match.startsWith("chrome:") || + match.startsWith("edge:") || + match.startsWith("file:") + ) + return match; + // Glob symbols may be escaped in the URL and some of them such as ? affect resolution, + // so we replace them with safe components first. + const relativePath = match + .split("/") + .map((token, index) => { + if (token === "." || token === ".." || token === "") return token; + // Handle special case of http*://, note that the new schema has to be + // a web schema so that slashes are properly inserted after domain. + if (index === 0 && token.endsWith(":")) { + // Replace any pattern with http: + if (token.indexOf("*") !== -1 || token.indexOf("{") !== -1) + return mapToken(token, "http:"); + // Preserve explicit schema as is as it may affect trailing slashes after domain. + return token; + } + const questionIndex = token.indexOf("?"); + if (questionIndex === -1) return mapToken(token, `$_${index}_$`); + const newPrefix = mapToken(token.substring(0, questionIndex), `$_${index}_$`); + const newSuffix = mapToken(token.substring(questionIndex), `?$_${index}_$`); + return newPrefix + newSuffix; + }) + .join("/"); + const result = resolveBaseURL(baseURL, relativePath); + let resolved = result.resolved; + for (const [token, original] of tokenMap) { + const normalize = result.caseInsensitivePart?.includes(token); + resolved = resolved.replace(token, normalize ? original.toLowerCase() : original); + } + match = resolved; + } + return match; +} + +function parseURL(url: string): URL | null { + try { + return new URL(url); + } catch (e) { + return null; + } +} + +export function constructURLBasedOnBaseURL(baseURL: string | undefined, givenURL: string): string { + try { + return resolveBaseURL(baseURL, givenURL).resolved; + } catch (e) { + return givenURL; + } +} + +function resolveBaseURL(baseURL: string | undefined, givenURL: string) { + try { + const url = new URL(givenURL, baseURL); + const resolved = url.toString(); + // Schema and domain are case-insensitive. + const caseInsensitivePrefix = url.origin; + return { resolved, caseInsensitivePart: caseInsensitivePrefix }; + } catch (e) { + return { resolved: givenURL }; + } +} diff --git a/daemon/src/sandbox/forked-client/src/utils/isomorphic/utilityScriptSerializers.ts b/daemon/src/sandbox/forked-client/src/utils/isomorphic/utilityScriptSerializers.ts new file mode 100644 index 00000000..3f5cfb9a --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/utils/isomorphic/utilityScriptSerializers.ts @@ -0,0 +1,299 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +type TypedArrayKind = + | "i8" + | "ui8" + | "ui8c" + | "i16" + | "ui16" + | "i32" + | "ui32" + | "f32" + | "f64" + | "bi64" + | "bui64"; + +export type SerializedValue = + | undefined + | boolean + | number + | string + | { v: "null" | "undefined" | "NaN" | "Infinity" | "-Infinity" | "-0" } + | { d: string } + | { u: string } + | { bi: string } + | { e: { n: string; m: string; s: string } } + | { r: { p: string; f: string } } + | { a: SerializedValue[]; id: number } + | { o: { k: string; v: SerializedValue }[]; id: number } + | { ref: number } + | { h: number } + | { ta: { b: string; k: TypedArrayKind } } + | { ab: { b: string } }; + +type HandleOrValue = { h: number } | { fallThrough: any }; + +type VisitorInfo = { + visited: Map; + lastId: number; +}; + +function isRegExp(obj: any): obj is RegExp { + try { + return obj instanceof RegExp || Object.prototype.toString.call(obj) === "[object RegExp]"; + } catch (error) { + return false; + } +} + +function isDate(obj: any): obj is Date { + try { + // eslint-disable-next-line no-restricted-globals + return obj instanceof Date || Object.prototype.toString.call(obj) === "[object Date]"; + } catch (error) { + return false; + } +} + +function isURL(obj: any): obj is URL { + try { + return obj instanceof URL || Object.prototype.toString.call(obj) === "[object URL]"; + } catch (error) { + return false; + } +} + +function isError(obj: any): obj is Error { + try { + return obj instanceof Error || (obj && Object.getPrototypeOf(obj)?.name === "Error"); + } catch (error) { + return false; + } +} + +function isTypedArray(obj: any, constructor: Function): boolean { + try { + return ( + obj instanceof constructor || + Object.prototype.toString.call(obj) === `[object ${constructor.name}]` + ); + } catch (error) { + return false; + } +} + +function isArrayBuffer(obj: any): obj is ArrayBuffer { + try { + return ( + obj instanceof ArrayBuffer || Object.prototype.toString.call(obj) === "[object ArrayBuffer]" + ); + } catch (error) { + return false; + } +} + +const typedArrayConstructors: Record = { + i8: Int8Array, + ui8: Uint8Array, + ui8c: Uint8ClampedArray, + i16: Int16Array, + ui16: Uint16Array, + i32: Int32Array, + ui32: Uint32Array, + // TODO: add Float16Array once it's in baseline + f32: Float32Array, + f64: Float64Array, + bi64: BigInt64Array, + bui64: BigUint64Array, +}; + +function typedArrayToBase64(array: any) { + /** + * Firefox does not support iterating over typed arrays, so we use `.toBase64`. + * Error: 'Accessing TypedArray data over Xrays is slow, and forbidden in order to encourage performant code. To copy TypedArrays across origin boundaries, consider using Components.utils.cloneInto().' + */ + if ("toBase64" in array) return array.toBase64(); + const binary = Array.from(new Uint8Array(array.buffer, array.byteOffset, array.byteLength)) + .map((b) => String.fromCharCode(b)) + .join(""); + return btoa(binary); +} + +function base64ToTypedArray(base64: string, TypedArrayConstructor: any) { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return new TypedArrayConstructor(bytes.buffer); +} + +export function parseEvaluationResultValue( + value: SerializedValue, + handles: any[] = [], + refs: Map = new Map() +): any { + if (Object.is(value, undefined)) return undefined; + if (typeof value === "object" && value) { + if ("ref" in value) return refs.get(value.ref); + if ("v" in value) { + if (value.v === "undefined") return undefined; + if (value.v === "null") return null; + if (value.v === "NaN") return NaN; + if (value.v === "Infinity") return Infinity; + if (value.v === "-Infinity") return -Infinity; + if (value.v === "-0") return -0; + return undefined; + } + if ("d" in value) { + // eslint-disable-next-line no-restricted-globals + return new Date(value.d); + } + if ("u" in value) return new URL(value.u); + if ("bi" in value) return BigInt(value.bi); + if ("e" in value) { + const error = new Error(value.e.m); + error.name = value.e.n; + error.stack = value.e.s; + return error; + } + if ("r" in value) return new RegExp(value.r.p, value.r.f); + if ("a" in value) { + const result: any[] = []; + refs.set(value.id, result); + for (const a of value.a) result.push(parseEvaluationResultValue(a, handles, refs)); + return result; + } + if ("o" in value) { + const result: any = {}; + refs.set(value.id, result); + for (const { k, v } of value.o) { + if (k === "__proto__") continue; + result[k] = parseEvaluationResultValue(v, handles, refs); + } + return result; + } + if ("h" in value) return handles[value.h]; + if ("ta" in value) return base64ToTypedArray(value.ta.b, typedArrayConstructors[value.ta.k]); + if ("ab" in value) return base64ToTypedArray(value.ab.b, Uint8Array).buffer; + } + return value; +} + +export function serializeAsCallArgument( + value: any, + handleSerializer: (value: any) => HandleOrValue +): SerializedValue { + return serialize(value, handleSerializer, { visited: new Map(), lastId: 0 }); +} + +function serialize( + value: any, + handleSerializer: (value: any) => HandleOrValue, + visitorInfo: VisitorInfo +): SerializedValue { + if (value && typeof value === "object") { + // eslint-disable-next-line no-restricted-globals + if (typeof globalThis.Window === "function" && value instanceof globalThis.Window) + return "ref: "; + // eslint-disable-next-line no-restricted-globals + if (typeof globalThis.Document === "function" && value instanceof globalThis.Document) + return "ref: "; + // eslint-disable-next-line no-restricted-globals + if (typeof globalThis.Node === "function" && value instanceof globalThis.Node) + return "ref: "; + } + return innerSerialize(value, handleSerializer, visitorInfo); +} + +function innerSerialize( + value: any, + handleSerializer: (value: any) => HandleOrValue, + visitorInfo: VisitorInfo +): SerializedValue { + const result = handleSerializer(value); + if ("fallThrough" in result) value = result.fallThrough; + else return result; + + if (typeof value === "symbol") return { v: "undefined" }; + if (Object.is(value, undefined)) return { v: "undefined" }; + if (Object.is(value, null)) return { v: "null" }; + if (Object.is(value, NaN)) return { v: "NaN" }; + if (Object.is(value, Infinity)) return { v: "Infinity" }; + if (Object.is(value, -Infinity)) return { v: "-Infinity" }; + if (Object.is(value, -0)) return { v: "-0" }; + + if (typeof value === "boolean") return value; + if (typeof value === "number") return value; + if (typeof value === "string") return value; + if (typeof value === "bigint") return { bi: value.toString() }; + + if (isError(value)) { + let stack; + if (value.stack?.startsWith(value.name + ": " + value.message)) { + // v8 + stack = value.stack; + } else { + stack = `${value.name}: ${value.message}\n${value.stack}`; + } + return { e: { n: value.name, m: value.message, s: stack } }; + } + if (isDate(value)) return { d: value.toJSON() }; + if (isURL(value)) return { u: value.toJSON() }; + if (isRegExp(value)) return { r: { p: value.source, f: value.flags } }; + for (const [k, ctor] of Object.entries(typedArrayConstructors) as [TypedArrayKind, Function][]) { + if (isTypedArray(value, ctor)) return { ta: { b: typedArrayToBase64(value), k } }; + } + if (isArrayBuffer(value)) return { ab: { b: typedArrayToBase64(new Uint8Array(value)) } }; + + const id = visitorInfo.visited.get(value); + if (id) return { ref: id }; + + if (Array.isArray(value)) { + const a = []; + const id = ++visitorInfo.lastId; + visitorInfo.visited.set(value, id); + for (let i = 0; i < value.length; ++i) + a.push(serialize(value[i], handleSerializer, visitorInfo)); + return { a, id }; + } + + if (typeof value === "object") { + const o: { k: string; v: SerializedValue }[] = []; + const id = ++visitorInfo.lastId; + visitorInfo.visited.set(value, id); + for (const name of Object.keys(value)) { + let item; + try { + item = value[name]; + } catch (e) { + continue; // native bindings will throw sometimes + } + if (name === "toJSON" && typeof item === "function") o.push({ k: name, v: { o: [], id: 0 } }); + else o.push({ k: name, v: serialize(item, handleSerializer, visitorInfo) }); + } + + let jsonWrapper; + try { + // If Object.keys().length === 0 we fall back to toJSON if it exists + if (o.length === 0 && value.toJSON && typeof value.toJSON === "function") + jsonWrapper = { value: value.toJSON() }; + } catch (e) {} + if (jsonWrapper) return innerSerialize(jsonWrapper.value, handleSerializer, visitorInfo); + + return { o, id }; + } +} diff --git a/daemon/src/sandbox/forked-client/src/utils/isomorphic/yaml.ts b/daemon/src/sandbox/forked-client/src/utils/isomorphic/yaml.ts new file mode 100644 index 00000000..25cb88a9 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/utils/isomorphic/yaml.ts @@ -0,0 +1,90 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export function yamlEscapeKeyIfNeeded(str: string): string { + if (!yamlStringNeedsQuotes(str)) return str; + return `'` + str.replace(/'/g, `''`) + `'`; +} + +export function yamlEscapeValueIfNeeded(str: string): string { + if (!yamlStringNeedsQuotes(str)) return str; + return ( + '"' + + str.replace(/[\\"\x00-\x1f\x7f-\x9f]/g, (c) => { + switch (c) { + case "\\": + return "\\\\"; + case '"': + return '\\"'; + case "\b": + return "\\b"; + case "\f": + return "\\f"; + case "\n": + return "\\n"; + case "\r": + return "\\r"; + case "\t": + return "\\t"; + default: + const code = c.charCodeAt(0); + return "\\x" + code.toString(16).padStart(2, "0"); + } + }) + + '"' + ); +} + +function yamlStringNeedsQuotes(str: string): boolean { + if (str.length === 0) return true; + + // Strings with leading or trailing whitespace need quotes + if (/^\s|\s$/.test(str)) return true; + + // Strings containing control characters need quotes + if (/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]/.test(str)) return true; + + // Strings starting with '-' need quotes + if (/^-/.test(str)) return true; + + // Strings containing ':' or '\n' followed by a space or at the end need quotes + if (/[\n:](\s|$)/.test(str)) return true; + + // Strings containing '#' preceded by a space need quotes (comment indicator) + if (/\s#/.test(str)) return true; + + // Strings that contain line breaks need quotes + if (/[\n\r]/.test(str)) return true; + + // Strings starting with indicator characters or quotes need quotes + if (/^[&*\],?!>|@"'#%]/.test(str)) return true; + + // Strings containing special characters that could cause ambiguity + if (/[{}`]/.test(str)) return true; + + // YAML array starts with [ + if (/^\[/.test(str)) return true; + + // Non-string types recognized by YAML + if ( + !isNaN(Number(str)) || + ["y", "n", "yes", "no", "true", "false", "on", "off", "null"].includes(str.toLowerCase()) + ) + return true; + + return false; +} diff --git a/daemon/src/sandbox/forked-client/types/protocol.d.ts b/daemon/src/sandbox/forked-client/types/protocol.d.ts new file mode 100644 index 00000000..f8f3fc1b --- /dev/null +++ b/daemon/src/sandbox/forked-client/types/protocol.d.ts @@ -0,0 +1,24798 @@ +// This is generated from /utils/protocol-types-generator/index.js +type binary = string; +export namespace Protocol { + export namespace Accessibility { + /** + * Unique accessibility node identifier. + */ + export type AXNodeId = string; + /** + * Enum of possible property types. + */ + export type AXValueType = + | "boolean" + | "tristate" + | "booleanOrUndefined" + | "idref" + | "idrefList" + | "integer" + | "node" + | "nodeList" + | "number" + | "string" + | "computedString" + | "token" + | "tokenList" + | "domRelation" + | "role" + | "internalRole" + | "valueUndefined"; + /** + * Enum of possible property sources. + */ + export type AXValueSourceType = + | "attribute" + | "implicit" + | "style" + | "contents" + | "placeholder" + | "relatedElement"; + /** + * Enum of possible native property sources (as a subtype of a particular AXValueSourceType). + */ + export type AXValueNativeSourceType = + | "description" + | "figcaption" + | "label" + | "labelfor" + | "labelwrapped" + | "legend" + | "rubyannotation" + | "tablecaption" + | "title" + | "other"; + /** + * A single source for a computed AX property. + */ + export interface AXValueSource { + /** + * What type of source this is. + */ + type: AXValueSourceType; + /** + * The value of this property source. + */ + value?: AXValue; + /** + * The name of the relevant attribute, if any. + */ + attribute?: string; + /** + * The value of the relevant attribute, if any. + */ + attributeValue?: AXValue; + /** + * Whether this source is superseded by a higher priority source. + */ + superseded?: boolean; + /** + * The native markup source for this value, e.g. a `