SuperCharge Claude Code v1.0.0 - Complete Customization Package
Features: - 30+ Custom Skills (cognitive, development, UI/UX, autonomous agents) - RalphLoop autonomous agent integration - Multi-AI consultation (Qwen) - Agent management system with sync capabilities - Custom hooks for session management - MCP servers integration - Plugin marketplace setup - Comprehensive installation script Components: - Skills: always-use-superpowers, ralph, brainstorming, ui-ux-pro-max, etc. - Agents: 100+ agents across engineering, marketing, product, etc. - Hooks: session-start-superpowers, qwen-consult, ralph-auto-trigger - Commands: /brainstorm, /write-plan, /execute-plan - MCP Servers: zai-mcp-server, web-search-prime, web-reader, zread - Binaries: ralphloop wrapper Installation: ./supercharge.sh
This commit is contained in:
13
skills/dev-browser/CHANGELOG.md
Normal file
13
skills/dev-browser/CHANGELOG.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# Changelog
|
||||
|
||||
## [1.0.1] - 2025-12-10
|
||||
|
||||
### Added
|
||||
|
||||
- Support for headless mode
|
||||
|
||||
## [1.0.0] - 2025-12-10
|
||||
|
||||
### Added
|
||||
|
||||
- Initial release
|
||||
102
skills/dev-browser/CLAUDE.md
Normal file
102
skills/dev-browser/CLAUDE.md
Normal file
@@ -0,0 +1,102 @@
|
||||
# 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
|
||||
25
skills/dev-browser/CONTRIBUTING.md
Normal file
25
skills/dev-browser/CONTRIBUTING.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# Contributing to dev-browser
|
||||
|
||||
Thank you for your interest in contributing!
|
||||
|
||||
## Before You Start
|
||||
|
||||
**Please open an issue before submitting a pull request.** This helps us:
|
||||
|
||||
- Discuss whether the change aligns with the project's direction
|
||||
- Avoid duplicate work if someone else is already working on it
|
||||
- Provide guidance on implementation approach
|
||||
|
||||
For bug reports, include steps to reproduce. For feature requests, explain the use case.
|
||||
|
||||
## Pull Request Process
|
||||
|
||||
1. Open an issue describing the proposed change
|
||||
2. Wait for maintainer feedback before starting work
|
||||
3. Fork the repo and create a branch from `main`
|
||||
4. Make your changes, ensuring tests pass (`npm test`) and types check (`npx tsc --noEmit`)
|
||||
5. Submit a PR referencing the related issue
|
||||
|
||||
## Questions?
|
||||
|
||||
Open an issue with your question - we're happy to help.
|
||||
21
skills/dev-browser/LICENSE
Normal file
21
skills/dev-browser/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Sawyer Hood
|
||||
|
||||
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.
|
||||
116
skills/dev-browser/README.md
Normal file
116
skills/dev-browser/README.md
Normal file
@@ -0,0 +1,116 @@
|
||||
<p align="center">
|
||||
<img src="assets/header.png" alt="Dev Browser - Browser automation for Claude Code" width="100%">
|
||||
</p>
|
||||
|
||||
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.
|
||||
|
||||
**Key features:**
|
||||
|
||||
- **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
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code) CLI installed
|
||||
- [Node.js](https://nodejs.org) (v18 or later) with npm
|
||||
|
||||
## Installation
|
||||
|
||||
### Claude Code
|
||||
|
||||
```
|
||||
/plugin marketplace add sawyerhood/dev-browser
|
||||
/plugin install dev-browser@sawyerhood/dev-browser
|
||||
```
|
||||
|
||||
Restart Claude Code after installation.
|
||||
|
||||
### Amp / Codex
|
||||
|
||||
Copy the skill to your skills directory:
|
||||
|
||||
```bash
|
||||
# For Amp: ~/.claude/skills | For Codex: ~/.codex/skills
|
||||
SKILLS_DIR=~/.claude/skills # or ~/.codex/skills
|
||||
|
||||
mkdir -p $SKILLS_DIR
|
||||
git clone https://github.com/sawyerhood/dev-browser /tmp/dev-browser-skill
|
||||
cp -r /tmp/dev-browser-skill/skills/dev-browser $SKILLS_DIR/dev-browser
|
||||
rm -rf /tmp/dev-browser-skill
|
||||
```
|
||||
|
||||
**Amp only:** Start the server manually before use:
|
||||
|
||||
```bash
|
||||
cd ~/.claude/skills/dev-browser && npm install && npm run start-server
|
||||
```
|
||||
|
||||
### Chrome Extension (Optional)
|
||||
|
||||
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.
|
||||
|
||||
**Installation:**
|
||||
|
||||
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
|
||||
|
||||
**Using the extension:**
|
||||
|
||||
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")
|
||||
|
||||
When active, Claude can control your existing Chrome tabs with all your logged-in sessions, cookies, and extensions intact.
|
||||
|
||||
## Permissions
|
||||
|
||||
To skip permission prompts, add to `~/.claude/settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"permissions": {
|
||||
"allow": ["Skill(dev-browser:dev-browser)", "Bash(npx tsx:*)"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Or run with `claude --dangerously-skip-permissions` (skips all prompts).
|
||||
|
||||
## Usage
|
||||
|
||||
Just ask Claude to interact with your browser:
|
||||
|
||||
> "Open localhost:3000 and verify the signup flow works"
|
||||
|
||||
> "Go to the settings page and figure out why the save button isn't working"
|
||||
|
||||
## Benchmarks
|
||||
|
||||
| Method | Time | Cost | Turns | Success |
|
||||
| ----------------------- | ------- | ----- | ----- | ------- |
|
||||
| **Dev Browser** | 3m 53s | $0.88 | 29 | 100% |
|
||||
| Playwright MCP | 4m 31s | $1.45 | 51 | 100% |
|
||||
| Playwright Skill | 8m 07s | $1.45 | 38 | 67% |
|
||||
| Claude Chrome Extension | 12m 54s | $2.81 | 80 | 100% |
|
||||
|
||||
_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
|
||||
|
||||
## Author
|
||||
|
||||
[Sawyer Hood](https://github.com/sawyerhood)
|
||||
BIN
skills/dev-browser/assets/header.png
Normal file
BIN
skills/dev-browser/assets/header.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 77 KiB |
101
skills/dev-browser/bun.lock
Normal file
101
skills/dev-browser/bun.lock
Normal file
@@ -0,0 +1,101 @@
|
||||
{
|
||||
"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=="],
|
||||
}
|
||||
}
|
||||
211
skills/dev-browser/extension/__tests__/CDPRouter.test.ts
Normal file
211
skills/dev-browser/extension/__tests__/CDPRouter.test.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { fakeBrowser } from "wxt/testing";
|
||||
import { CDPRouter } from "../services/CDPRouter";
|
||||
import { TabManager } from "../services/TabManager";
|
||||
import type { Logger } from "../utils/logger";
|
||||
import type { ExtensionCommandMessage } from "../utils/types";
|
||||
|
||||
// Mock chrome.debugger since fakeBrowser doesn't include it
|
||||
const mockDebuggerSendCommand = vi.fn();
|
||||
|
||||
vi.stubGlobal("chrome", {
|
||||
...fakeBrowser,
|
||||
debugger: {
|
||||
sendCommand: mockDebuggerSendCommand,
|
||||
attach: vi.fn(),
|
||||
detach: vi.fn(),
|
||||
onEvent: { addListener: vi.fn(), hasListener: vi.fn() },
|
||||
onDetach: { addListener: vi.fn(), hasListener: vi.fn() },
|
||||
getTargets: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
});
|
||||
|
||||
describe("CDPRouter", () => {
|
||||
let cdpRouter: CDPRouter;
|
||||
let tabManager: TabManager;
|
||||
let mockLogger: Logger;
|
||||
let mockSendMessage: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
fakeBrowser.reset();
|
||||
mockDebuggerSendCommand.mockReset();
|
||||
|
||||
mockLogger = {
|
||||
log: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
|
||||
mockSendMessage = vi.fn();
|
||||
|
||||
tabManager = new TabManager({
|
||||
logger: mockLogger,
|
||||
sendMessage: mockSendMessage,
|
||||
});
|
||||
|
||||
cdpRouter = new CDPRouter({
|
||||
logger: mockLogger,
|
||||
tabManager,
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleCommand", () => {
|
||||
it("should return early for non-forwardCDPCommand methods", async () => {
|
||||
const msg = {
|
||||
id: 1,
|
||||
method: "someOtherMethod" as const,
|
||||
params: { method: "Test.method" },
|
||||
};
|
||||
|
||||
// @ts-expect-error - testing invalid method
|
||||
const result = await cdpRouter.handleCommand(msg);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should throw error when no tab found for command", async () => {
|
||||
const msg: ExtensionCommandMessage = {
|
||||
id: 1,
|
||||
method: "forwardCDPCommand",
|
||||
params: {
|
||||
method: "Page.navigate",
|
||||
sessionId: "unknown-session",
|
||||
},
|
||||
};
|
||||
|
||||
await expect(cdpRouter.handleCommand(msg)).rejects.toThrow(
|
||||
"No tab found for method Page.navigate"
|
||||
);
|
||||
});
|
||||
|
||||
it("should find tab by sessionId", async () => {
|
||||
tabManager.set(123, {
|
||||
sessionId: "session-1",
|
||||
targetId: "target-1",
|
||||
state: "connected",
|
||||
});
|
||||
|
||||
mockDebuggerSendCommand.mockResolvedValue({ result: "ok" });
|
||||
|
||||
const msg: ExtensionCommandMessage = {
|
||||
id: 1,
|
||||
method: "forwardCDPCommand",
|
||||
params: {
|
||||
method: "Page.navigate",
|
||||
sessionId: "session-1",
|
||||
params: { url: "https://example.com" },
|
||||
},
|
||||
};
|
||||
|
||||
await cdpRouter.handleCommand(msg);
|
||||
|
||||
expect(mockDebuggerSendCommand).toHaveBeenCalledWith(
|
||||
{ tabId: 123, sessionId: undefined },
|
||||
"Page.navigate",
|
||||
{ url: "https://example.com" }
|
||||
);
|
||||
});
|
||||
|
||||
it("should find tab via child session", async () => {
|
||||
tabManager.set(123, {
|
||||
sessionId: "parent-session",
|
||||
targetId: "target-1",
|
||||
state: "connected",
|
||||
});
|
||||
tabManager.trackChildSession("child-session", 123);
|
||||
|
||||
mockDebuggerSendCommand.mockResolvedValue({});
|
||||
|
||||
const msg: ExtensionCommandMessage = {
|
||||
id: 1,
|
||||
method: "forwardCDPCommand",
|
||||
params: {
|
||||
method: "Runtime.evaluate",
|
||||
sessionId: "child-session",
|
||||
},
|
||||
};
|
||||
|
||||
await cdpRouter.handleCommand(msg);
|
||||
|
||||
expect(mockDebuggerSendCommand).toHaveBeenCalledWith(
|
||||
{ tabId: 123, sessionId: "child-session" },
|
||||
"Runtime.evaluate",
|
||||
undefined
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleDebuggerEvent", () => {
|
||||
it("should forward CDP events to relay", () => {
|
||||
tabManager.set(123, {
|
||||
sessionId: "session-1",
|
||||
targetId: "target-1",
|
||||
state: "connected",
|
||||
});
|
||||
|
||||
const sendMessage = vi.fn();
|
||||
|
||||
cdpRouter.handleDebuggerEvent(
|
||||
{ tabId: 123 },
|
||||
"Page.loadEventFired",
|
||||
{ timestamp: 12345 },
|
||||
sendMessage
|
||||
);
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledWith({
|
||||
method: "forwardCDPEvent",
|
||||
params: {
|
||||
sessionId: "session-1",
|
||||
method: "Page.loadEventFired",
|
||||
params: { timestamp: 12345 },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should track child sessions on Target.attachedToTarget", () => {
|
||||
tabManager.set(123, {
|
||||
sessionId: "session-1",
|
||||
targetId: "target-1",
|
||||
state: "connected",
|
||||
});
|
||||
|
||||
const sendMessage = vi.fn();
|
||||
|
||||
cdpRouter.handleDebuggerEvent(
|
||||
{ tabId: 123 },
|
||||
"Target.attachedToTarget",
|
||||
{ sessionId: "new-child-session", targetInfo: {} },
|
||||
sendMessage
|
||||
);
|
||||
|
||||
expect(tabManager.getParentTabId("new-child-session")).toBe(123);
|
||||
});
|
||||
|
||||
it("should untrack child sessions on Target.detachedFromTarget", () => {
|
||||
tabManager.set(123, {
|
||||
sessionId: "session-1",
|
||||
targetId: "target-1",
|
||||
state: "connected",
|
||||
});
|
||||
tabManager.trackChildSession("child-session", 123);
|
||||
|
||||
const sendMessage = vi.fn();
|
||||
|
||||
cdpRouter.handleDebuggerEvent(
|
||||
{ tabId: 123 },
|
||||
"Target.detachedFromTarget",
|
||||
{ sessionId: "child-session" },
|
||||
sendMessage
|
||||
);
|
||||
|
||||
expect(tabManager.getParentTabId("child-session")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should ignore events for unknown tabs", () => {
|
||||
const sendMessage = vi.fn();
|
||||
|
||||
cdpRouter.handleDebuggerEvent({ tabId: 999 }, "Page.loadEventFired", {}, sendMessage);
|
||||
|
||||
expect(sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
45
skills/dev-browser/extension/__tests__/StateManager.test.ts
Normal file
45
skills/dev-browser/extension/__tests__/StateManager.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { fakeBrowser } from "wxt/testing";
|
||||
import { StateManager } from "../services/StateManager";
|
||||
|
||||
describe("StateManager", () => {
|
||||
let stateManager: StateManager;
|
||||
|
||||
beforeEach(() => {
|
||||
fakeBrowser.reset();
|
||||
stateManager = new StateManager();
|
||||
});
|
||||
|
||||
describe("getState", () => {
|
||||
it("should return default inactive state when no stored state", async () => {
|
||||
const state = await stateManager.getState();
|
||||
expect(state).toEqual({ isActive: false });
|
||||
});
|
||||
|
||||
it("should return stored state when available", async () => {
|
||||
await fakeBrowser.storage.local.set({
|
||||
devBrowserActiveState: { isActive: true },
|
||||
});
|
||||
|
||||
const state = await stateManager.getState();
|
||||
expect(state).toEqual({ isActive: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("setState", () => {
|
||||
it("should persist state to storage", async () => {
|
||||
await stateManager.setState({ isActive: true });
|
||||
|
||||
const stored = await fakeBrowser.storage.local.get("devBrowserActiveState");
|
||||
expect(stored.devBrowserActiveState).toEqual({ isActive: true });
|
||||
});
|
||||
|
||||
it("should update state from active to inactive", async () => {
|
||||
await stateManager.setState({ isActive: true });
|
||||
await stateManager.setState({ isActive: false });
|
||||
|
||||
const state = await stateManager.getState();
|
||||
expect(state).toEqual({ isActive: false });
|
||||
});
|
||||
});
|
||||
});
|
||||
170
skills/dev-browser/extension/__tests__/TabManager.test.ts
Normal file
170
skills/dev-browser/extension/__tests__/TabManager.test.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { fakeBrowser } from "wxt/testing";
|
||||
import { TabManager } from "../services/TabManager";
|
||||
import type { Logger } from "../utils/logger";
|
||||
|
||||
describe("TabManager", () => {
|
||||
let tabManager: TabManager;
|
||||
let mockLogger: Logger;
|
||||
let mockSendMessage: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
fakeBrowser.reset();
|
||||
|
||||
mockLogger = {
|
||||
log: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
|
||||
mockSendMessage = vi.fn();
|
||||
|
||||
tabManager = new TabManager({
|
||||
logger: mockLogger,
|
||||
sendMessage: mockSendMessage,
|
||||
});
|
||||
});
|
||||
|
||||
describe("getBySessionId", () => {
|
||||
it("should return undefined when no tabs exist", () => {
|
||||
const result = tabManager.getBySessionId("session-1");
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should find tab by session ID", () => {
|
||||
tabManager.set(123, {
|
||||
sessionId: "session-1",
|
||||
targetId: "target-1",
|
||||
state: "connected",
|
||||
});
|
||||
|
||||
const result = tabManager.getBySessionId("session-1");
|
||||
expect(result).toEqual({
|
||||
tabId: 123,
|
||||
tab: {
|
||||
sessionId: "session-1",
|
||||
targetId: "target-1",
|
||||
state: "connected",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getByTargetId", () => {
|
||||
it("should return undefined when no tabs exist", () => {
|
||||
const result = tabManager.getByTargetId("target-1");
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should find tab by target ID", () => {
|
||||
tabManager.set(456, {
|
||||
sessionId: "session-2",
|
||||
targetId: "target-2",
|
||||
state: "connected",
|
||||
});
|
||||
|
||||
const result = tabManager.getByTargetId("target-2");
|
||||
expect(result).toEqual({
|
||||
tabId: 456,
|
||||
tab: {
|
||||
sessionId: "session-2",
|
||||
targetId: "target-2",
|
||||
state: "connected",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("child sessions", () => {
|
||||
it("should track child sessions", () => {
|
||||
tabManager.trackChildSession("child-session-1", 123);
|
||||
expect(tabManager.getParentTabId("child-session-1")).toBe(123);
|
||||
});
|
||||
|
||||
it("should untrack child sessions", () => {
|
||||
tabManager.trackChildSession("child-session-1", 123);
|
||||
tabManager.untrackChildSession("child-session-1");
|
||||
expect(tabManager.getParentTabId("child-session-1")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("set/get/has", () => {
|
||||
it("should set and get tab info", () => {
|
||||
tabManager.set(789, { state: "connecting" });
|
||||
expect(tabManager.get(789)).toEqual({ state: "connecting" });
|
||||
expect(tabManager.has(789)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return undefined for unknown tabs", () => {
|
||||
expect(tabManager.get(999)).toBeUndefined();
|
||||
expect(tabManager.has(999)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("detach", () => {
|
||||
it("should send detached event and remove tab", () => {
|
||||
tabManager.set(123, {
|
||||
sessionId: "session-1",
|
||||
targetId: "target-1",
|
||||
state: "connected",
|
||||
});
|
||||
|
||||
tabManager.detach(123, false);
|
||||
|
||||
expect(mockSendMessage).toHaveBeenCalledWith({
|
||||
method: "forwardCDPEvent",
|
||||
params: {
|
||||
method: "Target.detachedFromTarget",
|
||||
params: { sessionId: "session-1", targetId: "target-1" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(tabManager.has(123)).toBe(false);
|
||||
});
|
||||
|
||||
it("should clean up child sessions when detaching", () => {
|
||||
tabManager.set(123, {
|
||||
sessionId: "session-1",
|
||||
targetId: "target-1",
|
||||
state: "connected",
|
||||
});
|
||||
tabManager.trackChildSession("child-1", 123);
|
||||
tabManager.trackChildSession("child-2", 123);
|
||||
|
||||
tabManager.detach(123, false);
|
||||
|
||||
expect(tabManager.getParentTabId("child-1")).toBeUndefined();
|
||||
expect(tabManager.getParentTabId("child-2")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should do nothing for unknown tabs", () => {
|
||||
tabManager.detach(999, false);
|
||||
expect(mockSendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("clear", () => {
|
||||
it("should clear all tabs and child sessions", () => {
|
||||
tabManager.set(1, { state: "connected" });
|
||||
tabManager.set(2, { state: "connected" });
|
||||
tabManager.trackChildSession("child-1", 1);
|
||||
|
||||
tabManager.clear();
|
||||
|
||||
expect(tabManager.has(1)).toBe(false);
|
||||
expect(tabManager.has(2)).toBe(false);
|
||||
expect(tabManager.getParentTabId("child-1")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAllTabIds", () => {
|
||||
it("should return all tab IDs", () => {
|
||||
tabManager.set(1, { state: "connected" });
|
||||
tabManager.set(2, { state: "connecting" });
|
||||
tabManager.set(3, { state: "error" });
|
||||
|
||||
const ids = tabManager.getAllTabIds();
|
||||
expect(ids).toEqual([1, 2, 3]);
|
||||
});
|
||||
});
|
||||
});
|
||||
119
skills/dev-browser/extension/__tests__/logger.test.ts
Normal file
119
skills/dev-browser/extension/__tests__/logger.test.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { createLogger } from "../utils/logger";
|
||||
|
||||
describe("createLogger", () => {
|
||||
let mockSendMessage: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockSendMessage = vi.fn();
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
vi.spyOn(console, "debug").mockImplementation(() => {});
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
describe("log", () => {
|
||||
it("should log to console and send message", () => {
|
||||
const logger = createLogger(mockSendMessage);
|
||||
logger.log("test message", 123);
|
||||
|
||||
expect(console.log).toHaveBeenCalledWith("[dev-browser]", "test message", 123);
|
||||
expect(mockSendMessage).toHaveBeenCalledWith({
|
||||
method: "log",
|
||||
params: {
|
||||
level: "log",
|
||||
args: ["test message", "123"],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("debug", () => {
|
||||
it("should debug to console and send message", () => {
|
||||
const logger = createLogger(mockSendMessage);
|
||||
logger.debug("debug info");
|
||||
|
||||
expect(console.debug).toHaveBeenCalledWith("[dev-browser]", "debug info");
|
||||
expect(mockSendMessage).toHaveBeenCalledWith({
|
||||
method: "log",
|
||||
params: {
|
||||
level: "debug",
|
||||
args: ["debug info"],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("error", () => {
|
||||
it("should error to console and send message", () => {
|
||||
const logger = createLogger(mockSendMessage);
|
||||
logger.error("error occurred");
|
||||
|
||||
expect(console.error).toHaveBeenCalledWith("[dev-browser]", "error occurred");
|
||||
expect(mockSendMessage).toHaveBeenCalledWith({
|
||||
method: "log",
|
||||
params: {
|
||||
level: "error",
|
||||
args: ["error occurred"],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("argument formatting", () => {
|
||||
it("should format undefined as string", () => {
|
||||
const logger = createLogger(mockSendMessage);
|
||||
logger.log(undefined);
|
||||
|
||||
expect(mockSendMessage).toHaveBeenCalledWith({
|
||||
method: "log",
|
||||
params: {
|
||||
level: "log",
|
||||
args: ["undefined"],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should format null as string", () => {
|
||||
const logger = createLogger(mockSendMessage);
|
||||
logger.log(null);
|
||||
|
||||
expect(mockSendMessage).toHaveBeenCalledWith({
|
||||
method: "log",
|
||||
params: {
|
||||
level: "log",
|
||||
args: ["null"],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should JSON stringify objects", () => {
|
||||
const logger = createLogger(mockSendMessage);
|
||||
logger.log({ key: "value" });
|
||||
|
||||
expect(mockSendMessage).toHaveBeenCalledWith({
|
||||
method: "log",
|
||||
params: {
|
||||
level: "log",
|
||||
args: ['{"key":"value"}'],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle circular objects gracefully", () => {
|
||||
const logger = createLogger(mockSendMessage);
|
||||
const circular: Record<string, unknown> = { a: 1 };
|
||||
circular.self = circular;
|
||||
|
||||
logger.log(circular);
|
||||
|
||||
// Should fall back to String() when JSON.stringify fails
|
||||
expect(mockSendMessage).toHaveBeenCalledWith({
|
||||
method: "log",
|
||||
params: {
|
||||
level: "log",
|
||||
args: ["[object Object]"],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
174
skills/dev-browser/extension/entrypoints/background.ts
Normal file
174
skills/dev-browser/extension/entrypoints/background.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* dev-browser Chrome Extension Background Script
|
||||
*
|
||||
* This extension connects to the dev-browser relay server and allows
|
||||
* Playwright automation of the user's existing browser tabs.
|
||||
*/
|
||||
|
||||
import { createLogger } from "../utils/logger";
|
||||
import { TabManager } from "../services/TabManager";
|
||||
import { ConnectionManager } from "../services/ConnectionManager";
|
||||
import { CDPRouter } from "../services/CDPRouter";
|
||||
import { StateManager } from "../services/StateManager";
|
||||
import type { PopupMessage, StateResponse } from "../utils/types";
|
||||
|
||||
export default defineBackground(() => {
|
||||
// Create connection manager first (needed for sendMessage)
|
||||
let connectionManager: ConnectionManager;
|
||||
|
||||
// Create logger with sendMessage function
|
||||
const logger = createLogger((msg) => connectionManager?.send(msg));
|
||||
|
||||
// Create state manager for persistence
|
||||
const stateManager = new StateManager();
|
||||
|
||||
// Create tab manager
|
||||
const tabManager = new TabManager({
|
||||
logger,
|
||||
sendMessage: (msg) => connectionManager.send(msg),
|
||||
});
|
||||
|
||||
// Create CDP router
|
||||
const cdpRouter = new CDPRouter({
|
||||
logger,
|
||||
tabManager,
|
||||
});
|
||||
|
||||
// Create connection manager
|
||||
connectionManager = new ConnectionManager({
|
||||
logger,
|
||||
onMessage: (msg) => cdpRouter.handleCommand(msg),
|
||||
onDisconnect: () => tabManager.detachAll(),
|
||||
});
|
||||
|
||||
// Keep-alive alarm name for Chrome Alarms API
|
||||
const KEEPALIVE_ALARM = "keepAlive";
|
||||
|
||||
// Update badge to show active/inactive state
|
||||
function updateBadge(isActive: boolean): void {
|
||||
chrome.action.setBadgeText({ text: isActive ? "ON" : "" });
|
||||
chrome.action.setBadgeBackgroundColor({ color: "#4CAF50" });
|
||||
}
|
||||
|
||||
// Handle state changes
|
||||
async function handleStateChange(isActive: boolean): Promise<void> {
|
||||
await stateManager.setState({ isActive });
|
||||
if (isActive) {
|
||||
chrome.alarms.create(KEEPALIVE_ALARM, { periodInMinutes: 0.5 });
|
||||
connectionManager.startMaintaining();
|
||||
} else {
|
||||
chrome.alarms.clear(KEEPALIVE_ALARM);
|
||||
connectionManager.disconnect();
|
||||
}
|
||||
updateBadge(isActive);
|
||||
}
|
||||
|
||||
// Handle debugger events
|
||||
function onDebuggerEvent(
|
||||
source: chrome.debugger.DebuggerSession,
|
||||
method: string,
|
||||
params: unknown
|
||||
): void {
|
||||
cdpRouter.handleDebuggerEvent(source, method, params, (msg) => connectionManager.send(msg));
|
||||
}
|
||||
|
||||
function onDebuggerDetach(
|
||||
source: chrome.debugger.Debuggee,
|
||||
reason: `${chrome.debugger.DetachReason}`
|
||||
): void {
|
||||
const tabId = source.tabId;
|
||||
if (!tabId) return;
|
||||
|
||||
logger.debug(`Debugger detached for tab ${tabId}: ${reason}`);
|
||||
tabManager.handleDebuggerDetach(tabId);
|
||||
}
|
||||
|
||||
// Handle messages from popup
|
||||
chrome.runtime.onMessage.addListener(
|
||||
(
|
||||
message: PopupMessage,
|
||||
_sender: chrome.runtime.MessageSender,
|
||||
sendResponse: (response: StateResponse) => void
|
||||
) => {
|
||||
if (message.type === "getState") {
|
||||
(async () => {
|
||||
const state = await stateManager.getState();
|
||||
const isConnected = await connectionManager.checkConnection();
|
||||
sendResponse({
|
||||
isActive: state.isActive,
|
||||
isConnected,
|
||||
});
|
||||
})();
|
||||
return true; // Async response
|
||||
}
|
||||
|
||||
if (message.type === "setState") {
|
||||
(async () => {
|
||||
await handleStateChange(message.isActive);
|
||||
const state = await stateManager.getState();
|
||||
const isConnected = await connectionManager.checkConnection();
|
||||
sendResponse({
|
||||
isActive: state.isActive,
|
||||
isConnected,
|
||||
});
|
||||
})();
|
||||
return true; // Async response
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
);
|
||||
|
||||
// Set up event listeners
|
||||
|
||||
chrome.tabs.onRemoved.addListener((tabId) => {
|
||||
if (tabManager.has(tabId)) {
|
||||
logger.debug("Tab closed:", tabId);
|
||||
tabManager.detach(tabId, false);
|
||||
}
|
||||
});
|
||||
|
||||
// Register debugger event listeners
|
||||
chrome.debugger.onEvent.addListener(onDebuggerEvent);
|
||||
chrome.debugger.onDetach.addListener(onDebuggerDetach);
|
||||
|
||||
// Reset any stale debugger connections on startup
|
||||
chrome.debugger.getTargets().then((targets) => {
|
||||
const attached = targets.filter((t) => t.tabId && t.attached);
|
||||
if (attached.length > 0) {
|
||||
logger.log(`Detaching ${attached.length} stale debugger connections`);
|
||||
for (const target of attached) {
|
||||
chrome.debugger.detach({ tabId: target.tabId }).catch(() => {});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
logger.log("Extension initialized");
|
||||
|
||||
// Initialize from stored state
|
||||
stateManager.getState().then((state) => {
|
||||
updateBadge(state.isActive);
|
||||
if (state.isActive) {
|
||||
// Create keep-alive alarm only when extension is active
|
||||
chrome.alarms.create(KEEPALIVE_ALARM, { periodInMinutes: 0.5 });
|
||||
connectionManager.startMaintaining();
|
||||
}
|
||||
});
|
||||
|
||||
// Set up Chrome Alarms keep-alive listener
|
||||
// This ensures the connection is maintained even after service worker unloads
|
||||
chrome.alarms.onAlarm.addListener(async (alarm) => {
|
||||
if (alarm.name === KEEPALIVE_ALARM) {
|
||||
const state = await stateManager.getState();
|
||||
|
||||
if (state.isActive) {
|
||||
const isConnected = connectionManager.isConnected();
|
||||
|
||||
if (!isConnected) {
|
||||
logger.debug("Keep-alive: Connection lost, restarting...");
|
||||
connectionManager.startMaintaining();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
23
skills/dev-browser/extension/entrypoints/popup/index.html
Normal file
23
skills/dev-browser/extension/entrypoints/popup/index.html
Normal file
@@ -0,0 +1,23 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Dev Browser</title>
|
||||
<link rel="stylesheet" href="./style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="popup">
|
||||
<h1>Dev Browser</h1>
|
||||
<div class="toggle-row">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" id="active-toggle" />
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
<span id="status-text">Inactive</span>
|
||||
</div>
|
||||
<p id="connection-status" class="connection-status"></p>
|
||||
</div>
|
||||
<script type="module" src="./main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
52
skills/dev-browser/extension/entrypoints/popup/main.ts
Normal file
52
skills/dev-browser/extension/entrypoints/popup/main.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { GetStateMessage, SetStateMessage, StateResponse } from "../../utils/types";
|
||||
|
||||
const toggle = document.getElementById("active-toggle") as HTMLInputElement;
|
||||
const statusText = document.getElementById("status-text") as HTMLSpanElement;
|
||||
const connectionStatus = document.getElementById("connection-status") as HTMLParagraphElement;
|
||||
|
||||
function updateUI(state: StateResponse): void {
|
||||
toggle.checked = state.isActive;
|
||||
statusText.textContent = state.isActive ? "Active" : "Inactive";
|
||||
|
||||
if (state.isActive) {
|
||||
connectionStatus.textContent = state.isConnected ? "Connected to relay" : "Connecting...";
|
||||
connectionStatus.className = state.isConnected
|
||||
? "connection-status connected"
|
||||
: "connection-status connecting";
|
||||
} else {
|
||||
connectionStatus.textContent = "";
|
||||
connectionStatus.className = "connection-status";
|
||||
}
|
||||
}
|
||||
|
||||
function refreshState(): void {
|
||||
chrome.runtime.sendMessage<GetStateMessage, StateResponse>({ type: "getState" }, (response) => {
|
||||
if (response) {
|
||||
updateUI(response);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Load initial state
|
||||
refreshState();
|
||||
|
||||
// Poll for state updates while popup is open
|
||||
const pollInterval = setInterval(refreshState, 1000);
|
||||
|
||||
// Clean up on popup close
|
||||
window.addEventListener("unload", () => {
|
||||
clearInterval(pollInterval);
|
||||
});
|
||||
|
||||
// Handle toggle changes
|
||||
toggle.addEventListener("change", () => {
|
||||
const isActive = toggle.checked;
|
||||
chrome.runtime.sendMessage<SetStateMessage, StateResponse>(
|
||||
{ type: "setState", isActive },
|
||||
(response) => {
|
||||
if (response) {
|
||||
updateUI(response);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
96
skills/dev-browser/extension/entrypoints/popup/style.css
Normal file
96
skills/dev-browser/extension/entrypoints/popup/style.css
Normal file
@@ -0,0 +1,96 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
font-size: 14px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.popup {
|
||||
width: 200px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 16px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.toggle-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
#status-text {
|
||||
font-weight: 500;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
/* Toggle switch */
|
||||
.toggle {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.toggle input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: #ccc;
|
||||
transition: 0.2s;
|
||||
border-radius: 24px;
|
||||
}
|
||||
|
||||
.slider::before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
background-color: white;
|
||||
transition: 0.2s;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
input:checked + .slider {
|
||||
background-color: #4caf50;
|
||||
}
|
||||
|
||||
input:checked + .slider::before {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
/* Connection status */
|
||||
.connection-status {
|
||||
margin-top: 12px;
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
min-height: 16px;
|
||||
}
|
||||
|
||||
.connection-status.connected {
|
||||
color: #4caf50;
|
||||
}
|
||||
|
||||
.connection-status.connecting {
|
||||
color: #ff9800;
|
||||
}
|
||||
5902
skills/dev-browser/extension/package-lock.json
generated
Normal file
5902
skills/dev-browser/extension/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
21
skills/dev-browser/extension/package.json
Normal file
21
skills/dev-browser/extension/package.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "dev-browser-extension",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "wxt",
|
||||
"dev:firefox": "wxt --browser firefox",
|
||||
"build": "wxt build",
|
||||
"build:firefox": "wxt build --browser firefox",
|
||||
"zip": "wxt zip",
|
||||
"zip:firefox": "wxt zip --browser firefox",
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/chrome": "^0.1.32",
|
||||
"typescript": "^5.0.0",
|
||||
"vitest": "^3.0.0",
|
||||
"wxt": "^0.20.0"
|
||||
}
|
||||
}
|
||||
BIN
skills/dev-browser/extension/public/icons/icon-128.png
Normal file
BIN
skills/dev-browser/extension/public/icons/icon-128.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
BIN
skills/dev-browser/extension/public/icons/icon-16.png
Normal file
BIN
skills/dev-browser/extension/public/icons/icon-16.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 730 B |
BIN
skills/dev-browser/extension/public/icons/icon-32.png
Normal file
BIN
skills/dev-browser/extension/public/icons/icon-32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
BIN
skills/dev-browser/extension/public/icons/icon-48.png
Normal file
BIN
skills/dev-browser/extension/public/icons/icon-48.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
152
skills/dev-browser/extension/scripts/generate-icons.mjs
Normal file
152
skills/dev-browser/extension/scripts/generate-icons.mjs
Normal file
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Generate simple placeholder icons for the extension
|
||||
* Usage: node scripts/generate-icons.mjs
|
||||
*/
|
||||
|
||||
import { writeFileSync, mkdirSync } from "fs";
|
||||
import { join, dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// Minimal PNG generator (creates simple colored squares)
|
||||
function createPng(size, r, g, b) {
|
||||
// PNG header
|
||||
const signature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
||||
|
||||
// IHDR chunk
|
||||
const ihdrData = Buffer.alloc(13);
|
||||
ihdrData.writeUInt32BE(size, 0); // width
|
||||
ihdrData.writeUInt32BE(size, 4); // height
|
||||
ihdrData.writeUInt8(8, 8); // bit depth
|
||||
ihdrData.writeUInt8(2, 9); // color type (RGB)
|
||||
ihdrData.writeUInt8(0, 10); // compression
|
||||
ihdrData.writeUInt8(0, 11); // filter
|
||||
ihdrData.writeUInt8(0, 12); // interlace
|
||||
|
||||
const ihdr = createChunk("IHDR", ihdrData);
|
||||
|
||||
// IDAT chunk (image data)
|
||||
const rawData = [];
|
||||
for (let y = 0; y < size; y++) {
|
||||
rawData.push(0); // filter byte
|
||||
for (let x = 0; x < size; x++) {
|
||||
// Create a circle
|
||||
const cx = size / 2;
|
||||
const cy = size / 2;
|
||||
const radius = size / 2 - 1;
|
||||
const dist = Math.sqrt((x - cx) ** 2 + (y - cy) ** 2);
|
||||
|
||||
if (dist <= radius) {
|
||||
// Inside circle - use the color
|
||||
rawData.push(r, g, b);
|
||||
} else {
|
||||
// Outside circle - transparent (white for simplicity)
|
||||
rawData.push(255, 255, 255);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Use zlib-less compression (store method)
|
||||
const compressed = deflateStore(Buffer.from(rawData));
|
||||
const idat = createChunk("IDAT", compressed);
|
||||
|
||||
// IEND chunk
|
||||
const iend = createChunk("IEND", Buffer.alloc(0));
|
||||
|
||||
return Buffer.concat([signature, ihdr, idat, iend]);
|
||||
}
|
||||
|
||||
function createChunk(type, data) {
|
||||
const length = Buffer.alloc(4);
|
||||
length.writeUInt32BE(data.length);
|
||||
|
||||
const typeBuffer = Buffer.from(type);
|
||||
const crc = crc32(Buffer.concat([typeBuffer, data]));
|
||||
|
||||
const crcBuffer = Buffer.alloc(4);
|
||||
crcBuffer.writeUInt32BE(crc >>> 0);
|
||||
|
||||
return Buffer.concat([length, typeBuffer, data, crcBuffer]);
|
||||
}
|
||||
|
||||
// Simple deflate store (no compression)
|
||||
function deflateStore(data) {
|
||||
const blocks = [];
|
||||
let offset = 0;
|
||||
|
||||
while (offset < data.length) {
|
||||
const remaining = data.length - offset;
|
||||
const blockSize = Math.min(65535, remaining);
|
||||
const isLast = offset + blockSize >= data.length;
|
||||
|
||||
const header = Buffer.alloc(5);
|
||||
header.writeUInt8(isLast ? 1 : 0, 0);
|
||||
header.writeUInt16LE(blockSize, 1);
|
||||
header.writeUInt16LE(blockSize ^ 0xffff, 3);
|
||||
|
||||
blocks.push(header);
|
||||
blocks.push(data.subarray(offset, offset + blockSize));
|
||||
offset += blockSize;
|
||||
}
|
||||
|
||||
// Zlib header
|
||||
const zlibHeader = Buffer.from([0x78, 0x01]);
|
||||
|
||||
// Adler32 checksum
|
||||
const adler = adler32(data);
|
||||
const adlerBuffer = Buffer.alloc(4);
|
||||
adlerBuffer.writeUInt32BE(adler);
|
||||
|
||||
return Buffer.concat([zlibHeader, ...blocks, adlerBuffer]);
|
||||
}
|
||||
|
||||
function adler32(data) {
|
||||
let a = 1;
|
||||
let b = 0;
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
a = (a + data[i]) % 65521;
|
||||
b = (b + a) % 65521;
|
||||
}
|
||||
return ((b << 16) | a) >>> 0; // Ensure unsigned
|
||||
}
|
||||
|
||||
// CRC32 lookup table
|
||||
const crcTable = new Uint32Array(256);
|
||||
for (let i = 0; i < 256; i++) {
|
||||
let c = i;
|
||||
for (let j = 0; j < 8; j++) {
|
||||
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||
}
|
||||
crcTable[i] = c;
|
||||
}
|
||||
|
||||
function crc32(data) {
|
||||
let crc = 0xffffffff;
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
crc = crcTable[(crc ^ data[i]) & 0xff] ^ (crc >>> 8);
|
||||
}
|
||||
return crc ^ 0xffffffff;
|
||||
}
|
||||
|
||||
// Generate icons
|
||||
const sizes = [16, 32, 48, 128];
|
||||
const colors = {
|
||||
black: [26, 26, 26],
|
||||
gray: [156, 163, 175],
|
||||
green: [34, 197, 94],
|
||||
};
|
||||
|
||||
const iconsDir = join(__dirname, "..", "public", "icons");
|
||||
mkdirSync(iconsDir, { recursive: true });
|
||||
|
||||
for (const [name, [r, g, b]] of Object.entries(colors)) {
|
||||
for (const size of sizes) {
|
||||
const png = createPng(size, r, g, b);
|
||||
const filename = join(iconsDir, `icon-${name}-${size}.png`);
|
||||
writeFileSync(filename, png);
|
||||
console.log(`Created ${filename}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("Done!");
|
||||
211
skills/dev-browser/extension/services/CDPRouter.ts
Normal file
211
skills/dev-browser/extension/services/CDPRouter.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* CDPRouter - Routes CDP commands to the correct tab.
|
||||
*/
|
||||
|
||||
import type { Logger } from "../utils/logger";
|
||||
import type { TabManager } from "./TabManager";
|
||||
import type { ExtensionCommandMessage, TabInfo } from "../utils/types";
|
||||
|
||||
export interface CDPRouterDeps {
|
||||
logger: Logger;
|
||||
tabManager: TabManager;
|
||||
}
|
||||
|
||||
export class CDPRouter {
|
||||
private logger: Logger;
|
||||
private tabManager: TabManager;
|
||||
private devBrowserGroupId: number | null = null;
|
||||
|
||||
constructor(deps: CDPRouterDeps) {
|
||||
this.logger = deps.logger;
|
||||
this.tabManager = deps.tabManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets or creates the "Dev Browser" tab group, returning its ID.
|
||||
*/
|
||||
private async getOrCreateDevBrowserGroup(tabId: number): Promise<number> {
|
||||
// If we have a cached group ID, verify it still exists
|
||||
if (this.devBrowserGroupId !== null) {
|
||||
try {
|
||||
await chrome.tabGroups.get(this.devBrowserGroupId);
|
||||
// Group exists, add tab to it
|
||||
await chrome.tabs.group({ tabIds: [tabId], groupId: this.devBrowserGroupId });
|
||||
return this.devBrowserGroupId;
|
||||
} catch {
|
||||
// Group no longer exists, reset cache
|
||||
this.devBrowserGroupId = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Create a new group with this tab
|
||||
const groupId = await chrome.tabs.group({ tabIds: [tabId] });
|
||||
await chrome.tabGroups.update(groupId, {
|
||||
title: "Dev Browser",
|
||||
color: "blue",
|
||||
});
|
||||
this.devBrowserGroupId = groupId;
|
||||
return groupId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming CDP command from the relay.
|
||||
*/
|
||||
async handleCommand(msg: ExtensionCommandMessage): Promise<unknown> {
|
||||
if (msg.method !== "forwardCDPCommand") return;
|
||||
|
||||
let targetTabId: number | undefined;
|
||||
let targetTab: TabInfo | undefined;
|
||||
|
||||
// Find target tab by sessionId
|
||||
if (msg.params.sessionId) {
|
||||
const found = this.tabManager.getBySessionId(msg.params.sessionId);
|
||||
if (found) {
|
||||
targetTabId = found.tabId;
|
||||
targetTab = found.tab;
|
||||
}
|
||||
}
|
||||
|
||||
// Check child sessions (iframes, workers)
|
||||
if (!targetTab && msg.params.sessionId) {
|
||||
const parentTabId = this.tabManager.getParentTabId(msg.params.sessionId);
|
||||
if (parentTabId) {
|
||||
targetTabId = parentTabId;
|
||||
targetTab = this.tabManager.get(parentTabId);
|
||||
this.logger.debug(
|
||||
"Found parent tab for child session:",
|
||||
msg.params.sessionId,
|
||||
"tabId:",
|
||||
parentTabId
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Find by targetId in params
|
||||
if (
|
||||
!targetTab &&
|
||||
msg.params.params &&
|
||||
typeof msg.params.params === "object" &&
|
||||
"targetId" in msg.params.params
|
||||
) {
|
||||
const found = this.tabManager.getByTargetId(msg.params.params.targetId as string);
|
||||
if (found) {
|
||||
targetTabId = found.tabId;
|
||||
targetTab = found.tab;
|
||||
}
|
||||
}
|
||||
|
||||
const debuggee = targetTabId ? { tabId: targetTabId } : undefined;
|
||||
|
||||
// Handle special commands
|
||||
switch (msg.params.method) {
|
||||
case "Runtime.enable": {
|
||||
if (!debuggee) {
|
||||
throw new Error(
|
||||
`No debuggee found for Runtime.enable (sessionId: ${msg.params.sessionId})`
|
||||
);
|
||||
}
|
||||
// Disable and re-enable to reset state
|
||||
try {
|
||||
await chrome.debugger.sendCommand(debuggee, "Runtime.disable");
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
return await chrome.debugger.sendCommand(debuggee, "Runtime.enable", msg.params.params);
|
||||
}
|
||||
|
||||
case "Target.createTarget": {
|
||||
const url = (msg.params.params?.url as string) || "about:blank";
|
||||
this.logger.debug("Creating new tab with URL:", url);
|
||||
const tab = await chrome.tabs.create({ url, active: false });
|
||||
if (!tab.id) throw new Error("Failed to create tab");
|
||||
|
||||
// Add tab to "Dev Browser" group
|
||||
await this.getOrCreateDevBrowserGroup(tab.id);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
const targetInfo = await this.tabManager.attach(tab.id);
|
||||
return { targetId: targetInfo.targetId };
|
||||
}
|
||||
|
||||
case "Target.closeTarget": {
|
||||
if (!targetTabId) {
|
||||
this.logger.log(`Target not found: ${msg.params.params?.targetId}`);
|
||||
return { success: false };
|
||||
}
|
||||
await chrome.tabs.remove(targetTabId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
case "Target.activateTarget": {
|
||||
if (!targetTabId) {
|
||||
this.logger.log(`Target not found for activation: ${msg.params.params?.targetId}`);
|
||||
return {};
|
||||
}
|
||||
await chrome.tabs.update(targetTabId, { active: true });
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
if (!debuggee || !targetTab) {
|
||||
throw new Error(
|
||||
`No tab found for method ${msg.params.method} sessionId: ${msg.params.sessionId}`
|
||||
);
|
||||
}
|
||||
|
||||
this.logger.debug("CDP command:", msg.params.method, "for tab:", targetTabId);
|
||||
|
||||
const debuggerSession: chrome.debugger.DebuggerSession = {
|
||||
...debuggee,
|
||||
sessionId: msg.params.sessionId !== targetTab.sessionId ? msg.params.sessionId : undefined,
|
||||
};
|
||||
|
||||
return await chrome.debugger.sendCommand(debuggerSession, msg.params.method, msg.params.params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle debugger events from Chrome.
|
||||
*/
|
||||
handleDebuggerEvent(
|
||||
source: chrome.debugger.DebuggerSession,
|
||||
method: string,
|
||||
params: unknown,
|
||||
sendMessage: (msg: unknown) => void
|
||||
): void {
|
||||
const tab = source.tabId ? this.tabManager.get(source.tabId) : undefined;
|
||||
if (!tab) return;
|
||||
|
||||
this.logger.debug("Forwarding CDP event:", method, "from tab:", source.tabId);
|
||||
|
||||
// Track child sessions
|
||||
if (
|
||||
method === "Target.attachedToTarget" &&
|
||||
params &&
|
||||
typeof params === "object" &&
|
||||
"sessionId" in params
|
||||
) {
|
||||
const sessionId = (params as { sessionId: string }).sessionId;
|
||||
this.tabManager.trackChildSession(sessionId, source.tabId!);
|
||||
}
|
||||
|
||||
if (
|
||||
method === "Target.detachedFromTarget" &&
|
||||
params &&
|
||||
typeof params === "object" &&
|
||||
"sessionId" in params
|
||||
) {
|
||||
const sessionId = (params as { sessionId: string }).sessionId;
|
||||
this.tabManager.untrackChildSession(sessionId);
|
||||
}
|
||||
|
||||
sendMessage({
|
||||
method: "forwardCDPEvent",
|
||||
params: {
|
||||
sessionId: source.sessionId || tab.sessionId,
|
||||
method,
|
||||
params,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
214
skills/dev-browser/extension/services/ConnectionManager.ts
Normal file
214
skills/dev-browser/extension/services/ConnectionManager.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* ConnectionManager - Manages WebSocket connection to relay server.
|
||||
*/
|
||||
|
||||
import type { Logger } from "../utils/logger";
|
||||
import type { ExtensionCommandMessage, ExtensionResponseMessage } from "../utils/types";
|
||||
|
||||
const RELAY_URL = "ws://localhost:9222/extension";
|
||||
const RECONNECT_INTERVAL = 3000;
|
||||
|
||||
export interface ConnectionManagerDeps {
|
||||
logger: Logger;
|
||||
onMessage: (message: ExtensionCommandMessage) => Promise<unknown>;
|
||||
onDisconnect: () => void;
|
||||
}
|
||||
|
||||
export class ConnectionManager {
|
||||
private ws: WebSocket | null = null;
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private shouldMaintain = false;
|
||||
private logger: Logger;
|
||||
private onMessage: (message: ExtensionCommandMessage) => Promise<unknown>;
|
||||
private onDisconnect: () => void;
|
||||
|
||||
constructor(deps: ConnectionManagerDeps) {
|
||||
this.logger = deps.logger;
|
||||
this.onMessage = deps.onMessage;
|
||||
this.onDisconnect = deps.onDisconnect;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if WebSocket is open (may be stale if server crashed).
|
||||
*/
|
||||
isConnected(): boolean {
|
||||
return this.ws?.readyState === WebSocket.OPEN;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate connection by checking if server is reachable.
|
||||
* More reliable than isConnected() as it detects server crashes.
|
||||
*/
|
||||
async checkConnection(): Promise<boolean> {
|
||||
if (!this.isConnected()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verify server is actually reachable
|
||||
try {
|
||||
const response = await fetch("http://localhost:9222", {
|
||||
method: "HEAD",
|
||||
signal: AbortSignal.timeout(1000),
|
||||
});
|
||||
return response.ok;
|
||||
} catch {
|
||||
// Server unreachable - close stale socket
|
||||
if (this.ws) {
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
this.onDisconnect();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message to the relay server.
|
||||
*/
|
||||
send(message: unknown): void {
|
||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||
try {
|
||||
this.ws.send(JSON.stringify(message));
|
||||
} catch (error) {
|
||||
console.debug("Error sending message:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start maintaining connection (auto-reconnect).
|
||||
*/
|
||||
startMaintaining(): void {
|
||||
this.shouldMaintain = true;
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
|
||||
this.tryConnect().catch(() => {});
|
||||
this.reconnectTimer = setTimeout(() => this.startMaintaining(), RECONNECT_INTERVAL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop connection maintenance.
|
||||
*/
|
||||
stopMaintaining(): void {
|
||||
this.shouldMaintain = false;
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from relay and stop maintaining connection.
|
||||
*/
|
||||
disconnect(): void {
|
||||
this.stopMaintaining();
|
||||
if (this.ws) {
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
this.onDisconnect();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure connection is established, waiting if needed.
|
||||
*/
|
||||
async ensureConnected(): Promise<void> {
|
||||
if (this.isConnected()) return;
|
||||
|
||||
await this.tryConnect();
|
||||
|
||||
if (!this.isConnected()) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
await this.tryConnect();
|
||||
}
|
||||
|
||||
if (!this.isConnected()) {
|
||||
throw new Error("Could not connect to relay server");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to connect to relay server once.
|
||||
*/
|
||||
private async tryConnect(): Promise<void> {
|
||||
if (this.isConnected()) return;
|
||||
|
||||
// Check if server is available
|
||||
try {
|
||||
await fetch("http://localhost:9222", { method: "HEAD" });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.debug("Connecting to relay server...");
|
||||
const socket = new WebSocket(RELAY_URL);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
reject(new Error("Connection timeout"));
|
||||
}, 5000);
|
||||
|
||||
socket.onopen = () => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
};
|
||||
|
||||
socket.onerror = () => {
|
||||
clearTimeout(timeout);
|
||||
reject(new Error("WebSocket connection failed"));
|
||||
};
|
||||
|
||||
socket.onclose = (event) => {
|
||||
clearTimeout(timeout);
|
||||
reject(new Error(`WebSocket closed: ${event.reason || event.code}`));
|
||||
};
|
||||
});
|
||||
|
||||
this.ws = socket;
|
||||
this.setupSocketHandlers(socket);
|
||||
this.logger.log("Connected to relay server");
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up WebSocket event handlers.
|
||||
*/
|
||||
private setupSocketHandlers(socket: WebSocket): void {
|
||||
socket.onmessage = async (event: MessageEvent) => {
|
||||
let message: ExtensionCommandMessage;
|
||||
try {
|
||||
message = JSON.parse(event.data);
|
||||
} catch (error) {
|
||||
this.logger.debug("Error parsing message:", error);
|
||||
this.send({
|
||||
error: { code: -32700, message: "Parse error" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const response: ExtensionResponseMessage = { id: message.id };
|
||||
try {
|
||||
response.result = await this.onMessage(message);
|
||||
} catch (error) {
|
||||
this.logger.debug("Error handling command:", error);
|
||||
response.error = (error as Error).message;
|
||||
}
|
||||
this.send(response);
|
||||
};
|
||||
|
||||
socket.onclose = (event: CloseEvent) => {
|
||||
this.logger.debug("Connection closed:", event.code, event.reason);
|
||||
this.ws = null;
|
||||
this.onDisconnect();
|
||||
if (this.shouldMaintain) {
|
||||
this.startMaintaining();
|
||||
}
|
||||
};
|
||||
|
||||
socket.onerror = (event: Event) => {
|
||||
this.logger.debug("WebSocket error:", event);
|
||||
};
|
||||
}
|
||||
}
|
||||
28
skills/dev-browser/extension/services/StateManager.ts
Normal file
28
skills/dev-browser/extension/services/StateManager.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* StateManager - Manages extension active/inactive state with persistence.
|
||||
*/
|
||||
|
||||
const STORAGE_KEY = "devBrowserActiveState";
|
||||
|
||||
export interface ExtensionState {
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export class StateManager {
|
||||
/**
|
||||
* Get the current extension state.
|
||||
* Defaults to inactive if no state is stored.
|
||||
*/
|
||||
async getState(): Promise<ExtensionState> {
|
||||
const result = await chrome.storage.local.get(STORAGE_KEY);
|
||||
const state = result[STORAGE_KEY] as ExtensionState | undefined;
|
||||
return state ?? { isActive: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the extension state.
|
||||
*/
|
||||
async setState(state: ExtensionState): Promise<void> {
|
||||
await chrome.storage.local.set({ [STORAGE_KEY]: state });
|
||||
}
|
||||
}
|
||||
218
skills/dev-browser/extension/services/TabManager.ts
Normal file
218
skills/dev-browser/extension/services/TabManager.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* TabManager - Manages tab state and debugger attachment.
|
||||
*/
|
||||
|
||||
import type { TabInfo, TargetInfo } from "../utils/types";
|
||||
import type { Logger } from "../utils/logger";
|
||||
|
||||
export type SendMessageFn = (message: unknown) => void;
|
||||
|
||||
export interface TabManagerDeps {
|
||||
logger: Logger;
|
||||
sendMessage: SendMessageFn;
|
||||
}
|
||||
|
||||
export class TabManager {
|
||||
private tabs = new Map<number, TabInfo>();
|
||||
private childSessions = new Map<string, number>(); // sessionId -> parentTabId
|
||||
private nextSessionId = 1;
|
||||
private logger: Logger;
|
||||
private sendMessage: SendMessageFn;
|
||||
|
||||
constructor(deps: TabManagerDeps) {
|
||||
this.logger = deps.logger;
|
||||
this.sendMessage = deps.sendMessage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tab info by session ID.
|
||||
*/
|
||||
getBySessionId(sessionId: string): { tabId: number; tab: TabInfo } | undefined {
|
||||
for (const [tabId, tab] of this.tabs) {
|
||||
if (tab.sessionId === sessionId) {
|
||||
return { tabId, tab };
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tab info by target ID.
|
||||
*/
|
||||
getByTargetId(targetId: string): { tabId: number; tab: TabInfo } | undefined {
|
||||
for (const [tabId, tab] of this.tabs) {
|
||||
if (tab.targetId === targetId) {
|
||||
return { tabId, tab };
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get parent tab ID for a child session (iframe, worker).
|
||||
*/
|
||||
getParentTabId(sessionId: string): number | undefined {
|
||||
return this.childSessions.get(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tab info by tab ID.
|
||||
*/
|
||||
get(tabId: number): TabInfo | undefined {
|
||||
return this.tabs.get(tabId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a tab is tracked.
|
||||
*/
|
||||
has(tabId: number): boolean {
|
||||
return this.tabs.has(tabId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set tab info (used for intermediate states like "connecting").
|
||||
*/
|
||||
set(tabId: number, info: TabInfo): void {
|
||||
this.tabs.set(tabId, info);
|
||||
}
|
||||
|
||||
/**
|
||||
* Track a child session (iframe, worker).
|
||||
*/
|
||||
trackChildSession(sessionId: string, parentTabId: number): void {
|
||||
this.logger.debug("Child target attached:", sessionId, "for tab:", parentTabId);
|
||||
this.childSessions.set(sessionId, parentTabId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Untrack a child session.
|
||||
*/
|
||||
untrackChildSession(sessionId: string): void {
|
||||
this.logger.debug("Child target detached:", sessionId);
|
||||
this.childSessions.delete(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach debugger to a tab and register it.
|
||||
*/
|
||||
async attach(tabId: number): Promise<TargetInfo> {
|
||||
const debuggee = { tabId };
|
||||
|
||||
this.logger.debug("Attaching debugger to tab:", tabId);
|
||||
await chrome.debugger.attach(debuggee, "1.3");
|
||||
|
||||
const result = (await chrome.debugger.sendCommand(debuggee, "Target.getTargetInfo")) as {
|
||||
targetInfo: TargetInfo;
|
||||
};
|
||||
|
||||
const targetInfo = result.targetInfo;
|
||||
const sessionId = `pw-tab-${this.nextSessionId++}`;
|
||||
|
||||
this.tabs.set(tabId, {
|
||||
sessionId,
|
||||
targetId: targetInfo.targetId,
|
||||
state: "connected",
|
||||
});
|
||||
|
||||
// Notify relay of new target
|
||||
this.sendMessage({
|
||||
method: "forwardCDPEvent",
|
||||
params: {
|
||||
method: "Target.attachedToTarget",
|
||||
params: {
|
||||
sessionId,
|
||||
targetInfo: { ...targetInfo, attached: true },
|
||||
waitingForDebugger: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log("Tab attached:", tabId, "sessionId:", sessionId, "url:", targetInfo.url);
|
||||
return targetInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detach a tab and clean up.
|
||||
*/
|
||||
detach(tabId: number, shouldDetachDebugger: boolean): void {
|
||||
const tab = this.tabs.get(tabId);
|
||||
if (!tab) return;
|
||||
|
||||
this.logger.debug("Detaching tab:", tabId);
|
||||
|
||||
this.sendMessage({
|
||||
method: "forwardCDPEvent",
|
||||
params: {
|
||||
method: "Target.detachedFromTarget",
|
||||
params: { sessionId: tab.sessionId, targetId: tab.targetId },
|
||||
},
|
||||
});
|
||||
|
||||
this.tabs.delete(tabId);
|
||||
|
||||
// Clean up child sessions
|
||||
for (const [childSessionId, parentTabId] of this.childSessions) {
|
||||
if (parentTabId === tabId) {
|
||||
this.childSessions.delete(childSessionId);
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldDetachDebugger) {
|
||||
chrome.debugger.detach({ tabId }).catch((err) => {
|
||||
this.logger.debug("Error detaching debugger:", err);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle debugger detach event from Chrome.
|
||||
*/
|
||||
handleDebuggerDetach(tabId: number): void {
|
||||
if (!this.tabs.has(tabId)) return;
|
||||
|
||||
const tab = this.tabs.get(tabId);
|
||||
if (tab) {
|
||||
this.sendMessage({
|
||||
method: "forwardCDPEvent",
|
||||
params: {
|
||||
method: "Target.detachedFromTarget",
|
||||
params: { sessionId: tab.sessionId, targetId: tab.targetId },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Clean up child sessions
|
||||
for (const [childSessionId, parentTabId] of this.childSessions) {
|
||||
if (parentTabId === tabId) {
|
||||
this.childSessions.delete(childSessionId);
|
||||
}
|
||||
}
|
||||
|
||||
this.tabs.delete(tabId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all tabs and child sessions.
|
||||
*/
|
||||
clear(): void {
|
||||
this.tabs.clear();
|
||||
this.childSessions.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Detach all tabs (used on disconnect).
|
||||
*/
|
||||
detachAll(): void {
|
||||
for (const tabId of this.tabs.keys()) {
|
||||
chrome.debugger.detach({ tabId }).catch(() => {});
|
||||
}
|
||||
this.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all tab IDs.
|
||||
*/
|
||||
getAllTabIds(): number[] {
|
||||
return Array.from(this.tabs.keys());
|
||||
}
|
||||
}
|
||||
3
skills/dev-browser/extension/tsconfig.json
Normal file
3
skills/dev-browser/extension/tsconfig.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "./.wxt/tsconfig.json"
|
||||
}
|
||||
63
skills/dev-browser/extension/utils/logger.ts
Normal file
63
skills/dev-browser/extension/utils/logger.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Logger utility for the dev-browser extension.
|
||||
* Logs to console and optionally sends to relay server.
|
||||
*/
|
||||
|
||||
export type LogLevel = "log" | "debug" | "error";
|
||||
|
||||
export interface LogMessage {
|
||||
method: "log";
|
||||
params: {
|
||||
level: LogLevel;
|
||||
args: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export type SendMessageFn = (message: unknown) => void;
|
||||
|
||||
/**
|
||||
* Creates a logger instance that logs to console and sends to relay.
|
||||
*/
|
||||
export function createLogger(sendMessage: SendMessageFn) {
|
||||
function formatArgs(args: unknown[]): string[] {
|
||||
return args.map((arg) => {
|
||||
if (arg === undefined) return "undefined";
|
||||
if (arg === null) return "null";
|
||||
if (typeof arg === "object") {
|
||||
try {
|
||||
return JSON.stringify(arg);
|
||||
} catch {
|
||||
return String(arg);
|
||||
}
|
||||
}
|
||||
return String(arg);
|
||||
});
|
||||
}
|
||||
|
||||
function sendLog(level: LogLevel, args: unknown[]): void {
|
||||
sendMessage({
|
||||
method: "log",
|
||||
params: {
|
||||
level,
|
||||
args: formatArgs(args),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
log: (...args: unknown[]) => {
|
||||
console.log("[dev-browser]", ...args);
|
||||
sendLog("log", args);
|
||||
},
|
||||
debug: (...args: unknown[]) => {
|
||||
console.debug("[dev-browser]", ...args);
|
||||
sendLog("debug", args);
|
||||
},
|
||||
error: (...args: unknown[]) => {
|
||||
console.error("[dev-browser]", ...args);
|
||||
sendLog("error", args);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type Logger = ReturnType<typeof createLogger>;
|
||||
94
skills/dev-browser/extension/utils/types.ts
Normal file
94
skills/dev-browser/extension/utils/types.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Types for extension-relay communication
|
||||
*/
|
||||
|
||||
export type ConnectionState =
|
||||
| "disconnected"
|
||||
| "connecting"
|
||||
| "connected"
|
||||
| "reconnecting"
|
||||
| "error";
|
||||
|
||||
export type TabState = "connecting" | "connected" | "error";
|
||||
|
||||
export interface TabInfo {
|
||||
sessionId?: string;
|
||||
targetId?: string;
|
||||
state: TabState;
|
||||
errorText?: string;
|
||||
}
|
||||
|
||||
export interface ExtensionState {
|
||||
tabs: Map<number, TabInfo>;
|
||||
connectionState: ConnectionState;
|
||||
currentTabId?: number;
|
||||
errorText?: string;
|
||||
}
|
||||
|
||||
// Messages from relay to extension
|
||||
export interface ExtensionCommandMessage {
|
||||
id: number;
|
||||
method: "forwardCDPCommand";
|
||||
params: {
|
||||
method: string;
|
||||
params?: Record<string, unknown>;
|
||||
sessionId?: string;
|
||||
};
|
||||
}
|
||||
|
||||
// Messages from extension to relay (responses)
|
||||
export interface ExtensionResponseMessage {
|
||||
id: number;
|
||||
result?: unknown;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Messages from extension to relay (events)
|
||||
export interface ExtensionEventMessage {
|
||||
method: "forwardCDPEvent";
|
||||
params: {
|
||||
method: string;
|
||||
params?: Record<string, unknown>;
|
||||
sessionId?: string;
|
||||
};
|
||||
}
|
||||
|
||||
// Log message from extension to relay
|
||||
export interface ExtensionLogMessage {
|
||||
method: "log";
|
||||
params: {
|
||||
level: string;
|
||||
args: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export type ExtensionMessage =
|
||||
| ExtensionResponseMessage
|
||||
| ExtensionEventMessage
|
||||
| ExtensionLogMessage;
|
||||
|
||||
// Chrome debugger target info
|
||||
export interface TargetInfo {
|
||||
targetId: string;
|
||||
type: string;
|
||||
title: string;
|
||||
url: string;
|
||||
attached?: boolean;
|
||||
}
|
||||
|
||||
// Popup <-> Background messaging
|
||||
export interface GetStateMessage {
|
||||
type: "getState";
|
||||
}
|
||||
|
||||
export interface SetStateMessage {
|
||||
type: "setState";
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export interface StateResponse {
|
||||
isActive: boolean;
|
||||
isConnected: boolean;
|
||||
}
|
||||
|
||||
export type PopupMessage = GetStateMessage | SetStateMessage;
|
||||
10
skills/dev-browser/extension/vitest.config.ts
Normal file
10
skills/dev-browser/extension/vitest.config.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import { WxtVitest } from "wxt/testing";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [WxtVitest()],
|
||||
test: {
|
||||
mockReset: true,
|
||||
restoreMocks: true,
|
||||
},
|
||||
});
|
||||
16
skills/dev-browser/extension/wxt.config.ts
Normal file
16
skills/dev-browser/extension/wxt.config.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from "wxt";
|
||||
|
||||
export default defineConfig({
|
||||
manifest: {
|
||||
name: "dev-browser",
|
||||
description: "Connect your browser to dev-browser for Playwright automation",
|
||||
permissions: ["debugger", "tabGroups", "storage", "alarms"],
|
||||
host_permissions: ["<all_urls>"],
|
||||
icons: {
|
||||
16: "icons/icon-16.png",
|
||||
32: "icons/icon-32.png",
|
||||
48: "icons/icon-48.png",
|
||||
128: "icons/icon-128.png",
|
||||
},
|
||||
},
|
||||
});
|
||||
78
skills/dev-browser/install-dev.sh
Executable file
78
skills/dev-browser/install-dev.sh
Executable file
@@ -0,0 +1,78 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Development installation script for dev-browser plugin
|
||||
# This script removes any existing installation and reinstalls from the current directory
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
MARKETPLACE_NAME="dev-browser-marketplace"
|
||||
PLUGIN_NAME="dev-browser"
|
||||
|
||||
# Find claude command - check common locations
|
||||
if command -v claude &> /dev/null; then
|
||||
CLAUDE="claude"
|
||||
elif [ -x "$HOME/.claude/local/claude" ]; then
|
||||
CLAUDE="$HOME/.claude/local/claude"
|
||||
elif [ -x "/usr/local/bin/claude" ]; then
|
||||
CLAUDE="/usr/local/bin/claude"
|
||||
else
|
||||
echo "Error: claude command not found"
|
||||
echo "Please install Claude Code or add it to your PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Dev Browser - Development Installation"
|
||||
echo "======================================="
|
||||
echo ""
|
||||
|
||||
# Step 1: Remove existing plugin if installed
|
||||
echo "Checking for existing plugin installation..."
|
||||
if $CLAUDE plugin uninstall "${PLUGIN_NAME}@${MARKETPLACE_NAME}" 2>/dev/null; then
|
||||
echo " Removed existing plugin: ${PLUGIN_NAME}@${MARKETPLACE_NAME}"
|
||||
else
|
||||
echo " No existing plugin found (skipping)"
|
||||
fi
|
||||
|
||||
# Also try to remove from the GitHub marketplace if it exists
|
||||
if $CLAUDE plugin uninstall "${PLUGIN_NAME}@sawyerhood/dev-browser" 2>/dev/null; then
|
||||
echo " Removed plugin from GitHub marketplace: ${PLUGIN_NAME}@sawyerhood/dev-browser"
|
||||
else
|
||||
echo " No GitHub marketplace plugin found (skipping)"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# Step 2: Remove existing marketplaces
|
||||
echo "Checking for existing marketplace..."
|
||||
if $CLAUDE plugin marketplace remove "${MARKETPLACE_NAME}" 2>/dev/null; then
|
||||
echo " Removed marketplace: ${MARKETPLACE_NAME}"
|
||||
else
|
||||
echo " Local marketplace not found (skipping)"
|
||||
fi
|
||||
|
||||
if $CLAUDE plugin marketplace remove "sawyerhood/dev-browser" 2>/dev/null; then
|
||||
echo " Removed GitHub marketplace: sawyerhood/dev-browser"
|
||||
else
|
||||
echo " GitHub marketplace not found (skipping)"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# Step 3: Add the local marketplace
|
||||
echo "Adding local marketplace from: ${SCRIPT_DIR}"
|
||||
$CLAUDE plugin marketplace add "${SCRIPT_DIR}"
|
||||
echo " Added marketplace: ${MARKETPLACE_NAME}"
|
||||
|
||||
echo ""
|
||||
|
||||
# Step 4: Install the plugin
|
||||
echo "Installing plugin: ${PLUGIN_NAME}@${MARKETPLACE_NAME}"
|
||||
$CLAUDE plugin install "${PLUGIN_NAME}@${MARKETPLACE_NAME}"
|
||||
echo " Installed plugin successfully"
|
||||
|
||||
echo ""
|
||||
echo "======================================="
|
||||
echo "Installation complete!"
|
||||
echo ""
|
||||
echo "Restart Claude Code to activate the plugin."
|
||||
477
skills/dev-browser/package-lock.json
generated
Normal file
477
skills/dev-browser/package-lock.json
generated
Normal file
@@ -0,0 +1,477 @@
|
||||
{
|
||||
"name": "browser-skill",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "browser-skill",
|
||||
"devDependencies": {
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^16.2.7",
|
||||
"prettier": "^3.7.4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-escapes": {
|
||||
"version": "7.2.0",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"environment": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "6.2.2",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-styles": {
|
||||
"version": "6.2.3",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/braces": {
|
||||
"version": "3.0.3",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fill-range": "^7.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/cli-cursor": {
|
||||
"version": "5.0.0",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"restore-cursor": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/cli-truncate": {
|
||||
"version": "5.1.1",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"slice-ansi": "^7.1.0",
|
||||
"string-width": "^8.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/colorette": {
|
||||
"version": "2.0.20",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "14.0.2",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
"version": "10.6.0",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/environment": {
|
||||
"version": "1.1.0",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/eventemitter3": {
|
||||
"version": "5.0.1",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fill-range": {
|
||||
"version": "7.1.1",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"to-regex-range": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/get-east-asian-width": {
|
||||
"version": "1.4.0",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/husky": {
|
||||
"version": "9.1.7",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"husky": "bin.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/typicode"
|
||||
}
|
||||
},
|
||||
"node_modules/is-fullwidth-code-point": {
|
||||
"version": "5.1.0",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"get-east-asian-width": "^1.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/is-number": {
|
||||
"version": "7.0.0",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lint-staged": {
|
||||
"version": "16.2.7",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"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"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.17"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/lint-staged"
|
||||
}
|
||||
},
|
||||
"node_modules/listr2": {
|
||||
"version": "9.0.5",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"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"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/log-update": {
|
||||
"version": "6.1.0",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"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"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/micromatch": {
|
||||
"version": "4.0.8",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"braces": "^3.0.3",
|
||||
"picomatch": "^2.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mimic-function": {
|
||||
"version": "5.0.1",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/nano-spawn": {
|
||||
"version": "2.0.0",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.17"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sindresorhus/nano-spawn?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/onetime": {
|
||||
"version": "7.0.0",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mimic-function": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "2.3.1",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/pidtree": {
|
||||
"version": "0.6.0",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"pidtree": "bin/pidtree.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/prettier": {
|
||||
"version": "3.7.4",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"prettier": "bin/prettier.cjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/prettier/prettier?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/restore-cursor": {
|
||||
"version": "5.1.0",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"onetime": "^7.0.0",
|
||||
"signal-exit": "^4.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/rfdc": {
|
||||
"version": "1.4.1",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/signal-exit": {
|
||||
"version": "4.1.0",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/slice-ansi": {
|
||||
"version": "7.1.2",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^6.2.1",
|
||||
"is-fullwidth-code-point": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/slice-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/string-argv": {
|
||||
"version": "0.3.2",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.6.19"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "8.1.0",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"get-east-asian-width": "^1.3.0",
|
||||
"strip-ansi": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-ansi": {
|
||||
"version": "7.1.2",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/to-regex-range": {
|
||||
"version": "5.0.1",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-number": "^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "9.0.2",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^6.2.1",
|
||||
"string-width": "^7.0.0",
|
||||
"strip-ansi": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi/node_modules/string-width": {
|
||||
"version": "7.2.0",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^10.3.0",
|
||||
"get-east-asian-width": "^1.0.0",
|
||||
"strip-ansi": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "2.8.2",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"yaml": "bin.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/eemeli"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
19
skills/dev-browser/package.json
Normal file
19
skills/dev-browser/package.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "browser-skill",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"devDependencies": {
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^16.2.7",
|
||||
"prettier": "^3.7.4",
|
||||
"typescript": "^5"
|
||||
},
|
||||
"scripts": {
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check .",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,ts,tsx,json,md,yml,yaml}": "prettier --write"
|
||||
}
|
||||
}
|
||||
211
skills/dev-browser/skills/dev-browser/SKILL.md
Normal file
211
skills/dev-browser/skills/dev-browser/SKILL.md
Normal file
@@ -0,0 +1,211 @@
|
||||
---
|
||||
name: dev-browser
|
||||
description: Browser automation with persistent page state. Use when users ask to navigate websites, fill forms, take screenshots, extract web data, test web apps, or automate browser workflows. Trigger phrases include "go to [url]", "click on", "fill out the form", "take a screenshot", "scrape", "automate", "test the website", "log into", or any browser interaction request.
|
||||
---
|
||||
|
||||
# Dev Browser Skill
|
||||
|
||||
Browser automation that maintains page state across script executions. Write small, focused scripts to accomplish tasks incrementally. Once you've proven out part of a workflow and there is repeated work to be done, you can write a script to do the repeated work in a single execution.
|
||||
|
||||
## Choosing Your Approach
|
||||
|
||||
- **Local/source-available sites**: Read the source code first to write selectors directly
|
||||
- **Unknown page layouts**: Use `getAISnapshot()` to discover elements and `selectSnapshotRef()` to interact with them
|
||||
- **Visual feedback**: Take screenshots to see what the user sees
|
||||
|
||||
## Setup
|
||||
|
||||
Two modes available. Ask the user if unclear which to use.
|
||||
|
||||
### Standalone Mode (Default)
|
||||
|
||||
Launches a new Chromium browser for fresh automation sessions.
|
||||
|
||||
```bash
|
||||
./skills/dev-browser/server.sh &
|
||||
```
|
||||
|
||||
Add `--headless` flag if user requests it. **Wait for the `Ready` message before running scripts.**
|
||||
|
||||
### Extension Mode
|
||||
|
||||
Connects to user's existing Chrome browser. Use this when:
|
||||
|
||||
- The user is already logged into sites and wants you to do things behind an authed experience that isn't local dev.
|
||||
- The user asks you to use the extension
|
||||
|
||||
**Important**: The core flow is still the same. You create named pages inside of their browser.
|
||||
|
||||
**Start the relay server:**
|
||||
|
||||
```bash
|
||||
cd skills/dev-browser && npm i && npm run start-extension &
|
||||
```
|
||||
|
||||
Wait for `Waiting for extension to connect...` followed by `Extension connected` in the console. To know that a client has connected and the browser is ready to be controlled.
|
||||
**Workflow:**
|
||||
|
||||
1. Scripts call `client.page("name")` just like the normal mode to create new pages / connect to existing ones.
|
||||
2. Automation runs on the user's actual browser session
|
||||
|
||||
If the extension hasn't connected yet, tell the user to launch and activate it. Download link: https://github.com/SawyerHood/dev-browser/releases
|
||||
|
||||
## Writing Scripts
|
||||
|
||||
> **Run all scripts from `skills/dev-browser/` directory.** The `@/` import alias requires this directory's config.
|
||||
|
||||
Execute scripts inline using heredocs:
|
||||
|
||||
```bash
|
||||
cd skills/dev-browser && npx tsx <<'EOF'
|
||||
import { connect, waitForPageLoad } from "@/client.js";
|
||||
|
||||
const client = await connect();
|
||||
// Create page with custom viewport size (optional)
|
||||
const page = await client.page("example", { viewport: { width: 1920, height: 1080 } });
|
||||
|
||||
await page.goto("https://example.com");
|
||||
await waitForPageLoad(page);
|
||||
|
||||
console.log({ title: await page.title(), url: page.url() });
|
||||
await client.disconnect();
|
||||
EOF
|
||||
```
|
||||
|
||||
**Write to `tmp/` files only when** the script needs reuse, is complex, or user explicitly requests it.
|
||||
|
||||
### Key Principles
|
||||
|
||||
1. **Small scripts**: Each script does ONE thing (navigate, click, fill, check)
|
||||
2. **Evaluate state**: Log/return state at the end to decide next steps
|
||||
3. **Descriptive page names**: Use `"checkout"`, `"login"`, not `"main"`
|
||||
4. **Disconnect to exit**: `await client.disconnect()` - pages persist on server
|
||||
5. **Plain JS in evaluate**: `page.evaluate()` runs in browser - no TypeScript syntax
|
||||
|
||||
## Workflow Loop
|
||||
|
||||
Follow this pattern for complex tasks:
|
||||
|
||||
1. **Write a script** to perform one action
|
||||
2. **Run it** and observe the output
|
||||
3. **Evaluate** - did it work? What's the current state?
|
||||
4. **Decide** - is the task complete or do we need another script?
|
||||
5. **Repeat** until task is done
|
||||
|
||||
### No TypeScript in Browser Context
|
||||
|
||||
Code passed to `page.evaluate()` runs in the browser, which doesn't understand TypeScript:
|
||||
|
||||
```typescript
|
||||
// ✅ Correct: plain JavaScript
|
||||
const text = await page.evaluate(() => {
|
||||
return document.body.innerText;
|
||||
});
|
||||
|
||||
// ❌ Wrong: TypeScript syntax will fail at runtime
|
||||
const text = await page.evaluate(() => {
|
||||
const el: HTMLElement = document.body; // Type annotation breaks in browser!
|
||||
return el.innerText;
|
||||
});
|
||||
```
|
||||
|
||||
## Scraping Data
|
||||
|
||||
For scraping large datasets, intercept and replay network requests rather than scrolling the DOM. See [references/scraping.md](references/scraping.md) for the complete guide covering request capture, schema discovery, and paginated API replay.
|
||||
|
||||
## Client API
|
||||
|
||||
```typescript
|
||||
const client = await connect();
|
||||
|
||||
// Get or create named page (viewport only applies to new pages)
|
||||
const page = await client.page("name");
|
||||
const pageWithSize = await client.page("name", { viewport: { width: 1920, height: 1080 } });
|
||||
|
||||
const pages = await client.list(); // List all page names
|
||||
await client.close("name"); // Close a page
|
||||
await client.disconnect(); // Disconnect (pages persist)
|
||||
|
||||
// ARIA Snapshot methods
|
||||
const snapshot = await client.getAISnapshot("name"); // Get accessibility tree
|
||||
const element = await client.selectSnapshotRef("name", "e5"); // Get element by ref
|
||||
```
|
||||
|
||||
The `page` object is a standard Playwright Page.
|
||||
|
||||
## Waiting
|
||||
|
||||
```typescript
|
||||
import { waitForPageLoad } from "@/client.js";
|
||||
|
||||
await waitForPageLoad(page); // After navigation
|
||||
await page.waitForSelector(".results"); // For specific elements
|
||||
await page.waitForURL("**/success"); // For specific URL
|
||||
```
|
||||
|
||||
## Inspecting Page State
|
||||
|
||||
### Screenshots
|
||||
|
||||
```typescript
|
||||
await page.screenshot({ path: "tmp/screenshot.png" });
|
||||
await page.screenshot({ path: "tmp/full.png", fullPage: true });
|
||||
```
|
||||
|
||||
### ARIA Snapshot (Element Discovery)
|
||||
|
||||
Use `getAISnapshot()` to discover page elements. Returns YAML-formatted accessibility tree:
|
||||
|
||||
```yaml
|
||||
- banner:
|
||||
- link "Hacker News" [ref=e1]
|
||||
- navigation:
|
||||
- link "new" [ref=e2]
|
||||
- main:
|
||||
- list:
|
||||
- listitem:
|
||||
- link "Article Title" [ref=e8]
|
||||
- link "328 comments" [ref=e9]
|
||||
- contentinfo:
|
||||
- textbox [ref=e10]
|
||||
- /placeholder: "Search"
|
||||
```
|
||||
|
||||
**Interpreting refs:**
|
||||
|
||||
- `[ref=eN]` - Element reference for interaction (visible, clickable elements only)
|
||||
- `[checked]`, `[disabled]`, `[expanded]` - Element states
|
||||
- `[level=N]` - Heading level
|
||||
- `/url:`, `/placeholder:` - Element properties
|
||||
|
||||
**Interacting with refs:**
|
||||
|
||||
```typescript
|
||||
const snapshot = await client.getAISnapshot("hackernews");
|
||||
console.log(snapshot); // Find the ref you need
|
||||
|
||||
const element = await client.selectSnapshotRef("hackernews", "e2");
|
||||
await element.click();
|
||||
```
|
||||
|
||||
## Error Recovery
|
||||
|
||||
Page state persists after failures. Debug with:
|
||||
|
||||
```bash
|
||||
cd skills/dev-browser && npx tsx <<'EOF'
|
||||
import { connect } from "@/client.js";
|
||||
|
||||
const client = await connect();
|
||||
const page = await client.page("hackernews");
|
||||
|
||||
await page.screenshot({ path: "tmp/debug.png" });
|
||||
console.log({
|
||||
url: page.url(),
|
||||
title: await page.title(),
|
||||
bodyText: await page.textContent("body").then((t) => t?.slice(0, 200)),
|
||||
});
|
||||
|
||||
await client.disconnect();
|
||||
EOF
|
||||
```
|
||||
443
skills/dev-browser/skills/dev-browser/bun.lock
Normal file
443
skills/dev-browser/skills/dev-browser/bun.lock
Normal file
@@ -0,0 +1,443 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "dev-browser",
|
||||
"dependencies": {
|
||||
"express": "^4.21.0",
|
||||
"playwright": "^1.49.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^5.0.0",
|
||||
"tsx": "^4.21.0",
|
||||
"vitest": "^2.1.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-HHB50pdsBX6k47S4u5g/CaLjqS3qwaOVE5ILsq64jyzgMhLuCuZ8rGzM9yhsAjfjkbgUPMzZEPa7DAp7yz6vuA=="],
|
||||
|
||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.27.1", "", { "os": "android", "cpu": "arm" }, "sha512-kFqa6/UcaTbGm/NncN9kzVOODjhZW8e+FRdSeypWe6j33gzclHtwlANs26JrupOntlcWmB0u8+8HZo8s7thHvg=="],
|
||||
|
||||
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.1", "", { "os": "android", "cpu": "arm64" }, "sha512-45fuKmAJpxnQWixOGCrS+ro4Uvb4Re9+UTieUY2f8AEc+t7d4AaZ6eUJ3Hva7dtrxAAWHtlEFsXFMAgNnGU9uQ=="],
|
||||
|
||||
"@esbuild/android-x64": ["@esbuild/android-x64@0.27.1", "", { "os": "android", "cpu": "x64" }, "sha512-LBEpOz0BsgMEeHgenf5aqmn/lLNTFXVfoWMUox8CtWWYK9X4jmQzWjoGoNb8lmAYml/tQ/Ysvm8q7szu7BoxRQ=="],
|
||||
|
||||
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-veg7fL8eMSCVKL7IW4pxb54QERtedFDfY/ASrumK/SbFsXnRazxY4YykN/THYqFnFwJ0aVjiUrVG2PwcdAEqQQ=="],
|
||||
|
||||
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-+3ELd+nTzhfWb07Vol7EZ+5PTbJ/u74nC6iv4/lwIU99Ip5uuY6QoIf0Hn4m2HoV0qcnRivN3KSqc+FyCHjoVQ=="],
|
||||
|
||||
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-/8Rfgns4XD9XOSXlzUDepG8PX+AVWHliYlUkFI3K3GB6tqbdjYqdhcb4BKRd7C0BhZSoaCxhv8kTcBrcZWP+xg=="],
|
||||
|
||||
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-GITpD8dK9C+r+5yRT/UKVT36h/DQLOHdwGVwwoHidlnA168oD3uxA878XloXebK4Ul3gDBBIvEdL7go9gCUFzQ=="],
|
||||
|
||||
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.1", "", { "os": "linux", "cpu": "arm" }, "sha512-ieMID0JRZY/ZeCrsFQ3Y3NlHNCqIhTprJfDgSB3/lv5jJZ8FX3hqPyXWhe+gvS5ARMBJ242PM+VNz/ctNj//eA=="],
|
||||
|
||||
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-W9//kCrh/6in9rWIBdKaMtuTTzNj6jSeG/haWBADqLLa9P8O5YSRDzgD5y9QBok4AYlzS6ARHifAb75V6G670Q=="],
|
||||
|
||||
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-VIUV4z8GD8rtSVMfAj1aXFahsi/+tcoXXNYmXgzISL+KB381vbSTNdeZHHHIYqFyXcoEhu9n5cT+05tRv13rlw=="],
|
||||
|
||||
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.1", "", { "os": "linux", "cpu": "none" }, "sha512-l4rfiiJRN7sTNI//ff65zJ9z8U+k6zcCg0LALU5iEWzY+a1mVZ8iWC1k5EsNKThZ7XCQ6YWtsZ8EWYm7r1UEsg=="],
|
||||
|
||||
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.1", "", { "os": "linux", "cpu": "none" }, "sha512-U0bEuAOLvO/DWFdygTHWY8C067FXz+UbzKgxYhXC0fDieFa0kDIra1FAhsAARRJbvEyso8aAqvPdNxzWuStBnA=="],
|
||||
|
||||
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-NzdQ/Xwu6vPSf/GkdmRNsOfIeSGnh7muundsWItmBsVpMoNPVpM61qNzAVY3pZ1glzzAxLR40UyYM23eaDDbYQ=="],
|
||||
|
||||
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.1", "", { "os": "linux", "cpu": "none" }, "sha512-7zlw8p3IApcsN7mFw0O1Z1PyEk6PlKMu18roImfl3iQHTnr/yAfYv6s4hXPidbDoI2Q0pW+5xeoM4eTCC0UdrQ=="],
|
||||
|
||||
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-cGj5wli+G+nkVQdZo3+7FDKC25Uh4ZVwOAK6A06Hsvgr8WqBBuOy/1s+PUEd/6Je+vjfm6stX0kmib5b/O2Ykw=="],
|
||||
|
||||
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.1", "", { "os": "linux", "cpu": "x64" }, "sha512-z3H/HYI9MM0HTv3hQZ81f+AKb+yEoCRlUby1F80vbQ5XdzEMyY/9iNlAmhqiBKw4MJXwfgsh7ERGEOhrM1niMA=="],
|
||||
|
||||
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.1", "", { "os": "none", "cpu": "arm64" }, "sha512-wzC24DxAvk8Em01YmVXyjl96Mr+ecTPyOuADAvjGg+fyBpGmxmcr2E5ttf7Im8D0sXZihpxzO1isus8MdjMCXQ=="],
|
||||
|
||||
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.1", "", { "os": "none", "cpu": "x64" }, "sha512-1YQ8ybGi2yIXswu6eNzJsrYIGFpnlzEWRl6iR5gMgmsrR0FcNoV1m9k9sc3PuP5rUBLshOZylc9nqSgymI+TYg=="],
|
||||
|
||||
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-5Z+DzLCrq5wmU7RDaMDe2DVXMRm2tTDvX2KU14JJVBN2CT/qov7XVix85QoJqHltpvAOZUAc3ndU56HSMWrv8g=="],
|
||||
|
||||
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-Q73ENzIdPF5jap4wqLtsfh8YbYSZ8Q0wnxplOlZUOyZy7B4ZKW8DXGWgTCZmF8VWD7Tciwv5F4NsRf6vYlZtqg=="],
|
||||
|
||||
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ajbHrGM/XiK+sXM0JzEbJAen+0E+JMQZ2l4RR4VFwvV9JEERx+oxtgkpoKv1SevhjavK2z2ReHk32pjzktWbGg=="],
|
||||
|
||||
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-IPUW+y4VIjuDVn+OMzHc5FV4GubIwPnsz6ubkvN8cuhEqH81NovB53IUlrlBkPMEPxvNnf79MGBoz8rZ2iW8HA=="],
|
||||
|
||||
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-RIVRWiljWA6CdVu8zkWcRmGP7iRRIIwvhDKem8UMBjPql2TXM5PkDVvvrzMtj1V+WFPB4K7zkIGM7VzRtFkjdg=="],
|
||||
|
||||
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-2BR5M8CPbptC1AK5JbJT1fWrHLvejwZidKx3UMSF0ecHMa+smhi16drIrCEggkgviBwLYd5nwrFLSl5Kho96RQ=="],
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.1", "", { "os": "win32", "cpu": "x64" }, "sha512-d5X6RMYv6taIymSk8JBP+nxv8DQAMY6A51GPgusqLdK9wBz5wWIXy1KjTck6HnjE9hqJzJRdk+1p/t5soSbCtw=="],
|
||||
|
||||
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
||||
|
||||
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.53.3", "", { "os": "android", "cpu": "arm" }, "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w=="],
|
||||
|
||||
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.53.3", "", { "os": "android", "cpu": "arm64" }, "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w=="],
|
||||
|
||||
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.53.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA=="],
|
||||
|
||||
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.53.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ=="],
|
||||
|
||||
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.53.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w=="],
|
||||
|
||||
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.53.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.53.3", "", { "os": "linux", "cpu": "arm" }, "sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.53.3", "", { "os": "linux", "cpu": "arm" }, "sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.53.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.53.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A=="],
|
||||
|
||||
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.53.3", "", { "os": "linux", "cpu": "none" }, "sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g=="],
|
||||
|
||||
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.53.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.53.3", "", { "os": "linux", "cpu": "none" }, "sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.53.3", "", { "os": "linux", "cpu": "none" }, "sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A=="],
|
||||
|
||||
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.53.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.53.3", "", { "os": "linux", "cpu": "x64" }, "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.53.3", "", { "os": "linux", "cpu": "x64" }, "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q=="],
|
||||
|
||||
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.53.3", "", { "os": "none", "cpu": "arm64" }, "sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw=="],
|
||||
|
||||
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.53.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw=="],
|
||||
|
||||
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.53.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA=="],
|
||||
|
||||
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.53.3", "", { "os": "win32", "cpu": "x64" }, "sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg=="],
|
||||
|
||||
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.53.3", "", { "os": "win32", "cpu": "x64" }, "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ=="],
|
||||
|
||||
"@types/body-parser": ["@types/body-parser@1.19.6", "", { "dependencies": { "@types/connect": "*", "@types/node": "*" } }, "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g=="],
|
||||
|
||||
"@types/connect": ["@types/connect@3.4.38", "", { "dependencies": { "@types/node": "*" } }, "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||
|
||||
"@types/express": ["@types/express@5.0.6", "", { "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", "@types/serve-static": "^2" } }, "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA=="],
|
||||
|
||||
"@types/express-serve-static-core": ["@types/express-serve-static-core@5.1.0", "", { "dependencies": { "@types/node": "*", "@types/qs": "*", "@types/range-parser": "*", "@types/send": "*" } }, "sha512-jnHMsrd0Mwa9Cf4IdOzbz543y4XJepXrbia2T4b6+spXC2We3t1y6K44D3mR8XMFSXMCf3/l7rCgddfx7UNVBA=="],
|
||||
|
||||
"@types/http-errors": ["@types/http-errors@2.0.5", "", {}, "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg=="],
|
||||
|
||||
"@types/node": ["@types/node@24.10.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="],
|
||||
|
||||
"@types/qs": ["@types/qs@6.14.0", "", {}, "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ=="],
|
||||
|
||||
"@types/range-parser": ["@types/range-parser@1.2.7", "", {}, "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ=="],
|
||||
|
||||
"@types/send": ["@types/send@1.2.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ=="],
|
||||
|
||||
"@types/serve-static": ["@types/serve-static@2.2.0", "", { "dependencies": { "@types/http-errors": "*", "@types/node": "*" } }, "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ=="],
|
||||
|
||||
"@vitest/expect": ["@vitest/expect@2.1.9", "", { "dependencies": { "@vitest/spy": "2.1.9", "@vitest/utils": "2.1.9", "chai": "^5.1.2", "tinyrainbow": "^1.2.0" } }, "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw=="],
|
||||
|
||||
"@vitest/mocker": ["@vitest/mocker@2.1.9", "", { "dependencies": { "@vitest/spy": "2.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.12" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg=="],
|
||||
|
||||
"@vitest/pretty-format": ["@vitest/pretty-format@2.1.9", "", { "dependencies": { "tinyrainbow": "^1.2.0" } }, "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ=="],
|
||||
|
||||
"@vitest/runner": ["@vitest/runner@2.1.9", "", { "dependencies": { "@vitest/utils": "2.1.9", "pathe": "^1.1.2" } }, "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g=="],
|
||||
|
||||
"@vitest/snapshot": ["@vitest/snapshot@2.1.9", "", { "dependencies": { "@vitest/pretty-format": "2.1.9", "magic-string": "^0.30.12", "pathe": "^1.1.2" } }, "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ=="],
|
||||
|
||||
"@vitest/spy": ["@vitest/spy@2.1.9", "", { "dependencies": { "tinyspy": "^3.0.2" } }, "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ=="],
|
||||
|
||||
"@vitest/utils": ["@vitest/utils@2.1.9", "", { "dependencies": { "@vitest/pretty-format": "2.1.9", "loupe": "^3.1.2", "tinyrainbow": "^1.2.0" } }, "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ=="],
|
||||
|
||||
"accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="],
|
||||
|
||||
"array-flatten": ["array-flatten@1.1.1", "", {}, "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="],
|
||||
|
||||
"assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
|
||||
|
||||
"body-parser": ["body-parser@1.20.4", "", { "dependencies": { "bytes": "~3.1.2", "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "~1.2.0", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "on-finished": "~2.4.1", "qs": "~6.14.0", "raw-body": "~2.5.3", "type-is": "~1.6.18", "unpipe": "~1.0.0" } }, "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA=="],
|
||||
|
||||
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
|
||||
|
||||
"cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="],
|
||||
|
||||
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
|
||||
|
||||
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
|
||||
|
||||
"chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="],
|
||||
|
||||
"check-error": ["check-error@2.1.1", "", {}, "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw=="],
|
||||
|
||||
"content-disposition": ["content-disposition@0.5.4", "", { "dependencies": { "safe-buffer": "5.2.1" } }, "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ=="],
|
||||
|
||||
"content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
|
||||
|
||||
"cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
|
||||
|
||||
"cookie-signature": ["cookie-signature@1.0.7", "", {}, "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="],
|
||||
|
||||
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
|
||||
|
||||
"destroy": ["destroy@1.2.0", "", {}, "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg=="],
|
||||
|
||||
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
||||
|
||||
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
|
||||
|
||||
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
|
||||
|
||||
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
|
||||
|
||||
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
|
||||
|
||||
"es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="],
|
||||
|
||||
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
|
||||
|
||||
"esbuild": ["esbuild@0.27.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.1", "@esbuild/android-arm": "0.27.1", "@esbuild/android-arm64": "0.27.1", "@esbuild/android-x64": "0.27.1", "@esbuild/darwin-arm64": "0.27.1", "@esbuild/darwin-x64": "0.27.1", "@esbuild/freebsd-arm64": "0.27.1", "@esbuild/freebsd-x64": "0.27.1", "@esbuild/linux-arm": "0.27.1", "@esbuild/linux-arm64": "0.27.1", "@esbuild/linux-ia32": "0.27.1", "@esbuild/linux-loong64": "0.27.1", "@esbuild/linux-mips64el": "0.27.1", "@esbuild/linux-ppc64": "0.27.1", "@esbuild/linux-riscv64": "0.27.1", "@esbuild/linux-s390x": "0.27.1", "@esbuild/linux-x64": "0.27.1", "@esbuild/netbsd-arm64": "0.27.1", "@esbuild/netbsd-x64": "0.27.1", "@esbuild/openbsd-arm64": "0.27.1", "@esbuild/openbsd-x64": "0.27.1", "@esbuild/openharmony-arm64": "0.27.1", "@esbuild/sunos-x64": "0.27.1", "@esbuild/win32-arm64": "0.27.1", "@esbuild/win32-ia32": "0.27.1", "@esbuild/win32-x64": "0.27.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-yY35KZckJJuVVPXpvjgxiCuVEJT67F6zDeVTv4rizyPrfGBUpZQsvmxnN+C371c2esD/hNMjj4tpBhuueLN7aA=="],
|
||||
|
||||
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
|
||||
|
||||
"estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
|
||||
|
||||
"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
|
||||
|
||||
"expect-type": ["expect-type@1.2.2", "", {}, "sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA=="],
|
||||
|
||||
"express": ["express@4.22.1", "", { "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", "body-parser": "~1.20.3", "content-disposition": "~0.5.4", "content-type": "~1.0.4", "cookie": "~0.7.1", "cookie-signature": "~1.0.6", "debug": "2.6.9", "depd": "2.0.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "finalhandler": "~1.3.1", "fresh": "~0.5.2", "http-errors": "~2.0.0", "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", "qs": "~6.14.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", "serve-static": "~1.16.2", "setprototypeof": "1.2.0", "statuses": "~2.0.1", "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" } }, "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g=="],
|
||||
|
||||
"finalhandler": ["finalhandler@1.3.2", "", { "dependencies": { "debug": "2.6.9", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "statuses": "~2.0.2", "unpipe": "~1.0.0" } }, "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg=="],
|
||||
|
||||
"forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
|
||||
|
||||
"fresh": ["fresh@0.5.2", "", {}, "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||
|
||||
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
|
||||
|
||||
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
|
||||
|
||||
"get-tsconfig": ["get-tsconfig@4.13.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ=="],
|
||||
|
||||
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
|
||||
|
||||
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
|
||||
|
||||
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
||||
|
||||
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
|
||||
|
||||
"iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="],
|
||||
|
||||
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
||||
|
||||
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
|
||||
|
||||
"loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="],
|
||||
|
||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||
|
||||
"media-typer": ["media-typer@0.3.0", "", {}, "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="],
|
||||
|
||||
"merge-descriptors": ["merge-descriptors@1.0.3", "", {}, "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ=="],
|
||||
|
||||
"methods": ["methods@1.1.2", "", {}, "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w=="],
|
||||
|
||||
"mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="],
|
||||
|
||||
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
||||
|
||||
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||
|
||||
"negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="],
|
||||
|
||||
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
|
||||
|
||||
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
|
||||
|
||||
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
|
||||
|
||||
"path-to-regexp": ["path-to-regexp@0.1.12", "", {}, "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ=="],
|
||||
|
||||
"pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="],
|
||||
|
||||
"pathval": ["pathval@2.0.1", "", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"playwright": ["playwright@1.57.0", "", { "dependencies": { "playwright-core": "1.57.0" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw=="],
|
||||
|
||||
"playwright-core": ["playwright-core@1.57.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ=="],
|
||||
|
||||
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
|
||||
|
||||
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
|
||||
|
||||
"qs": ["qs@6.14.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w=="],
|
||||
|
||||
"range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
|
||||
|
||||
"raw-body": ["raw-body@2.5.3", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "unpipe": "~1.0.0" } }, "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA=="],
|
||||
|
||||
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
|
||||
|
||||
"rollup": ["rollup@4.53.3", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.53.3", "@rollup/rollup-android-arm64": "4.53.3", "@rollup/rollup-darwin-arm64": "4.53.3", "@rollup/rollup-darwin-x64": "4.53.3", "@rollup/rollup-freebsd-arm64": "4.53.3", "@rollup/rollup-freebsd-x64": "4.53.3", "@rollup/rollup-linux-arm-gnueabihf": "4.53.3", "@rollup/rollup-linux-arm-musleabihf": "4.53.3", "@rollup/rollup-linux-arm64-gnu": "4.53.3", "@rollup/rollup-linux-arm64-musl": "4.53.3", "@rollup/rollup-linux-loong64-gnu": "4.53.3", "@rollup/rollup-linux-ppc64-gnu": "4.53.3", "@rollup/rollup-linux-riscv64-gnu": "4.53.3", "@rollup/rollup-linux-riscv64-musl": "4.53.3", "@rollup/rollup-linux-s390x-gnu": "4.53.3", "@rollup/rollup-linux-x64-gnu": "4.53.3", "@rollup/rollup-linux-x64-musl": "4.53.3", "@rollup/rollup-openharmony-arm64": "4.53.3", "@rollup/rollup-win32-arm64-msvc": "4.53.3", "@rollup/rollup-win32-ia32-msvc": "4.53.3", "@rollup/rollup-win32-x64-gnu": "4.53.3", "@rollup/rollup-win32-x64-msvc": "4.53.3", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA=="],
|
||||
|
||||
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
|
||||
|
||||
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
||||
|
||||
"send": ["send@0.19.1", "", { "dependencies": { "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "fresh": "0.5.2", "http-errors": "2.0.0", "mime": "1.6.0", "ms": "2.1.3", "on-finished": "2.4.1", "range-parser": "~1.2.1", "statuses": "2.0.1" } }, "sha512-p4rRk4f23ynFEfcD9LA0xRYngj+IyGiEYyqqOak8kaN0TvNmuxC2dcVeBn62GpCeR2CpWqyHCNScTP91QbAVFg=="],
|
||||
|
||||
"serve-static": ["serve-static@1.16.2", "", { "dependencies": { "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", "send": "0.19.0" } }, "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw=="],
|
||||
|
||||
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
|
||||
|
||||
"side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
|
||||
|
||||
"side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="],
|
||||
|
||||
"side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="],
|
||||
|
||||
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
|
||||
|
||||
"siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
|
||||
|
||||
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
|
||||
|
||||
"std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="],
|
||||
|
||||
"tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
|
||||
|
||||
"tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="],
|
||||
|
||||
"tinypool": ["tinypool@1.1.1", "", {}, "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg=="],
|
||||
|
||||
"tinyrainbow": ["tinyrainbow@1.2.0", "", {}, "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ=="],
|
||||
|
||||
"tinyspy": ["tinyspy@3.0.2", "", {}, "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q=="],
|
||||
|
||||
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
|
||||
|
||||
"tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="],
|
||||
|
||||
"type-is": ["type-is@1.6.18", "", { "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" } }, "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g=="],
|
||||
|
||||
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||
|
||||
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
|
||||
|
||||
"utils-merge": ["utils-merge@1.0.1", "", {}, "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="],
|
||||
|
||||
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
|
||||
|
||||
"vite": ["vite@5.4.21", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="],
|
||||
|
||||
"vite-node": ["vite-node@2.1.9", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.3.7", "es-module-lexer": "^1.5.4", "pathe": "^1.1.2", "vite": "^5.0.0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA=="],
|
||||
|
||||
"vitest": ["vitest@2.1.9", "", { "dependencies": { "@vitest/expect": "2.1.9", "@vitest/mocker": "2.1.9", "@vitest/pretty-format": "^2.1.9", "@vitest/runner": "2.1.9", "@vitest/snapshot": "2.1.9", "@vitest/spy": "2.1.9", "@vitest/utils": "2.1.9", "chai": "^5.1.2", "debug": "^4.3.7", "expect-type": "^1.1.0", "magic-string": "^0.30.12", "pathe": "^1.1.2", "std-env": "^3.8.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.1", "tinypool": "^1.0.1", "tinyrainbow": "^1.2.0", "vite": "^5.0.0", "vite-node": "2.1.9", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/node": "^18.0.0 || >=20.0.0", "@vitest/browser": "2.1.9", "@vitest/ui": "2.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q=="],
|
||||
|
||||
"why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],
|
||||
|
||||
"body-parser/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
|
||||
|
||||
"express/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
|
||||
|
||||
"finalhandler/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
|
||||
|
||||
"playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
|
||||
|
||||
"send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
|
||||
|
||||
"send/http-errors": ["http-errors@2.0.0", "", { "dependencies": { "depd": "2.0.0", "inherits": "2.0.4", "setprototypeof": "1.2.0", "statuses": "2.0.1", "toidentifier": "1.0.1" } }, "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ=="],
|
||||
|
||||
"send/statuses": ["statuses@2.0.1", "", {}, "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ=="],
|
||||
|
||||
"serve-static/send": ["send@0.19.0", "", { "dependencies": { "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "encodeurl": "~1.0.2", "escape-html": "~1.0.3", "etag": "~1.8.1", "fresh": "0.5.2", "http-errors": "2.0.0", "mime": "1.6.0", "ms": "2.1.3", "on-finished": "2.4.1", "range-parser": "~1.2.1", "statuses": "2.0.1" } }, "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw=="],
|
||||
|
||||
"vite/esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="],
|
||||
|
||||
"body-parser/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
|
||||
|
||||
"express/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
|
||||
|
||||
"finalhandler/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
|
||||
|
||||
"send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
|
||||
|
||||
"serve-static/send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
|
||||
|
||||
"serve-static/send/encodeurl": ["encodeurl@1.0.2", "", {}, "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w=="],
|
||||
|
||||
"serve-static/send/http-errors": ["http-errors@2.0.0", "", { "dependencies": { "depd": "2.0.0", "inherits": "2.0.4", "setprototypeof": "1.2.0", "statuses": "2.0.1", "toidentifier": "1.0.1" } }, "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ=="],
|
||||
|
||||
"serve-static/send/statuses": ["statuses@2.0.1", "", {}, "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ=="],
|
||||
|
||||
"vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="],
|
||||
|
||||
"vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.21.5", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="],
|
||||
|
||||
"vite/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.21.5", "", { "os": "android", "cpu": "arm64" }, "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A=="],
|
||||
|
||||
"vite/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.21.5", "", { "os": "android", "cpu": "x64" }, "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA=="],
|
||||
|
||||
"vite/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.21.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ=="],
|
||||
|
||||
"vite/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.21.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw=="],
|
||||
|
||||
"vite/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.21.5", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g=="],
|
||||
|
||||
"vite/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.21.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ=="],
|
||||
|
||||
"vite/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.21.5", "", { "os": "linux", "cpu": "arm" }, "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA=="],
|
||||
|
||||
"vite/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.21.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q=="],
|
||||
|
||||
"vite/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.21.5", "", { "os": "linux", "cpu": "ia32" }, "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg=="],
|
||||
|
||||
"vite/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg=="],
|
||||
|
||||
"vite/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg=="],
|
||||
|
||||
"vite/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.21.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w=="],
|
||||
|
||||
"vite/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA=="],
|
||||
|
||||
"vite/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.21.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A=="],
|
||||
|
||||
"vite/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.21.5", "", { "os": "linux", "cpu": "x64" }, "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ=="],
|
||||
|
||||
"vite/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.21.5", "", { "os": "none", "cpu": "x64" }, "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg=="],
|
||||
|
||||
"vite/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.21.5", "", { "os": "openbsd", "cpu": "x64" }, "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow=="],
|
||||
|
||||
"vite/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.21.5", "", { "os": "sunos", "cpu": "x64" }, "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg=="],
|
||||
|
||||
"vite/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.21.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A=="],
|
||||
|
||||
"vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.21.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA=="],
|
||||
|
||||
"vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="],
|
||||
|
||||
"serve-static/send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
|
||||
}
|
||||
}
|
||||
2988
skills/dev-browser/skills/dev-browser/package-lock.json
generated
Normal file
2988
skills/dev-browser/skills/dev-browser/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
31
skills/dev-browser/skills/dev-browser/package.json
Normal file
31
skills/dev-browser/skills/dev-browser/package.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "dev-browser",
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"imports": {
|
||||
"@/*": "./src/*"
|
||||
},
|
||||
"scripts": {
|
||||
"start-server": "npx tsx scripts/start-server.ts",
|
||||
"start-extension": "npx tsx scripts/start-relay.ts",
|
||||
"dev": "npx tsx --watch src/index.ts",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.19.7",
|
||||
"@hono/node-ws": "^1.2.0",
|
||||
"express": "^4.21.0",
|
||||
"hono": "^4.11.1",
|
||||
"playwright": "^1.49.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^5.0.0",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.0.0",
|
||||
"vitest": "^2.1.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rollup/rollup-linux-x64-gnu": "^4.0.0"
|
||||
}
|
||||
}
|
||||
155
skills/dev-browser/skills/dev-browser/references/scraping.md
Normal file
155
skills/dev-browser/skills/dev-browser/references/scraping.md
Normal file
@@ -0,0 +1,155 @@
|
||||
# Data Scraping Guide
|
||||
|
||||
For large datasets (followers, posts, search results), **intercept and replay network requests** rather than scrolling and parsing the DOM. This is faster, more reliable, and handles pagination automatically.
|
||||
|
||||
## Why Not Scroll?
|
||||
|
||||
Scrolling is slow, unreliable, and wastes time. APIs return structured data with pagination built in. Always prefer API replay.
|
||||
|
||||
## Start Small, Then Scale
|
||||
|
||||
**Don't try to automate everything at once.** Work incrementally:
|
||||
|
||||
1. **Capture one request** - verify you're intercepting the right endpoint
|
||||
2. **Inspect one response** - understand the schema before writing extraction code
|
||||
3. **Extract a few items** - make sure your parsing logic works
|
||||
4. **Then scale up** - add pagination loop only after the basics work
|
||||
|
||||
This prevents wasting time debugging a complex script when the issue is a simple path like `data.user.timeline` vs `data.user.result.timeline`.
|
||||
|
||||
## Step-by-Step Workflow
|
||||
|
||||
### 1. Capture Request Details
|
||||
|
||||
First, intercept a request to understand URL structure and required headers:
|
||||
|
||||
```typescript
|
||||
import { connect, waitForPageLoad } from "@/client.js";
|
||||
import * as fs from "node:fs";
|
||||
|
||||
const client = await connect();
|
||||
const page = await client.page("site");
|
||||
|
||||
let capturedRequest = null;
|
||||
page.on("request", (request) => {
|
||||
const url = request.url();
|
||||
// Look for API endpoints (adjust pattern for your target site)
|
||||
if (url.includes("/api/") || url.includes("/graphql/")) {
|
||||
capturedRequest = {
|
||||
url: url,
|
||||
headers: request.headers(),
|
||||
method: request.method(),
|
||||
};
|
||||
fs.writeFileSync("tmp/request-details.json", JSON.stringify(capturedRequest, null, 2));
|
||||
console.log("Captured request:", url.substring(0, 80) + "...");
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto("https://example.com/profile");
|
||||
await waitForPageLoad(page);
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
await client.disconnect();
|
||||
```
|
||||
|
||||
### 2. Capture Response to Understand Schema
|
||||
|
||||
Save a raw response to inspect the data structure:
|
||||
|
||||
```typescript
|
||||
page.on("response", async (response) => {
|
||||
const url = response.url();
|
||||
if (url.includes("UserTweets") || url.includes("/api/data")) {
|
||||
const json = await response.json();
|
||||
fs.writeFileSync("tmp/api-response.json", JSON.stringify(json, null, 2));
|
||||
console.log("Captured response");
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
Then analyze the structure to find:
|
||||
|
||||
- Where the data array lives (e.g., `data.user.result.timeline.instructions[].entries`)
|
||||
- Where pagination cursors are (e.g., `cursor-bottom` entries)
|
||||
- What fields you need to extract
|
||||
|
||||
### 3. Replay API with Pagination
|
||||
|
||||
Once you understand the schema, replay requests directly:
|
||||
|
||||
```typescript
|
||||
import { connect } from "@/client.js";
|
||||
import * as fs from "node:fs";
|
||||
|
||||
const client = await connect();
|
||||
const page = await client.page("site");
|
||||
|
||||
const results = new Map(); // Use Map for deduplication
|
||||
const headers = JSON.parse(fs.readFileSync("tmp/request-details.json", "utf8")).headers;
|
||||
const baseUrl = "https://example.com/api/data";
|
||||
|
||||
let cursor = null;
|
||||
let hasMore = true;
|
||||
|
||||
while (hasMore) {
|
||||
// Build URL with pagination cursor
|
||||
const params = { count: 20 };
|
||||
if (cursor) params.cursor = cursor;
|
||||
const url = `${baseUrl}?params=${encodeURIComponent(JSON.stringify(params))}`;
|
||||
|
||||
// Execute fetch in browser context (has auth cookies/headers)
|
||||
const response = await page.evaluate(
|
||||
async ({ url, headers }) => {
|
||||
const res = await fetch(url, { headers });
|
||||
return res.json();
|
||||
},
|
||||
{ url, headers }
|
||||
);
|
||||
|
||||
// Extract data and cursor (adjust paths for your API)
|
||||
const entries = response?.data?.entries || [];
|
||||
for (const entry of entries) {
|
||||
if (entry.type === "cursor-bottom") {
|
||||
cursor = entry.value;
|
||||
} else if (entry.id && !results.has(entry.id)) {
|
||||
results.set(entry.id, {
|
||||
id: entry.id,
|
||||
text: entry.content,
|
||||
timestamp: entry.created_at,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Fetched page, total: ${results.size}`);
|
||||
|
||||
// Check stop conditions
|
||||
if (!cursor || entries.length === 0) hasMore = false;
|
||||
|
||||
// Rate limiting - be respectful
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
|
||||
// Export results
|
||||
const data = Array.from(results.values());
|
||||
fs.writeFileSync("tmp/results.json", JSON.stringify(data, null, 2));
|
||||
console.log(`Saved ${data.length} items`);
|
||||
|
||||
await client.disconnect();
|
||||
```
|
||||
|
||||
## Key Patterns
|
||||
|
||||
| Pattern | Description |
|
||||
| ----------------------- | ------------------------------------------------------ |
|
||||
| `page.on('request')` | Capture outgoing request URL + headers |
|
||||
| `page.on('response')` | Capture response data to understand schema |
|
||||
| `page.evaluate(fetch)` | Replay requests in browser context (inherits auth) |
|
||||
| `Map` for deduplication | APIs often return overlapping data across pages |
|
||||
| Cursor-based pagination | Look for `cursor`, `next_token`, `offset` in responses |
|
||||
|
||||
## Tips
|
||||
|
||||
- **Extension mode**: `page.context().cookies()` doesn't work - capture auth headers from intercepted requests instead
|
||||
- **Rate limiting**: Add 500ms+ delays between requests to avoid blocks
|
||||
- **Stop conditions**: Check for empty results, missing cursor, or reaching a date/ID threshold
|
||||
- **GraphQL APIs**: URL params often include `variables` and `features` JSON objects - capture and reuse them
|
||||
32
skills/dev-browser/skills/dev-browser/scripts/start-relay.ts
Normal file
32
skills/dev-browser/skills/dev-browser/scripts/start-relay.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Start the CDP relay server for Chrome extension mode
|
||||
*
|
||||
* Usage: npm run start-extension
|
||||
*/
|
||||
|
||||
import { serveRelay } from "@/relay.js";
|
||||
|
||||
const PORT = parseInt(process.env.PORT || "9222", 10);
|
||||
const HOST = process.env.HOST || "127.0.0.1";
|
||||
|
||||
async function main() {
|
||||
const server = await serveRelay({
|
||||
port: PORT,
|
||||
host: HOST,
|
||||
});
|
||||
|
||||
// Handle shutdown
|
||||
const shutdown = async () => {
|
||||
console.log("\nShutting down relay server...");
|
||||
await server.stop();
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.on("SIGINT", shutdown);
|
||||
process.on("SIGTERM", shutdown);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("Failed to start relay server:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
117
skills/dev-browser/skills/dev-browser/scripts/start-server.ts
Normal file
117
skills/dev-browser/skills/dev-browser/scripts/start-server.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { serve } from "@/index.js";
|
||||
import { execSync } from "child_process";
|
||||
import { mkdirSync, existsSync, readdirSync } from "fs";
|
||||
import { join, dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const tmpDir = join(__dirname, "..", "tmp");
|
||||
const profileDir = join(__dirname, "..", "profiles");
|
||||
|
||||
// Create tmp and profile directories if they don't exist
|
||||
console.log("Creating tmp directory...");
|
||||
mkdirSync(tmpDir, { recursive: true });
|
||||
console.log("Creating profiles directory...");
|
||||
mkdirSync(profileDir, { recursive: true });
|
||||
|
||||
// Install Playwright browsers if not already installed
|
||||
console.log("Checking Playwright browser installation...");
|
||||
|
||||
function findPackageManager(): { name: string; command: string } | null {
|
||||
const managers = [
|
||||
{ name: "bun", command: "bunx playwright install chromium" },
|
||||
{ name: "pnpm", command: "pnpm exec playwright install chromium" },
|
||||
{ name: "npm", command: "npx playwright install chromium" },
|
||||
];
|
||||
|
||||
for (const manager of managers) {
|
||||
try {
|
||||
execSync(`which ${manager.name}`, { stdio: "ignore" });
|
||||
return manager;
|
||||
} catch {
|
||||
// Package manager not found, try next
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isChromiumInstalled(): boolean {
|
||||
const homeDir = process.env.HOME || process.env.USERPROFILE || "";
|
||||
const playwrightCacheDir = join(homeDir, ".cache", "ms-playwright");
|
||||
|
||||
if (!existsSync(playwrightCacheDir)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for chromium directories (e.g., chromium-1148, chromium_headless_shell-1148)
|
||||
try {
|
||||
const entries = readdirSync(playwrightCacheDir);
|
||||
return entries.some((entry) => entry.startsWith("chromium"));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (!isChromiumInstalled()) {
|
||||
console.log("Playwright Chromium not found. Installing (this may take a minute)...");
|
||||
|
||||
const pm = findPackageManager();
|
||||
if (!pm) {
|
||||
throw new Error("No package manager found (tried bun, pnpm, npm)");
|
||||
}
|
||||
|
||||
console.log(`Using ${pm.name} to install Playwright...`);
|
||||
execSync(pm.command, { stdio: "inherit" });
|
||||
console.log("Chromium installed successfully.");
|
||||
} else {
|
||||
console.log("Playwright Chromium already installed.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to install Playwright browsers:", error);
|
||||
console.log("You may need to run: npx playwright install chromium");
|
||||
}
|
||||
|
||||
// Check if server is already running
|
||||
console.log("Checking for existing servers...");
|
||||
try {
|
||||
const res = await fetch("http://localhost:9222", {
|
||||
signal: AbortSignal.timeout(1000),
|
||||
});
|
||||
if (res.ok) {
|
||||
console.log("Server already running on port 9222");
|
||||
process.exit(0);
|
||||
}
|
||||
} catch {
|
||||
// Server not running, continue to start
|
||||
}
|
||||
|
||||
// Clean up stale CDP port if HTTP server isn't running (crash recovery)
|
||||
// This handles the case where Node crashed but Chrome is still running on 9223
|
||||
try {
|
||||
const pid = execSync("lsof -ti:9223", { encoding: "utf-8" }).trim();
|
||||
if (pid) {
|
||||
console.log(`Cleaning up stale Chrome process on CDP port 9223 (PID: ${pid})`);
|
||||
execSync(`kill -9 ${pid}`);
|
||||
}
|
||||
} catch {
|
||||
// No process on CDP port, which is expected
|
||||
}
|
||||
|
||||
console.log("Starting dev browser server...");
|
||||
const headless = process.env.HEADLESS === "true";
|
||||
const server = await serve({
|
||||
port: 9222,
|
||||
headless,
|
||||
profileDir,
|
||||
});
|
||||
|
||||
console.log(`Dev browser server started`);
|
||||
console.log(` WebSocket: ${server.wsEndpoint}`);
|
||||
console.log(` Tmp directory: ${tmpDir}`);
|
||||
console.log(` Profile directory: ${profileDir}`);
|
||||
console.log(`\nReady`);
|
||||
console.log(`\nPress Ctrl+C to stop`);
|
||||
|
||||
// Keep the process running
|
||||
await new Promise(() => {});
|
||||
24
skills/dev-browser/skills/dev-browser/server.sh
Executable file
24
skills/dev-browser/skills/dev-browser/server.sh
Executable file
@@ -0,0 +1,24 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Get the directory where this script is located
|
||||
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
|
||||
|
||||
# Change to the script directory
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
# Parse command line arguments
|
||||
HEADLESS=false
|
||||
while [[ "$#" -gt 0 ]]; do
|
||||
case $1 in
|
||||
--headless) HEADLESS=true ;;
|
||||
*) echo "Unknown parameter: $1"; exit 1 ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
echo "Installing dependencies..."
|
||||
npm install
|
||||
|
||||
echo "Starting dev-browser server..."
|
||||
export HEADLESS=$HEADLESS
|
||||
npx tsx scripts/start-server.ts
|
||||
474
skills/dev-browser/skills/dev-browser/src/client.ts
Normal file
474
skills/dev-browser/skills/dev-browser/src/client.ts
Normal file
@@ -0,0 +1,474 @@
|
||||
import { chromium, type Browser, type Page, type ElementHandle } from "playwright";
|
||||
import type {
|
||||
GetPageRequest,
|
||||
GetPageResponse,
|
||||
ListPagesResponse,
|
||||
ServerInfoResponse,
|
||||
ViewportSize,
|
||||
} from "./types";
|
||||
import { getSnapshotScript } from "./snapshot/browser-script";
|
||||
|
||||
/**
|
||||
* Options for waiting for page load
|
||||
*/
|
||||
export interface WaitForPageLoadOptions {
|
||||
/** Maximum time to wait in ms (default: 10000) */
|
||||
timeout?: number;
|
||||
/** How often to check page state in ms (default: 50) */
|
||||
pollInterval?: number;
|
||||
/** Minimum time to wait even if page appears ready in ms (default: 100) */
|
||||
minimumWait?: number;
|
||||
/** Wait for network to be idle (no pending requests) (default: true) */
|
||||
waitForNetworkIdle?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of waiting for page load
|
||||
*/
|
||||
export interface WaitForPageLoadResult {
|
||||
/** Whether the page is considered loaded */
|
||||
success: boolean;
|
||||
/** Document ready state when finished */
|
||||
readyState: string;
|
||||
/** Number of pending network requests when finished */
|
||||
pendingRequests: number;
|
||||
/** Time spent waiting in ms */
|
||||
waitTimeMs: number;
|
||||
/** Whether timeout was reached */
|
||||
timedOut: boolean;
|
||||
}
|
||||
|
||||
interface PageLoadState {
|
||||
documentReadyState: string;
|
||||
documentLoading: boolean;
|
||||
pendingRequests: PendingRequest[];
|
||||
}
|
||||
|
||||
interface PendingRequest {
|
||||
url: string;
|
||||
loadingDurationMs: number;
|
||||
resourceType: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a page to finish loading using document.readyState and performance API.
|
||||
*
|
||||
* Uses browser-use's approach of:
|
||||
* - Checking document.readyState for 'complete'
|
||||
* - Monitoring pending network requests via Performance API
|
||||
* - Filtering out ads, tracking, and non-critical resources
|
||||
* - Graceful timeout handling (continues even if timeout reached)
|
||||
*/
|
||||
export async function waitForPageLoad(
|
||||
page: Page,
|
||||
options: WaitForPageLoadOptions = {}
|
||||
): Promise<WaitForPageLoadResult> {
|
||||
const {
|
||||
timeout = 10000,
|
||||
pollInterval = 50,
|
||||
minimumWait = 100,
|
||||
waitForNetworkIdle = true,
|
||||
} = options;
|
||||
|
||||
const startTime = Date.now();
|
||||
let lastState: PageLoadState | null = null;
|
||||
|
||||
// Wait minimum time first
|
||||
if (minimumWait > 0) {
|
||||
await new Promise((resolve) => setTimeout(resolve, minimumWait));
|
||||
}
|
||||
|
||||
// Poll until ready or timeout
|
||||
while (Date.now() - startTime < timeout) {
|
||||
try {
|
||||
lastState = await getPageLoadState(page);
|
||||
|
||||
// Check if document is complete
|
||||
const documentReady = lastState.documentReadyState === "complete";
|
||||
|
||||
// Check if network is idle (no pending critical requests)
|
||||
const networkIdle = !waitForNetworkIdle || lastState.pendingRequests.length === 0;
|
||||
|
||||
if (documentReady && networkIdle) {
|
||||
return {
|
||||
success: true,
|
||||
readyState: lastState.documentReadyState,
|
||||
pendingRequests: lastState.pendingRequests.length,
|
||||
waitTimeMs: Date.now() - startTime,
|
||||
timedOut: false,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Page may be navigating, continue polling
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, pollInterval));
|
||||
}
|
||||
|
||||
// Timeout reached - return current state
|
||||
return {
|
||||
success: false,
|
||||
readyState: lastState?.documentReadyState ?? "unknown",
|
||||
pendingRequests: lastState?.pendingRequests.length ?? 0,
|
||||
waitTimeMs: Date.now() - startTime,
|
||||
timedOut: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current page load state including document ready state and pending requests.
|
||||
* Filters out ads, tracking, and non-critical resources that shouldn't block loading.
|
||||
*/
|
||||
async function getPageLoadState(page: Page): Promise<PageLoadState> {
|
||||
const result = await page.evaluate(() => {
|
||||
// Access browser globals via globalThis for TypeScript compatibility
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
const g = globalThis as { document?: any; performance?: any };
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
const perf = g.performance!;
|
||||
const doc = g.document!;
|
||||
|
||||
const now = perf.now();
|
||||
const resources = perf.getEntriesByType("resource");
|
||||
const pending: Array<{ url: string; loadingDurationMs: number; resourceType: string }> = [];
|
||||
|
||||
// Common ad/tracking domains and patterns to filter out
|
||||
const adPatterns = [
|
||||
"doubleclick.net",
|
||||
"googlesyndication.com",
|
||||
"googletagmanager.com",
|
||||
"google-analytics.com",
|
||||
"facebook.net",
|
||||
"connect.facebook.net",
|
||||
"analytics",
|
||||
"ads",
|
||||
"tracking",
|
||||
"pixel",
|
||||
"hotjar.com",
|
||||
"clarity.ms",
|
||||
"mixpanel.com",
|
||||
"segment.com",
|
||||
"newrelic.com",
|
||||
"nr-data.net",
|
||||
"/tracker/",
|
||||
"/collector/",
|
||||
"/beacon/",
|
||||
"/telemetry/",
|
||||
"/log/",
|
||||
"/events/",
|
||||
"/track.",
|
||||
"/metrics/",
|
||||
];
|
||||
|
||||
// Non-critical resource types
|
||||
const nonCriticalTypes = ["img", "image", "icon", "font"];
|
||||
|
||||
for (const entry of resources) {
|
||||
// Resources with responseEnd === 0 are still loading
|
||||
if (entry.responseEnd === 0) {
|
||||
const url = entry.name;
|
||||
|
||||
// Filter out ads and tracking
|
||||
const isAd = adPatterns.some((pattern) => url.includes(pattern));
|
||||
if (isAd) continue;
|
||||
|
||||
// Filter out data: URLs and very long URLs
|
||||
if (url.startsWith("data:") || url.length > 500) continue;
|
||||
|
||||
const loadingDuration = now - entry.startTime;
|
||||
|
||||
// Skip requests loading > 10 seconds (likely stuck/polling)
|
||||
if (loadingDuration > 10000) continue;
|
||||
|
||||
const resourceType = entry.initiatorType || "unknown";
|
||||
|
||||
// Filter out non-critical resources loading > 3 seconds
|
||||
if (nonCriticalTypes.includes(resourceType) && loadingDuration > 3000) continue;
|
||||
|
||||
// Filter out image URLs even if type is unknown
|
||||
const isImageUrl = /\.(jpg|jpeg|png|gif|webp|svg|ico)(\?|$)/i.test(url);
|
||||
if (isImageUrl && loadingDuration > 3000) continue;
|
||||
|
||||
pending.push({
|
||||
url,
|
||||
loadingDurationMs: Math.round(loadingDuration),
|
||||
resourceType,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
documentReadyState: doc.readyState,
|
||||
documentLoading: doc.readyState !== "complete",
|
||||
pendingRequests: pending,
|
||||
};
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Server mode information */
|
||||
export interface ServerInfo {
|
||||
wsEndpoint: string;
|
||||
mode: "launch" | "extension";
|
||||
extensionConnected?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for creating or getting a page
|
||||
*/
|
||||
export interface PageOptions {
|
||||
/** Viewport size for new pages */
|
||||
viewport?: ViewportSize;
|
||||
}
|
||||
|
||||
export interface DevBrowserClient {
|
||||
page: (name: string, options?: PageOptions) => Promise<Page>;
|
||||
list: () => Promise<string[]>;
|
||||
close: (name: string) => Promise<void>;
|
||||
disconnect: () => Promise<void>;
|
||||
/**
|
||||
* Get AI-friendly ARIA snapshot for a page.
|
||||
* Returns YAML format with refs like [ref=e1], [ref=e2].
|
||||
* Refs are stored on window.__devBrowserRefs for cross-connection persistence.
|
||||
*/
|
||||
getAISnapshot: (name: string) => Promise<string>;
|
||||
/**
|
||||
* Get an element handle by its ref from the last getAISnapshot call.
|
||||
* Refs persist across Playwright connections.
|
||||
*/
|
||||
selectSnapshotRef: (name: string, ref: string) => Promise<ElementHandle | null>;
|
||||
/**
|
||||
* Get server information including mode and extension connection status.
|
||||
*/
|
||||
getServerInfo: () => Promise<ServerInfo>;
|
||||
}
|
||||
|
||||
export async function connect(serverUrl = "http://localhost:9222"): Promise<DevBrowserClient> {
|
||||
let browser: Browser | null = null;
|
||||
let wsEndpoint: string | null = null;
|
||||
let connectingPromise: Promise<Browser> | null = null;
|
||||
|
||||
async function ensureConnected(): Promise<Browser> {
|
||||
// Return existing connection if still active
|
||||
if (browser && browser.isConnected()) {
|
||||
return browser;
|
||||
}
|
||||
|
||||
// If already connecting, wait for that connection (prevents race condition)
|
||||
if (connectingPromise) {
|
||||
return connectingPromise;
|
||||
}
|
||||
|
||||
// Start new connection with mutex
|
||||
connectingPromise = (async () => {
|
||||
try {
|
||||
// Fetch wsEndpoint from server
|
||||
const res = await fetch(serverUrl);
|
||||
if (!res.ok) {
|
||||
throw new Error(`Server returned ${res.status}: ${await res.text()}`);
|
||||
}
|
||||
const info = (await res.json()) as ServerInfoResponse;
|
||||
wsEndpoint = info.wsEndpoint;
|
||||
|
||||
// Connect to the browser via CDP
|
||||
browser = await chromium.connectOverCDP(wsEndpoint);
|
||||
return browser;
|
||||
} finally {
|
||||
connectingPromise = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return connectingPromise;
|
||||
}
|
||||
|
||||
// Find page by CDP targetId - more reliable than JS globals
|
||||
async function findPageByTargetId(b: Browser, targetId: string): Promise<Page | null> {
|
||||
for (const context of b.contexts()) {
|
||||
for (const page of context.pages()) {
|
||||
let cdpSession;
|
||||
try {
|
||||
cdpSession = await context.newCDPSession(page);
|
||||
const { targetInfo } = await cdpSession.send("Target.getTargetInfo");
|
||||
if (targetInfo.targetId === targetId) {
|
||||
return page;
|
||||
}
|
||||
} catch (err) {
|
||||
// Only ignore "target closed" errors, log unexpected ones
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (!msg.includes("Target closed") && !msg.includes("Session closed")) {
|
||||
console.warn(`Unexpected error checking page target: ${msg}`);
|
||||
}
|
||||
} finally {
|
||||
if (cdpSession) {
|
||||
try {
|
||||
await cdpSession.detach();
|
||||
} catch {
|
||||
// Ignore detach errors - session may already be closed
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Helper to get a page by name (used by multiple methods)
|
||||
async function getPage(name: string, options?: PageOptions): Promise<Page> {
|
||||
// Request the page from server (creates if doesn't exist)
|
||||
const res = await fetch(`${serverUrl}/pages`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name, viewport: options?.viewport } satisfies GetPageRequest),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to get page: ${await res.text()}`);
|
||||
}
|
||||
|
||||
const pageInfo = (await res.json()) as GetPageResponse & { url?: string };
|
||||
const { targetId } = pageInfo;
|
||||
|
||||
// Connect to browser
|
||||
const b = await ensureConnected();
|
||||
|
||||
// Check if we're in extension mode
|
||||
const infoRes = await fetch(serverUrl);
|
||||
const info = (await infoRes.json()) as { mode?: string };
|
||||
const isExtensionMode = info.mode === "extension";
|
||||
|
||||
if (isExtensionMode) {
|
||||
// In extension mode, DON'T use findPageByTargetId as it corrupts page state
|
||||
// Instead, find page by URL or use the only available page
|
||||
const allPages = b.contexts().flatMap((ctx) => ctx.pages());
|
||||
|
||||
if (allPages.length === 0) {
|
||||
throw new Error(`No pages available in browser`);
|
||||
}
|
||||
|
||||
if (allPages.length === 1) {
|
||||
return allPages[0]!;
|
||||
}
|
||||
|
||||
// Multiple pages - try to match by URL if available
|
||||
if (pageInfo.url) {
|
||||
const matchingPage = allPages.find((p) => p.url() === pageInfo.url);
|
||||
if (matchingPage) {
|
||||
return matchingPage;
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to first page
|
||||
if (!allPages[0]) {
|
||||
throw new Error(`No pages available in browser`);
|
||||
}
|
||||
return allPages[0];
|
||||
}
|
||||
|
||||
// In launch mode, use the original targetId-based lookup
|
||||
const page = await findPageByTargetId(b, targetId);
|
||||
if (!page) {
|
||||
throw new Error(`Page "${name}" not found in browser contexts`);
|
||||
}
|
||||
|
||||
return page;
|
||||
}
|
||||
|
||||
return {
|
||||
page: getPage,
|
||||
|
||||
async list(): Promise<string[]> {
|
||||
const res = await fetch(`${serverUrl}/pages`);
|
||||
const data = (await res.json()) as ListPagesResponse;
|
||||
return data.pages;
|
||||
},
|
||||
|
||||
async close(name: string): Promise<void> {
|
||||
const res = await fetch(`${serverUrl}/pages/${encodeURIComponent(name)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to close page: ${await res.text()}`);
|
||||
}
|
||||
},
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
// Just disconnect the CDP connection - pages persist on server
|
||||
if (browser) {
|
||||
await browser.close();
|
||||
browser = null;
|
||||
}
|
||||
},
|
||||
|
||||
async getAISnapshot(name: string): Promise<string> {
|
||||
// Get the page
|
||||
const page = await getPage(name);
|
||||
|
||||
// Inject the snapshot script and call getAISnapshot
|
||||
const snapshotScript = getSnapshotScript();
|
||||
const snapshot = await page.evaluate((script: string) => {
|
||||
// Inject script if not already present
|
||||
// Note: page.evaluate runs in browser context where window exists
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const w = globalThis as any;
|
||||
if (!w.__devBrowser_getAISnapshot) {
|
||||
// eslint-disable-next-line no-eval
|
||||
eval(script);
|
||||
}
|
||||
return w.__devBrowser_getAISnapshot();
|
||||
}, snapshotScript);
|
||||
|
||||
return snapshot;
|
||||
},
|
||||
|
||||
async selectSnapshotRef(name: string, ref: string): Promise<ElementHandle | null> {
|
||||
// Get the page
|
||||
const page = await getPage(name);
|
||||
|
||||
// Find the element using the stored refs
|
||||
const elementHandle = await page.evaluateHandle((refId: string) => {
|
||||
// Note: page.evaluateHandle runs in browser context where globalThis is the window
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const w = globalThis as any;
|
||||
const refs = w.__devBrowserRefs;
|
||||
if (!refs) {
|
||||
throw new Error("No snapshot refs found. Call getAISnapshot first.");
|
||||
}
|
||||
const element = refs[refId];
|
||||
if (!element) {
|
||||
throw new Error(
|
||||
`Ref "${refId}" not found. Available refs: ${Object.keys(refs).join(", ")}`
|
||||
);
|
||||
}
|
||||
return element;
|
||||
}, ref);
|
||||
|
||||
// Check if we got an element
|
||||
const element = elementHandle.asElement();
|
||||
if (!element) {
|
||||
await elementHandle.dispose();
|
||||
return null;
|
||||
}
|
||||
|
||||
return element;
|
||||
},
|
||||
|
||||
async getServerInfo(): Promise<ServerInfo> {
|
||||
const res = await fetch(serverUrl);
|
||||
if (!res.ok) {
|
||||
throw new Error(`Server returned ${res.status}: ${await res.text()}`);
|
||||
}
|
||||
const info = (await res.json()) as {
|
||||
wsEndpoint: string;
|
||||
mode?: string;
|
||||
extensionConnected?: boolean;
|
||||
};
|
||||
return {
|
||||
wsEndpoint: info.wsEndpoint,
|
||||
mode: (info.mode as "launch" | "extension") ?? "launch",
|
||||
extensionConnected: info.extensionConnected,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
287
skills/dev-browser/skills/dev-browser/src/index.ts
Normal file
287
skills/dev-browser/skills/dev-browser/src/index.ts
Normal file
@@ -0,0 +1,287 @@
|
||||
import express, { type Express, type Request, type Response } from "express";
|
||||
import { chromium, type BrowserContext, type Page } from "playwright";
|
||||
import { mkdirSync } from "fs";
|
||||
import { join } from "path";
|
||||
import type { Socket } from "net";
|
||||
import type {
|
||||
ServeOptions,
|
||||
GetPageRequest,
|
||||
GetPageResponse,
|
||||
ListPagesResponse,
|
||||
ServerInfoResponse,
|
||||
} from "./types";
|
||||
|
||||
export type { ServeOptions, GetPageResponse, ListPagesResponse, ServerInfoResponse };
|
||||
|
||||
export interface DevBrowserServer {
|
||||
wsEndpoint: string;
|
||||
port: number;
|
||||
stop: () => Promise<void>;
|
||||
}
|
||||
|
||||
// Helper to retry fetch with exponential backoff
|
||||
async function fetchWithRetry(
|
||||
url: string,
|
||||
maxRetries = 5,
|
||||
delayMs = 500
|
||||
): Promise<globalThis.Response> {
|
||||
let lastError: Error | null = null;
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
if (res.ok) return res;
|
||||
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
||||
} catch (err) {
|
||||
lastError = err instanceof Error ? err : new Error(String(err));
|
||||
if (i < maxRetries - 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, delayMs * (i + 1)));
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error(`Failed after ${maxRetries} retries: ${lastError?.message}`);
|
||||
}
|
||||
|
||||
// Helper to add timeout to promises
|
||||
function withTimeout<T>(promise: Promise<T>, ms: number, message: string): Promise<T> {
|
||||
return Promise.race([
|
||||
promise,
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error(`Timeout: ${message}`)), ms)
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
export async function serve(options: ServeOptions = {}): Promise<DevBrowserServer> {
|
||||
const port = options.port ?? 9222;
|
||||
const headless = options.headless ?? false;
|
||||
const cdpPort = options.cdpPort ?? 9223;
|
||||
const profileDir = options.profileDir;
|
||||
|
||||
// Validate port numbers
|
||||
if (port < 1 || port > 65535) {
|
||||
throw new Error(`Invalid port: ${port}. Must be between 1 and 65535`);
|
||||
}
|
||||
if (cdpPort < 1 || cdpPort > 65535) {
|
||||
throw new Error(`Invalid cdpPort: ${cdpPort}. Must be between 1 and 65535`);
|
||||
}
|
||||
if (port === cdpPort) {
|
||||
throw new Error("port and cdpPort must be different");
|
||||
}
|
||||
|
||||
// Determine user data directory for persistent context
|
||||
const userDataDir = profileDir
|
||||
? join(profileDir, "browser-data")
|
||||
: join(process.cwd(), ".browser-data");
|
||||
|
||||
// Create directory if it doesn't exist
|
||||
mkdirSync(userDataDir, { recursive: true });
|
||||
console.log(`Using persistent browser profile: ${userDataDir}`);
|
||||
|
||||
console.log("Launching browser with persistent context...");
|
||||
|
||||
// Launch persistent context - this persists cookies, localStorage, cache, etc.
|
||||
const context: BrowserContext = await chromium.launchPersistentContext(userDataDir, {
|
||||
headless,
|
||||
args: [`--remote-debugging-port=${cdpPort}`],
|
||||
});
|
||||
console.log("Browser launched with persistent profile...");
|
||||
|
||||
// Get the CDP WebSocket endpoint from Chrome's JSON API (with retry for slow startup)
|
||||
const cdpResponse = await fetchWithRetry(`http://127.0.0.1:${cdpPort}/json/version`);
|
||||
const cdpInfo = (await cdpResponse.json()) as { webSocketDebuggerUrl: string };
|
||||
const wsEndpoint = cdpInfo.webSocketDebuggerUrl;
|
||||
console.log(`CDP WebSocket endpoint: ${wsEndpoint}`);
|
||||
|
||||
// Registry entry type for page tracking
|
||||
interface PageEntry {
|
||||
page: Page;
|
||||
targetId: string;
|
||||
}
|
||||
|
||||
// Registry: name -> PageEntry
|
||||
const registry = new Map<string, PageEntry>();
|
||||
|
||||
// Helper to get CDP targetId for a page
|
||||
async function getTargetId(page: Page): Promise<string> {
|
||||
const cdpSession = await context.newCDPSession(page);
|
||||
try {
|
||||
const { targetInfo } = await cdpSession.send("Target.getTargetInfo");
|
||||
return targetInfo.targetId;
|
||||
} finally {
|
||||
await cdpSession.detach();
|
||||
}
|
||||
}
|
||||
|
||||
// Express server for page management
|
||||
const app: Express = express();
|
||||
app.use(express.json());
|
||||
|
||||
// GET / - server info
|
||||
app.get("/", (_req: Request, res: Response) => {
|
||||
const response: ServerInfoResponse = { wsEndpoint };
|
||||
res.json(response);
|
||||
});
|
||||
|
||||
// GET /pages - list all pages
|
||||
app.get("/pages", (_req: Request, res: Response) => {
|
||||
const response: ListPagesResponse = {
|
||||
pages: Array.from(registry.keys()),
|
||||
};
|
||||
res.json(response);
|
||||
});
|
||||
|
||||
// POST /pages - get or create page
|
||||
app.post("/pages", async (req: Request, res: Response) => {
|
||||
const body = req.body as GetPageRequest;
|
||||
const { name, viewport } = body;
|
||||
|
||||
if (!name || typeof name !== "string") {
|
||||
res.status(400).json({ error: "name is required and must be a string" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (name.length === 0) {
|
||||
res.status(400).json({ error: "name cannot be empty" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (name.length > 256) {
|
||||
res.status(400).json({ error: "name must be 256 characters or less" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if page already exists
|
||||
let entry = registry.get(name);
|
||||
if (!entry) {
|
||||
// Create new page in the persistent context (with timeout to prevent hangs)
|
||||
const page = await withTimeout(context.newPage(), 30000, "Page creation timed out after 30s");
|
||||
|
||||
// Apply viewport if provided
|
||||
if (viewport) {
|
||||
await page.setViewportSize(viewport);
|
||||
}
|
||||
|
||||
const targetId = await getTargetId(page);
|
||||
entry = { page, targetId };
|
||||
registry.set(name, entry);
|
||||
|
||||
// Clean up registry when page is closed (e.g., user clicks X)
|
||||
page.on("close", () => {
|
||||
registry.delete(name);
|
||||
});
|
||||
}
|
||||
|
||||
const response: GetPageResponse = { wsEndpoint, name, targetId: entry.targetId };
|
||||
res.json(response);
|
||||
});
|
||||
|
||||
// DELETE /pages/:name - close a page
|
||||
app.delete("/pages/:name", async (req: Request<{ name: string }>, res: Response) => {
|
||||
const name = decodeURIComponent(req.params.name);
|
||||
const entry = registry.get(name);
|
||||
|
||||
if (entry) {
|
||||
await entry.page.close();
|
||||
registry.delete(name);
|
||||
res.json({ success: true });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(404).json({ error: "page not found" });
|
||||
});
|
||||
|
||||
// Start the server
|
||||
const server = app.listen(port, () => {
|
||||
console.log(`HTTP API server running on port ${port}`);
|
||||
});
|
||||
|
||||
// Track active connections for clean shutdown
|
||||
const connections = new Set<Socket>();
|
||||
server.on("connection", (socket: Socket) => {
|
||||
connections.add(socket);
|
||||
socket.on("close", () => connections.delete(socket));
|
||||
});
|
||||
|
||||
// Track if cleanup has been called to avoid double cleanup
|
||||
let cleaningUp = false;
|
||||
|
||||
// Cleanup function
|
||||
const cleanup = async () => {
|
||||
if (cleaningUp) return;
|
||||
cleaningUp = true;
|
||||
|
||||
console.log("\nShutting down...");
|
||||
|
||||
// Close all active HTTP connections
|
||||
for (const socket of connections) {
|
||||
socket.destroy();
|
||||
}
|
||||
connections.clear();
|
||||
|
||||
// Close all pages
|
||||
for (const entry of registry.values()) {
|
||||
try {
|
||||
await entry.page.close();
|
||||
} catch {
|
||||
// Page might already be closed
|
||||
}
|
||||
}
|
||||
registry.clear();
|
||||
|
||||
// Close context (this also closes the browser)
|
||||
try {
|
||||
await context.close();
|
||||
} catch {
|
||||
// Context might already be closed
|
||||
}
|
||||
|
||||
server.close();
|
||||
console.log("Server stopped.");
|
||||
};
|
||||
|
||||
// Synchronous cleanup for forced exits
|
||||
const syncCleanup = () => {
|
||||
try {
|
||||
context.close();
|
||||
} catch {
|
||||
// Best effort
|
||||
}
|
||||
};
|
||||
|
||||
// Signal handlers (consolidated to reduce duplication)
|
||||
const signals = ["SIGINT", "SIGTERM", "SIGHUP"] as const;
|
||||
|
||||
const signalHandler = async () => {
|
||||
await cleanup();
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
const errorHandler = async (err: unknown) => {
|
||||
console.error("Unhandled error:", err);
|
||||
await cleanup();
|
||||
process.exit(1);
|
||||
};
|
||||
|
||||
// Register handlers
|
||||
signals.forEach((sig) => process.on(sig, signalHandler));
|
||||
process.on("uncaughtException", errorHandler);
|
||||
process.on("unhandledRejection", errorHandler);
|
||||
process.on("exit", syncCleanup);
|
||||
|
||||
// Helper to remove all handlers
|
||||
const removeHandlers = () => {
|
||||
signals.forEach((sig) => process.off(sig, signalHandler));
|
||||
process.off("uncaughtException", errorHandler);
|
||||
process.off("unhandledRejection", errorHandler);
|
||||
process.off("exit", syncCleanup);
|
||||
};
|
||||
|
||||
return {
|
||||
wsEndpoint,
|
||||
port,
|
||||
async stop() {
|
||||
removeHandlers();
|
||||
await cleanup();
|
||||
},
|
||||
};
|
||||
}
|
||||
731
skills/dev-browser/skills/dev-browser/src/relay.ts
Normal file
731
skills/dev-browser/skills/dev-browser/src/relay.ts
Normal file
@@ -0,0 +1,731 @@
|
||||
/**
|
||||
* CDP Relay Server for Chrome Extension mode
|
||||
*
|
||||
* This server acts as a bridge between Playwright clients and a Chrome extension.
|
||||
* Instead of launching a browser, it waits for the extension to connect and
|
||||
* forwards CDP commands/events between them.
|
||||
*/
|
||||
|
||||
import { Hono } from "hono";
|
||||
import { serve } from "@hono/node-server";
|
||||
import { createNodeWebSocket } from "@hono/node-ws";
|
||||
import type { WSContext } from "hono/ws";
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
export interface RelayOptions {
|
||||
port?: number;
|
||||
host?: string;
|
||||
}
|
||||
|
||||
export interface RelayServer {
|
||||
wsEndpoint: string;
|
||||
port: number;
|
||||
stop(): Promise<void>;
|
||||
}
|
||||
|
||||
interface TargetInfo {
|
||||
targetId: string;
|
||||
type: string;
|
||||
title: string;
|
||||
url: string;
|
||||
attached: boolean;
|
||||
}
|
||||
|
||||
interface ConnectedTarget {
|
||||
sessionId: string;
|
||||
targetId: string;
|
||||
targetInfo: TargetInfo;
|
||||
}
|
||||
|
||||
interface PlaywrightClient {
|
||||
id: string;
|
||||
ws: WSContext;
|
||||
knownTargets: Set<string>; // targetIds this client has received attachedToTarget for
|
||||
}
|
||||
|
||||
// Message types for extension communication
|
||||
interface ExtensionCommandMessage {
|
||||
id: number;
|
||||
method: "forwardCDPCommand";
|
||||
params: {
|
||||
method: string;
|
||||
params?: Record<string, unknown>;
|
||||
sessionId?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface ExtensionResponseMessage {
|
||||
id: number;
|
||||
result?: unknown;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface ExtensionEventMessage {
|
||||
method: "forwardCDPEvent";
|
||||
params: {
|
||||
method: string;
|
||||
params?: Record<string, unknown>;
|
||||
sessionId?: string;
|
||||
};
|
||||
}
|
||||
|
||||
type ExtensionMessage =
|
||||
| ExtensionResponseMessage
|
||||
| ExtensionEventMessage
|
||||
| { method: "log"; params: { level: string; args: string[] } };
|
||||
|
||||
// CDP message types
|
||||
interface CDPCommand {
|
||||
id: number;
|
||||
method: string;
|
||||
params?: Record<string, unknown>;
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
interface CDPResponse {
|
||||
id: number;
|
||||
sessionId?: string;
|
||||
result?: unknown;
|
||||
error?: { message: string };
|
||||
}
|
||||
|
||||
interface CDPEvent {
|
||||
method: string;
|
||||
sessionId?: string;
|
||||
params?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Relay Server Implementation
|
||||
// ============================================================================
|
||||
|
||||
export async function serveRelay(options: RelayOptions = {}): Promise<RelayServer> {
|
||||
const port = options.port ?? 9222;
|
||||
const host = options.host ?? "127.0.0.1";
|
||||
|
||||
// State
|
||||
const connectedTargets = new Map<string, ConnectedTarget>();
|
||||
const namedPages = new Map<string, string>(); // name -> sessionId
|
||||
const playwrightClients = new Map<string, PlaywrightClient>();
|
||||
let extensionWs: WSContext | null = null;
|
||||
|
||||
// Pending requests to extension
|
||||
const extensionPendingRequests = new Map<
|
||||
number,
|
||||
{
|
||||
resolve: (result: unknown) => void;
|
||||
reject: (error: Error) => void;
|
||||
}
|
||||
>();
|
||||
let extensionMessageId = 0;
|
||||
|
||||
// ============================================================================
|
||||
// Helper Functions
|
||||
// ============================================================================
|
||||
|
||||
function log(...args: unknown[]) {
|
||||
console.log("[relay]", ...args);
|
||||
}
|
||||
|
||||
function sendToPlaywright(message: CDPResponse | CDPEvent, clientId?: string) {
|
||||
const messageStr = JSON.stringify(message);
|
||||
|
||||
if (clientId) {
|
||||
const client = playwrightClients.get(clientId);
|
||||
if (client) {
|
||||
client.ws.send(messageStr);
|
||||
}
|
||||
} else {
|
||||
// Broadcast to all clients
|
||||
for (const client of playwrightClients.values()) {
|
||||
client.ws.send(messageStr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send Target.attachedToTarget event with deduplication.
|
||||
* Tracks which targets each client has seen to prevent "Duplicate target" errors.
|
||||
*/
|
||||
function sendAttachedToTarget(
|
||||
target: ConnectedTarget,
|
||||
clientId?: string,
|
||||
waitingForDebugger = false
|
||||
) {
|
||||
const event: CDPEvent = {
|
||||
method: "Target.attachedToTarget",
|
||||
params: {
|
||||
sessionId: target.sessionId,
|
||||
targetInfo: { ...target.targetInfo, attached: true },
|
||||
waitingForDebugger,
|
||||
},
|
||||
};
|
||||
|
||||
if (clientId) {
|
||||
const client = playwrightClients.get(clientId);
|
||||
if (client && !client.knownTargets.has(target.targetId)) {
|
||||
client.knownTargets.add(target.targetId);
|
||||
client.ws.send(JSON.stringify(event));
|
||||
}
|
||||
} else {
|
||||
// Broadcast to all clients that don't know about this target yet
|
||||
for (const client of playwrightClients.values()) {
|
||||
if (!client.knownTargets.has(target.targetId)) {
|
||||
client.knownTargets.add(target.targetId);
|
||||
client.ws.send(JSON.stringify(event));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function sendToExtension({
|
||||
method,
|
||||
params,
|
||||
timeout = 30000,
|
||||
}: {
|
||||
method: string;
|
||||
params?: Record<string, unknown>;
|
||||
timeout?: number;
|
||||
}): Promise<unknown> {
|
||||
if (!extensionWs) {
|
||||
throw new Error("Extension not connected");
|
||||
}
|
||||
|
||||
const id = ++extensionMessageId;
|
||||
const message = { id, method, params };
|
||||
|
||||
extensionWs.send(JSON.stringify(message));
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
extensionPendingRequests.delete(id);
|
||||
reject(new Error(`Extension request timeout after ${timeout}ms: ${method}`));
|
||||
}, timeout);
|
||||
|
||||
extensionPendingRequests.set(id, {
|
||||
resolve: (result) => {
|
||||
clearTimeout(timeoutId);
|
||||
resolve(result);
|
||||
},
|
||||
reject: (error) => {
|
||||
clearTimeout(timeoutId);
|
||||
reject(error);
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function routeCdpCommand({
|
||||
method,
|
||||
params,
|
||||
sessionId,
|
||||
}: {
|
||||
method: string;
|
||||
params?: Record<string, unknown>;
|
||||
sessionId?: string;
|
||||
}): Promise<unknown> {
|
||||
// Handle some CDP commands locally
|
||||
switch (method) {
|
||||
case "Browser.getVersion":
|
||||
return {
|
||||
protocolVersion: "1.3",
|
||||
product: "Chrome/Extension-Bridge",
|
||||
revision: "1.0.0",
|
||||
userAgent: "dev-browser-relay/1.0.0",
|
||||
jsVersion: "V8",
|
||||
};
|
||||
|
||||
case "Browser.setDownloadBehavior":
|
||||
return {};
|
||||
|
||||
case "Target.setAutoAttach":
|
||||
if (sessionId) {
|
||||
break; // Forward to extension for child frames
|
||||
}
|
||||
return {};
|
||||
|
||||
case "Target.setDiscoverTargets":
|
||||
return {};
|
||||
|
||||
case "Target.attachToBrowserTarget":
|
||||
// Browser-level session - return a fake session since we only proxy tabs
|
||||
return { sessionId: "browser" };
|
||||
|
||||
case "Target.detachFromTarget":
|
||||
// If detaching from our fake "browser" session, just return success
|
||||
if (sessionId === "browser" || params?.sessionId === "browser") {
|
||||
return {};
|
||||
}
|
||||
// Otherwise forward to extension
|
||||
break;
|
||||
|
||||
case "Target.attachToTarget": {
|
||||
const targetId = params?.targetId as string;
|
||||
if (!targetId) {
|
||||
throw new Error("targetId is required for Target.attachToTarget");
|
||||
}
|
||||
|
||||
for (const target of connectedTargets.values()) {
|
||||
if (target.targetId === targetId) {
|
||||
return { sessionId: target.sessionId };
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Target ${targetId} not found in connected targets`);
|
||||
}
|
||||
|
||||
case "Target.getTargetInfo": {
|
||||
const targetId = params?.targetId as string;
|
||||
|
||||
if (targetId) {
|
||||
for (const target of connectedTargets.values()) {
|
||||
if (target.targetId === targetId) {
|
||||
return { targetInfo: target.targetInfo };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (sessionId) {
|
||||
const target = connectedTargets.get(sessionId);
|
||||
if (target) {
|
||||
return { targetInfo: target.targetInfo };
|
||||
}
|
||||
}
|
||||
|
||||
// Return first target if no specific one requested
|
||||
const firstTarget = Array.from(connectedTargets.values())[0];
|
||||
return { targetInfo: firstTarget?.targetInfo };
|
||||
}
|
||||
|
||||
case "Target.getTargets":
|
||||
return {
|
||||
targetInfos: Array.from(connectedTargets.values()).map((t) => ({
|
||||
...t.targetInfo,
|
||||
attached: true,
|
||||
})),
|
||||
};
|
||||
|
||||
case "Target.createTarget":
|
||||
case "Target.closeTarget":
|
||||
// Forward to extension
|
||||
return await sendToExtension({
|
||||
method: "forwardCDPCommand",
|
||||
params: { method, params },
|
||||
});
|
||||
}
|
||||
|
||||
// Forward all other commands to extension
|
||||
return await sendToExtension({
|
||||
method: "forwardCDPCommand",
|
||||
params: { sessionId, method, params },
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// HTTP/WebSocket Server
|
||||
// ============================================================================
|
||||
|
||||
const app = new Hono();
|
||||
const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app });
|
||||
|
||||
// Health check / server info
|
||||
app.get("/", (c) => {
|
||||
return c.json({
|
||||
wsEndpoint: `ws://${host}:${port}/cdp`,
|
||||
extensionConnected: extensionWs !== null,
|
||||
mode: "extension",
|
||||
});
|
||||
});
|
||||
|
||||
// List named pages
|
||||
app.get("/pages", (c) => {
|
||||
return c.json({
|
||||
pages: Array.from(namedPages.keys()),
|
||||
});
|
||||
});
|
||||
|
||||
// Get or create a named page
|
||||
app.post("/pages", async (c) => {
|
||||
const body = await c.req.json();
|
||||
const name = body.name as string;
|
||||
|
||||
if (!name) {
|
||||
return c.json({ error: "name is required" }, 400);
|
||||
}
|
||||
|
||||
// Check if page already exists by name
|
||||
const existingSessionId = namedPages.get(name);
|
||||
if (existingSessionId) {
|
||||
const target = connectedTargets.get(existingSessionId);
|
||||
if (target) {
|
||||
// Activate the tab so it becomes the active tab
|
||||
await sendToExtension({
|
||||
method: "forwardCDPCommand",
|
||||
params: {
|
||||
method: "Target.activateTarget",
|
||||
params: { targetId: target.targetId },
|
||||
},
|
||||
});
|
||||
return c.json({
|
||||
wsEndpoint: `ws://${host}:${port}/cdp`,
|
||||
name,
|
||||
targetId: target.targetId,
|
||||
url: target.targetInfo.url,
|
||||
});
|
||||
}
|
||||
// Session no longer valid, remove it
|
||||
namedPages.delete(name);
|
||||
}
|
||||
|
||||
// Create a new tab
|
||||
if (!extensionWs) {
|
||||
return c.json({ error: "Extension not connected" }, 503);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = (await sendToExtension({
|
||||
method: "forwardCDPCommand",
|
||||
params: { method: "Target.createTarget", params: { url: "about:blank" } },
|
||||
})) as { targetId: string };
|
||||
|
||||
// Wait for Target.attachedToTarget event to register the new target
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
// Find and name the new target
|
||||
for (const [sessionId, target] of connectedTargets) {
|
||||
if (target.targetId === result.targetId) {
|
||||
namedPages.set(name, sessionId);
|
||||
// Activate the tab so it becomes the active tab
|
||||
await sendToExtension({
|
||||
method: "forwardCDPCommand",
|
||||
params: {
|
||||
method: "Target.activateTarget",
|
||||
params: { targetId: target.targetId },
|
||||
},
|
||||
});
|
||||
return c.json({
|
||||
wsEndpoint: `ws://${host}:${port}/cdp`,
|
||||
name,
|
||||
targetId: target.targetId,
|
||||
url: target.targetInfo.url,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("Target created but not found in registry");
|
||||
} catch (err) {
|
||||
log("Error creating tab:", err);
|
||||
return c.json({ error: (err as Error).message }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// Delete a named page (removes the name, doesn't close the tab)
|
||||
app.delete("/pages/:name", (c) => {
|
||||
const name = c.req.param("name");
|
||||
const deleted = namedPages.delete(name);
|
||||
return c.json({ success: deleted });
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Playwright Client WebSocket
|
||||
// ============================================================================
|
||||
|
||||
app.get(
|
||||
"/cdp/:clientId?",
|
||||
upgradeWebSocket((c) => {
|
||||
const clientId =
|
||||
c.req.param("clientId") || `client-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
|
||||
return {
|
||||
onOpen(_event, ws) {
|
||||
if (playwrightClients.has(clientId)) {
|
||||
log(`Rejecting duplicate client ID: ${clientId}`);
|
||||
ws.close(1000, "Client ID already connected");
|
||||
return;
|
||||
}
|
||||
|
||||
playwrightClients.set(clientId, { id: clientId, ws, knownTargets: new Set() });
|
||||
log(`Playwright client connected: ${clientId}`);
|
||||
},
|
||||
|
||||
async onMessage(event, _ws) {
|
||||
let message: CDPCommand;
|
||||
|
||||
try {
|
||||
message = JSON.parse(event.data.toString());
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const { id, sessionId, method, params } = message;
|
||||
|
||||
if (!extensionWs) {
|
||||
sendToPlaywright(
|
||||
{
|
||||
id,
|
||||
sessionId,
|
||||
error: { message: "Extension not connected" },
|
||||
},
|
||||
clientId
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await routeCdpCommand({ method, params, sessionId });
|
||||
|
||||
// After Target.setAutoAttach, send attachedToTarget for existing targets
|
||||
// Uses deduplication to prevent "Duplicate target" errors
|
||||
if (method === "Target.setAutoAttach" && !sessionId) {
|
||||
for (const target of connectedTargets.values()) {
|
||||
sendAttachedToTarget(target, clientId);
|
||||
}
|
||||
}
|
||||
|
||||
// After Target.setDiscoverTargets, send targetCreated events
|
||||
if (
|
||||
method === "Target.setDiscoverTargets" &&
|
||||
(params as { discover?: boolean })?.discover
|
||||
) {
|
||||
for (const target of connectedTargets.values()) {
|
||||
sendToPlaywright(
|
||||
{
|
||||
method: "Target.targetCreated",
|
||||
params: {
|
||||
targetInfo: { ...target.targetInfo, attached: true },
|
||||
},
|
||||
},
|
||||
clientId
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// After Target.attachToTarget, send attachedToTarget event (with deduplication)
|
||||
if (
|
||||
method === "Target.attachToTarget" &&
|
||||
(result as { sessionId?: string })?.sessionId
|
||||
) {
|
||||
const targetId = params?.targetId as string;
|
||||
const target = Array.from(connectedTargets.values()).find(
|
||||
(t) => t.targetId === targetId
|
||||
);
|
||||
if (target) {
|
||||
sendAttachedToTarget(target, clientId);
|
||||
}
|
||||
}
|
||||
|
||||
sendToPlaywright({ id, sessionId, result }, clientId);
|
||||
} catch (e) {
|
||||
log("Error handling CDP command:", method, e);
|
||||
sendToPlaywright(
|
||||
{
|
||||
id,
|
||||
sessionId,
|
||||
error: { message: (e as Error).message },
|
||||
},
|
||||
clientId
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
onClose() {
|
||||
playwrightClients.delete(clientId);
|
||||
log(`Playwright client disconnected: ${clientId}`);
|
||||
},
|
||||
|
||||
onError(event) {
|
||||
log(`Playwright WebSocket error [${clientId}]:`, event);
|
||||
},
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// Extension WebSocket
|
||||
// ============================================================================
|
||||
|
||||
app.get(
|
||||
"/extension",
|
||||
upgradeWebSocket(() => {
|
||||
return {
|
||||
onOpen(_event, ws) {
|
||||
if (extensionWs) {
|
||||
log("Closing existing extension connection");
|
||||
extensionWs.close(4001, "Extension Replaced");
|
||||
|
||||
// Clear state
|
||||
connectedTargets.clear();
|
||||
namedPages.clear();
|
||||
for (const pending of extensionPendingRequests.values()) {
|
||||
pending.reject(new Error("Extension connection replaced"));
|
||||
}
|
||||
extensionPendingRequests.clear();
|
||||
}
|
||||
|
||||
extensionWs = ws;
|
||||
log("Extension connected");
|
||||
},
|
||||
|
||||
async onMessage(event, ws) {
|
||||
let message: ExtensionMessage;
|
||||
|
||||
try {
|
||||
message = JSON.parse(event.data.toString());
|
||||
} catch {
|
||||
ws.close(1000, "Invalid JSON");
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle response to our request
|
||||
if ("id" in message && typeof message.id === "number") {
|
||||
const pending = extensionPendingRequests.get(message.id);
|
||||
if (!pending) {
|
||||
log("Unexpected response with id:", message.id);
|
||||
return;
|
||||
}
|
||||
|
||||
extensionPendingRequests.delete(message.id);
|
||||
|
||||
if ((message as ExtensionResponseMessage).error) {
|
||||
pending.reject(new Error((message as ExtensionResponseMessage).error));
|
||||
} else {
|
||||
pending.resolve((message as ExtensionResponseMessage).result);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle log messages
|
||||
if ("method" in message && message.method === "log") {
|
||||
const { level, args } = message.params;
|
||||
console.log(`[extension:${level}]`, ...args);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle CDP events from extension
|
||||
if ("method" in message && message.method === "forwardCDPEvent") {
|
||||
const eventMsg = message as ExtensionEventMessage;
|
||||
const { method, params, sessionId } = eventMsg.params;
|
||||
|
||||
// Handle target lifecycle events
|
||||
if (method === "Target.attachedToTarget") {
|
||||
const targetParams = params as {
|
||||
sessionId: string;
|
||||
targetInfo: TargetInfo;
|
||||
};
|
||||
|
||||
const target: ConnectedTarget = {
|
||||
sessionId: targetParams.sessionId,
|
||||
targetId: targetParams.targetInfo.targetId,
|
||||
targetInfo: targetParams.targetInfo,
|
||||
};
|
||||
connectedTargets.set(targetParams.sessionId, target);
|
||||
|
||||
log(`Target attached: ${targetParams.targetInfo.url} (${targetParams.sessionId})`);
|
||||
|
||||
// Use deduplication helper - only sends to clients that don't know about this target
|
||||
sendAttachedToTarget(target);
|
||||
} else if (method === "Target.detachedFromTarget") {
|
||||
const detachParams = params as { sessionId: string };
|
||||
connectedTargets.delete(detachParams.sessionId);
|
||||
|
||||
// Also remove any name mapping
|
||||
for (const [name, sid] of namedPages) {
|
||||
if (sid === detachParams.sessionId) {
|
||||
namedPages.delete(name);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
log(`Target detached: ${detachParams.sessionId}`);
|
||||
|
||||
sendToPlaywright({
|
||||
method: "Target.detachedFromTarget",
|
||||
params: detachParams,
|
||||
});
|
||||
} else if (method === "Target.targetInfoChanged") {
|
||||
const infoParams = params as { targetInfo: TargetInfo };
|
||||
for (const target of connectedTargets.values()) {
|
||||
if (target.targetId === infoParams.targetInfo.targetId) {
|
||||
target.targetInfo = infoParams.targetInfo;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
sendToPlaywright({
|
||||
method: "Target.targetInfoChanged",
|
||||
params: infoParams,
|
||||
});
|
||||
} else {
|
||||
// Forward other CDP events to Playwright
|
||||
sendToPlaywright({
|
||||
sessionId,
|
||||
method,
|
||||
params,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
onClose(_event, ws) {
|
||||
if (extensionWs && extensionWs !== ws) {
|
||||
log("Old extension connection closed");
|
||||
return;
|
||||
}
|
||||
|
||||
log("Extension disconnected");
|
||||
|
||||
for (const pending of extensionPendingRequests.values()) {
|
||||
pending.reject(new Error("Extension connection closed"));
|
||||
}
|
||||
extensionPendingRequests.clear();
|
||||
|
||||
extensionWs = null;
|
||||
connectedTargets.clear();
|
||||
namedPages.clear();
|
||||
|
||||
// Close all Playwright clients
|
||||
for (const client of playwrightClients.values()) {
|
||||
client.ws.close(1000, "Extension disconnected");
|
||||
}
|
||||
playwrightClients.clear();
|
||||
},
|
||||
|
||||
onError(event) {
|
||||
log("Extension WebSocket error:", event);
|
||||
},
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// Start Server
|
||||
// ============================================================================
|
||||
|
||||
const server = serve({ fetch: app.fetch, port, hostname: host });
|
||||
injectWebSocket(server);
|
||||
|
||||
const wsEndpoint = `ws://${host}:${port}/cdp`;
|
||||
|
||||
log("CDP relay server started");
|
||||
log(` HTTP: http://${host}:${port}`);
|
||||
log(` CDP endpoint: ${wsEndpoint}`);
|
||||
log(` Extension endpoint: ws://${host}:${port}/extension`);
|
||||
log("");
|
||||
log("Waiting for extension to connect...");
|
||||
|
||||
return {
|
||||
wsEndpoint,
|
||||
port,
|
||||
async stop() {
|
||||
for (const client of playwrightClients.values()) {
|
||||
client.ws.close(1000, "Server stopped");
|
||||
}
|
||||
playwrightClients.clear();
|
||||
extensionWs?.close(1000, "Server stopped");
|
||||
server.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import { chromium } from "playwright";
|
||||
import type { Browser, BrowserContext, Page } from "playwright";
|
||||
import { beforeAll, afterAll, beforeEach, afterEach, describe, test, expect } from "vitest";
|
||||
import { getSnapshotScript, clearSnapshotScriptCache } from "../browser-script";
|
||||
|
||||
let browser: Browser;
|
||||
let context: BrowserContext;
|
||||
let page: Page;
|
||||
|
||||
beforeAll(async () => {
|
||||
browser = await chromium.launch();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await browser.close();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
context = await browser.newContext();
|
||||
page = await context.newPage();
|
||||
clearSnapshotScriptCache(); // Start fresh for each test
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await context.close();
|
||||
});
|
||||
|
||||
async function setContent(html: string): Promise<void> {
|
||||
await page.setContent(html, { waitUntil: "domcontentloaded" });
|
||||
}
|
||||
|
||||
async function getSnapshot(): Promise<string> {
|
||||
const script = getSnapshotScript();
|
||||
return await page.evaluate((s: string) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const w = globalThis as any;
|
||||
if (!w.__devBrowser_getAISnapshot) {
|
||||
// eslint-disable-next-line no-eval
|
||||
eval(s);
|
||||
}
|
||||
return w.__devBrowser_getAISnapshot();
|
||||
}, script);
|
||||
}
|
||||
|
||||
async function selectRef(ref: string): Promise<unknown> {
|
||||
return await page.evaluate((refId: string) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const w = globalThis as any;
|
||||
const element = w.__devBrowser_selectSnapshotRef(refId);
|
||||
return {
|
||||
tagName: element.tagName,
|
||||
textContent: element.textContent?.trim(),
|
||||
};
|
||||
}, ref);
|
||||
}
|
||||
|
||||
describe("ARIA Snapshot", () => {
|
||||
test("generates snapshot for simple page", async () => {
|
||||
await setContent(`
|
||||
<html>
|
||||
<body>
|
||||
<h1>Hello World</h1>
|
||||
<button>Click me</button>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
|
||||
const snapshot = await getSnapshot();
|
||||
|
||||
expect(snapshot).toContain("heading");
|
||||
expect(snapshot).toContain("Hello World");
|
||||
expect(snapshot).toContain("button");
|
||||
expect(snapshot).toContain("Click me");
|
||||
});
|
||||
|
||||
test("assigns refs to interactive elements", async () => {
|
||||
await setContent(`
|
||||
<html>
|
||||
<body>
|
||||
<button id="btn1">Button 1</button>
|
||||
<button id="btn2">Button 2</button>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
|
||||
const snapshot = await getSnapshot();
|
||||
|
||||
// Should have refs
|
||||
expect(snapshot).toMatch(/\[ref=e\d+\]/);
|
||||
});
|
||||
|
||||
test("refs persist on window.__devBrowserRefs", async () => {
|
||||
await setContent(`
|
||||
<html>
|
||||
<body>
|
||||
<button>Test Button</button>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
|
||||
await getSnapshot();
|
||||
|
||||
// Check that refs are stored
|
||||
const hasRefs = await page.evaluate(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const w = globalThis as any;
|
||||
return typeof w.__devBrowserRefs === "object" && Object.keys(w.__devBrowserRefs).length > 0;
|
||||
});
|
||||
|
||||
expect(hasRefs).toBe(true);
|
||||
});
|
||||
|
||||
test("selectSnapshotRef returns element for valid ref", async () => {
|
||||
await setContent(`
|
||||
<html>
|
||||
<body>
|
||||
<button>My Button</button>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
|
||||
const snapshot = await getSnapshot();
|
||||
|
||||
// Extract a ref from the snapshot
|
||||
const refMatch = snapshot.match(/\[ref=(e\d+)\]/);
|
||||
expect(refMatch).toBeTruthy();
|
||||
expect(refMatch![1]).toBeDefined();
|
||||
const ref = refMatch![1] as string;
|
||||
|
||||
// Select the element by ref
|
||||
const result = (await selectRef(ref)) as { tagName: string; textContent: string };
|
||||
expect(result.tagName).toBe("BUTTON");
|
||||
expect(result.textContent).toBe("My Button");
|
||||
});
|
||||
|
||||
test("includes links with URLs", async () => {
|
||||
await setContent(`
|
||||
<html>
|
||||
<body>
|
||||
<a href="https://example.com">Example Link</a>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
|
||||
const snapshot = await getSnapshot();
|
||||
|
||||
expect(snapshot).toContain("link");
|
||||
expect(snapshot).toContain("Example Link");
|
||||
// URL should be included as a prop
|
||||
expect(snapshot).toContain("/url:");
|
||||
});
|
||||
|
||||
test("includes form elements", async () => {
|
||||
await setContent(`
|
||||
<html>
|
||||
<body>
|
||||
<input type="text" placeholder="Enter name" />
|
||||
<input type="checkbox" />
|
||||
<select>
|
||||
<option>Option 1</option>
|
||||
<option>Option 2</option>
|
||||
</select>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
|
||||
const snapshot = await getSnapshot();
|
||||
|
||||
expect(snapshot).toContain("textbox");
|
||||
expect(snapshot).toContain("checkbox");
|
||||
expect(snapshot).toContain("combobox");
|
||||
});
|
||||
|
||||
test("renders nested structure correctly", async () => {
|
||||
await setContent(`
|
||||
<html>
|
||||
<body>
|
||||
<nav>
|
||||
<ul>
|
||||
<li><a href="/home">Home</a></li>
|
||||
<li><a href="/about">About</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
|
||||
const snapshot = await getSnapshot();
|
||||
|
||||
expect(snapshot).toContain("navigation");
|
||||
expect(snapshot).toContain("list");
|
||||
expect(snapshot).toContain("listitem");
|
||||
expect(snapshot).toContain("link");
|
||||
});
|
||||
|
||||
test("handles disabled elements", async () => {
|
||||
await setContent(`
|
||||
<html>
|
||||
<body>
|
||||
<button disabled>Disabled Button</button>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
|
||||
const snapshot = await getSnapshot();
|
||||
|
||||
expect(snapshot).toContain("[disabled]");
|
||||
});
|
||||
|
||||
test("handles checked checkboxes", async () => {
|
||||
await setContent(`
|
||||
<html>
|
||||
<body>
|
||||
<input type="checkbox" checked />
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
|
||||
const snapshot = await getSnapshot();
|
||||
|
||||
expect(snapshot).toContain("[checked]");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,877 @@
|
||||
/**
|
||||
* Browser-injectable snapshot script.
|
||||
*
|
||||
* This module provides the snapshot functionality as a string that can be
|
||||
* injected into the browser via page.addScriptTag() or page.evaluate().
|
||||
*
|
||||
* The approach is to read the compiled JavaScript at runtime and bundle it
|
||||
* into a single script that exposes window.__devBrowser_getAISnapshot() and
|
||||
* window.__devBrowser_selectSnapshotRef().
|
||||
*/
|
||||
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
|
||||
// Cache the bundled script
|
||||
let cachedScript: string | null = null;
|
||||
|
||||
/**
|
||||
* Get the snapshot script that can be injected into the browser.
|
||||
* Returns a self-contained JavaScript string that:
|
||||
* 1. Defines all necessary functions (domUtils, roleUtils, yaml, ariaSnapshot)
|
||||
* 2. Exposes window.__devBrowser_getAISnapshot()
|
||||
* 3. Exposes window.__devBrowser_selectSnapshotRef()
|
||||
*/
|
||||
export function getSnapshotScript(): string {
|
||||
if (cachedScript) return cachedScript;
|
||||
|
||||
// Read the compiled JavaScript files
|
||||
const snapshotDir = path.dirname(new URL(import.meta.url).pathname);
|
||||
|
||||
// For now, we'll inline the functions directly
|
||||
// In production, we could use a bundler like esbuild to create a single file
|
||||
cachedScript = `
|
||||
(function() {
|
||||
// Skip if already injected
|
||||
if (window.__devBrowser_getAISnapshot) return;
|
||||
|
||||
${getDomUtilsCode()}
|
||||
${getYamlCode()}
|
||||
${getRoleUtilsCode()}
|
||||
${getAriaSnapshotCode()}
|
||||
|
||||
// Expose main functions
|
||||
window.__devBrowser_getAISnapshot = getAISnapshot;
|
||||
window.__devBrowser_selectSnapshotRef = selectSnapshotRef;
|
||||
})();
|
||||
`;
|
||||
|
||||
return cachedScript;
|
||||
}
|
||||
|
||||
function getDomUtilsCode(): string {
|
||||
return `
|
||||
// === domUtils ===
|
||||
let cacheStyle;
|
||||
let cachesCounter = 0;
|
||||
|
||||
function beginDOMCaches() {
|
||||
++cachesCounter;
|
||||
cacheStyle = cacheStyle || new Map();
|
||||
}
|
||||
|
||||
function endDOMCaches() {
|
||||
if (!--cachesCounter) {
|
||||
cacheStyle = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function getElementComputedStyle(element, pseudo) {
|
||||
const cache = cacheStyle;
|
||||
const cacheKey = pseudo ? undefined : element;
|
||||
if (cache && cacheKey && cache.has(cacheKey)) return cache.get(cacheKey);
|
||||
const style = element.ownerDocument && element.ownerDocument.defaultView
|
||||
? element.ownerDocument.defaultView.getComputedStyle(element, pseudo)
|
||||
: undefined;
|
||||
if (cache && cacheKey) cache.set(cacheKey, style);
|
||||
return style;
|
||||
}
|
||||
|
||||
function parentElementOrShadowHost(element) {
|
||||
if (element.parentElement) return element.parentElement;
|
||||
if (!element.parentNode) return;
|
||||
if (element.parentNode.nodeType === 11 && element.parentNode.host)
|
||||
return element.parentNode.host;
|
||||
}
|
||||
|
||||
function enclosingShadowRootOrDocument(element) {
|
||||
let node = element;
|
||||
while (node.parentNode) node = node.parentNode;
|
||||
if (node.nodeType === 11 || node.nodeType === 9)
|
||||
return node;
|
||||
}
|
||||
|
||||
function closestCrossShadow(element, css, scope) {
|
||||
while (element) {
|
||||
const closest = element.closest(css);
|
||||
if (scope && closest !== scope && closest?.contains(scope)) return;
|
||||
if (closest) return closest;
|
||||
element = enclosingShadowHost(element);
|
||||
}
|
||||
}
|
||||
|
||||
function enclosingShadowHost(element) {
|
||||
while (element.parentElement) element = element.parentElement;
|
||||
return parentElementOrShadowHost(element);
|
||||
}
|
||||
|
||||
function isElementStyleVisibilityVisible(element, style) {
|
||||
style = style || getElementComputedStyle(element);
|
||||
if (!style) return true;
|
||||
if (style.visibility !== "visible") return false;
|
||||
const detailsOrSummary = element.closest("details,summary");
|
||||
if (detailsOrSummary !== element && detailsOrSummary?.nodeName === "DETAILS" && !detailsOrSummary.open)
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function computeBox(element) {
|
||||
const style = getElementComputedStyle(element);
|
||||
if (!style) return { visible: true, inline: false };
|
||||
const cursor = style.cursor;
|
||||
if (style.display === "contents") {
|
||||
for (let child = element.firstChild; child; child = child.nextSibling) {
|
||||
if (child.nodeType === 1 && isElementVisible(child))
|
||||
return { visible: true, inline: false, cursor };
|
||||
if (child.nodeType === 3 && isVisibleTextNode(child))
|
||||
return { visible: true, inline: true, cursor };
|
||||
}
|
||||
return { visible: false, inline: false, cursor };
|
||||
}
|
||||
if (!isElementStyleVisibilityVisible(element, style))
|
||||
return { cursor, visible: false, inline: false };
|
||||
const rect = element.getBoundingClientRect();
|
||||
return { rect, cursor, visible: rect.width > 0 && rect.height > 0, inline: style.display === "inline" };
|
||||
}
|
||||
|
||||
function isElementVisible(element) {
|
||||
return computeBox(element).visible;
|
||||
}
|
||||
|
||||
function isVisibleTextNode(node) {
|
||||
const range = node.ownerDocument.createRange();
|
||||
range.selectNode(node);
|
||||
const rect = range.getBoundingClientRect();
|
||||
return rect.width > 0 && rect.height > 0;
|
||||
}
|
||||
|
||||
function elementSafeTagName(element) {
|
||||
const tagName = element.tagName;
|
||||
if (typeof tagName === "string") return tagName.toUpperCase();
|
||||
if (element instanceof HTMLFormElement) return "FORM";
|
||||
return element.tagName.toUpperCase();
|
||||
}
|
||||
|
||||
function normalizeWhiteSpace(text) {
|
||||
return text.split("\\u00A0").map(chunk =>
|
||||
chunk.replace(/\\r\\n/g, "\\n").replace(/[\\u200b\\u00ad]/g, "").replace(/\\s\\s*/g, " ")
|
||||
).join("\\u00A0").trim();
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
function getYamlCode(): string {
|
||||
return `
|
||||
// === yaml ===
|
||||
function yamlEscapeKeyIfNeeded(str) {
|
||||
if (!yamlStringNeedsQuotes(str)) return str;
|
||||
return "'" + str.replace(/'/g, "''") + "'";
|
||||
}
|
||||
|
||||
function yamlEscapeValueIfNeeded(str) {
|
||||
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) {
|
||||
if (str.length === 0) return true;
|
||||
if (/^\\s|\\s$/.test(str)) return true;
|
||||
if (/[\\x00-\\x08\\x0b\\x0c\\x0e-\\x1f\\x7f-\\x9f]/.test(str)) return true;
|
||||
if (/^-/.test(str)) return true;
|
||||
if (/[\\n:](\\s|$)/.test(str)) return true;
|
||||
if (/\\s#/.test(str)) return true;
|
||||
if (/[\\n\\r]/.test(str)) return true;
|
||||
if (/^[&*\\],?!>|@"'#%]/.test(str)) return true;
|
||||
if (/[{}\`]/.test(str)) return true;
|
||||
if (/^\\[/.test(str)) return true;
|
||||
if (!isNaN(Number(str)) || ["y","n","yes","no","true","false","on","off","null"].includes(str.toLowerCase())) return true;
|
||||
return false;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
function getRoleUtilsCode(): string {
|
||||
return `
|
||||
// === roleUtils ===
|
||||
const validRoles = ["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"];
|
||||
|
||||
let cacheAccessibleName;
|
||||
let cacheIsHidden;
|
||||
let cachePointerEvents;
|
||||
let ariaCachesCounter = 0;
|
||||
|
||||
function beginAriaCaches() {
|
||||
beginDOMCaches();
|
||||
++ariaCachesCounter;
|
||||
cacheAccessibleName = cacheAccessibleName || new Map();
|
||||
cacheIsHidden = cacheIsHidden || new Map();
|
||||
cachePointerEvents = cachePointerEvents || new Map();
|
||||
}
|
||||
|
||||
function endAriaCaches() {
|
||||
if (!--ariaCachesCounter) {
|
||||
cacheAccessibleName = undefined;
|
||||
cacheIsHidden = undefined;
|
||||
cachePointerEvents = undefined;
|
||||
}
|
||||
endDOMCaches();
|
||||
}
|
||||
|
||||
function hasExplicitAccessibleName(e) {
|
||||
return e.hasAttribute("aria-label") || e.hasAttribute("aria-labelledby");
|
||||
}
|
||||
|
||||
const kAncestorPreventingLandmark = "article:not([role]), aside:not([role]), main:not([role]), nav:not([role]), section:not([role]), [role=article], [role=complementary], [role=main], [role=navigation], [role=region]";
|
||||
|
||||
const kGlobalAriaAttributes = [
|
||||
["aria-atomic", undefined],["aria-busy", undefined],["aria-controls", undefined],["aria-current", undefined],
|
||||
["aria-describedby", undefined],["aria-details", undefined],["aria-dropeffect", undefined],["aria-flowto", undefined],
|
||||
["aria-grabbed", undefined],["aria-hidden", undefined],["aria-keyshortcuts", undefined],
|
||||
["aria-label", ["caption","code","deletion","emphasis","generic","insertion","paragraph","presentation","strong","subscript","superscript"]],
|
||||
["aria-labelledby", ["caption","code","deletion","emphasis","generic","insertion","paragraph","presentation","strong","subscript","superscript"]],
|
||||
["aria-live", undefined],["aria-owns", undefined],["aria-relevant", undefined],["aria-roledescription", ["generic"]]
|
||||
];
|
||||
|
||||
function hasGlobalAriaAttribute(element, forRole) {
|
||||
return kGlobalAriaAttributes.some(([attr, prohibited]) => !prohibited?.includes(forRole || "") && element.hasAttribute(attr));
|
||||
}
|
||||
|
||||
function hasTabIndex(element) {
|
||||
return !Number.isNaN(Number(String(element.getAttribute("tabindex"))));
|
||||
}
|
||||
|
||||
function isFocusable(element) {
|
||||
return !isNativelyDisabled(element) && (isNativelyFocusable(element) || hasTabIndex(element));
|
||||
}
|
||||
|
||||
function isNativelyFocusable(element) {
|
||||
const tagName = elementSafeTagName(element);
|
||||
if (["BUTTON","DETAILS","SELECT","TEXTAREA"].includes(tagName)) return true;
|
||||
if (tagName === "A" || tagName === "AREA") return element.hasAttribute("href");
|
||||
if (tagName === "INPUT") return !element.hidden;
|
||||
return false;
|
||||
}
|
||||
|
||||
function isNativelyDisabled(element) {
|
||||
const isNativeFormControl = ["BUTTON","INPUT","SELECT","TEXTAREA","OPTION","OPTGROUP"].includes(elementSafeTagName(element));
|
||||
return isNativeFormControl && (element.hasAttribute("disabled") || belongsToDisabledFieldSet(element));
|
||||
}
|
||||
|
||||
function belongsToDisabledFieldSet(element) {
|
||||
const fieldSetElement = element?.closest("FIELDSET[DISABLED]");
|
||||
if (!fieldSetElement) return false;
|
||||
const legendElement = fieldSetElement.querySelector(":scope > LEGEND");
|
||||
return !legendElement || !legendElement.contains(element);
|
||||
}
|
||||
|
||||
const inputTypeToRole = {button:"button",checkbox:"checkbox",image:"button",number:"spinbutton",radio:"radio",range:"slider",reset:"button",submit:"button"};
|
||||
|
||||
function getIdRefs(element, ref) {
|
||||
if (!ref) return [];
|
||||
const root = enclosingShadowRootOrDocument(element);
|
||||
if (!root) return [];
|
||||
try {
|
||||
const ids = ref.split(" ").filter(id => !!id);
|
||||
const result = [];
|
||||
for (const id of ids) {
|
||||
const firstElement = root.querySelector("#" + CSS.escape(id));
|
||||
if (firstElement && !result.includes(firstElement)) result.push(firstElement);
|
||||
}
|
||||
return result;
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
const kImplicitRoleByTagName = {
|
||||
A: e => e.hasAttribute("href") ? "link" : null,
|
||||
AREA: e => e.hasAttribute("href") ? "link" : null,
|
||||
ARTICLE: () => "article", ASIDE: () => "complementary", BLOCKQUOTE: () => "blockquote", BUTTON: () => "button",
|
||||
CAPTION: () => "caption", CODE: () => "code", DATALIST: () => "listbox", DD: () => "definition",
|
||||
DEL: () => "deletion", DETAILS: () => "group", DFN: () => "term", DIALOG: () => "dialog", DT: () => "term",
|
||||
EM: () => "emphasis", FIELDSET: () => "group", FIGURE: () => "figure",
|
||||
FOOTER: e => closestCrossShadow(e, kAncestorPreventingLandmark) ? null : "contentinfo",
|
||||
FORM: e => hasExplicitAccessibleName(e) ? "form" : null,
|
||||
H1: () => "heading", H2: () => "heading", H3: () => "heading", H4: () => "heading", H5: () => "heading", H6: () => "heading",
|
||||
HEADER: e => closestCrossShadow(e, kAncestorPreventingLandmark) ? null : "banner",
|
||||
HR: () => "separator", HTML: () => "document",
|
||||
IMG: e => e.getAttribute("alt") === "" && !e.getAttribute("title") && !hasGlobalAriaAttribute(e) && !hasTabIndex(e) ? "presentation" : "img",
|
||||
INPUT: e => {
|
||||
const type = e.type.toLowerCase();
|
||||
if (type === "search") return e.hasAttribute("list") ? "combobox" : "searchbox";
|
||||
if (["email","tel","text","url",""].includes(type)) {
|
||||
const list = getIdRefs(e, e.getAttribute("list"))[0];
|
||||
return list && elementSafeTagName(list) === "DATALIST" ? "combobox" : "textbox";
|
||||
}
|
||||
if (type === "hidden") return null;
|
||||
if (type === "file") return "button";
|
||||
return inputTypeToRole[type] || "textbox";
|
||||
},
|
||||
INS: () => "insertion", LI: () => "listitem", MAIN: () => "main", MARK: () => "mark", MATH: () => "math",
|
||||
MENU: () => "list", METER: () => "meter", NAV: () => "navigation", OL: () => "list", OPTGROUP: () => "group",
|
||||
OPTION: () => "option", OUTPUT: () => "status", P: () => "paragraph", PROGRESS: () => "progressbar",
|
||||
SEARCH: () => "search", SECTION: e => hasExplicitAccessibleName(e) ? "region" : null,
|
||||
SELECT: e => e.hasAttribute("multiple") || e.size > 1 ? "listbox" : "combobox",
|
||||
STRONG: () => "strong", SUB: () => "subscript", SUP: () => "superscript", SVG: () => "img",
|
||||
TABLE: () => "table", TBODY: () => "rowgroup",
|
||||
TD: e => { const table = closestCrossShadow(e, "table"); const role = table ? getExplicitAriaRole(table) : ""; return role === "grid" || role === "treegrid" ? "gridcell" : "cell"; },
|
||||
TEXTAREA: () => "textbox", TFOOT: () => "rowgroup",
|
||||
TH: e => { const scope = e.getAttribute("scope"); if (scope === "col" || scope === "colgroup") return "columnheader"; if (scope === "row" || scope === "rowgroup") return "rowheader"; return "columnheader"; },
|
||||
THEAD: () => "rowgroup", TIME: () => "time", TR: () => "row", UL: () => "list"
|
||||
};
|
||||
|
||||
function getExplicitAriaRole(element) {
|
||||
const roles = (element.getAttribute("role") || "").split(" ").map(role => role.trim());
|
||||
return roles.find(role => validRoles.includes(role)) || null;
|
||||
}
|
||||
|
||||
function getImplicitAriaRole(element) {
|
||||
const fn = kImplicitRoleByTagName[elementSafeTagName(element)];
|
||||
return fn ? fn(element) : null;
|
||||
}
|
||||
|
||||
function hasPresentationConflictResolution(element, role) {
|
||||
return hasGlobalAriaAttribute(element, role) || isFocusable(element);
|
||||
}
|
||||
|
||||
function getAriaRole(element) {
|
||||
const explicitRole = getExplicitAriaRole(element);
|
||||
if (!explicitRole) return getImplicitAriaRole(element);
|
||||
if (explicitRole === "none" || explicitRole === "presentation") {
|
||||
const implicitRole = getImplicitAriaRole(element);
|
||||
if (hasPresentationConflictResolution(element, implicitRole)) return implicitRole;
|
||||
}
|
||||
return explicitRole;
|
||||
}
|
||||
|
||||
function getAriaBoolean(attr) {
|
||||
return attr === null ? undefined : attr.toLowerCase() === "true";
|
||||
}
|
||||
|
||||
function isElementIgnoredForAria(element) {
|
||||
return ["STYLE","SCRIPT","NOSCRIPT","TEMPLATE"].includes(elementSafeTagName(element));
|
||||
}
|
||||
|
||||
function isElementHiddenForAria(element) {
|
||||
if (isElementIgnoredForAria(element)) return true;
|
||||
const style = getElementComputedStyle(element);
|
||||
const isSlot = element.nodeName === "SLOT";
|
||||
if (style?.display === "contents" && !isSlot) {
|
||||
for (let child = element.firstChild; child; child = child.nextSibling) {
|
||||
if (child.nodeType === 1 && !isElementHiddenForAria(child)) return false;
|
||||
if (child.nodeType === 3 && isVisibleTextNode(child)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
const isOptionInsideSelect = element.nodeName === "OPTION" && !!element.closest("select");
|
||||
if (!isOptionInsideSelect && !isSlot && !isElementStyleVisibilityVisible(element, style)) return true;
|
||||
return belongsToDisplayNoneOrAriaHiddenOrNonSlotted(element);
|
||||
}
|
||||
|
||||
function belongsToDisplayNoneOrAriaHiddenOrNonSlotted(element) {
|
||||
let hidden = cacheIsHidden?.get(element);
|
||||
if (hidden === undefined) {
|
||||
hidden = false;
|
||||
if (element.parentElement && element.parentElement.shadowRoot && !element.assignedSlot) hidden = true;
|
||||
if (!hidden) {
|
||||
const style = getElementComputedStyle(element);
|
||||
hidden = !style || style.display === "none" || getAriaBoolean(element.getAttribute("aria-hidden")) === true;
|
||||
}
|
||||
if (!hidden) {
|
||||
const parent = parentElementOrShadowHost(element);
|
||||
if (parent) hidden = belongsToDisplayNoneOrAriaHiddenOrNonSlotted(parent);
|
||||
}
|
||||
cacheIsHidden?.set(element, hidden);
|
||||
}
|
||||
return hidden;
|
||||
}
|
||||
|
||||
function getAriaLabelledByElements(element) {
|
||||
const ref = element.getAttribute("aria-labelledby");
|
||||
if (ref === null) return null;
|
||||
const refs = getIdRefs(element, ref);
|
||||
return refs.length ? refs : null;
|
||||
}
|
||||
|
||||
function getElementAccessibleName(element, includeHidden) {
|
||||
let accessibleName = cacheAccessibleName?.get(element);
|
||||
if (accessibleName === undefined) {
|
||||
accessibleName = "";
|
||||
const elementProhibitsNaming = ["caption","code","definition","deletion","emphasis","generic","insertion","mark","paragraph","presentation","strong","subscript","suggestion","superscript","term","time"].includes(getAriaRole(element) || "");
|
||||
if (!elementProhibitsNaming) {
|
||||
accessibleName = normalizeWhiteSpace(getTextAlternativeInternal(element, { includeHidden, visitedElements: new Set(), embeddedInTargetElement: "self" }));
|
||||
}
|
||||
cacheAccessibleName?.set(element, accessibleName);
|
||||
}
|
||||
return accessibleName;
|
||||
}
|
||||
|
||||
function getTextAlternativeInternal(element, options) {
|
||||
if (options.visitedElements.has(element)) return "";
|
||||
const childOptions = { ...options, embeddedInTargetElement: options.embeddedInTargetElement === "self" ? "descendant" : options.embeddedInTargetElement };
|
||||
|
||||
if (!options.includeHidden) {
|
||||
const isEmbeddedInHiddenReferenceTraversal = !!options.embeddedInLabelledBy?.hidden || !!options.embeddedInLabel?.hidden;
|
||||
if (isElementIgnoredForAria(element) || (!isEmbeddedInHiddenReferenceTraversal && isElementHiddenForAria(element))) {
|
||||
options.visitedElements.add(element);
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
const labelledBy = getAriaLabelledByElements(element);
|
||||
if (!options.embeddedInLabelledBy) {
|
||||
const accessibleName = (labelledBy || []).map(ref => getTextAlternativeInternal(ref, { ...options, embeddedInLabelledBy: { element: ref, hidden: isElementHiddenForAria(ref) }, embeddedInTargetElement: undefined, embeddedInLabel: undefined })).join(" ");
|
||||
if (accessibleName) return accessibleName;
|
||||
}
|
||||
|
||||
const role = getAriaRole(element) || "";
|
||||
const tagName = elementSafeTagName(element);
|
||||
|
||||
const ariaLabel = element.getAttribute("aria-label") || "";
|
||||
if (ariaLabel.trim()) { options.visitedElements.add(element); return ariaLabel; }
|
||||
|
||||
if (!["presentation","none"].includes(role)) {
|
||||
if (tagName === "INPUT" && ["button","submit","reset"].includes(element.type)) {
|
||||
options.visitedElements.add(element);
|
||||
const value = element.value || "";
|
||||
if (value.trim()) return value;
|
||||
if (element.type === "submit") return "Submit";
|
||||
if (element.type === "reset") return "Reset";
|
||||
return element.getAttribute("title") || "";
|
||||
}
|
||||
if (tagName === "INPUT" && element.type === "image") {
|
||||
options.visitedElements.add(element);
|
||||
const alt = element.getAttribute("alt") || "";
|
||||
if (alt.trim()) return alt;
|
||||
const title = element.getAttribute("title") || "";
|
||||
if (title.trim()) return title;
|
||||
return "Submit";
|
||||
}
|
||||
if (tagName === "IMG") {
|
||||
options.visitedElements.add(element);
|
||||
const alt = element.getAttribute("alt") || "";
|
||||
if (alt.trim()) return alt;
|
||||
return element.getAttribute("title") || "";
|
||||
}
|
||||
if (!labelledBy && ["BUTTON","INPUT","TEXTAREA","SELECT"].includes(tagName)) {
|
||||
const labels = element.labels;
|
||||
if (labels?.length) {
|
||||
options.visitedElements.add(element);
|
||||
return [...labels].map(label => getTextAlternativeInternal(label, { ...options, embeddedInLabel: { element: label, hidden: isElementHiddenForAria(label) }, embeddedInLabelledBy: undefined, embeddedInTargetElement: undefined })).filter(name => !!name).join(" ");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const allowsNameFromContent = ["button","cell","checkbox","columnheader","gridcell","heading","link","menuitem","menuitemcheckbox","menuitemradio","option","radio","row","rowheader","switch","tab","tooltip","treeitem"].includes(role);
|
||||
if (allowsNameFromContent || !!options.embeddedInLabelledBy || !!options.embeddedInLabel) {
|
||||
options.visitedElements.add(element);
|
||||
const accessibleName = innerAccumulatedElementText(element, childOptions);
|
||||
const maybeTrimmedAccessibleName = options.embeddedInTargetElement === "self" ? accessibleName.trim() : accessibleName;
|
||||
if (maybeTrimmedAccessibleName) return accessibleName;
|
||||
}
|
||||
|
||||
if (!["presentation","none"].includes(role) || tagName === "IFRAME") {
|
||||
options.visitedElements.add(element);
|
||||
const title = element.getAttribute("title") || "";
|
||||
if (title.trim()) return title;
|
||||
}
|
||||
|
||||
options.visitedElements.add(element);
|
||||
return "";
|
||||
}
|
||||
|
||||
function innerAccumulatedElementText(element, options) {
|
||||
const tokens = [];
|
||||
const visit = (node, skipSlotted) => {
|
||||
if (skipSlotted && node.assignedSlot) return;
|
||||
if (node.nodeType === 1) {
|
||||
const display = getElementComputedStyle(node)?.display || "inline";
|
||||
let token = getTextAlternativeInternal(node, options);
|
||||
if (display !== "inline" || node.nodeName === "BR") token = " " + token + " ";
|
||||
tokens.push(token);
|
||||
} else if (node.nodeType === 3) {
|
||||
tokens.push(node.textContent || "");
|
||||
}
|
||||
};
|
||||
const assignedNodes = element.nodeName === "SLOT" ? element.assignedNodes() : [];
|
||||
if (assignedNodes.length) {
|
||||
for (const child of assignedNodes) visit(child, false);
|
||||
} else {
|
||||
for (let child = element.firstChild; child; child = child.nextSibling) visit(child, true);
|
||||
if (element.shadowRoot) {
|
||||
for (let child = element.shadowRoot.firstChild; child; child = child.nextSibling) visit(child, true);
|
||||
}
|
||||
}
|
||||
return tokens.join("");
|
||||
}
|
||||
|
||||
const kAriaCheckedRoles = ["checkbox","menuitemcheckbox","option","radio","switch","menuitemradio","treeitem"];
|
||||
function getAriaChecked(element) {
|
||||
const tagName = elementSafeTagName(element);
|
||||
if (tagName === "INPUT" && element.indeterminate) return "mixed";
|
||||
if (tagName === "INPUT" && ["checkbox","radio"].includes(element.type)) return element.checked;
|
||||
if (kAriaCheckedRoles.includes(getAriaRole(element) || "")) {
|
||||
const checked = element.getAttribute("aria-checked");
|
||||
if (checked === "true") return true;
|
||||
if (checked === "mixed") return "mixed";
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const kAriaDisabledRoles = ["application","button","composite","gridcell","group","input","link","menuitem","scrollbar","separator","tab","checkbox","columnheader","combobox","grid","listbox","menu","menubar","menuitemcheckbox","menuitemradio","option","radio","radiogroup","row","rowheader","searchbox","select","slider","spinbutton","switch","tablist","textbox","toolbar","tree","treegrid","treeitem"];
|
||||
function getAriaDisabled(element) {
|
||||
return isNativelyDisabled(element) || hasExplicitAriaDisabled(element);
|
||||
}
|
||||
function hasExplicitAriaDisabled(element, isAncestor) {
|
||||
if (!element) return false;
|
||||
if (isAncestor || kAriaDisabledRoles.includes(getAriaRole(element) || "")) {
|
||||
const attribute = (element.getAttribute("aria-disabled") || "").toLowerCase();
|
||||
if (attribute === "true") return true;
|
||||
if (attribute === "false") return false;
|
||||
return hasExplicitAriaDisabled(parentElementOrShadowHost(element), true);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const kAriaExpandedRoles = ["application","button","checkbox","combobox","gridcell","link","listbox","menuitem","row","rowheader","tab","treeitem","columnheader","menuitemcheckbox","menuitemradio","switch"];
|
||||
function getAriaExpanded(element) {
|
||||
if (elementSafeTagName(element) === "DETAILS") return element.open;
|
||||
if (kAriaExpandedRoles.includes(getAriaRole(element) || "")) {
|
||||
const expanded = element.getAttribute("aria-expanded");
|
||||
if (expanded === null) return undefined;
|
||||
if (expanded === "true") return true;
|
||||
return false;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const kAriaLevelRoles = ["heading","listitem","row","treeitem"];
|
||||
function getAriaLevel(element) {
|
||||
const native = {H1:1,H2:2,H3:3,H4:4,H5:5,H6:6}[elementSafeTagName(element)];
|
||||
if (native) return native;
|
||||
if (kAriaLevelRoles.includes(getAriaRole(element) || "")) {
|
||||
const attr = element.getAttribute("aria-level");
|
||||
const value = attr === null ? Number.NaN : Number(attr);
|
||||
if (Number.isInteger(value) && value >= 1) return value;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
const kAriaPressedRoles = ["button"];
|
||||
function getAriaPressed(element) {
|
||||
if (kAriaPressedRoles.includes(getAriaRole(element) || "")) {
|
||||
const pressed = element.getAttribute("aria-pressed");
|
||||
if (pressed === "true") return true;
|
||||
if (pressed === "mixed") return "mixed";
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const kAriaSelectedRoles = ["gridcell","option","row","tab","rowheader","columnheader","treeitem"];
|
||||
function getAriaSelected(element) {
|
||||
if (elementSafeTagName(element) === "OPTION") return element.selected;
|
||||
if (kAriaSelectedRoles.includes(getAriaRole(element) || "")) return getAriaBoolean(element.getAttribute("aria-selected")) === true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function receivesPointerEvents(element) {
|
||||
const cache = cachePointerEvents;
|
||||
let e = element;
|
||||
let result;
|
||||
const parents = [];
|
||||
for (; e; e = parentElementOrShadowHost(e)) {
|
||||
const cached = cache?.get(e);
|
||||
if (cached !== undefined) { result = cached; break; }
|
||||
parents.push(e);
|
||||
const style = getElementComputedStyle(e);
|
||||
if (!style) { result = true; break; }
|
||||
const value = style.pointerEvents;
|
||||
if (value) { result = value !== "none"; break; }
|
||||
}
|
||||
if (result === undefined) result = true;
|
||||
for (const parent of parents) cache?.set(parent, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
function getCSSContent(element, pseudo) {
|
||||
const style = getElementComputedStyle(element, pseudo);
|
||||
if (!style) return undefined;
|
||||
const contentValue = style.content;
|
||||
if (!contentValue || contentValue === "none" || contentValue === "normal") return undefined;
|
||||
if (style.display === "none" || style.visibility === "hidden") return undefined;
|
||||
const match = contentValue.match(/^"(.*)"$/);
|
||||
if (match) {
|
||||
const content = match[1].replace(/\\\\"/g, '"');
|
||||
if (pseudo) {
|
||||
const display = style.display || "inline";
|
||||
if (display !== "inline") return " " + content + " ";
|
||||
}
|
||||
return content;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
function getAriaSnapshotCode(): string {
|
||||
return `
|
||||
// === ariaSnapshot ===
|
||||
let lastRef = 0;
|
||||
|
||||
function generateAriaTree(rootElement) {
|
||||
const options = { visibility: "ariaOrVisible", refs: "interactable", refPrefix: "", includeGenericRole: true, renderActive: true, renderCursorPointer: true };
|
||||
const visited = new Set();
|
||||
const snapshot = {
|
||||
root: { role: "fragment", name: "", children: [], element: rootElement, props: {}, box: computeBox(rootElement), receivesPointerEvents: true },
|
||||
elements: new Map(),
|
||||
refs: new Map(),
|
||||
iframeRefs: []
|
||||
};
|
||||
|
||||
const visit = (ariaNode, node, parentElementVisible) => {
|
||||
if (visited.has(node)) return;
|
||||
visited.add(node);
|
||||
if (node.nodeType === Node.TEXT_NODE && node.nodeValue) {
|
||||
if (!parentElementVisible) return;
|
||||
const text = node.nodeValue;
|
||||
if (ariaNode.role !== "textbox" && text) ariaNode.children.push(node.nodeValue || "");
|
||||
return;
|
||||
}
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) return;
|
||||
const element = node;
|
||||
const isElementVisibleForAria = !isElementHiddenForAria(element);
|
||||
let visible = isElementVisibleForAria;
|
||||
if (options.visibility === "ariaOrVisible") visible = isElementVisibleForAria || isElementVisible(element);
|
||||
if (options.visibility === "ariaAndVisible") visible = isElementVisibleForAria && isElementVisible(element);
|
||||
if (options.visibility === "aria" && !visible) return;
|
||||
const ariaChildren = [];
|
||||
if (element.hasAttribute("aria-owns")) {
|
||||
const ids = element.getAttribute("aria-owns").split(/\\s+/);
|
||||
for (const id of ids) {
|
||||
const ownedElement = rootElement.ownerDocument.getElementById(id);
|
||||
if (ownedElement) ariaChildren.push(ownedElement);
|
||||
}
|
||||
}
|
||||
const childAriaNode = visible ? toAriaNode(element, options) : null;
|
||||
if (childAriaNode) {
|
||||
if (childAriaNode.ref) {
|
||||
snapshot.elements.set(childAriaNode.ref, element);
|
||||
snapshot.refs.set(element, childAriaNode.ref);
|
||||
if (childAriaNode.role === "iframe") snapshot.iframeRefs.push(childAriaNode.ref);
|
||||
}
|
||||
ariaNode.children.push(childAriaNode);
|
||||
}
|
||||
processElement(childAriaNode || ariaNode, element, ariaChildren, visible);
|
||||
};
|
||||
|
||||
function processElement(ariaNode, element, ariaChildren, parentElementVisible) {
|
||||
const display = getElementComputedStyle(element)?.display || "inline";
|
||||
const treatAsBlock = display !== "inline" || element.nodeName === "BR" ? " " : "";
|
||||
if (treatAsBlock) ariaNode.children.push(treatAsBlock);
|
||||
ariaNode.children.push(getCSSContent(element, "::before") || "");
|
||||
const assignedNodes = element.nodeName === "SLOT" ? element.assignedNodes() : [];
|
||||
if (assignedNodes.length) {
|
||||
for (const child of assignedNodes) visit(ariaNode, child, parentElementVisible);
|
||||
} else {
|
||||
for (let child = element.firstChild; child; child = child.nextSibling) {
|
||||
if (!child.assignedSlot) visit(ariaNode, child, parentElementVisible);
|
||||
}
|
||||
if (element.shadowRoot) {
|
||||
for (let child = element.shadowRoot.firstChild; child; child = child.nextSibling) visit(ariaNode, child, parentElementVisible);
|
||||
}
|
||||
}
|
||||
for (const child of ariaChildren) visit(ariaNode, child, parentElementVisible);
|
||||
ariaNode.children.push(getCSSContent(element, "::after") || "");
|
||||
if (treatAsBlock) ariaNode.children.push(treatAsBlock);
|
||||
if (ariaNode.children.length === 1 && ariaNode.name === ariaNode.children[0]) ariaNode.children = [];
|
||||
if (ariaNode.role === "link" && element.hasAttribute("href")) ariaNode.props["url"] = element.getAttribute("href");
|
||||
if (ariaNode.role === "textbox" && element.hasAttribute("placeholder") && element.getAttribute("placeholder") !== ariaNode.name) ariaNode.props["placeholder"] = element.getAttribute("placeholder");
|
||||
}
|
||||
|
||||
beginAriaCaches();
|
||||
try { visit(snapshot.root, rootElement, true); }
|
||||
finally { endAriaCaches(); }
|
||||
normalizeStringChildren(snapshot.root);
|
||||
normalizeGenericRoles(snapshot.root);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
function computeAriaRef(ariaNode, options) {
|
||||
if (options.refs === "none") return;
|
||||
if (options.refs === "interactable" && (!ariaNode.box.visible || !ariaNode.receivesPointerEvents)) return;
|
||||
let ariaRef = ariaNode.element._ariaRef;
|
||||
if (!ariaRef || ariaRef.role !== ariaNode.role || ariaRef.name !== ariaNode.name) {
|
||||
ariaRef = { role: ariaNode.role, name: ariaNode.name, ref: (options.refPrefix || "") + "e" + (++lastRef) };
|
||||
ariaNode.element._ariaRef = ariaRef;
|
||||
}
|
||||
ariaNode.ref = ariaRef.ref;
|
||||
}
|
||||
|
||||
function toAriaNode(element, options) {
|
||||
const active = element.ownerDocument.activeElement === element;
|
||||
if (element.nodeName === "IFRAME") {
|
||||
const ariaNode = { role: "iframe", name: "", children: [], props: {}, element, box: computeBox(element), receivesPointerEvents: true, active };
|
||||
computeAriaRef(ariaNode, options);
|
||||
return ariaNode;
|
||||
}
|
||||
const defaultRole = options.includeGenericRole ? "generic" : null;
|
||||
const role = getAriaRole(element) || defaultRole;
|
||||
if (!role || role === "presentation" || role === "none") return null;
|
||||
const name = normalizeWhiteSpace(getElementAccessibleName(element, false) || "");
|
||||
const receivesPointerEventsValue = receivesPointerEvents(element);
|
||||
const box = computeBox(element);
|
||||
if (role === "generic" && box.inline && element.childNodes.length === 1 && element.childNodes[0].nodeType === Node.TEXT_NODE) return null;
|
||||
const result = { role, name, children: [], props: {}, element, box, receivesPointerEvents: receivesPointerEventsValue, active };
|
||||
computeAriaRef(result, options);
|
||||
if (kAriaCheckedRoles.includes(role)) result.checked = getAriaChecked(element);
|
||||
if (kAriaDisabledRoles.includes(role)) result.disabled = getAriaDisabled(element);
|
||||
if (kAriaExpandedRoles.includes(role)) result.expanded = getAriaExpanded(element);
|
||||
if (kAriaLevelRoles.includes(role)) result.level = getAriaLevel(element);
|
||||
if (kAriaPressedRoles.includes(role)) result.pressed = getAriaPressed(element);
|
||||
if (kAriaSelectedRoles.includes(role)) result.selected = getAriaSelected(element);
|
||||
if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) {
|
||||
if (element.type !== "checkbox" && element.type !== "radio" && element.type !== "file") result.children = [element.value];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function normalizeGenericRoles(node) {
|
||||
const normalizeChildren = (node) => {
|
||||
const result = [];
|
||||
for (const child of node.children || []) {
|
||||
if (typeof child === "string") { result.push(child); continue; }
|
||||
const normalized = normalizeChildren(child);
|
||||
result.push(...normalized);
|
||||
}
|
||||
const removeSelf = node.role === "generic" && !node.name && result.length <= 1 && result.every(c => typeof c !== "string" && !!c.ref);
|
||||
if (removeSelf) return result;
|
||||
node.children = result;
|
||||
return [node];
|
||||
};
|
||||
normalizeChildren(node);
|
||||
}
|
||||
|
||||
function normalizeStringChildren(rootA11yNode) {
|
||||
const flushChildren = (buffer, normalizedChildren) => {
|
||||
if (!buffer.length) return;
|
||||
const text = normalizeWhiteSpace(buffer.join(""));
|
||||
if (text) normalizedChildren.push(text);
|
||||
buffer.length = 0;
|
||||
};
|
||||
const visit = (ariaNode) => {
|
||||
const normalizedChildren = [];
|
||||
const buffer = [];
|
||||
for (const child of ariaNode.children || []) {
|
||||
if (typeof child === "string") { buffer.push(child); }
|
||||
else { flushChildren(buffer, normalizedChildren); visit(child); normalizedChildren.push(child); }
|
||||
}
|
||||
flushChildren(buffer, normalizedChildren);
|
||||
ariaNode.children = normalizedChildren.length ? normalizedChildren : [];
|
||||
if (ariaNode.children.length === 1 && ariaNode.children[0] === ariaNode.name) ariaNode.children = [];
|
||||
};
|
||||
visit(rootA11yNode);
|
||||
}
|
||||
|
||||
function hasPointerCursor(ariaNode) { return ariaNode.box.cursor === "pointer"; }
|
||||
|
||||
function renderAriaTree(ariaSnapshot) {
|
||||
const options = { visibility: "ariaOrVisible", refs: "interactable", refPrefix: "", includeGenericRole: true, renderActive: true, renderCursorPointer: true };
|
||||
const lines = [];
|
||||
let nodesToRender = ariaSnapshot.root.role === "fragment" ? ariaSnapshot.root.children : [ariaSnapshot.root];
|
||||
|
||||
const visitText = (text, indent) => {
|
||||
const escaped = yamlEscapeValueIfNeeded(text);
|
||||
if (escaped) lines.push(indent + "- text: " + escaped);
|
||||
};
|
||||
|
||||
const createKey = (ariaNode, renderCursorPointer) => {
|
||||
let key = ariaNode.role;
|
||||
if (ariaNode.name && ariaNode.name.length <= 900) {
|
||||
const name = ariaNode.name;
|
||||
if (name) {
|
||||
const stringifiedName = name.startsWith("/") && name.endsWith("/") ? name : JSON.stringify(name);
|
||||
key += " " + stringifiedName;
|
||||
}
|
||||
}
|
||||
if (ariaNode.checked === "mixed") key += " [checked=mixed]";
|
||||
if (ariaNode.checked === true) key += " [checked]";
|
||||
if (ariaNode.disabled) key += " [disabled]";
|
||||
if (ariaNode.expanded) key += " [expanded]";
|
||||
if (ariaNode.active && options.renderActive) key += " [active]";
|
||||
if (ariaNode.level) key += " [level=" + ariaNode.level + "]";
|
||||
if (ariaNode.pressed === "mixed") key += " [pressed=mixed]";
|
||||
if (ariaNode.pressed === true) key += " [pressed]";
|
||||
if (ariaNode.selected === true) key += " [selected]";
|
||||
if (ariaNode.ref) {
|
||||
key += " [ref=" + ariaNode.ref + "]";
|
||||
if (renderCursorPointer && hasPointerCursor(ariaNode)) key += " [cursor=pointer]";
|
||||
}
|
||||
return key;
|
||||
};
|
||||
|
||||
const getSingleInlinedTextChild = (ariaNode) => {
|
||||
return ariaNode?.children.length === 1 && typeof ariaNode.children[0] === "string" && !Object.keys(ariaNode.props).length ? ariaNode.children[0] : undefined;
|
||||
};
|
||||
|
||||
const visit = (ariaNode, indent, renderCursorPointer) => {
|
||||
const escapedKey = indent + "- " + yamlEscapeKeyIfNeeded(createKey(ariaNode, renderCursorPointer));
|
||||
const singleInlinedTextChild = getSingleInlinedTextChild(ariaNode);
|
||||
if (!ariaNode.children.length && !Object.keys(ariaNode.props).length) {
|
||||
lines.push(escapedKey);
|
||||
} else if (singleInlinedTextChild !== undefined) {
|
||||
lines.push(escapedKey + ": " + yamlEscapeValueIfNeeded(singleInlinedTextChild));
|
||||
} else {
|
||||
lines.push(escapedKey + ":");
|
||||
for (const [name, value] of Object.entries(ariaNode.props)) lines.push(indent + " - /" + name + ": " + yamlEscapeValueIfNeeded(value));
|
||||
const childIndent = indent + " ";
|
||||
const inCursorPointer = !!ariaNode.ref && renderCursorPointer && hasPointerCursor(ariaNode);
|
||||
for (const child of ariaNode.children) {
|
||||
if (typeof child === "string") visitText(child, childIndent);
|
||||
else visit(child, childIndent, renderCursorPointer && !inCursorPointer);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
for (const nodeToRender of nodesToRender) {
|
||||
if (typeof nodeToRender === "string") visitText(nodeToRender, "");
|
||||
else visit(nodeToRender, "", !!options.renderCursorPointer);
|
||||
}
|
||||
return lines.join("\\n");
|
||||
}
|
||||
|
||||
function getAISnapshot() {
|
||||
const snapshot = generateAriaTree(document.body);
|
||||
const refsObject = {};
|
||||
for (const [ref, element] of snapshot.elements) refsObject[ref] = element;
|
||||
window.__devBrowserRefs = refsObject;
|
||||
return renderAriaTree(snapshot);
|
||||
}
|
||||
|
||||
function selectSnapshotRef(ref) {
|
||||
const refs = window.__devBrowserRefs;
|
||||
if (!refs) throw new Error("No snapshot refs found. Call getAISnapshot first.");
|
||||
const element = refs[ref];
|
||||
if (!element) throw new Error('Ref "' + ref + '" not found. Available refs: ' + Object.keys(refs).join(", "));
|
||||
return element;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the cached script (useful for development/testing)
|
||||
*/
|
||||
export function clearSnapshotScriptCache(): void {
|
||||
cachedScript = null;
|
||||
}
|
||||
14
skills/dev-browser/skills/dev-browser/src/snapshot/index.ts
Normal file
14
skills/dev-browser/skills/dev-browser/src/snapshot/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* ARIA Snapshot module for dev-browser.
|
||||
*
|
||||
* Provides Playwright-compatible ARIA snapshots with cross-connection ref persistence.
|
||||
* Refs are stored on window.__devBrowserRefs and survive across Playwright reconnections.
|
||||
*
|
||||
* Usage:
|
||||
* import { getSnapshotScript } from './snapshot';
|
||||
* const script = getSnapshotScript();
|
||||
* await page.evaluate(script);
|
||||
* // Now window.__devBrowser_getAISnapshot() and window.__devBrowser_selectSnapshotRef(ref) are available
|
||||
*/
|
||||
|
||||
export { getSnapshotScript, clearSnapshotScriptCache } from "./browser-script";
|
||||
13
skills/dev-browser/skills/dev-browser/src/snapshot/inject.ts
Normal file
13
skills/dev-browser/skills/dev-browser/src/snapshot/inject.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Injectable snapshot script for browser context.
|
||||
*
|
||||
* This module provides the getSnapshotScript function that returns a
|
||||
* self-contained JavaScript string for injection into browser contexts.
|
||||
*
|
||||
* The script is injected via page.evaluate() and exposes:
|
||||
* - window.__devBrowser_getAISnapshot(): Returns ARIA snapshot YAML
|
||||
* - window.__devBrowser_selectSnapshotRef(ref): Returns element for given ref
|
||||
* - window.__devBrowserRefs: Map of ref -> Element (persists across connections)
|
||||
*/
|
||||
|
||||
export { getSnapshotScript, clearSnapshotScriptCache } from "./browser-script";
|
||||
34
skills/dev-browser/skills/dev-browser/src/types.ts
Normal file
34
skills/dev-browser/skills/dev-browser/src/types.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
// API request/response types - shared between client and server
|
||||
|
||||
export interface ServeOptions {
|
||||
port?: number;
|
||||
headless?: boolean;
|
||||
cdpPort?: number;
|
||||
/** Directory to store persistent browser profiles (cookies, localStorage, etc.) */
|
||||
profileDir?: string;
|
||||
}
|
||||
|
||||
export interface ViewportSize {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface GetPageRequest {
|
||||
name: string;
|
||||
/** Optional viewport size for new pages */
|
||||
viewport?: ViewportSize;
|
||||
}
|
||||
|
||||
export interface GetPageResponse {
|
||||
wsEndpoint: string;
|
||||
name: string;
|
||||
targetId: string; // CDP target ID for reliable page matching
|
||||
}
|
||||
|
||||
export interface ListPagesResponse {
|
||||
pages: string[];
|
||||
}
|
||||
|
||||
export interface ServerInfoResponse {
|
||||
wsEndpoint: string;
|
||||
}
|
||||
36
skills/dev-browser/skills/dev-browser/tsconfig.json
Normal file
36
skills/dev-browser/skills/dev-browser/tsconfig.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
// Environment setup & latest features
|
||||
"lib": ["ESNext"],
|
||||
"target": "ESNext",
|
||||
"module": "Preserve",
|
||||
"moduleDetection": "force",
|
||||
"jsx": "react-jsx",
|
||||
"allowJs": true,
|
||||
|
||||
// Bundler mode
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true,
|
||||
|
||||
// Path aliases
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
|
||||
// Best practices
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
|
||||
// Some stricter flags (disabled by default)
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noPropertyAccessFromIndexSignature": false
|
||||
},
|
||||
"include": ["src/**/*", "scripts/**/*"]
|
||||
}
|
||||
12
skills/dev-browser/skills/dev-browser/vitest.config.ts
Normal file
12
skills/dev-browser/skills/dev-browser/vitest.config.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "node",
|
||||
include: ["src/**/*.test.ts"],
|
||||
testTimeout: 60000, // Playwright tests can be slow
|
||||
hookTimeout: 60000,
|
||||
teardownTimeout: 60000,
|
||||
},
|
||||
});
|
||||
29
skills/dev-browser/tsconfig.json
Normal file
29
skills/dev-browser/tsconfig.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
// Environment setup & latest features
|
||||
"lib": ["ESNext"],
|
||||
"target": "ESNext",
|
||||
"module": "Preserve",
|
||||
"moduleDetection": "force",
|
||||
"jsx": "react-jsx",
|
||||
"allowJs": true,
|
||||
|
||||
// Bundler mode
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true,
|
||||
|
||||
// Best practices
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
|
||||
// Some stricter flags (disabled by default)
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noPropertyAccessFromIndexSignature": false
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user