refactor(swipe): split swipe screen modules

Move swipe screen logic into hooks/services and extract
state-specific UI components to keep files small while
preserving existing behavior.

Reference: US-002
This commit is contained in:
jjbrunton committed 2026-01-25 21:05:54 +00:00
1 parent 209dc652d2
commit 8d4d9d8750
55 files changed
+11127 -1681

No files matched your search

+219
View File
@@ -0,0 +1,219 @@
---
name: commit
description: Write conventional commit messages with type, scope, and subject when the user wants to commit changes or save work.
---
# Git Commit
Creates git commits following Conventional Commits format with proper type, scope, and subject.
## Quick Start
```bash
# 1. Stage changes
git add <files> # or: git add -A
# 2. Create commit (branch commit format)
git commit -m "type(scope): subject
Body explaining HOW and WHY.
Reference: Task X.Y, Req N"
```
## Commit Types
### Regular Branch Commits (During Development)
**Format**: `type(scope): subject`
| Type | Purpose |
|------|---------|
| `feat` | New feature or functionality |
| `fix` | Bug fix or issue resolution |
| `refactor` | Code refactoring without behavior change |
| `perf` | Performance improvements |
| `test` | Test additions or modifications |
| `ci` | CI/CD configuration changes |
| `docs` | Documentation updates |
| `chore` | Maintenance, dependencies, tooling |
| `style` | Code formatting, linting (non-functional) |
| `security` | Security vulnerability fixes or hardening |
### Scope (Required, kebab-case)
Examples: `validation`, `auth`, `cookie-service`, `template`, `config`, `tests`, `api`
### Subject Line Rules
- Max 50 characters after colon
- Present tense imperative: add, implement, fix, improve, enhance, refactor, remove, prevent
- NO period at the end
- Specific and descriptive - state WHAT, not WHY
## Core Workflow
### 1. Review Changes
```bash
git status
git diff --staged # if already staged
git diff # if not staged
```
### 2. Stage Files
```bash
git add <specific-files> # preferred
# or
git add -A # all changes
```
**NEVER commit**:
- `.env`, `credentials.json`, secrets
- `node_modules/`, `__pycache__/`, `.venv/`
- Large binary files without explicit approval
### 3. Create Commit
**Simple change**:
```bash
git commit -m "fix(auth): use hmac.compare_digest for secure comparison"
```
**Complex change (with body)**:
```bash
git commit -m "$(cat <<'EOF'
feat(validation): add URLValidator with domain whitelist
Implement URLValidator class supporting:
- Domain whitelist enforcement (youtube.com, youtu.be)
- Dangerous scheme blocking (javascript, data, file)
- URL parsing with embedded credentials handling
Addresses Requirement 31: Input validation
Part of Task 5.1: Input Validation Utilities
EOF
)"
```
### 4. Verify Commit
```bash
git log -1 --format="%h %s"
git show --stat HEAD
```
## Body Format (Recommended for Complex Changes)
```
<blank line>
Explain HOW and WHY the change was made.
- Use bullet points for multiple items
- Wrap at 72 characters
Reference: Task X.Y
Addresses: Req N
```
## Git Trailers
| Trailer | Purpose |
|---------|---------|
| `Fixes #N` | Links and closes issue on merge |
| `Closes #N` | Same as Fixes |
| `Co-authored-by: Name <email>` | Credit co-contributors |
Place trailers at end of body after blank line. See `references/commit_examples.md` for examples.
## Breaking Changes
For incompatible API/behavior changes, use `!` after scope OR `BREAKING CHANGE:` footer:
```
feat(api)!: change response format to JSON:API
BREAKING CHANGE: Response envelope changed from `{ data }` to `{ data: { type, id, attributes } }`.
```
Triggers major version bump in semantic-release.
## Merge Commits (PR Closure)
For PRs, use extended description with sections:
```bash
gh pr create --title "feat(security): implement input validation (Task 5)" --body "$(cat <<'EOF'
## Summary
- Input validation utilities (URLValidator, FormatValidator)
- Secure template processor with path traversal prevention
- API key authentication middleware
## Task Breakdown
Task 5.1: Input Validation - URLValidator, FormatValidator
Task 5.2: Template Processing - Path traversal prevention
Task 5.3: API Key Auth - Multi-key support, excluded paths
Task 5.4: Security Tests - 102 path traversal tests
## Requirements Covered
Req 7, Req 9, Req 31, Req 33
## Test Coverage
- All 473 tests passing
- Coverage: 93%
- Pre-commit checks: passing
EOF
)"
```
## Integration with Other Skills
### From github-pr-review
When fixing review comments, use this format:
```bash
git commit -m "fix(scope): address review comment #ID
Brief explanation of what was wrong and how it's fixed.
Addresses review comment #123456789."
```
### From github-pr-creation
Before creating PR, ensure all commits follow this format. The PR skill will:
1. Analyze commits for proper format
2. Extract types for PR labels
3. Build PR description from commit bodies
## Important Rules
- **ALWAYS** include scope in parentheses
- **ALWAYS** use present tense imperative verb
- **NEVER** end subject with period
- **NEVER** commit secrets or credentials
- **NEVER** use generic messages ("update code", "fix bug", "changes")
- **NEVER** exceed 50 chars in subject line
- Group related changes -> single focused commit
## Examples
**Good**:
```
feat(validation): add URLValidator with domain whitelist
fix(auth): use hmac.compare_digest for secure key comparison
refactor(template): consolidate filename sanitization logic
test(security): add 102 path traversal prevention tests
```
**Bad**:
```
update validation code # no type, no scope, vague
feat: add stuff # missing scope, too vague
fix(auth): fix bug # circular, not specific
chore: make changes # missing scope, vague
feat(security): improve things. # has period, vague
```
## References
- `references/commit_examples.md` - Extended examples by type
@@ -0,0 +1,292 @@
# Commit Examples by Type
Extended examples for each commit type with body content.
## feat - New Features
```
feat(validation): add URLValidator with domain whitelist
Implement URLValidator class supporting:
- Domain whitelist enforcement (youtube.com, youtu.be, m.youtube.com)
- Dangerous scheme blocking (javascript, data, file)
- URL parsing with embedded credentials handling
- Port number validation (1-65535)
Addresses Requirement 31: Input validation
Part of Task 5.1: Input Validation Utilities
```
```
feat(api): add video metadata endpoint
New GET /api/v1/videos/{id}/metadata endpoint:
- Returns title, duration, formats, thumbnails
- Supports format filtering via query params
- Implements caching with 5-minute TTL
Part of Task 6.2: API Endpoints
```
## fix - Bug Fixes
```
fix(auth): use hmac.compare_digest for secure key comparison
Replace direct string equality with hmac.compare_digest to prevent
timing attacks on API key validation. Ensures constant-time comparison
regardless of key length or content.
Addresses security best practice for sensitive data comparison
```
```
fix(download): handle network timeout during video fetch
Add retry logic with exponential backoff for network failures:
- Max 3 attempts with delays [2, 4, 8] seconds
- Classify retriable errors (5xx, timeout, connection)
- Log each retry attempt with remaining count
Fixes issue where downloads would fail silently on flaky connections
```
## refactor - Code Improvements
```
refactor(template): consolidate filename sanitization logic
Extract common sanitization patterns into helper methods:
- Path traversal prevention (.., /, absolute paths)
- Special character removal (control chars, null bytes)
- Windows reserved name handling (CON, PRN, LPT1-9, etc)
Improves code maintainability and reduces duplication
```
```
refactor(providers): extract common yt-dlp options builder
Move duplicated option building from get_info/download to
_build_base_options helper. Reduces code duplication and ensures
consistent option handling across all provider methods.
No behavior change, pure refactoring
```
## test - Test Changes
```
test(security): add 102 path traversal prevention tests
Comprehensive test coverage for TemplateProcessor including:
- Basic path traversal attempts (.., /)
- URL-encoded variants (%2e%2e, %2f)
- Unicode/UTF-8 bypass attempts
- Windows edge cases (backslashes, drive letters)
Part of Task 5.4: Security Test Suite
```
```
test(validation): add parametrized URL validation tests
Add 25 test cases covering:
- Valid YouTube URL formats (watch, shorts, embed, youtu.be)
- Invalid domains (vimeo, dailymotion)
- Malformed URLs (no scheme, wrong port)
- Edge cases (trailing slashes, query params)
Coverage for URLValidator: 98%
```
## perf - Performance
```
perf(cache): implement LRU eviction for metadata cache
Replace dict-based cache with LRU implementation:
- Max 1000 entries with automatic eviction
- 40% memory reduction under high load
- Sub-millisecond lookup times maintained
Addresses memory growth issue in long-running instances
```
## security - Security Fixes
```
security(cookie): validate cookie file integrity before use
Add SHA256 checksum verification for cookie files:
- Compute hash on first load, store in memory
- Verify hash before each use
- Reject modified files with clear error message
Prevents use of tampered cookie files
Addresses Requirement 33: Security validation
```
## ci - CI/CD Changes
```
ci(github): add security scanning to PR workflow
Enable Bandit security scanner in GitHub Actions:
- Run on all Python files
- Fail on HIGH/CRITICAL findings
- Cache virtualenv for faster runs
Part of Task 15.3: Basic security validation
```
## docs - Documentation
```
docs(api): add OpenAPI description for download endpoint
Document /api/v1/download endpoint:
- Request body schema with format options
- Response codes (200, 400, 401, 404, 500)
- Example requests and responses
Improves API documentation for consumers
```
## chore - Maintenance
```
chore(deps): update yt-dlp to 2024.12.06
Update yt-dlp from 2024.11.15 to 2024.12.06:
- Fixes YouTube throttling detection
- Adds support for new Instagram format
- Improves error messages for geo-blocked content
No breaking changes expected
```
## style - Formatting
```
style(providers): apply black formatting to youtube.py
Apply black formatter with 88 char line length.
No functional changes, formatting only.
```
## Merge Commit Examples
### Feature Branch to Develop
```
Merge pull request #5 from fvadicamo/feature/input-validation-security
feat(security): implement input validation and security (Task 5)
Merges comprehensive security implementation (Task 5) into develop:
- Input validation utilities (URLValidator, FormatValidator, ParameterValidator)
- Secure template processor with path traversal prevention
- API key authentication middleware with multi-key support
- 473 tests with 93% coverage
Task 5.1: Input Validation Utilities
- URLValidator: Domain whitelist (youtube.com, youtu.be), dangerous scheme blocking
- FormatValidator: yt-dlp format ID validation with regex and selectors
- ParameterValidator: Audio quality/format and language code validation
Task 5.2: Template Processor
- Path traversal prevention (.., /, absolute paths, URL encoding)
- Filename sanitization (illegal chars, control chars, null bytes)
- Windows reserved names handling (CON, PRN, AUX, NUL, COM1-9, LPT1-9)
- Collision handling with numeric suffix, max length 200 chars
Task 5.3: API Key Authentication
- APIKeyAuth class with multi-key support
- Excluded paths for health/doc endpoints
- Secure hashing for logging (SHA256 first 8 chars)
- FastAPI dependency injection integration
Task 5.4: Security Tests
- 102 path traversal prevention tests with edge cases
- URL validation tests with malicious inputs
- API key authentication and credential tests
- Sensitive data redaction verification
Requirements Covered:
- Req 7: Output template processing with security
- Req 9: API key authentication
- Req 31: Input validation
- Req 33: Security (secure comparison, log redaction)
Test Coverage:
- All 473 tests passing
- Coverage: 93% (exceeds 80% minimum)
- Pre-commit checks: all passing
```
### Develop to Main (Release)
```
Merge pull request #10 from fvadicamo/develop
release: v0.1.0 - MVP with YouTube provider
First stable release with core functionality:
- YouTube video info, formats, download, audio extraction
- Cookie-based authentication for age-restricted content
- API key authentication
- Input validation and security hardening
- 500+ tests with 92% coverage
Breaking Changes: None (initial release)
Features:
- GET /api/v1/info - Video metadata
- GET /api/v1/formats - Available formats
- POST /api/v1/download - Video/audio download
- Cookie file support for authenticated requests
Documentation:
- API documentation at /docs (Swagger UI)
- OpenAPI spec at /openapi.json
```
## Commits with Trailers
### Single Issue
```
fix(validation): prevent XSS in user input
Escape HTML entities before rendering.
Fixes #78
```
### Multiple Issues + Co-author
```
fix(auth): resolve session and token issues
- Fix session expiry not triggering logout
- Fix token refresh race condition
Fixes #101
Fixes #103
Co-authored-by: Bob <bob@example.com>
```
## Breaking Changes
### With ! Notation
```
feat(api)!: migrate to v2 endpoints
BREAKING CHANGE: /api/v1/* endpoints removed. Update base URL to /api/v2/.
```
### Config Breaking Change
```
chore(config)!: rename environment variables
BREAKING CHANGE: DATABASE_URL -> APP_DATABASE_URL, API_KEY -> APP_API_KEY
```
+211
View 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
View 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=="],
}
}
File diff suppressed because it is too large. Load diff
+31
View 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"
}
}
@@ -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
@@ -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);
});
@@ -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
View 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
View 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
View 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
View 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;
}
@@ -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";
@@ -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
View 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
View 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/**/*"]
}
@@ -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,
},
});
+235
View File
@@ -0,0 +1,235 @@
---
name: prd
description: "Generate a Product Requirements Document (PRD) as JSON for Ralph. Triggers on: create a prd, write prd for, plan this feature, requirements for, spec out."
---
# PRD Generator (JSON Only)
Create a structured JSON PRD that Ralph can execute deterministically. This PRD is the **single source of truth** for stories, gates, and status.
---
## The Job
1. Receive a feature description from the user
2. Ask **5–10** clarifying questions (with lettered options), in batches of up to 5 at a time (ask 5, wait for answers, then ask the next batch if needed).
3. **Always ask about quality gates** (commands that must pass)
4. Generate a **detailed** JSON PRD and save it to the provided path
**Important:** Do NOT implement anything. Only generate JSON.
---
## Step 1: Clarifying Questions
Ask questions even if the prompt seems clear. The goal is to capture missing product details, stack choices, and UI/route structure so the PRD is implementable without guesswork. Focus on:
- **Problem/Goal:** What problem does this solve?
- **Core Functionality:** What are the key actions?
- **Scope/Boundaries:** What should it NOT do?
- **Success Criteria:** How do we know it's done?
- **Stack + Environment:** frameworks, hosting, runtime, database, auth approach
- **UI + Routes:** key screens, navigation, route map, layout constraints
- **Data Model + Import Format:** entities, relationships, external data shape
- **Rules/Calculations:** business logic, progression rules, edge cases
- **Quality Gates:** tests, lint, typecheck, build/dev verification (REQUIRED)
Always ask explicitly:
- **Is this a new project or an existing codebase?**
### Format Questions Like This:
```
1. What is the primary goal of this feature?
A. Improve user onboarding experience
B. Increase user retention
C. Reduce support burden
D. Other: [please specify]
2. Who is the target user?
A. New users only
B. Existing users only
C. All users
D. Admin users only
3. What quality commands must pass for each story?
A. npm test
B. npm run lint
C. npm run typecheck
D. Other: [specify]
Note: All example questions and options in this section are illustrative only. Do not copy them verbatim into the PRD unless the user explicitly chooses them. Derive the final quality gates and requirements from the user's answers and the feature context.
4. What stack + hosting are we using (and any constraints)?
A. React + Vite (static hosting)
B. Next.js (Node/Edge)
C. TanStack Start (Cloudflare)
D. Other: [specify]
5. What UI screens/routes are required?
A. Minimal (1–2 pages)
B. Basic app shell (dashboard + detail pages)
C. Full routing map (list all routes)
D. Other: [specify]
```
---
## Step 2: JSON Structure
Output a JSON file with this shape (include detailed top-level fields so the PRD is fully self-contained):
```json
{
"version": 1,
"project": "Feature Name",
"overview": "Short problem + solution summary",
"goals": [
"Goal 1",
"Goal 2"
],
"nonGoals": [
"Explicitly out of scope items"
],
"successMetrics": [
"How success is measured"
],
"openQuestions": [
"Remaining unknowns"
],
"stack": {
"framework": "TanStack Start",
"hosting": "Cloudflare",
"database": "D1",
"auth": "describe approach"
},
"routes": [
{ "path": "/", "name": "Home", "purpose": "..." }
],
"uiNotes": [
"Layout or component requirements"
],
"dataModel": [
{ "entity": "Workout", "fields": ["id", "userId", "date", "notes"] }
],
"importFormat": {
"description": "Expected JSON shape",
"example": { "programName": "..." }
},
"rules": [
"Key business rules / calculations"
],
"qualityGates": ["npm run test:ping"],
"stories": [
{
"id": "US-001",
"title": "Short story title",
"status": "open",
"dependsOn": [],
"description": "As a [user], I want [feature] so that [benefit].",
"acceptanceCriteria": [
"Specific verifiable criterion",
"Another criterion"
]
}
]
}
```
### Rules
- **IDs**: Sequential (`US-001`, `US-002`, ...)
- **Status**: Always `"open"` for new stories
- **DependsOn**: Use IDs only; empty array if none
- **Quality Gates**: Only at the top-level `qualityGates`
- **Acceptance Criteria**: Verifiable, specific, testable
- **Every story must include**: at least 1 example + 1 negative case
- **UI stories**: include explicit routes, components, and UI states
- **New projects**: include initial setup stories (scaffold, env/config, local dev, deploy basics, **package installs**)
- **Dependencies**: any new package/library introduced must be called out with install commands in acceptance criteria (e.g., `npm install <pkg>`), plus any required config or scripts.
- **Ordering**: if this is a new project, the **first story must be setup** (scaffold + installs + scripts + env/config). Migrations or data model work come after setup.
---
## Output Requirements
- Save the JSON to the exact path provided in the prompt (e.g., `.agents/tasks/prd-<slug>.json`)
- Output **only** the JSON file content (no Markdown PRD)
- Do not include extra commentary in the file
After saving, tell the user:
`PRD JSON saved to <path>. Close this chat and run \`ralph build\`.`
If the prompt provides a **directory** (not a filename), choose a short filename:
- `prd-<short-slug>.json` where `<short-slug>` is 1–3 meaningful words (avoid filler like “i want to”).
- Examples: `prd-workout-tracker.json`, `prd-usage-billing.json`
---
## Story Size (Critical)
Each story must be completable in a **single Ralph iteration**.
If a story feels too large, split it into multiple smaller stories with dependencies.
---
## Example Output (JSON)
```json
{
"version": 1,
"project": "Task Priority System",
"overview": "Add priority levels to tasks so users can focus on what matters most.",
"goals": [
"Allow assigning priority (high/medium/low) to any task",
"Enable filtering by priority"
],
"nonGoals": [
"No automatic priority assignment"
],
"successMetrics": [
"Users can change priority in under 2 clicks"
],
"openQuestions": [
"Should priority affect ordering within a column?"
],
"stack": {
"framework": "React",
"hosting": "Cloudflare Pages",
"database": "D1",
"auth": "single shared login"
},
"routes": [
{ "path": "/tasks", "name": "Task List", "purpose": "View and filter tasks" },
{ "path": "/tasks/:id", "name": "Task Detail", "purpose": "Edit task priority" }
],
"uiNotes": [
"Priority badge colors: high=red, medium=yellow, low=gray"
],
"dataModel": [
{ "entity": "Task", "fields": ["id", "title", "priority"] }
],
"importFormat": {
"description": "Not applicable",
"example": {}
},
"rules": [
"Priority defaults to medium when not set"
],
"qualityGates": ["npm run test:ping"],
"stories": [
{
"id": "US-001",
"title": "Add priority field to database",
"status": "open",
"dependsOn": [],
"description": "As a developer, I want to store task priority so it persists across sessions.",
"acceptanceCriteria": [
"Add priority column with default 'medium'",
"Example: creating a task without priority -> defaults to 'medium'",
"Negative case: invalid priority 'urgent' -> validation error",
"Migration runs successfully"
]
}
]
}
```