diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 3cd6802..7de6f26 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -12,7 +12,7 @@ jobs:
strategy:
matrix:
- node-version: [18, 20, 22]
+ node-version: [20, 22]
steps:
- uses: actions/checkout@v4
@@ -25,5 +25,20 @@ jobs:
- name: Install dependencies
run: npm ci
+ - name: Build and type check
+ run: npm run build
+
- name: Run tests
- run: npm test
+ run: npm test --workspaces --if-present
+
+ - name: Lint extension
+ run: npm run lint
+ working-directory: packages/extension
+
+ - name: Install Playwright Chromium
+ run: npx playwright install chromium
+ working-directory: packages/extension
+
+ - name: Run E2E tests
+ run: npx playwright test
+ working-directory: packages/extension
diff --git a/.gitignore b/.gitignore
index cc832be..f74f75a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,3 +5,6 @@ node_modules/
.claude/
.playwright-cli/
coverage/
+.playwright-mcp/
+packages/*/dist/
+*.tsbuildinfo
diff --git a/BACKLOG.md b/BACKLOG.md
index 11d10a7..f71ee73 100644
--- a/BACKLOG.md
+++ b/BACKLOG.md
@@ -6,6 +6,8 @@
- [ ] **String escaping misses newlines** — `packages/cli/src/repl.mjs`: `esc()` for verify commands doesn't escape `\n`, `\r`, `\t` — multiline user input breaks generated code
- [ ] **Ghost completion crash on empty array** — `packages/cli/src/repl.mjs`: `renderGhost(matches[0])` has no guard if `cmds` is empty
+- [ ] **Auto-inject `expect` in `run-code`** — auto-prepend `const { expect } = require('@playwright/test')` in the `run-code` auto-wrap so users can write `run-code await expect(page).toHaveTitle('Todo')` without manual imports
+
## Medium Priority
- [ ] **Failed commands not recorded** — `packages/cli/src/repl.mjs`: `session.record(line)` only runs after success; replay files miss failed commands
@@ -15,5 +17,9 @@
## Low Priority
+- [ ] **`@types/chrome` types `sendCommand` as void** — `packages/extension/background.js`: `await chrome.debugger.sendCommand(...)`, `chrome.debugger.attach()`, `chrome.debugger.detach()` are correctly awaited (MV3 returns Promises) but `@types/chrome` still types them as `void`, causing IDE "await has no effect" warnings. Revisit when `@types/chrome` updates or if migrating to TypeScript.
+
- [ ] **Convert to TypeScript** — migrate `.mjs` files to `.ts` across `packages/core` and `packages/cli` for type safety and better IDE support
-- [ ] **Extension server (Phase 8)** — `playwright-repl --extension` starts a WebSocket server; extension connects as thin CDP relay instead of reimplementing all commands
+- [x] **Extension server (Phase 8)** — `playwright-repl --extension` starts a WebSocket server; extension connects as thin CDP relay instead of reimplementing all commands
+- [ ] **Improve README structure** — Consider splitting README into per-package docs (`packages/cli/README.md`, `packages/extension/README.md`) with a concise root README linking to both. Current root README covers both CLI and extension but could be better organized.
+- [ ] **Restructure the extension code structure, add src folder, and add build step
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0a34140..de3369a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,38 @@
# Changelog
+## v0.5.0 — Extension Mode & TypeScript
+
+**2026-02-22**
+
+### Breaking Changes
+
+- **Requires Node.js >= 20** (was >= 18)
+- **TypeScript throughout** — all three packages now compiled from TypeScript
+
+### Features
+
+- **Extension mode** (`--extension`): Chrome side panel extension with REPL input, script editor, visual recorder, and export to Playwright tests. Uses direct CDP connection — Engine connects to Chrome via `--remote-debugging-port`.
+- **CommandServer**: HTTP server (`POST /run`, `GET /health`) relays commands from the extension panel to the Engine.
+- **Recording**: Extension-side recorder captures clicks, form input, selections, checkboxes, and key presses with automatic `--nth` disambiguation for ambiguous text locators.
+- **Suppress snapshot for non-snapshot commands**: `goto` now shows only URL and title instead of the full accessibility tree.
+- **Text locator `--nth` support**: `click "npm" --nth 1` to target a specific match when multiple elements share the same text.
+
+### Technical Details
+
+- **TypeScript migration**: `packages/core` and `packages/cli` compiled via `tsc --build` with project references; `packages/extension` compiled via Vite.
+- **`tsc --build`** handles dependency ordering (core before cli) automatically.
+- **Module resolution**: `NodeNext` (tracks latest Node.js module behavior).
+- **Testing**: 390+ unit tests (vitest) + 59 E2E tests (Playwright Test) across 3 packages.
+- **Extension E2E**: Launches Chrome with the extension loaded, tests panel rendering, command execution, recording, and theme switching.
+
+### Removed
+
+- Stale planning docs (PLAN-CRX.md, PLAN-RECORDING.md, PLAN-TYPESCRIPT.md, MIGRATION_PLAN.md)
+- Architecture diagram PNGs (outdated after extension mode redesign)
+- `packages/repl-ext/` (moved to separate `playwright-repl-crx` repo)
+
+---
+
## v0.4.0 — In-Process Engine & Monorepo
**2026-02-18**
@@ -12,7 +45,7 @@
### Features
-- **In-process Engine** (`packages/core/src/engine.mjs`): Wraps Playwright's `BrowserServerBackend` directly — faster startup, simpler architecture, no IPC overhead.
+- **In-process Engine** (`packages/core/src/engine.ts`): Wraps Playwright's `BrowserServerBackend` directly — faster startup, simpler architecture, no IPC overhead.
- **Connect mode** (`--connect [port]`): Attach to an existing Chrome instance via CDP. Start Chrome with `--remote-debugging-port=9222`, then `playwright-repl --connect`.
- **Monorepo structure**: Restructured into `packages/core` (engine + utilities) and `packages/cli` (REPL + recorder) using npm workspaces.
@@ -27,7 +60,6 @@
- Engine uses dependency injection for testability — Playwright internals loaded lazily via absolute path resolution to bypass the `exports` map
- 214 tests (147 cli + 67 core) across 10 test files
-- Pure ESM JavaScript, no build step
---
diff --git a/CLAUDE.md b/CLAUDE.md
index 440cf07..6031eea 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -11,26 +11,46 @@ Think of it as a **keyword-driven test runner** (like Robot Framework) backed by
```
playwright-repl/
├── package.json # Root workspace config (npm workspaces)
+├── tsconfig.base.json # Shared TypeScript compiler options
├── packages/
-│ ├── core/ # Shared engine + utilities
+│ ├── core/ # Shared engine + utilities (TypeScript, tsc)
│ │ ├── src/
-│ │ │ ├── engine.mjs # Wraps BrowserServerBackend in-process
-│ │ │ ├── parser.mjs # Command parsing + alias resolution
-│ │ │ ├── page-scripts.mjs # Text locators + assertion helpers
-│ │ │ ├── completion-data.mjs # Ghost completion items
-│ │ │ ├── colors.mjs # ANSI color helpers
-│ │ │ └── resolve.mjs # COMMANDS map, minimist re-export
+│ │ │ ├── engine.ts # Wraps BrowserServerBackend in-process
+│ │ │ ├── parser.ts # Command parsing + alias resolution
+│ │ │ ├── page-scripts.ts # Text locators + assertion helpers
+│ │ │ ├── completion-data.ts # Ghost completion items
+│ │ │ ├── extension-server.ts # HTTP server for extension commands
+│ │ │ ├── colors.ts # ANSI color helpers
+│ │ │ └── resolve.ts # COMMANDS map, minimist re-export
+│ │ ├── dist/ # Compiled output (gitignored)
│ │ └── test/
│ │
-│ └── cli/ # Terminal REPL (published as "playwright-repl")
-│ ├── bin/
-│ │ └── playwright-repl.mjs # CLI entry point
+│ ├── cli/ # Terminal REPL (published as "playwright-repl", TypeScript, tsc)
+│ │ ├── src/
+│ │ │ ├── playwright-repl.ts # CLI entry point (compiles to dist/)
+│ │ │ ├── repl.ts # Interactive readline loop
+│ │ │ ├── recorder.ts # Session recording/replay
+│ │ │ └── index.ts # Public API exports
+│ │ ├── dist/ # Compiled output (gitignored)
+│ │ ├── test/
+│ │ └── examples/ # .pw session files
+│ │
+│ └── extension/ # Chrome side panel extension (TypeScript, Vite)
+│ ├── public/
+│ │ └── manifest.json # Manifest V3 config (copied to dist/ by Vite)
│ ├── src/
-│ │ ├── repl.mjs # Interactive readline loop
-│ │ ├── recorder.mjs # Session recording/replay
-│ │ └── index.mjs # Public API exports
-│ ├── test/
-│ └── examples/ # .pw session files
+│ │ ├── background.ts # Side panel behavior + recording handlers
+│ │ ├── panel/ # Side panel UI
+│ │ │ ├── panel.html
+│ │ │ ├── panel.ts
+│ │ │ └── panel.css
+│ │ ├── content/
+│ │ │ └── recorder.ts # Event recorder injected into pages
+│ │ └── lib/
+│ │ └── converter.ts # .pw → Playwright test export
+│ ├── dist/ # Vite build output (gitignored, loaded by Chrome)
+│ ├── vite.config.ts # Vite build config (3 entry points)
+│ └── e2e/ # Playwright E2E tests
```
## Architecture
@@ -75,7 +95,7 @@ browser: locator.click()
Chrome: actual DOM click event
```
-### Engine (packages/core/src/engine.mjs)
+### Engine (packages/core/src/engine.ts)
The `Engine` class wraps Playwright's `BrowserServerBackend` in-process:
@@ -90,6 +110,7 @@ await engine.close();
Three connection modes via `start(opts)`:
- **launch** (default): `contextFactory(config)` → new browser
- **connect**: `opts.connect = 9222` → `cdpEndpoint` → `connectOverCDP()`
+- **extension**: `opts.extension = true` → starts `CommandServer`, Chrome launched with `--remote-debugging-port`, side panel sends commands via HTTP
- Dependency injection: constructor accepts `deps` for testing
Key Playwright internals used (via `createRequire`):
@@ -99,6 +120,31 @@ Key Playwright internals used (via `createRequire`):
- `playwright/lib/mcp/terminal/commands` → `commands` map
- `playwright/lib/mcp/terminal/command` → `parseCommand`
+### CommandServer (packages/core/src/extension-server.ts)
+
+When `--extension` mode is used, `CommandServer` starts an HTTP server:
+
+```
+┌──────────────────────────────────────────────┐
+│ Chrome Extension (Side Panel) │
+│ panel.js ─── fetch POST /run ───────────┐ │
+│ ▲ │ │
+│ │ JSON response │ │
+└─────┼────────────────────────────────────┼───┘
+ │ │
+ │ ▼
+┌─────────────────────────────────────────────────────┐
+│ CommandServer (HTTP :3000) │
+│ ├── POST /run ← panel sends commands here │
+│ └── GET /health ← panel checks server status │
+│ Engine → connectOverCDP → CDP :3001 → Chrome │
+└─────────────────────────────────────────────────────┘
+```
+
+- **Direct CDP**: Engine connects to Chrome via `--remote-debugging-port` (no relay)
+- **Command channel**: panel sends commands via `fetch()` → CommandServer → `Engine.run()` → results back
+- **Recording**: extension-side (inject recorder.js via `chrome.scripting.executeScript`)
+
### Element Refs (e1, e5, etc.)
When you run `snapshot`, Playwright walks the page's accessibility tree via CDP, assigns short refs like `e1`, `e2`, `e5` to interactive elements. When you later say `click e5`, it resolves back via the backend's internal ref tracking.
@@ -137,17 +183,19 @@ async function processQueue() {
## Tech Stack
-- **Runtime**: Node.js (ESM modules, `.mjs`)
+- **Runtime**: Node.js (ESM modules)
+- **Language**: TypeScript throughout — `packages/core` and `packages/cli` compiled via `tsc`; `packages/extension` compiled via Vite
+- **Build**: `tsc --build packages/core packages/cli` (project references) + Vite for extension. Run `npm run build` at root.
- **Dependencies**: `minimist` (command parsing), `playwright@>=1.59.0-alpha` (browser engine)
-- **Monorepo**: npm workspaces (`packages/core`, `packages/cli`)
-- **Testing**: vitest
+- **Monorepo**: npm workspaces (`packages/core`, `packages/cli`, `packages/extension`)
+- **Testing**: vitest (unit tests), Playwright Test (extension E2E)
- **Key insight**: `playwright@1.59.0-alpha` includes `lib/mcp/browser/` (BrowserServerBackend, contextFactory).
The stable `playwright@1.58` does NOT. Once 1.59 goes stable, the alpha pin can be removed.
-- No build step — plain ESM JavaScript
## Code Style
- ESM imports (`import ... from`)
+- TypeScript with `"module": "NodeNext"` — relative imports in core/cli use `.js` extensions (resolved to `.ts` at compile time)
+- Extension uses Vite — standard `.ts` imports (no `.js` extension needed)
- Async/await throughout
-- No TypeScript (keep it simple, scripting-oriented)
- Sections separated by `// ─── Section Name ───` comments
diff --git a/PLAN.md b/PLAN.md
index 683add5..c8f5af6 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -1,10 +1,10 @@
# PLAN.md — Roadmap
-## Completed Phases (v0.1–v0.3)
+## Completed Phases
-### Phase 1: Core REPL (Done)
+### Phase 1: Core REPL (v0.1)
-The foundation is built and working. A persistent REPL that connects to the Playwright MCP daemon over Unix socket.
+The foundation — a persistent REPL connected to the Playwright MCP daemon.
- [x] DaemonConnection class (Unix socket client, newline-delimited JSON)
- [x] parseInput() with minimist matching daemon expectations
@@ -18,256 +18,84 @@ The foundation is built and working. A persistent REPL that connects to the Play
- [x] Boolean option handling (strip false defaults)
- [x] Async command queue (prevents race conditions on piped input)
-### Phase 2: Modularize + Repo Setup (Done)
-
-Refactored into clean modules for maintainability and extensibility.
+### Phase 2: Modularize + Repo Setup (v0.1)
- [x] Split into `src/` modules: connection, parser, workspace, repl, recorder, resolve, colors, index
-- [x] Create `bin/playwright-repl.mjs` CLI entry point
-- [x] Create `package.json` with proper metadata and bin field
-- [x] Add verify commands (verify-text, verify-element, verify-value, verify-list) via run-code translation
-- [x] Text-based locators — click/fill/check/etc. accept text args, auto-resolved to Playwright native locators
-- [x] README.md with usage, examples, command reference, architecture
+- [x] CLI entry point, package.json, bin field
+- [x] Verify commands (verify-text, verify-element, verify-value, verify-list)
+- [x] Text-based locators — click/fill/check/etc. accept text args
+- [x] README.md with usage, examples, command reference
-### Phase 3: Session Record & Replay (Done)
+### Phase 3: Session Record & Replay (v0.2)
-- [x] SessionRecorder class (captures commands, writes .pw files)
-- [x] SessionPlayer class (reads .pw files, strips comments/blanks)
-- [x] SessionManager state machine (idle/recording/paused/replaying)
+- [x] SessionRecorder, SessionPlayer, SessionManager
- [x] .record / .save / .replay / .pause / .discard meta-commands
-- [x] --replay CLI flag for headless execution
-- [x] --step flag for interactive step-through
-- [x] Error handling during replay (stop on error)
-- [x] 6 example .pw files in examples/ (TodoMVC)
+- [x] --replay and --step CLI flags
+- [x] 6 example .pw files
-### Phase 4: Testing (Done)
+### Phase 4: Testing (v0.3)
- [x] Unit tests with vitest — 254 tests, 96% coverage
-- [x] Tests for parser, connection, recorder, repl helpers, workspace
- [x] Cross-platform support (Windows named pipes)
-- [x] v0.3.0: page-scripts refactor, run-code auto-wrap, eval raw parsing, red errors
-
----
-
-## Architecture Redesign: Direct Engine + Monorepo (v0.4+)
-
-### Problem
-
-playwright-repl currently routes all commands through a Playwright daemon over Unix socket. This creates three limitations:
-
-1. **vm sandbox** restricts `run-code` — no `expect`, no `require`, no full Node.js context
-2. **Extension divergence** — the Chrome extension reimplements all commands via raw CDP (800 lines), can't share code with the REPL
-3. **Daemon coupling** — adding commands requires daemon support; extra process to manage
-
-### Solution
-
-Replace the daemon with an **in-process Playwright engine**. Restructure into a **monorepo** so REPL and extension share the same core. Support **three browser connection modes**.
-
-### Key Discovery
-
-`BrowserServerBackend` from `playwright/lib/mcp/browser/browserServerBackend.js` can be instantiated directly in any Node.js process. It provides all 35+ tool handlers (click, fill, snapshot, run-code, etc.) without the daemon. The daemon's routing logic is ~15 lines we replicate in a new `Engine` class.
-
-### Three Connection Modes
-
-| Mode | Flag | What it does |
-|------|------|-------------|
-| **Launch** | `--headed` (default) | Launches a new Chromium instance via Playwright |
-| **Connect** | `--connect [port]` | Connects to existing Chrome via CDP (`chrome --remote-debugging-port=9222`) |
-| **Extension** | `--extension` | Starts WebSocket server; Chrome extension relays CDP from user's browser |
-
-### Monorepo Structure
+- [x] Page-scripts refactor, run-code auto-wrap, eval raw parsing, red errors
-```
-playwright-repl/
-├── package.json # Root workspace config (private)
-├── packages/
-│ ├── core/ # Shared engine + utilities
-│ │ ├── package.json # @playwright-repl/core (private, workspace)
-│ │ ├── src/
-│ │ │ ├── engine.mjs # NEW: wraps BrowserServerBackend in-process
-│ │ │ ├── parser.mjs # MOVED from src/ (unchanged)
-│ │ │ ├── page-scripts.mjs # MOVED from src/ (unchanged)
-│ │ │ ├── completion-data.mjs # MOVED from src/ (unchanged)
-│ │ │ ├── colors.mjs # MOVED from src/ (unchanged)
-│ │ │ └── resolve.mjs # MOVED from src/ (COMMANDS map, minimist)
-│ │ └── test/
-│ │ ├── engine.test.mjs # NEW
-│ │ ├── parser.test.mjs # MOVED from test/
-│ │ └── page-scripts.test.mjs # MOVED from test/
-│ │
-│ ├── cli/ # Terminal REPL (published to npm as "playwright-repl")
-│ │ ├── package.json # name: "playwright-repl"
-│ │ ├── bin/
-│ │ │ └── playwright-repl.mjs # MOVED from bin/ (add --connect, --extension flags)
-│ │ ├── src/
-│ │ │ ├── repl.mjs # MOVED from src/ (use Engine instead of DaemonConnection)
-│ │ │ ├── recorder.mjs # MOVED from src/ (unchanged)
-│ │ │ └── index.mjs # Public API exports
-│ │ └── test/
-│ │ ├── repl-processline.test.mjs # MOVED (update imports)
-│ │ └── ...other repl tests
-│ │
-│ └── extension/ # Chrome DevTools panel extension
-│ ├── package.json # @playwright-repl/extension (private)
-│ ├── manifest.json # MOVED from playwright-repl-extension
-│ ├── background.js # REWRITTEN: thin WebSocket relay (~150 lines)
-│ ├── panel/
-│ │ ├── panel.html # MOVED (unchanged)
-│ │ ├── panel.js # MOVED (minor: send via background WS relay)
-│ │ └── panel.css # MOVED (unchanged)
-│ ├── content/
-│ │ └── recorder.js # MOVED (unchanged, still uses CDP for recording)
-│ └── lib/
-│ └── converter.js # MOVED (unchanged, .pw → Playwright test export)
-```
+### Phase 5: Monorepo Setup (v0.4)
-### Files to DELETE after migration
-- `src/connection.mjs` — DaemonConnection (Unix socket client)
-- `src/workspace.mjs` — daemon startup, socket paths
-- `bin/daemon-launcher.cjs` — daemon launcher
-- Extension's `lib/page-scripts.js`, `lib/locators.js`, `lib/formatter.js`, `lib/commands.js` (replaced by server-side Playwright)
+- [x] Restructured into `packages/core`, `packages/cli`, `packages/extension`
+- [x] npm workspaces with shared dependencies
----
-
-## Phase 5: Monorepo Setup
-
-**Goal**: Restructure into `packages/` layout with npm workspaces. No behavior changes — just move files.
-
-**Status**: In progress (branch: `monorepo-restructure`, partial work stashed)
-
-### Steps
-1. Create `packages/core/`, `packages/cli/`, `packages/extension/`
-2. Move files per structure above (via `git mv`)
-3. Update all imports (relative paths change; CLI imports from `@playwright-repl/core`)
-4. Add root `package.json` with `"workspaces": ["packages/*"]`
-5. Run `npm install` to link workspaces
-6. Run `npm test --workspaces` — all existing tests pass
-
-### Verify
-- `npm test --workspaces` — all 254 tests pass
-- `node packages/cli/bin/playwright-repl.mjs --headed` — REPL still works via daemon (unchanged behavior)
-
----
-
-## Phase 6: Engine (Core Change)
-
-**Goal**: Create `Engine` class that wraps `BrowserServerBackend` in-process. REPL uses Engine by default.
-
-### New file: `packages/core/src/engine.mjs` (~200 lines)
+### Phase 6: Engine (v0.4)
-```js
-import { createRequire } from 'node:module';
-const require = createRequire(import.meta.url);
+- [x] `Engine` class wrapping `BrowserServerBackend` in-process
+- [x] No daemon, no socket — commands execute directly
+- [x] `Engine.run()` API matches `DaemonConnection.run()`
-const { BrowserServerBackend } = require('playwright/lib/mcp/browser/browserServerBackend');
-const { contextFactory } = require('playwright/lib/mcp/browser/browserContextFactory');
+### Phase 7: Connect Mode (v0.4)
-export class Engine {
- // Same interface as DaemonConnection: run(args), connected, close(), connect()
- async start(opts) // Create config → factory → BrowserServerBackend → initialize
- async run(minimistArgs) // parseCliCommand(args) → backend.callTool(name, params) → format result
- async close() // Shutdown backend + browser
- get connected() // Boolean
-}
-```
+- [x] `--connect [port]` connects to existing Chrome via CDP
-**Key**: `Engine.run()` matches `DaemonConnection.run()` — returns `{ text: "..." }`. This means `repl.mjs`'s `processLine()` and `filterResponse()` work unchanged.
+### Phase 8: Extension Mode (v0.5)
-### Modify: `packages/cli/src/repl.mjs`
+- [x] Side panel extension (Manifest V3) with REPL, script editor, recorder
+- [x] Direct CDP: Engine connects to Chrome via `--remote-debugging-port`
+- [x] CommandServer: HTTP server relays commands from panel to Engine
+- [x] Extension-side recording with `--nth` auto-detection
+- [x] Export to Playwright TypeScript tests
+- [x] E2E tests with Playwright Test (59 tests)
-Replace daemon startup:
-```js
-// Before:
-const conn = new DaemonConnection(socketPath(sessionName), replVersion);
-await conn.connect();
+### Phase 9: TypeScript Migration & Cleanup (v0.5)
-// After:
-const conn = new Engine();
-await conn.start(opts);
-```
-
-### Verify
-- `npm test --workspaces` — all tests pass
-- `node packages/cli/bin/playwright-repl.mjs --headed` — launches browser in-process (no daemon!)
-- `run-code await expect(page).toHaveTitle(...)` — works (no vm sandbox!)
+- [x] All 3 packages converted to TypeScript
+- [x] `tsc --build` with project references (core → cli dependency ordering)
+- [x] Vite build for extension (3 entry points)
+- [x] Suppress snapshot for non-snapshot commands (goto shows only URL/title)
+- [x] Text locator `--nth` support for disambiguating multiple matches
+- [x] Stale files and daemon code removed
---
-## Phase 7: Connect Mode
-
-**Goal**: `playwright-repl --connect [port]` connects to existing Chrome via CDP.
-
-~30 lines in engine.mjs — map `opts.connect` to `cdpEndpoint` in config.
-
-### Verify
-```bash
-chrome --remote-debugging-port=9222
-node packages/cli/bin/playwright-repl.mjs --connect 9222
-snapshot
-click "Sign In"
-```
-
----
-
-## Phase 8: Extension Server + Extension Rewrite
-
-**Goal**: `playwright-repl --extension` starts a WebSocket server. Extension connects as thin CDP relay.
-
-### New file: `packages/core/src/extension-server.mjs` (~150 lines)
-
-WebSocket server that:
-1. Starts CDP relay (reuse `CDPRelayServer` from Playwright)
-2. Accepts extension WebSocket connection for CDP forwarding
-3. Accepts command WebSocket connection from panel
-4. Routes commands → `Engine.run()` → results back to panel
-
-### Extension `background.js` rewrite (~150 lines, replaces 800)
-
-Two roles:
-1. **CDP relay client**: connect to server's relay WebSocket, forward `chrome.debugger` commands
-2. **Command proxy**: receive commands from panel, forward to server's command WebSocket
-
-### Verify
-```bash
-node packages/cli/bin/playwright-repl.mjs --extension --port 9876
-# Extension auto-connects, commands work in DevTools panel
-# Recording still works
-```
-
----
-
-## Phase 9: Cleanup
-
-- Delete `src/connection.mjs`, `src/workspace.mjs`, `bin/daemon-launcher.cjs`
-- Remove daemon-related code from repl.mjs
-- Delete extension's `lib/page-scripts.js`, `lib/locators.js`, `lib/formatter.js`, `lib/commands.js`
-- Update CLAUDE.md, README.md, CHANGELOG.md
-
----
-
-## Phase Dependencies
-
-```
-Phase 5 (Monorepo) → Phase 6 (Engine) → Phase 7 (Connect)
- → Phase 8 (Extension)
- → Phase 9 (Cleanup)
-```
+## Backlog
-Phases 7 and 8 are independent of each other. Phase 9 after all modes are verified.
+### Open Issues
-## Key Risks
+- [ ] **#16 Chaining selectors** — support combining locators (e.g., `click "Delete" "Buy groceries"`)
+- [ ] **#15 Add `clear` command** — clear the REPL console
+- [ ] **#14 Add `highlight` command** — visually highlight elements on the page
+- [ ] **#5 Convert to Playwright tests** — export `.pw` files as Playwright TypeScript test suites
+- [ ] **#4 CSV/Excel/Markdown export** — save session data in tabular formats
-1. **Playwright internal imports** (`lib/mcp/browser/*`): Not public API, may break on upgrades. Mitigate by pinning Playwright version and testing on upgrade.
-2. **Element refs** require `page._snapshotForAI()` (internal). Same risk — already used by daemon.
-3. **Monorepo migration**: Import paths all change. Mitigate by doing Phase 5 as pure move with no behavior changes, verify all tests pass before proceeding.
+### Future Ideas
-## Backlog
-
-- [ ] **Replace custom recorder with Playwright's recording infrastructure** — our `content/recorder.js` (188 lines) uses simple DOM heuristics for element identification. Playwright's recorder has battle-tested locator generation (getByRole → getByText → getByTestId fallback chain), shadow DOM/iframe handling, and years of edge case fixes. With the Engine running Playwright in-process, we could hook into Playwright's recording API and convert output to `.pw` format. Risk: Playwright's recording API is internal, and may assume codegen lifecycle (not "record while user browses" model). Investigate before committing.
+- [ ] Replace custom recorder with Playwright's recording infrastructure (battle-tested locator generation)
- [ ] Variable substitution in .pw files (e.g., `${URL}`, `${USER}`)
-- [ ] Create PR to Playwright repo to add `declareCommand()` entries for verify commands
-- [ ] Add missing commands: keydown, keyup, mousemove, mousedown, mouseup, mousewheel, tracing, video, delete-data
-- [ ] Integration tests with actual browser
+- [ ] CLI strict mode violation hint — suggest `--nth` when multiple elements match
+- [ ] CLI replay regression tests — run `.pw` folders as test suites with pass/fail reporting
+- [ ] Add missing commands: keydown, keyup, mousemove, mousedown, mouseup, mousewheel, tracing, video
- [ ] npx support (`npx playwright-repl`)
- [ ] Config file support (.playwright-repl.json)
- [ ] Plugin system for custom commands
+
+## Key Risks
+
+1. **Playwright internal imports** (`lib/mcp/browser/*`): Not public API, may break on upgrades. Mitigate by pinning Playwright version and testing on upgrade.
+2. **Element refs** require `page._snapshotForAI()` (internal). Same risk — already used by the MCP tools.
diff --git a/README.md b/README.md
index 5fe0bf6..3749353 100644
--- a/README.md
+++ b/README.md
@@ -2,96 +2,110 @@

-Interactive REPL for Playwright browser automation — keyword-driven testing from your terminal.
+Interactive browser automation powered by Playwright — use it from your **terminal** or as a **Chrome side panel**.
-Inspired by [playwright-cli](https://github.com/anthropics/playwright-cli), reusing its command vocabulary and Playwright's browser tools. Where playwright-cli is designed for AI agents (one command per process), playwright-repl is designed for **humans** — a persistent session with recording, replay, and instant feedback.
+Two frontends, one engine: the CLI gives you a terminal REPL with recording and replay; the Chrome extension gives you a DevTools panel with a script editor and visual recorder. Both run the same 35+ Playwright commands through a shared Engine — no command duplication.
## Why?
-**playwright-repl** runs Playwright's browser tools in-process. Type a command, see the result instantly. Record your session, replay it later — no code, no tokens, no setup.
+**playwright-repl** runs Playwright's browser tools in-process. Type a command, see the result instantly. No code, no tokens, no setup.
+
+- **CLI** — terminal REPL with recording, replay, piping, and 50+ aliases
+- **Extension** — Chrome DevTools panel with script editor, recorder, and light/dark themes
+- **Same commands everywhere** — `click`, `fill`, `snapshot`, `verify-text` work identically in both
Key features:
-- **Text locators** — use `click "Submit"` or `fill "Email" "test@example.com"` instead of element refs. Auto-resolves via getByText, getByLabel, getByPlaceholder, and getByRole with fallback chains
-- **Element refs** — also supports ref-based commands (`click e5`, `fill e7 "hello"`) from `snapshot` output
-- **Assertions** — `verify-text`, `verify-element`, `verify-value`, `verify-list` for inline validation
-- **Record & replay** — capture sessions as `.pw` files and replay them headlessly or step-by-step
-- **Connect mode** — attach to an existing Chrome instance via `--connect [port]`
+- **Text locators** — `click "Submit"` or `fill "Email" "test@example.com"` instead of element refs
+- **Element refs** — `click e5`, `fill e7 "hello"` from `snapshot` output
+- **Assertions** — `verify-text`, `verify-element`, `verify-value`, `verify-list`
+- **Record & replay** — capture sessions as `.pw` files (CLI) or record interactions visually (extension)
+- **Three connection modes** — launch a new browser, connect to existing Chrome, or use the extension relay
+
+## Architecture
+
+Both CLI and Extension are frontends to the Engine. Neither directly accesses Chrome.
```
-pw> goto https://demo.playwright.dev/todomvc/
-pw> fill "What needs to be done?" "Buy groceries"
-pw> press Enter
-pw> fill "What needs to be done?" "Write tests"
-pw> press Enter
-pw> check "Buy groceries"
-pw> verify-text "1 item left"
+┌──────────────┐ ┌──────────────┐
+│ CLI (REPL) │ │ Extension │
+│ packages/cli│ │ (Side Panel)│
+└──────┬───────┘ └──────┬───────┘
+ │ │ fetch POST /run
+ │ ▼
+ │ ┌─────────────┐
+ └──────────────► Engine │
+ │ (in-process)│
+ └──────┬──────┘
+ │ CDP
+ ▼
+ ┌─────────────┐
+ │ Chrome │
+ └─────────────┘
```
-Record it, replay it later:
+### Three Connection Modes
-```bash
-pw> .record smoke-test
-⏺ Recording to smoke-test.pw
+| Mode | Flag | Browser Source | Use Case |
+|------|------|---------------|----------|
+| **Launch** | `--headed` (default) | Launches new Chromium via Playwright | General automation |
+| **Connect** | `--connect [port]` | Existing Chrome with `--remote-debugging-port` | Debug running app |
+| **Extension** | `--extension` | Chrome launched with CDP port; side panel sends commands via HTTP | DevTools panel REPL |
-pw> goto https://demo.playwright.dev/todomvc/
-pw> fill "What needs to be done?" "Buy groceries"
-pw> press Enter
-pw> verify-text "1 item left"
-pw> .save
-✓ Saved 4 commands to smoke-test.pw
+### Extension Mode
-# Replay any time
-$ playwright-repl --replay smoke-test.pw
-```
+The extension is a Chrome side panel. The CLI starts Chrome with `--remote-debugging-port`, and the Engine connects directly via CDP. The panel sends commands to a local CommandServer (`POST /run`), which routes them through the Engine.
-## Install
+## Quick Start — CLI
```bash
+# Install
npm install -g playwright-repl
+npx playwright install # browser binaries (if needed)
-# If you don't have browser binaries yet
-npx playwright install
-
-# Or install from source
-git clone https://github.com/stevez/playwright-repl.git
-cd playwright-repl && npm install && npm link
-```
-
-## Quick Start
-
-```bash
# Start the REPL (launches browser automatically)
playwright-repl
# With a visible browser
playwright-repl --headed
-# With a specific browser
-playwright-repl --headed --browser firefox
-
-# Connect to existing Chrome (must be launched with --remote-debugging-port)
+# Connect to existing Chrome
playwright-repl --connect 9222
```
-Once inside the REPL, use either **text locators** or **element refs**:
-
```
pw> goto https://demo.playwright.dev/todomvc/
-
-# Text locators — no snapshot needed
pw> fill "What needs to be done?" "Buy groceries"
pw> press Enter
+pw> fill "What needs to be done?" "Write tests"
+pw> press Enter
pw> check "Buy groceries"
-pw> verify-text "0 items left"
+pw> verify-text "1 item left"
+```
+
+## Quick Start — Extension
+
+```bash
+# 1. Start in extension mode (launches Chrome with extension loaded)
+playwright-repl --extension
+
+# 2. Open any website → click the side panel icon → "Playwright REPL" panel
+
+# 3. Type commands in the panel — same syntax as CLI
+```
+
+The extension side panel includes a REPL input, script editor, visual recorder, and export to Playwright tests.
+
+## Install
+
+```bash
+npm install -g playwright-repl
-# Or use element refs from snapshot
-pw> snapshot
-- textbox "What needs to be done?" [ref=e8]
-- listitem "Buy groceries" [ref=e21]
+# If you don't have browser binaries yet
+npx playwright install
-pw> click e21 # click by ref
-pw> screenshot # take a screenshot
-pw> close # close browser
+# Or install from source
+git clone https://github.com/stevez/playwright-repl.git
+cd playwright-repl && npm install && npm link
```
## Usage
@@ -115,6 +129,10 @@ echo -e "goto https://example.com\nsnapshot" | playwright-repl
# Connect to existing Chrome via CDP
playwright-repl --connect # default port 9222
playwright-repl --connect 9333 # custom port
+
+# Extension mode (launches Chrome with side panel)
+playwright-repl --extension # default port 3000
+playwright-repl --extension --port 4000 # custom command server port
```
### CLI Options
@@ -126,6 +144,8 @@ playwright-repl --connect 9333 # custom port
| `--persistent` | Use persistent browser profile |
| `--profile
` | Persistent profile directory |
| `--connect [port]` | Connect to existing Chrome via CDP (default: `9222`) |
+| `--extension` | Launch Chrome with side panel extension and command server |
+| `--port ` | Command server port (default: `3000`) |
| `--config ` | Path to config file |
| `--replay ` | Replay a `.pw` session file |
| `--record ` | Start REPL with recording to file |
@@ -135,6 +155,8 @@ playwright-repl --connect 9333 # custom port
## Commands
+All commands work in both CLI and extension. In the CLI, type directly at the `pw>` prompt. In the extension, type in the REPL input or use the script editor.
+
### Navigation
| Command | Alias | Description |
@@ -236,6 +258,8 @@ playwright-repl --connect 9333 # custom port
| `close` | `q` | Close the browser |
| `config-print` | — | Print browser config |
+## CLI Features
+
### REPL Meta-Commands
| Command | Description |
@@ -251,11 +275,11 @@ playwright-repl --connect 9333 # custom port
| `.replay ` | Replay a recorded session |
| `.exit` | Exit REPL (also Ctrl+D) |
-## Session Recording & Replay
+### Session Recording & Replay
Record your browser interactions and replay them later — great for regression tests, onboarding demos, or sharing reproducible flows.
-### Record
+#### Record
```bash
# From CLI
@@ -272,7 +296,7 @@ pw> .save
✓ Saved 4 commands to my-test.pw
```
-### Replay
+#### Replay
```bash
# Full speed
@@ -285,7 +309,7 @@ playwright-repl --replay my-test.pw --step --headed
pw> .replay my-test.pw
```
-### File Format
+#### File Format
`.pw` files are plain text — human-readable, diffable, version-controllable:
@@ -300,7 +324,7 @@ verify-text "Buy groceries"
verify-text "1 item left"
```
-### Recording Controls
+#### Recording Controls
| Command | Description |
|---------|-------------|
@@ -309,37 +333,11 @@ verify-text "1 item left"
| `.save` | Stop and save to file |
| `.discard` | Discard without saving |
-## Examples
-
-All examples use the [TodoMVC demo](https://demo.playwright.dev/todomvc/) and can be run directly:
-
-| File | Description |
-|------|-------------|
-| [01-add-todos.pw](packages/cli/examples/01-add-todos.pw) | Add todos and verify with assertions |
-| [02-complete-and-filter.pw](packages/cli/examples/02-complete-and-filter.pw) | Complete todos, use filters |
-| [03-record-session.pw](packages/cli/examples/03-record-session.pw) | Record a test session |
-| [04-replay-session.pw](packages/cli/examples/04-replay-session.pw) | Replay with step-through |
-| [05-ci-pipe.pw](packages/cli/examples/05-ci-pipe.pw) | CI smoke test |
-| [06-edit-todo.pw](packages/cli/examples/06-edit-todo.pw) | Double-click to edit a todo |
-
-Try one:
-
-```bash
-# Run an example with a visible browser
-playwright-repl --replay packages/cli/examples/01-add-todos.pw --headed
-
-# Step through an example interactively
-playwright-repl --replay packages/cli/examples/04-replay-session.pw --step --headed
-
-# Run as a CI smoke test (headless, silent)
-playwright-repl --replay packages/cli/examples/05-ci-pipe.pw --silent
-```
-
-## eval & run-code
+### eval & run-code
Two ways to run custom code from the REPL:
-### eval — Browser Context
+#### eval — Browser Context
Runs JavaScript inside the browser page (via `page.evaluate`). Use browser globals like `document`, `window`, `location`:
@@ -354,7 +352,7 @@ pw> eval document.querySelectorAll('a').length
42
```
-### run-code — Playwright API
+#### run-code — Playwright API
Runs code with full access to the Playwright `page` object. The REPL auto-wraps your code — just write the body:
@@ -366,9 +364,6 @@ pw> run-code page.url()
pw> run-code page.locator('h1').textContent()
→ async (page) => { return await page.locator('h1').textContent() }
"Installation"
-
-pw> run-code await page.locator('.nav a').allTextContents()
-→ async (page) => { await page.locator('.nav a').allTextContents() }
```
For multiple statements, use semicolons:
@@ -377,41 +372,43 @@ For multiple statements, use semicolons:
pw> run-code const u = await page.url(); const t = await page.title(); return {u, t}
```
-Full function expressions also work:
+## Extension Features
-```
-pw> run-code async (page) => { await page.waitForSelector('.loaded'); return await page.title(); }
-```
+### Side Panel
-## Architecture
+The extension adds a "Playwright REPL" side panel in Chrome with:
+
+- **REPL input** — type commands at the bottom, results appear in the console pane
+- **Script editor** — write multi-line `.pw` scripts with line numbers, run all or step through
+- **Visual recorder** — click Record, interact with the page, recorded commands appear automatically
+- **Export** — convert `.pw` commands to Playwright TypeScript test code
+- **Light/dark themes** — matches your DevTools theme
-The REPL runs Playwright's `BrowserServerBackend` in-process via an `Engine` class. No daemon, no socket — commands execute directly.
+### Recording in Extension
-
+Click the Record button in the toolbar, then interact with the page normally. The recorder captures:
-### How It Works
+- **Clicks** — with text locators and context (e.g., `click "Delete" "Buy groceries"`)
+- **Form input** — debounced fill commands (e.g., `fill "Email" "test@example.com"`)
+- **Selections** — dropdown changes (e.g., `select "Country" "United States"`)
+- **Checkboxes** — check/uncheck with label context
+- **Key presses** — Enter, Tab, Escape
-
+### Export to Playwright
-### Monorepo Structure
+The extension can export `.pw` commands to Playwright TypeScript:
```
-packages/
-├── core/ # Engine + shared utilities
-│ └── src/
-│ ├── engine.mjs # Wraps BrowserServerBackend in-process
-│ ├── parser.mjs # Command parsing and alias resolution
-│ ├── page-scripts.mjs # Text locator and assertion helpers
-│ └── ...
-└── cli/ # Terminal REPL
- └── src/
- ├── repl.mjs # Interactive readline loop
- └── recorder.mjs # Session recording/replay
+# .pw commands → Playwright TypeScript
+goto https://example.com → await page.goto("https://example.com");
+click "Submit" → await page.getByText("Submit").click();
+fill "Email" "test@example.com" → await page.getByLabel("Email").fill("test@example.com");
+verify-text "Success" → await expect(page.getByText("Success")).toBeVisible();
```
### Connect Mode
-To control an existing Chrome instance:
+To control an existing Chrome instance from the CLI:
```bash
# Start Chrome with debugging port
@@ -421,9 +418,69 @@ chrome --remote-debugging-port=9222
playwright-repl --connect 9222
```
+## Examples
+
+All examples use the [TodoMVC demo](https://demo.playwright.dev/todomvc/) and can be run directly:
+
+| File | Description |
+|------|-------------|
+| [01-add-todos.pw](packages/cli/examples/01-add-todos.pw) | Add todos and verify with assertions |
+| [02-complete-and-filter.pw](packages/cli/examples/02-complete-and-filter.pw) | Complete todos, use filters |
+| [03-record-session.pw](packages/cli/examples/03-record-session.pw) | Record a test session |
+| [04-replay-session.pw](packages/cli/examples/04-replay-session.pw) | Replay with step-through |
+| [05-ci-pipe.pw](packages/cli/examples/05-ci-pipe.pw) | CI smoke test |
+| [06-edit-todo.pw](packages/cli/examples/06-edit-todo.pw) | Double-click to edit a todo |
+
+Try one:
+
+```bash
+# Run an example with a visible browser
+playwright-repl --replay packages/cli/examples/01-add-todos.pw --headed
+
+# Step through an example interactively
+playwright-repl --replay packages/cli/examples/04-replay-session.pw --step --headed
+
+# Run as a CI smoke test (headless, silent)
+playwright-repl --replay packages/cli/examples/05-ci-pipe.pw --silent
+```
+
+## Monorepo Structure
+
+```
+packages/
+├── core/ # Engine + shared utilities (TypeScript, tsc)
+│ └── src/
+│ ├── engine.ts # Wraps BrowserServerBackend in-process
+│ ├── extension-server.ts # HTTP server for extension commands
+│ ├── parser.ts # Command parsing and alias resolution
+│ ├── page-scripts.ts # Text locator and assertion helpers
+│ ├── completion-data.ts # Ghost completion items
+│ ├── colors.ts # ANSI color helpers
+│ └── resolve.ts # COMMANDS map, minimist re-export
+├── cli/ # Terminal REPL (TypeScript, tsc)
+│ └── src/
+│ ├── playwright-repl.ts # CLI entry point
+│ ├── repl.ts # Interactive readline loop
+│ ├── recorder.ts # Session recording/replay
+│ └── index.ts # Public API exports
+└── extension/ # Chrome side panel extension (TypeScript, Vite)
+ ├── src/
+ │ ├── background.ts # Side panel behavior + recording handlers
+ │ ├── panel/ # Side panel UI
+ │ │ ├── panel.html
+ │ │ ├── panel.ts
+ │ │ └── panel.css
+ │ ├── content/
+ │ │ └── recorder.ts # Event recorder injected into pages
+ │ └── lib/
+ │ └── converter.ts # .pw → Playwright test export
+ └── public/
+ └── manifest.json # Manifest V3 config
+```
+
## Requirements
-- **Node.js** >= 18
+- **Node.js** >= 20
- **playwright** >= 1.59.0-alpha (includes `lib/mcp/browser/` engine)
## License
diff --git a/architecture-diagram.png b/architecture-diagram.png
deleted file mode 100644
index fe4497f..0000000
Binary files a/architecture-diagram.png and /dev/null differ
diff --git a/flow-diagram.png b/flow-diagram.png
deleted file mode 100644
index e089339..0000000
Binary files a/flow-diagram.png and /dev/null differ
diff --git a/package-lock.json b/package-lock.json
index c8b34da..c9db671 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -5,11 +5,15 @@
"requires": true,
"packages": {
"": {
+ "name": "playwright-repl",
"workspaces": [
"packages/*"
],
"devDependencies": {
+ "@types/minimist": "^1.2.5",
+ "@types/node": "^25.3.0",
"@vitest/coverage-v8": "^4.0.18",
+ "typescript": "^5.9.3",
"vitest": "^4.0.18"
}
},
@@ -515,6 +519,186 @@
"node": ">=18"
}
},
+ "node_modules/@eslint-community/eslint-utils": {
+ "version": "4.9.1",
+ "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
+ "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "eslint-visitor-keys": "^3.4.3"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0"
+ }
+ },
+ "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": {
+ "version": "3.4.3",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
+ "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/@eslint-community/regexpp": {
+ "version": "4.12.2",
+ "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz",
+ "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^12.0.0 || ^14.0.0 || >=16.0.0"
+ }
+ },
+ "node_modules/@eslint/config-array": {
+ "version": "0.23.2",
+ "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.2.tgz",
+ "integrity": "sha512-YF+fE6LV4v5MGWRGj7G404/OZzGNepVF8fxk7jqmqo3lrza7a0uUcDnROGRBG1WFC1omYUS/Wp1f42i0M+3Q3A==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/object-schema": "^3.0.2",
+ "debug": "^4.3.1",
+ "minimatch": "^10.2.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ }
+ },
+ "node_modules/@eslint/config-helpers": {
+ "version": "0.5.2",
+ "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.2.tgz",
+ "integrity": "sha512-a5MxrdDXEvqnIq+LisyCX6tQMPF/dSJpCfBgBauY+pNZ28yCtSsTvyTYrMhaI+LK26bVyCJfJkT0u8KIj2i1dQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/core": "^1.1.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ }
+ },
+ "node_modules/@eslint/core": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.1.0.tgz",
+ "integrity": "sha512-/nr9K9wkr3P1EzFTdFdMoLuo1PmIxjmwvPozwoSodjNBdefGujXQUF93u1DDZpEaTuDvMsIQddsd35BwtrW9Xw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@types/json-schema": "^7.0.15"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ }
+ },
+ "node_modules/@eslint/js": {
+ "version": "10.0.1",
+ "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz",
+ "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ },
+ "funding": {
+ "url": "https://eslint.org/donate"
+ },
+ "peerDependencies": {
+ "eslint": "^10.0.0"
+ },
+ "peerDependenciesMeta": {
+ "eslint": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@eslint/object-schema": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.2.tgz",
+ "integrity": "sha512-HOy56KJt48Bx8KmJ+XGQNSUMT/6dZee/M54XyUyuvTvPXJmsERRvBchsUVx1UMe1WwIH49XLAczNC7V2INsuUw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ }
+ },
+ "node_modules/@eslint/plugin-kit": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.6.0.tgz",
+ "integrity": "sha512-bIZEUzOI1jkhviX2cp5vNyXQc6olzb2ohewQubuYlMXZ2Q/XjBO0x0XhGPvc9fjSIiUN0vw+0hq53BJ4eQSJKQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/core": "^1.1.0",
+ "levn": "^0.4.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ }
+ },
+ "node_modules/@humanfs/core": {
+ "version": "0.19.1",
+ "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
+ "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18.18.0"
+ }
+ },
+ "node_modules/@humanfs/node": {
+ "version": "0.16.7",
+ "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz",
+ "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@humanfs/core": "^0.19.1",
+ "@humanwhocodes/retry": "^0.4.0"
+ },
+ "engines": {
+ "node": ">=18.18.0"
+ }
+ },
+ "node_modules/@humanwhocodes/module-importer": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
+ "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.22"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/nzakas"
+ }
+ },
+ "node_modules/@humanwhocodes/retry": {
+ "version": "0.4.3",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz",
+ "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18.18"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/nzakas"
+ }
+ },
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
@@ -551,10 +735,26 @@
"resolved": "packages/extension",
"link": true
},
+ "node_modules/@playwright/test": {
+ "version": "1.59.0-alpha-2026-02-21",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.0-alpha-2026-02-21.tgz",
+ "integrity": "sha512-j9yPOBz/fsALTsUKuOYMDDhI7mrDTdW5OD5KUqxzQh5cC0+nMWeYmjhBIGfprTxOMX+JcwOIMqAlhJSXd8tMTg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright": "1.59.0-alpha-2026-02-21"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/@rollup/rollup-android-arm-eabi": {
- "version": "4.57.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz",
- "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==",
+ "version": "4.58.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.58.0.tgz",
+ "integrity": "sha512-mr0tmS/4FoVk1cnaeN244A/wjvGDNItZKR8hRhnmCzygyRXYtKF5jVDSIILR1U97CTzAYmbgIj/Dukg62ggG5w==",
"cpu": [
"arm"
],
@@ -566,9 +766,9 @@
]
},
"node_modules/@rollup/rollup-android-arm64": {
- "version": "4.57.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz",
- "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==",
+ "version": "4.58.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.58.0.tgz",
+ "integrity": "sha512-+s++dbp+/RTte62mQD9wLSbiMTV+xr/PeRJEc/sFZFSBRlHPNPVaf5FXlzAL77Mr8FtSfQqCN+I598M8U41ccQ==",
"cpu": [
"arm64"
],
@@ -580,9 +780,9 @@
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
- "version": "4.57.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz",
- "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==",
+ "version": "4.58.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.58.0.tgz",
+ "integrity": "sha512-MFWBwTcYs0jZbINQBXHfSrpSQJq3IUOakcKPzfeSznONop14Pxuqa0Kg19GD0rNBMPQI2tFtu3UzapZpH0Uc1Q==",
"cpu": [
"arm64"
],
@@ -594,9 +794,9 @@
]
},
"node_modules/@rollup/rollup-darwin-x64": {
- "version": "4.57.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz",
- "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==",
+ "version": "4.58.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.58.0.tgz",
+ "integrity": "sha512-yiKJY7pj9c9JwzuKYLFaDZw5gma3fI9bkPEIyofvVfsPqjCWPglSHdpdwXpKGvDeYDms3Qal8qGMEHZ1M/4Udg==",
"cpu": [
"x64"
],
@@ -608,9 +808,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
- "version": "4.57.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz",
- "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==",
+ "version": "4.58.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.58.0.tgz",
+ "integrity": "sha512-x97kCoBh5MOevpn/CNK9W1x8BEzO238541BGWBc315uOlN0AD/ifZ1msg+ZQB05Ux+VF6EcYqpiagfLJ8U3LvQ==",
"cpu": [
"arm64"
],
@@ -622,9 +822,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
- "version": "4.57.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz",
- "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==",
+ "version": "4.58.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.58.0.tgz",
+ "integrity": "sha512-Aa8jPoZ6IQAG2eIrcXPpjRcMjROMFxCt1UYPZZtCxRV68WkuSigYtQ/7Zwrcr2IvtNJo7T2JfDXyMLxq5L4Jlg==",
"cpu": [
"x64"
],
@@ -636,9 +836,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
- "version": "4.57.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz",
- "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==",
+ "version": "4.58.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.58.0.tgz",
+ "integrity": "sha512-Ob8YgT5kD/lSIYW2Rcngs5kNB/44Q2RzBSPz9brf2WEtcGR7/f/E9HeHn1wYaAwKBni+bdXEwgHvUd0x12lQSA==",
"cpu": [
"arm"
],
@@ -650,9 +850,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
- "version": "4.57.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz",
- "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==",
+ "version": "4.58.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.58.0.tgz",
+ "integrity": "sha512-K+RI5oP1ceqoadvNt1FecL17Qtw/n9BgRSzxif3rTL2QlIu88ccvY+Y9nnHe/cmT5zbH9+bpiJuG1mGHRVwF4Q==",
"cpu": [
"arm"
],
@@ -664,9 +864,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
- "version": "4.57.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz",
- "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==",
+ "version": "4.58.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.58.0.tgz",
+ "integrity": "sha512-T+17JAsCKUjmbopcKepJjHWHXSjeW7O5PL7lEFaeQmiVyw4kkc5/lyYKzrv6ElWRX/MrEWfPiJWqbTvfIvjM1Q==",
"cpu": [
"arm64"
],
@@ -678,9 +878,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
- "version": "4.57.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz",
- "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==",
+ "version": "4.58.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.58.0.tgz",
+ "integrity": "sha512-cCePktb9+6R9itIJdeCFF9txPU7pQeEHB5AbHu/MKsfH/k70ZtOeq1k4YAtBv9Z7mmKI5/wOLYjQ+B9QdxR6LA==",
"cpu": [
"arm64"
],
@@ -692,9 +892,9 @@
]
},
"node_modules/@rollup/rollup-linux-loong64-gnu": {
- "version": "4.57.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz",
- "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==",
+ "version": "4.58.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.58.0.tgz",
+ "integrity": "sha512-iekUaLkfliAsDl4/xSdoCJ1gnnIXvoNz85C8U8+ZxknM5pBStfZjeXgB8lXobDQvvPRCN8FPmmuTtH+z95HTmg==",
"cpu": [
"loong64"
],
@@ -706,9 +906,9 @@
]
},
"node_modules/@rollup/rollup-linux-loong64-musl": {
- "version": "4.57.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz",
- "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==",
+ "version": "4.58.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.58.0.tgz",
+ "integrity": "sha512-68ofRgJNl/jYJbxFjCKE7IwhbfxOl1muPN4KbIqAIe32lm22KmU7E8OPvyy68HTNkI2iV/c8y2kSPSm2mW/Q9Q==",
"cpu": [
"loong64"
],
@@ -720,9 +920,9 @@
]
},
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
- "version": "4.57.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz",
- "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==",
+ "version": "4.58.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.58.0.tgz",
+ "integrity": "sha512-dpz8vT0i+JqUKuSNPCP5SYyIV2Lh0sNL1+FhM7eLC457d5B9/BC3kDPp5BBftMmTNsBarcPcoz5UGSsnCiw4XQ==",
"cpu": [
"ppc64"
],
@@ -734,9 +934,9 @@
]
},
"node_modules/@rollup/rollup-linux-ppc64-musl": {
- "version": "4.57.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz",
- "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==",
+ "version": "4.58.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.58.0.tgz",
+ "integrity": "sha512-4gdkkf9UJ7tafnweBCR/mk4jf3Jfl0cKX9Np80t5i78kjIH0ZdezUv/JDI2VtruE5lunfACqftJ8dIMGN4oHew==",
"cpu": [
"ppc64"
],
@@ -748,9 +948,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
- "version": "4.57.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz",
- "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==",
+ "version": "4.58.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.58.0.tgz",
+ "integrity": "sha512-YFS4vPnOkDTD/JriUeeZurFYoJhPf9GQQEF/v4lltp3mVcBmnsAdjEWhr2cjUCZzZNzxCG0HZOvJU44UGHSdzw==",
"cpu": [
"riscv64"
],
@@ -762,9 +962,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
- "version": "4.57.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz",
- "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==",
+ "version": "4.58.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.58.0.tgz",
+ "integrity": "sha512-x2xgZlFne+QVNKV8b4wwaCS8pwq3y14zedZ5DqLzjdRITvreBk//4Knbcvm7+lWmms9V9qFp60MtUd0/t/PXPw==",
"cpu": [
"riscv64"
],
@@ -776,9 +976,9 @@
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
- "version": "4.57.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz",
- "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==",
+ "version": "4.58.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.58.0.tgz",
+ "integrity": "sha512-jIhrujyn4UnWF8S+DHSkAkDEO3hLX0cjzxJZPLF80xFyzyUIYgSMRcYQ3+uqEoyDD2beGq7Dj7edi8OnJcS/hg==",
"cpu": [
"s390x"
],
@@ -790,9 +990,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
- "version": "4.57.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz",
- "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==",
+ "version": "4.58.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.58.0.tgz",
+ "integrity": "sha512-+410Srdoh78MKSJxTQ+hZ/Mx+ajd6RjjPwBPNd0R3J9FtL6ZA0GqiiyNjCO9In0IzZkCNrpGymSfn+kgyPQocg==",
"cpu": [
"x64"
],
@@ -804,9 +1004,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
- "version": "4.57.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz",
- "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==",
+ "version": "4.58.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.58.0.tgz",
+ "integrity": "sha512-ZjMyby5SICi227y1MTR3VYBpFTdZs823Rs/hpakufleBoufoOIB6jtm9FEoxn/cgO7l6PM2rCEl5Kre5vX0QrQ==",
"cpu": [
"x64"
],
@@ -818,9 +1018,9 @@
]
},
"node_modules/@rollup/rollup-openbsd-x64": {
- "version": "4.57.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz",
- "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==",
+ "version": "4.58.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.58.0.tgz",
+ "integrity": "sha512-ds4iwfYkSQ0k1nb8LTcyXw//ToHOnNTJtceySpL3fa7tc/AsE+UpUFphW126A6fKBGJD5dhRvg8zw1rvoGFxmw==",
"cpu": [
"x64"
],
@@ -832,9 +1032,9 @@
]
},
"node_modules/@rollup/rollup-openharmony-arm64": {
- "version": "4.57.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz",
- "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==",
+ "version": "4.58.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.58.0.tgz",
+ "integrity": "sha512-fd/zpJniln4ICdPkjWFhZYeY/bpnaN9pGa6ko+5WD38I0tTqk9lXMgXZg09MNdhpARngmxiCg0B0XUamNw/5BQ==",
"cpu": [
"arm64"
],
@@ -846,9 +1046,9 @@
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
- "version": "4.57.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz",
- "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==",
+ "version": "4.58.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.58.0.tgz",
+ "integrity": "sha512-YpG8dUOip7DCz3nr/JUfPbIUo+2d/dy++5bFzgi4ugOGBIox+qMbbqt/JoORwvI/C9Kn2tz6+Bieoqd5+B1CjA==",
"cpu": [
"arm64"
],
@@ -860,9 +1060,9 @@
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
- "version": "4.57.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz",
- "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==",
+ "version": "4.58.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.58.0.tgz",
+ "integrity": "sha512-b9DI8jpFQVh4hIXFr0/+N/TzLdpBIoPzjt0Rt4xJbW3mzguV3mduR9cNgiuFcuL/TeORejJhCWiAXe3E/6PxWA==",
"cpu": [
"ia32"
],
@@ -874,9 +1074,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-gnu": {
- "version": "4.57.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz",
- "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==",
+ "version": "4.58.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.58.0.tgz",
+ "integrity": "sha512-CSrVpmoRJFN06LL9xhkitkwUcTZtIotYAF5p6XOR2zW0Zz5mzb3IPpcoPhB02frzMHFNo1reQ9xSF5fFm3hUsQ==",
"cpu": [
"x64"
],
@@ -888,9 +1088,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
- "version": "4.57.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz",
- "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==",
+ "version": "4.58.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.58.0.tgz",
+ "integrity": "sha512-QFsBgQNTnh5K0t/sBsjJLq24YVqEIVkGpfN2VHsnN90soZyhaiA9UUHufcctVNL4ypJY0wrwad0wslx2KJQ1/w==",
"cpu": [
"x64"
],
@@ -919,6 +1119,17 @@
"assertion-error": "^2.0.1"
}
},
+ "node_modules/@types/chrome": {
+ "version": "0.0.114",
+ "resolved": "https://registry.npmjs.org/@types/chrome/-/chrome-0.0.114.tgz",
+ "integrity": "sha512-i7qRr74IrxHtbnrZSKUuP5Uvd5EOKwlwJq/yp7+yTPihOXnPhNQO4Z5bqb1XTnrjdbUKEJicaVVbhcgtRijmLA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/filesystem": "*",
+ "@types/har-format": "*"
+ }
+ },
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
@@ -926,6 +1137,13 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/esrecurse": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
+ "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -933,152 +1151,503 @@
"dev": true,
"license": "MIT"
},
- "node_modules/@vitest/coverage-v8": {
- "version": "4.0.18",
- "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.18.tgz",
- "integrity": "sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg==",
+ "node_modules/@types/filesystem": {
+ "version": "0.0.36",
+ "resolved": "https://registry.npmjs.org/@types/filesystem/-/filesystem-0.0.36.tgz",
+ "integrity": "sha512-vPDXOZuannb9FZdxgHnqSwAG/jvdGM8Wq+6N4D/d80z+D4HWH+bItqsZaVRQykAn6WEVeEkLm2oQigyHtgb0RA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@bcoe/v8-coverage": "^1.0.2",
- "@vitest/utils": "4.0.18",
- "ast-v8-to-istanbul": "^0.3.10",
- "istanbul-lib-coverage": "^3.2.2",
- "istanbul-lib-report": "^3.0.1",
- "istanbul-reports": "^3.2.0",
- "magicast": "^0.5.1",
- "obug": "^2.1.1",
- "std-env": "^3.10.0",
- "tinyrainbow": "^3.0.3"
- },
- "funding": {
- "url": "https://opencollective.com/vitest"
- },
- "peerDependencies": {
- "@vitest/browser": "4.0.18",
- "vitest": "4.0.18"
- },
- "peerDependenciesMeta": {
- "@vitest/browser": {
- "optional": true
- }
+ "@types/filewriter": "*"
}
},
- "node_modules/@vitest/expect": {
- "version": "4.0.18",
- "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz",
- "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==",
+ "node_modules/@types/filewriter": {
+ "version": "0.0.33",
+ "resolved": "https://registry.npmjs.org/@types/filewriter/-/filewriter-0.0.33.tgz",
+ "integrity": "sha512-xFU8ZXTw4gd358lb2jw25nxY9QAgqn2+bKKjKOYfNCzN4DKCFetK7sPtrlpg66Ywe3vWY9FNxprZawAh9wfJ3g==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/har-format": {
+ "version": "1.2.16",
+ "resolved": "https://registry.npmjs.org/@types/har-format/-/har-format-1.2.16.tgz",
+ "integrity": "sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/json-schema": {
+ "version": "7.0.15",
+ "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
+ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/minimist": {
+ "version": "1.2.5",
+ "resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.5.tgz",
+ "integrity": "sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/node": {
+ "version": "25.3.0",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.0.tgz",
+ "integrity": "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@standard-schema/spec": "^1.0.0",
- "@types/chai": "^5.2.2",
- "@vitest/spy": "4.0.18",
- "@vitest/utils": "4.0.18",
- "chai": "^6.2.1",
- "tinyrainbow": "^3.0.3"
- },
- "funding": {
- "url": "https://opencollective.com/vitest"
+ "undici-types": "~7.18.0"
}
},
- "node_modules/@vitest/mocker": {
- "version": "4.0.18",
- "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz",
- "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==",
+ "node_modules/@types/whatwg-mimetype": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz",
+ "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/ws": {
+ "version": "8.18.1",
+ "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
+ "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/spy": "4.0.18",
- "estree-walker": "^3.0.3",
- "magic-string": "^0.30.21"
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@typescript-eslint/eslint-plugin": {
+ "version": "8.56.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.0.tgz",
+ "integrity": "sha512-lRyPDLzNCuae71A3t9NEINBiTn7swyOhvUj3MyUOxb8x6g6vPEFoOU+ZRmGMusNC3X3YMhqMIX7i8ShqhT74Pw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/regexpp": "^4.12.2",
+ "@typescript-eslint/scope-manager": "8.56.0",
+ "@typescript-eslint/type-utils": "8.56.0",
+ "@typescript-eslint/utils": "8.56.0",
+ "@typescript-eslint/visitor-keys": "8.56.0",
+ "ignore": "^7.0.5",
+ "natural-compare": "^1.4.0",
+ "ts-api-utils": "^2.4.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
- "url": "https://opencollective.com/vitest"
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "msw": "^2.4.9",
- "vite": "^6.0.0 || ^7.0.0-0"
- },
- "peerDependenciesMeta": {
- "msw": {
- "optional": true
- },
- "vite": {
- "optional": true
- }
+ "@typescript-eslint/parser": "^8.56.0",
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.0.0"
}
},
- "node_modules/@vitest/pretty-format": {
- "version": "4.0.18",
- "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz",
- "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==",
+ "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
+ "version": "7.0.5",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
+ "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/@typescript-eslint/parser": {
+ "version": "8.56.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.0.tgz",
+ "integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "tinyrainbow": "^3.0.3"
+ "@typescript-eslint/scope-manager": "8.56.0",
+ "@typescript-eslint/types": "8.56.0",
+ "@typescript-eslint/typescript-estree": "8.56.0",
+ "@typescript-eslint/visitor-keys": "8.56.0",
+ "debug": "^4.4.3"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
- "url": "https://opencollective.com/vitest"
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.0.0"
}
},
- "node_modules/@vitest/runner": {
- "version": "4.0.18",
- "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz",
- "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==",
+ "node_modules/@typescript-eslint/project-service": {
+ "version": "8.56.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.56.0.tgz",
+ "integrity": "sha512-M3rnyL1vIQOMeWxTWIW096/TtVP+8W3p/XnaFflhmcFp+U4zlxUxWj4XwNs6HbDeTtN4yun0GNTTDBw/SvufKg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/utils": "4.0.18",
- "pathe": "^2.0.3"
+ "@typescript-eslint/tsconfig-utils": "^8.56.0",
+ "@typescript-eslint/types": "^8.56.0",
+ "debug": "^4.4.3"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
- "url": "https://opencollective.com/vitest"
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.0.0"
}
},
- "node_modules/@vitest/snapshot": {
- "version": "4.0.18",
- "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz",
- "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==",
+ "node_modules/@typescript-eslint/scope-manager": {
+ "version": "8.56.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.56.0.tgz",
+ "integrity": "sha512-7UiO/XwMHquH+ZzfVCfUNkIXlp/yQjjnlYUyYz7pfvlK3/EyyN6BK+emDmGNyQLBtLGaYrTAI6KOw8tFucWL2w==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/pretty-format": "4.0.18",
- "magic-string": "^0.30.21",
- "pathe": "^2.0.3"
+ "@typescript-eslint/types": "8.56.0",
+ "@typescript-eslint/visitor-keys": "8.56.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
- "url": "https://opencollective.com/vitest"
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
}
},
- "node_modules/@vitest/spy": {
- "version": "4.0.18",
- "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz",
- "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==",
+ "node_modules/@typescript-eslint/tsconfig-utils": {
+ "version": "8.56.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.56.0.tgz",
+ "integrity": "sha512-bSJoIIt4o3lKXD3xmDh9chZcjCz5Lk8xS7Rxn+6l5/pKrDpkCwtQNQQwZ2qRPk7TkUYhrq3WPIHXOXlbXP0itg==",
"dev": true,
"license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
"funding": {
- "url": "https://opencollective.com/vitest"
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.0.0"
}
},
- "node_modules/@vitest/utils": {
- "version": "4.0.18",
- "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz",
- "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==",
+ "node_modules/@typescript-eslint/type-utils": {
+ "version": "8.56.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.56.0.tgz",
+ "integrity": "sha512-qX2L3HWOU2nuDs6GzglBeuFXviDODreS58tLY/BALPC7iu3Fa+J7EOTwnX9PdNBxUI7Uh0ntP0YWGnxCkXzmfA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/pretty-format": "4.0.18",
- "tinyrainbow": "^3.0.3"
+ "@typescript-eslint/types": "8.56.0",
+ "@typescript-eslint/typescript-estree": "8.56.0",
+ "@typescript-eslint/utils": "8.56.0",
+ "debug": "^4.4.3",
+ "ts-api-utils": "^2.4.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
- "url": "https://opencollective.com/vitest"
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.0.0"
}
},
- "node_modules/assertion-error": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
- "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+ "node_modules/@typescript-eslint/types": {
+ "version": "8.56.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.56.0.tgz",
+ "integrity": "sha512-DBsLPs3GsWhX5HylbP9HNG15U0bnwut55Lx12bHB9MpXxQ+R5GC8MwQe+N1UFXxAeQDvEsEDY6ZYwX03K7Z6HQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree": {
+ "version": "8.56.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.56.0.tgz",
+ "integrity": "sha512-ex1nTUMWrseMltXUHmR2GAQ4d+WjkZCT4f+4bVsps8QEdh0vlBsaCokKTPlnqBFqqGaxilDNJG7b8dolW2m43Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/project-service": "8.56.0",
+ "@typescript-eslint/tsconfig-utils": "8.56.0",
+ "@typescript-eslint/types": "8.56.0",
+ "@typescript-eslint/visitor-keys": "8.56.0",
+ "debug": "^4.4.3",
+ "minimatch": "^9.0.5",
+ "semver": "^7.7.3",
+ "tinyglobby": "^0.2.15",
+ "ts-api-utils": "^2.4.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
+ "version": "9.0.6",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.6.tgz",
+ "integrity": "sha512-kQAVowdR33euIqeA0+VZTDqU+qo1IeVY+hrKYtZMio3Pg0P0vuh/kwRylLUddJhB6pf3q/botcOvRtx4IN1wqQ==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^5.0.2"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/@typescript-eslint/utils": {
+ "version": "8.56.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.56.0.tgz",
+ "integrity": "sha512-RZ3Qsmi2nFGsS+n+kjLAYDPVlrzf7UhTffrDIKr+h2yzAlYP/y5ZulU0yeDEPItos2Ph46JAL5P/On3pe7kDIQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/eslint-utils": "^4.9.1",
+ "@typescript-eslint/scope-manager": "8.56.0",
+ "@typescript-eslint/types": "8.56.0",
+ "@typescript-eslint/typescript-estree": "8.56.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/visitor-keys": {
+ "version": "8.56.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.56.0.tgz",
+ "integrity": "sha512-q+SL+b+05Ud6LbEE35qe4A99P+htKTKVbyiNEe45eCbJFyh/HVK9QXwlrbz+Q4L8SOW4roxSVwXYj4DMBT7Ieg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.56.0",
+ "eslint-visitor-keys": "^5.0.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@vitest/coverage-v8": {
+ "version": "4.0.18",
+ "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.18.tgz",
+ "integrity": "sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@bcoe/v8-coverage": "^1.0.2",
+ "@vitest/utils": "4.0.18",
+ "ast-v8-to-istanbul": "^0.3.10",
+ "istanbul-lib-coverage": "^3.2.2",
+ "istanbul-lib-report": "^3.0.1",
+ "istanbul-reports": "^3.2.0",
+ "magicast": "^0.5.1",
+ "obug": "^2.1.1",
+ "std-env": "^3.10.0",
+ "tinyrainbow": "^3.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@vitest/browser": "4.0.18",
+ "vitest": "4.0.18"
+ },
+ "peerDependenciesMeta": {
+ "@vitest/browser": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/expect": {
+ "version": "4.0.18",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz",
+ "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@standard-schema/spec": "^1.0.0",
+ "@types/chai": "^5.2.2",
+ "@vitest/spy": "4.0.18",
+ "@vitest/utils": "4.0.18",
+ "chai": "^6.2.1",
+ "tinyrainbow": "^3.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/mocker": {
+ "version": "4.0.18",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz",
+ "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/spy": "4.0.18",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^0.30.21"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9",
+ "vite": "^6.0.0 || ^7.0.0-0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/pretty-format": {
+ "version": "4.0.18",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz",
+ "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyrainbow": "^3.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/runner": {
+ "version": "4.0.18",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz",
+ "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/utils": "4.0.18",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/snapshot": {
+ "version": "4.0.18",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz",
+ "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.0.18",
+ "magic-string": "^0.30.21",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "4.0.18",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz",
+ "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/utils": {
+ "version": "4.0.18",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz",
+ "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.0.18",
+ "tinyrainbow": "^3.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/acorn": {
+ "version": "8.16.0",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
+ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/acorn-jsx": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
+ "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ }
+ },
+ "node_modules/ajv": {
+ "version": "6.14.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
+ "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.1",
+ "fast-json-stable-stringify": "^2.0.0",
+ "json-schema-traverse": "^0.4.1",
+ "uri-js": "^4.2.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/assertion-error": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
+ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -1097,6 +1666,29 @@
"js-tokens": "^10.0.0"
}
},
+ "node_modules/balanced-match": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.3.tgz",
+ "integrity": "sha512-1pHv8LX9CpKut1Zp4EXey7Z8OfH11ONNH6Dhi2WDUt31VVZFXZzKwXcysBgqSumFCmR+0dqjMK5v5JiFHzi0+g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
+ "node_modules/brace-expansion": {
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.2.tgz",
+ "integrity": "sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^4.0.2"
+ },
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
"node_modules/chai": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
@@ -1107,6 +1699,59 @@
"node": ">=18"
}
},
+ "node_modules/cross-spawn": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
+ "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "path-key": "^3.1.0",
+ "shebang-command": "^2.0.0",
+ "which": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/deep-is": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
+ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/entities": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
+ "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
"node_modules/es-module-lexer": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
@@ -1156,9 +1801,164 @@
"@esbuild/win32-x64": "0.27.3"
}
},
- "node_modules/estree-walker": {
- "version": "3.0.3",
- "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "node_modules/escape-string-regexp": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/eslint": {
+ "version": "10.0.1",
+ "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.0.1.tgz",
+ "integrity": "sha512-20MV9SUdeN6Jd84xESsKhRly+/vxI+hwvpBMA93s+9dAcjdCuCojn4IqUGS3lvVaqjVYGYHSRMCpeFtF2rQYxQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/eslint-utils": "^4.8.0",
+ "@eslint-community/regexpp": "^4.12.2",
+ "@eslint/config-array": "^0.23.2",
+ "@eslint/config-helpers": "^0.5.2",
+ "@eslint/core": "^1.1.0",
+ "@eslint/plugin-kit": "^0.6.0",
+ "@humanfs/node": "^0.16.6",
+ "@humanwhocodes/module-importer": "^1.0.1",
+ "@humanwhocodes/retry": "^0.4.2",
+ "@types/estree": "^1.0.6",
+ "ajv": "^6.12.4",
+ "cross-spawn": "^7.0.6",
+ "debug": "^4.3.2",
+ "escape-string-regexp": "^4.0.0",
+ "eslint-scope": "^9.1.1",
+ "eslint-visitor-keys": "^5.0.1",
+ "espree": "^11.1.1",
+ "esquery": "^1.7.0",
+ "esutils": "^2.0.2",
+ "fast-deep-equal": "^3.1.3",
+ "file-entry-cache": "^8.0.0",
+ "find-up": "^5.0.0",
+ "glob-parent": "^6.0.2",
+ "ignore": "^5.2.0",
+ "imurmurhash": "^0.1.4",
+ "is-glob": "^4.0.0",
+ "json-stable-stringify-without-jsonify": "^1.0.1",
+ "minimatch": "^10.2.1",
+ "natural-compare": "^1.4.0",
+ "optionator": "^0.9.3"
+ },
+ "bin": {
+ "eslint": "bin/eslint.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ },
+ "funding": {
+ "url": "https://eslint.org/donate"
+ },
+ "peerDependencies": {
+ "jiti": "*"
+ },
+ "peerDependenciesMeta": {
+ "jiti": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/eslint-scope": {
+ "version": "9.1.1",
+ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.1.tgz",
+ "integrity": "sha512-GaUN0sWim5qc8KVErfPBWmc31LEsOkrUJbvJZV+xuL3u2phMUK4HIvXlWAakfC8W4nzlK+chPEAkYOYb5ZScIw==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "@types/esrecurse": "^4.3.1",
+ "@types/estree": "^1.0.8",
+ "esrecurse": "^4.3.0",
+ "estraverse": "^5.2.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/eslint-visitor-keys": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
+ "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/espree": {
+ "version": "11.1.1",
+ "resolved": "https://registry.npmjs.org/espree/-/espree-11.1.1.tgz",
+ "integrity": "sha512-AVHPqQoZYc+RUM4/3Ly5udlZY/U4LS8pIG05jEjWM2lQMU/oaZ7qshzAl2YP1tfNmXfftH3ohurfwNAug+MnsQ==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "acorn": "^8.16.0",
+ "acorn-jsx": "^5.3.2",
+ "eslint-visitor-keys": "^5.0.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/esquery": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
+ "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "estraverse": "^5.1.0"
+ },
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
+ "node_modules/esrecurse": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
+ "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "estraverse": "^5.2.0"
+ },
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/estraverse": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
+ "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"license": "MIT",
@@ -1166,6 +1966,16 @@
"@types/estree": "^1.0.0"
}
},
+ "node_modules/esutils": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
+ "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/expect-type": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
@@ -1176,6 +1986,27 @@
"node": ">=12.0.0"
}
},
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fast-json-stable-stringify": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
+ "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fast-levenshtein": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
+ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -1194,6 +2025,57 @@
}
}
},
+ "node_modules/file-entry-cache": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
+ "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "flat-cache": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
+ "node_modules/find-up": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
+ "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "locate-path": "^6.0.0",
+ "path-exists": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/flat-cache": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
+ "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "flatted": "^3.2.9",
+ "keyv": "^4.5.4"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/flatted": {
+ "version": "3.3.3",
+ "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
+ "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
+ "dev": true,
+ "license": "ISC"
+ },
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
@@ -1208,6 +2090,37 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
+ "node_modules/glob-parent": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
+ "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/happy-dom": {
+ "version": "20.6.2",
+ "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.6.2.tgz",
+ "integrity": "sha512-Xk/Y0cuq9ngN/my8uvK4gKoyDl6sBKkIl8A/hJ0IabZVH7E5SJLHNE7uKRPVmSrQbhJaLIHTEcvTct4GgNtsRA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": ">=20.0.0",
+ "@types/whatwg-mimetype": "^3.0.2",
+ "@types/ws": "^8.18.1",
+ "entities": "^7.0.1",
+ "whatwg-mimetype": "^3.0.0",
+ "ws": "^8.18.3"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
@@ -1225,6 +2138,56 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/ignore": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
+ "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/imurmurhash": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
+ "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.8.19"
+ }
+ },
+ "node_modules/is-extglob": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-glob": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-extglob": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/isexe": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+ "dev": true,
+ "license": "ISC"
+ },
"node_modules/istanbul-lib-coverage": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
@@ -1271,6 +2234,67 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/json-buffer": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
+ "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json-schema-traverse": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
+ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json-stable-stringify-without-jsonify": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
+ "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/keyv": {
+ "version": "4.5.4",
+ "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
+ "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "json-buffer": "3.0.1"
+ }
+ },
+ "node_modules/levn": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
+ "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "prelude-ls": "^1.2.1",
+ "type-check": "~0.4.0"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/locate-path": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
+ "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "p-locate": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -1309,6 +2333,22 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/minimatch": {
+ "version": "10.2.2",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.2.tgz",
+ "integrity": "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "brace-expansion": "^5.0.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
@@ -1318,6 +2358,13 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
@@ -1337,6 +2384,13 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
+ "node_modules/natural-compare": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
+ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/obug": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
@@ -1348,6 +2402,76 @@
],
"license": "MIT"
},
+ "node_modules/optionator": {
+ "version": "0.9.4",
+ "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
+ "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "deep-is": "^0.1.3",
+ "fast-levenshtein": "^2.0.6",
+ "levn": "^0.4.1",
+ "prelude-ls": "^1.2.1",
+ "type-check": "^0.4.0",
+ "word-wrap": "^1.2.5"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/p-limit": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
+ "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "yocto-queue": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-locate": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
+ "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "p-limit": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/path-exists": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
+ "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/path-key": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
@@ -1376,12 +2500,12 @@
}
},
"node_modules/playwright": {
- "version": "1.59.0-alpha-2026-02-09",
- "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.0-alpha-2026-02-09.tgz",
- "integrity": "sha512-yyOXu0vpIfNnBG6M0JkUDkxDyvZUDieqoXMxNoNbcVV+DROrJB1oDmkgTNojkxXhpsAHwG56TH2tE/Rqju86sA==",
+ "version": "1.59.0-alpha-2026-02-21",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.0-alpha-2026-02-21.tgz",
+ "integrity": "sha512-ZZSHXJwIjJ/LnrY/7EI+3fZskVHETHIq+AKOYcHgVBgcmuiiqHI10nfpgcdy299mJkQB6OBxShcneWh2Qxm/yA==",
"license": "Apache-2.0",
"dependencies": {
- "playwright-core": "1.59.0-alpha-2026-02-09"
+ "playwright-core": "1.59.0-alpha-2026-02-21"
},
"bin": {
"playwright": "cli.js"
@@ -1394,9 +2518,9 @@
}
},
"node_modules/playwright-core": {
- "version": "1.59.0-alpha-2026-02-09",
- "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.0-alpha-2026-02-09.tgz",
- "integrity": "sha512-O4Ugl6oq68hWeGIti4U3pNa/I7vEwn8PdzG/mZgcTkou+0IBNWAVH4WWk0+BJoufqaGBHmH0H7pVSjmlCusoYA==",
+ "version": "1.59.0-alpha-2026-02-21",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.0-alpha-2026-02-21.tgz",
+ "integrity": "sha512-+oEepN1820aXYLEm+xfVq/ZDst3W5pjI20umfWHdCoR+JKi5DC9SJwULBae63to4yC1LbAVLYmyZMRz2E9jATQ==",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
@@ -1438,49 +2562,24 @@
"node": "^10 || ^12 || >=14"
}
},
- "node_modules/rollup": {
- "version": "4.57.1",
- "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz",
- "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==",
+ "node_modules/prelude-ls": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
+ "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
"dev": true,
"license": "MIT",
- "dependencies": {
- "@types/estree": "1.0.8"
- },
- "bin": {
- "rollup": "dist/bin/rollup"
- },
"engines": {
- "node": ">=18.0.0",
- "npm": ">=8.0.0"
- },
- "optionalDependencies": {
- "@rollup/rollup-android-arm-eabi": "4.57.1",
- "@rollup/rollup-android-arm64": "4.57.1",
- "@rollup/rollup-darwin-arm64": "4.57.1",
- "@rollup/rollup-darwin-x64": "4.57.1",
- "@rollup/rollup-freebsd-arm64": "4.57.1",
- "@rollup/rollup-freebsd-x64": "4.57.1",
- "@rollup/rollup-linux-arm-gnueabihf": "4.57.1",
- "@rollup/rollup-linux-arm-musleabihf": "4.57.1",
- "@rollup/rollup-linux-arm64-gnu": "4.57.1",
- "@rollup/rollup-linux-arm64-musl": "4.57.1",
- "@rollup/rollup-linux-loong64-gnu": "4.57.1",
- "@rollup/rollup-linux-loong64-musl": "4.57.1",
- "@rollup/rollup-linux-ppc64-gnu": "4.57.1",
- "@rollup/rollup-linux-ppc64-musl": "4.57.1",
- "@rollup/rollup-linux-riscv64-gnu": "4.57.1",
- "@rollup/rollup-linux-riscv64-musl": "4.57.1",
- "@rollup/rollup-linux-s390x-gnu": "4.57.1",
- "@rollup/rollup-linux-x64-gnu": "4.57.1",
- "@rollup/rollup-linux-x64-musl": "4.57.1",
- "@rollup/rollup-openbsd-x64": "4.57.1",
- "@rollup/rollup-openharmony-arm64": "4.57.1",
- "@rollup/rollup-win32-arm64-msvc": "4.57.1",
- "@rollup/rollup-win32-ia32-msvc": "4.57.1",
- "@rollup/rollup-win32-x64-gnu": "4.57.1",
- "@rollup/rollup-win32-x64-msvc": "4.57.1",
- "fsevents": "~2.3.2"
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/punycode": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
+ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
}
},
"node_modules/semver": {
@@ -1496,6 +2595,29 @@
"node": ">=10"
}
},
+ "node_modules/shebang-command": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+ "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "shebang-regex": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/shebang-regex": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
@@ -1520,89 +2642,1098 @@
"dev": true,
"license": "MIT"
},
- "node_modules/std-env": {
- "version": "3.10.0",
- "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
- "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
+ "node_modules/std-env": {
+ "version": "3.10.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
+ "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/tinybench": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+ "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinyexec": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",
+ "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.15",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
+ "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tinyrainbow": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz",
+ "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/ts-api-utils": {
+ "version": "2.4.0",
+ "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz",
+ "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.12"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4"
+ }
+ },
+ "node_modules/type-check": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
+ "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "prelude-ls": "^1.2.1"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/typescript-eslint": {
+ "version": "8.56.0",
+ "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.56.0.tgz",
+ "integrity": "sha512-c7toRLrotJ9oixgdW7liukZpsnq5CZ7PuKztubGYlNppuTqhIoWfhgHo/7EU0v06gS2l/x0i2NEFK1qMIf0rIg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/eslint-plugin": "8.56.0",
+ "@typescript-eslint/parser": "8.56.0",
+ "@typescript-eslint/typescript-estree": "8.56.0",
+ "@typescript-eslint/utils": "8.56.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "7.18.2",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
+ "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/uri-js": {
+ "version": "4.4.1",
+ "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
+ "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "punycode": "^2.1.0"
+ }
+ },
+ "node_modules/vite": {
+ "version": "7.3.1",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
+ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.27.0",
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3",
+ "postcss": "^8.5.6",
+ "rollup": "^4.43.0",
+ "tinyglobby": "^0.2.15"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "lightningcss": "^1.21.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vite/node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/vite/node_modules/rollup": {
+ "version": "4.58.0",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.58.0.tgz",
+ "integrity": "sha512-wbT0mBmWbIvvq8NeEYWWvevvxnOyhKChir47S66WCxw1SXqhw7ssIYejnQEVt7XYQpsj2y8F9PM+Cr3SNEa0gw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.8"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.58.0",
+ "@rollup/rollup-android-arm64": "4.58.0",
+ "@rollup/rollup-darwin-arm64": "4.58.0",
+ "@rollup/rollup-darwin-x64": "4.58.0",
+ "@rollup/rollup-freebsd-arm64": "4.58.0",
+ "@rollup/rollup-freebsd-x64": "4.58.0",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.58.0",
+ "@rollup/rollup-linux-arm-musleabihf": "4.58.0",
+ "@rollup/rollup-linux-arm64-gnu": "4.58.0",
+ "@rollup/rollup-linux-arm64-musl": "4.58.0",
+ "@rollup/rollup-linux-loong64-gnu": "4.58.0",
+ "@rollup/rollup-linux-loong64-musl": "4.58.0",
+ "@rollup/rollup-linux-ppc64-gnu": "4.58.0",
+ "@rollup/rollup-linux-ppc64-musl": "4.58.0",
+ "@rollup/rollup-linux-riscv64-gnu": "4.58.0",
+ "@rollup/rollup-linux-riscv64-musl": "4.58.0",
+ "@rollup/rollup-linux-s390x-gnu": "4.58.0",
+ "@rollup/rollup-linux-x64-gnu": "4.58.0",
+ "@rollup/rollup-linux-x64-musl": "4.58.0",
+ "@rollup/rollup-openbsd-x64": "4.58.0",
+ "@rollup/rollup-openharmony-arm64": "4.58.0",
+ "@rollup/rollup-win32-arm64-msvc": "4.58.0",
+ "@rollup/rollup-win32-ia32-msvc": "4.58.0",
+ "@rollup/rollup-win32-x64-gnu": "4.58.0",
+ "@rollup/rollup-win32-x64-msvc": "4.58.0",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/vitest": {
+ "version": "4.0.18",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz",
+ "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/expect": "4.0.18",
+ "@vitest/mocker": "4.0.18",
+ "@vitest/pretty-format": "4.0.18",
+ "@vitest/runner": "4.0.18",
+ "@vitest/snapshot": "4.0.18",
+ "@vitest/spy": "4.0.18",
+ "@vitest/utils": "4.0.18",
+ "es-module-lexer": "^1.7.0",
+ "expect-type": "^1.2.2",
+ "magic-string": "^0.30.21",
+ "obug": "^2.1.1",
+ "pathe": "^2.0.3",
+ "picomatch": "^4.0.3",
+ "std-env": "^3.10.0",
+ "tinybench": "^2.9.0",
+ "tinyexec": "^1.0.2",
+ "tinyglobby": "^0.2.15",
+ "tinyrainbow": "^3.0.3",
+ "vite": "^6.0.0 || ^7.0.0",
+ "why-is-node-running": "^2.3.0"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^20.0.0 || ^22.0.0 || >=24.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@opentelemetry/api": "^1.9.0",
+ "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
+ "@vitest/browser-playwright": "4.0.18",
+ "@vitest/browser-preview": "4.0.18",
+ "@vitest/browser-webdriverio": "4.0.18",
+ "@vitest/ui": "4.0.18",
+ "happy-dom": "*",
+ "jsdom": "*"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser-playwright": {
+ "optional": true
+ },
+ "@vitest/browser-preview": {
+ "optional": true
+ },
+ "@vitest/browser-webdriverio": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vitest-chrome": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/vitest-chrome/-/vitest-chrome-0.1.0.tgz",
+ "integrity": "sha512-Bs1uywAolbc46h2tcaETQyFMTnOHjSA85iH10Zoe8ZMEl/71nuSdxs3z+KMxVZtN88TEgWicnl5vmbDO1OhoEw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/chrome": "^0.0.114"
+ }
+ },
+ "node_modules/whatwg-mimetype": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz",
+ "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/which": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "node-which": "bin/node-which"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/why-is-node-running": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "siginfo": "^2.0.0",
+ "stackback": "0.0.2"
+ },
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/word-wrap": {
+ "version": "1.2.5",
+ "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
+ "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/ws": {
+ "version": "8.19.0",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
+ "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/yocto-queue": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+ "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "packages/cli": {
+ "name": "playwright-repl",
+ "version": "0.5.0",
+ "license": "MIT",
+ "dependencies": {
+ "@playwright-repl/core": "file:../core",
+ "playwright": ">=1.59.0-alpha-2026-02-01"
+ },
+ "bin": {
+ "playwright-repl": "dist/playwright-repl.js"
+ },
+ "devDependencies": {
+ "vitest": "^4.0.18"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "packages/core": {
+ "name": "@playwright-repl/core",
+ "version": "0.5.0",
+ "dependencies": {
+ "minimist": "^1.2.8"
+ },
+ "devDependencies": {
+ "vitest": "^4.0.18"
+ },
+ "peerDependencies": {
+ "playwright": ">=1.59.0-alpha-2026-02-01"
+ }
+ },
+ "packages/crx": {
+ "name": "@playwright-repl/crx",
+ "version": "0.1.0",
+ "extraneous": true,
+ "dependencies": {
+ "playwright-crx": "^0.15.0"
+ },
+ "devDependencies": {
+ "@types/chrome": "^0.0.281",
+ "rollup-plugin-sourcemaps": "^0.6.3",
+ "typescript": "^5.7.3",
+ "vite": "^6.1.0"
+ }
+ },
+ "packages/extension": {
+ "name": "@playwright-repl/extension",
+ "version": "1.1.0",
+ "devDependencies": {
+ "@eslint/js": "^10.0.1",
+ "@playwright/test": ">=1.59.0-alpha-2026-02-01",
+ "@types/chrome": "^0.0.304",
+ "@vitest/coverage-v8": "^4.0.18",
+ "eslint": "^10.0.1",
+ "happy-dom": "^20.6.1",
+ "typescript": "^5.7.3",
+ "typescript-eslint": "^8.56.0",
+ "vite": "^6.1.0",
+ "vitest": "^4.0.18",
+ "vitest-chrome": "^0.1.0"
+ }
+ },
+ "packages/extension/node_modules/@esbuild/aix-ppc64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
+ "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "packages/extension/node_modules/@esbuild/android-arm": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
+ "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "packages/extension/node_modules/@esbuild/android-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
+ "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "packages/extension/node_modules/@esbuild/android-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
+ "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "packages/extension/node_modules/@esbuild/darwin-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
+ "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "packages/extension/node_modules/@esbuild/darwin-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
+ "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "packages/extension/node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "packages/extension/node_modules/@esbuild/freebsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
+ "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "packages/extension/node_modules/@esbuild/linux-arm": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
+ "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "packages/extension/node_modules/@esbuild/linux-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
+ "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "packages/extension/node_modules/@esbuild/linux-ia32": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
+ "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "packages/extension/node_modules/@esbuild/linux-loong64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
+ "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "packages/extension/node_modules/@esbuild/linux-mips64el": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
+ "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "packages/extension/node_modules/@esbuild/linux-ppc64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
+ "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "packages/extension/node_modules/@esbuild/linux-riscv64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
+ "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "packages/extension/node_modules/@esbuild/linux-s390x": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
+ "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "packages/extension/node_modules/@esbuild/linux-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
+ "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "packages/extension/node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "packages/extension/node_modules/@esbuild/netbsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "packages/extension/node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "packages/extension/node_modules/@esbuild/openbsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "packages/extension/node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
+ "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "packages/extension/node_modules/@esbuild/sunos-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
+ "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
+ "cpu": [
+ "x64"
+ ],
"dev": true,
- "license": "MIT"
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
},
- "node_modules/supports-color": {
- "version": "7.2.0",
- "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
- "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "packages/extension/node_modules/@esbuild/win32-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
+ "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
+ "cpu": [
+ "arm64"
+ ],
"dev": true,
"license": "MIT",
- "dependencies": {
- "has-flag": "^4.0.0"
- },
+ "optional": true,
+ "os": [
+ "win32"
+ ],
"engines": {
- "node": ">=8"
+ "node": ">=18"
}
},
- "node_modules/tinybench": {
- "version": "2.9.0",
- "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
- "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+ "packages/extension/node_modules/@esbuild/win32-ia32": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
+ "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
+ "cpu": [
+ "ia32"
+ ],
"dev": true,
- "license": "MIT"
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
},
- "node_modules/tinyexec": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",
- "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==",
+ "packages/extension/node_modules/@esbuild/win32-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
+ "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
+ "cpu": [
+ "x64"
+ ],
"dev": true,
"license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
"engines": {
"node": ">=18"
}
},
- "node_modules/tinyglobby": {
- "version": "0.2.15",
- "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
- "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
+ "packages/extension/node_modules/@types/chrome": {
+ "version": "0.0.304",
+ "resolved": "https://registry.npmjs.org/@types/chrome/-/chrome-0.0.304.tgz",
+ "integrity": "sha512-ms9CLILU+FEMK7gcmgz/Mtn2E81YQWiMIzCFF8ktp98EVNIIfoqaDTD4+ailOCq1sGjbnEmfJxQ1FAsQtk5M3A==",
"dev": true,
"license": "MIT",
"dependencies": {
- "fdir": "^6.5.0",
- "picomatch": "^4.0.3"
+ "@types/filesystem": "*",
+ "@types/har-format": "*"
+ }
+ },
+ "packages/extension/node_modules/esbuild": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
+ "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
},
"engines": {
- "node": ">=12.0.0"
+ "node": ">=18"
},
- "funding": {
- "url": "https://github.com/sponsors/SuperchupuDev"
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.25.12",
+ "@esbuild/android-arm": "0.25.12",
+ "@esbuild/android-arm64": "0.25.12",
+ "@esbuild/android-x64": "0.25.12",
+ "@esbuild/darwin-arm64": "0.25.12",
+ "@esbuild/darwin-x64": "0.25.12",
+ "@esbuild/freebsd-arm64": "0.25.12",
+ "@esbuild/freebsd-x64": "0.25.12",
+ "@esbuild/linux-arm": "0.25.12",
+ "@esbuild/linux-arm64": "0.25.12",
+ "@esbuild/linux-ia32": "0.25.12",
+ "@esbuild/linux-loong64": "0.25.12",
+ "@esbuild/linux-mips64el": "0.25.12",
+ "@esbuild/linux-ppc64": "0.25.12",
+ "@esbuild/linux-riscv64": "0.25.12",
+ "@esbuild/linux-s390x": "0.25.12",
+ "@esbuild/linux-x64": "0.25.12",
+ "@esbuild/netbsd-arm64": "0.25.12",
+ "@esbuild/netbsd-x64": "0.25.12",
+ "@esbuild/openbsd-arm64": "0.25.12",
+ "@esbuild/openbsd-x64": "0.25.12",
+ "@esbuild/openharmony-arm64": "0.25.12",
+ "@esbuild/sunos-x64": "0.25.12",
+ "@esbuild/win32-arm64": "0.25.12",
+ "@esbuild/win32-ia32": "0.25.12",
+ "@esbuild/win32-x64": "0.25.12"
+ }
+ },
+ "packages/extension/node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
- "node_modules/tinyrainbow": {
- "version": "3.0.3",
- "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz",
- "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==",
+ "packages/extension/node_modules/rollup": {
+ "version": "4.58.0",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.58.0.tgz",
+ "integrity": "sha512-wbT0mBmWbIvvq8NeEYWWvevvxnOyhKChir47S66WCxw1SXqhw7ssIYejnQEVt7XYQpsj2y8F9PM+Cr3SNEa0gw==",
"dev": true,
"license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.8"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
"engines": {
- "node": ">=14.0.0"
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.58.0",
+ "@rollup/rollup-android-arm64": "4.58.0",
+ "@rollup/rollup-darwin-arm64": "4.58.0",
+ "@rollup/rollup-darwin-x64": "4.58.0",
+ "@rollup/rollup-freebsd-arm64": "4.58.0",
+ "@rollup/rollup-freebsd-x64": "4.58.0",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.58.0",
+ "@rollup/rollup-linux-arm-musleabihf": "4.58.0",
+ "@rollup/rollup-linux-arm64-gnu": "4.58.0",
+ "@rollup/rollup-linux-arm64-musl": "4.58.0",
+ "@rollup/rollup-linux-loong64-gnu": "4.58.0",
+ "@rollup/rollup-linux-loong64-musl": "4.58.0",
+ "@rollup/rollup-linux-ppc64-gnu": "4.58.0",
+ "@rollup/rollup-linux-ppc64-musl": "4.58.0",
+ "@rollup/rollup-linux-riscv64-gnu": "4.58.0",
+ "@rollup/rollup-linux-riscv64-musl": "4.58.0",
+ "@rollup/rollup-linux-s390x-gnu": "4.58.0",
+ "@rollup/rollup-linux-x64-gnu": "4.58.0",
+ "@rollup/rollup-linux-x64-musl": "4.58.0",
+ "@rollup/rollup-openbsd-x64": "4.58.0",
+ "@rollup/rollup-openharmony-arm64": "4.58.0",
+ "@rollup/rollup-win32-arm64-msvc": "4.58.0",
+ "@rollup/rollup-win32-ia32-msvc": "4.58.0",
+ "@rollup/rollup-win32-x64-gnu": "4.58.0",
+ "@rollup/rollup-win32-x64-msvc": "4.58.0",
+ "fsevents": "~2.3.2"
}
},
- "node_modules/vite": {
- "version": "7.3.1",
- "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
- "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
+ "packages/extension/node_modules/vite": {
+ "version": "6.4.1",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
+ "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"dev": true,
"license": "MIT",
"dependencies": {
- "esbuild": "^0.27.0",
- "fdir": "^6.5.0",
- "picomatch": "^4.0.3",
- "postcss": "^8.5.6",
- "rollup": "^4.43.0",
- "tinyglobby": "^0.2.15"
+ "esbuild": "^0.25.0",
+ "fdir": "^6.4.4",
+ "picomatch": "^4.0.2",
+ "postcss": "^8.5.3",
+ "rollup": "^4.34.9",
+ "tinyglobby": "^0.2.13"
},
"bin": {
"vite": "bin/vite.js"
},
"engines": {
- "node": "^20.19.0 || >=22.12.0"
+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
},
"funding": {
"url": "https://github.com/vitejs/vite?sponsor=1"
@@ -1611,14 +3742,14 @@
"fsevents": "~2.3.3"
},
"peerDependencies": {
- "@types/node": "^20.19.0 || >=22.12.0",
+ "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
"jiti": ">=1.21.0",
- "less": "^4.0.0",
+ "less": "*",
"lightningcss": "^1.21.0",
- "sass": "^1.70.0",
- "sass-embedded": "^1.70.0",
- "stylus": ">=0.54.8",
- "sugarss": "^5.0.0",
+ "sass": "*",
+ "sass-embedded": "*",
+ "stylus": "*",
+ "sugarss": "*",
"terser": "^5.16.0",
"tsx": "^4.8.1",
"yaml": "^2.4.2"
@@ -1659,147 +3790,19 @@
}
}
},
- "node_modules/vite/node_modules/fsevents": {
- "version": "2.3.3",
- "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
- "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
- "dev": true,
- "hasInstallScript": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
- }
- },
- "node_modules/vitest": {
- "version": "4.0.18",
- "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz",
- "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@vitest/expect": "4.0.18",
- "@vitest/mocker": "4.0.18",
- "@vitest/pretty-format": "4.0.18",
- "@vitest/runner": "4.0.18",
- "@vitest/snapshot": "4.0.18",
- "@vitest/spy": "4.0.18",
- "@vitest/utils": "4.0.18",
- "es-module-lexer": "^1.7.0",
- "expect-type": "^1.2.2",
- "magic-string": "^0.30.21",
- "obug": "^2.1.1",
- "pathe": "^2.0.3",
- "picomatch": "^4.0.3",
- "std-env": "^3.10.0",
- "tinybench": "^2.9.0",
- "tinyexec": "^1.0.2",
- "tinyglobby": "^0.2.15",
- "tinyrainbow": "^3.0.3",
- "vite": "^6.0.0 || ^7.0.0",
- "why-is-node-running": "^2.3.0"
- },
- "bin": {
- "vitest": "vitest.mjs"
- },
- "engines": {
- "node": "^20.0.0 || ^22.0.0 || >=24.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/vitest"
- },
- "peerDependencies": {
- "@edge-runtime/vm": "*",
- "@opentelemetry/api": "^1.9.0",
- "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
- "@vitest/browser-playwright": "4.0.18",
- "@vitest/browser-preview": "4.0.18",
- "@vitest/browser-webdriverio": "4.0.18",
- "@vitest/ui": "4.0.18",
- "happy-dom": "*",
- "jsdom": "*"
- },
- "peerDependenciesMeta": {
- "@edge-runtime/vm": {
- "optional": true
- },
- "@opentelemetry/api": {
- "optional": true
- },
- "@types/node": {
- "optional": true
- },
- "@vitest/browser-playwright": {
- "optional": true
- },
- "@vitest/browser-preview": {
- "optional": true
- },
- "@vitest/browser-webdriverio": {
- "optional": true
- },
- "@vitest/ui": {
- "optional": true
- },
- "happy-dom": {
- "optional": true
- },
- "jsdom": {
- "optional": true
- }
- }
- },
- "node_modules/why-is-node-running": {
- "version": "2.3.0",
- "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
- "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "siginfo": "^2.0.0",
- "stackback": "0.0.2"
- },
- "bin": {
- "why-is-node-running": "cli.js"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "packages/cli": {
- "name": "playwright-repl",
- "version": "0.4.0",
- "license": "MIT",
- "dependencies": {
- "@playwright-repl/core": "file:../core",
- "playwright": ">=1.59.0-alpha-2026-02-01"
- },
- "bin": {
- "playwright-repl": "bin/playwright-repl.mjs"
- },
- "devDependencies": {
- "vitest": "^4.0.18"
- },
- "engines": {
- "node": ">=18.0.0"
- }
- },
- "packages/core": {
- "name": "@playwright-repl/core",
- "version": "0.3.0",
+ "packages/repl-ext": {
+ "name": "@playwright-repl/repl-ext",
+ "version": "0.1.0",
+ "extraneous": true,
"dependencies": {
- "minimist": "^1.2.8"
+ "playwright-crx": "^0.15.0"
},
"devDependencies": {
- "vitest": "^4.0.18"
+ "@types/chrome": "^0.0.281",
+ "rollup-plugin-sourcemaps": "^0.6.3",
+ "typescript": "^5.7.3",
+ "vite": "^6.1.0"
}
- },
- "packages/extension": {
- "name": "@playwright-repl/extension",
- "version": "0.9.3"
}
}
}
diff --git a/package.json b/package.json
index 5927175..4ddb83d 100644
--- a/package.json
+++ b/package.json
@@ -1,14 +1,20 @@
{
"name": "playwright-repl",
"private": true,
- "workspaces": ["packages/*"],
+ "workspaces": [
+ "packages/*"
+ ],
"scripts": {
+ "build": "tsc --build packages/core/tsconfig.build.json packages/cli/tsconfig.build.json && npm run build -w packages/extension",
"test": "npm test --workspaces --if-present",
"test:core": "npm test -w packages/core",
"test:cli": "npm test -w packages/cli"
},
"devDependencies": {
+ "@types/minimist": "^1.2.5",
+ "@types/node": "^25.3.0",
"@vitest/coverage-v8": "^4.0.18",
+ "typescript": "^5.9.3",
"vitest": "^4.0.18"
}
}
diff --git a/packages/cli/examples/07-test-click-nth.pw b/packages/cli/examples/07-test-click-nth.pw
new file mode 100644
index 0000000..0d9ba67
--- /dev/null
+++ b/packages/cli/examples/07-test-click-nth.pw
@@ -0,0 +1,17 @@
+goto https://playwright.dev/
+click "Get started"
+click "npm" --nth 0
+click "yarn" --nth 0
+click "pnpm" --nth 0
+click "npm" --nth 1
+click "yarn" --nth 1
+click "pnpm" --nth 1
+click "npm" --nth 2
+click "yarn" --nth 2
+click "pnpm" --nth 2
+click "npm" --nth 3
+click "yarn" --nth 3
+click "pnpm" --nth 3
+click "npm" --nth 4
+click "yarn" --nth 4
+click "pnpm" --nth 4
\ No newline at end of file
diff --git a/packages/cli/package.json b/packages/cli/package.json
index eda8575..c223984 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -1,16 +1,18 @@
{
"name": "playwright-repl",
- "version": "0.4.0",
+ "version": "0.5.0",
"description": "Interactive REPL for Playwright browser automation — keyword-driven testing from your terminal",
"type": "module",
"bin": {
- "playwright-repl": "./bin/playwright-repl.mjs"
+ "playwright-repl": "./dist/playwright-repl.js"
},
- "main": "./src/index.mjs",
+ "main": "./dist/index.js",
"exports": {
- ".": "./src/index.mjs"
+ ".": "./dist/index.js"
},
+ "types": "./dist/index.d.ts",
"scripts": {
+ "build": "tsc",
"test": "vitest run"
},
"keywords": [
@@ -29,11 +31,10 @@
"url": "git+https://github.com/stevez/playwright-repl.git"
},
"files": [
- "bin/",
- "src/"
+ "dist/"
],
"engines": {
- "node": ">=18.0.0"
+ "node": ">=20.0.0"
},
"dependencies": {
"@playwright-repl/core": "file:../core",
diff --git a/packages/cli/src/index.mjs b/packages/cli/src/index.ts
similarity index 76%
rename from packages/cli/src/index.mjs
rename to packages/cli/src/index.ts
index a4cb49a..0c48228 100644
--- a/packages/cli/src/index.mjs
+++ b/packages/cli/src/index.ts
@@ -12,5 +12,5 @@
export { parseInput, ALIASES, ALL_COMMANDS, buildCompletionItems, Engine } from '@playwright-repl/core';
// CLI-specific
-export { SessionRecorder, SessionPlayer } from './recorder.mjs';
-export { startRepl } from './repl.mjs';
+export { SessionRecorder, SessionPlayer } from './recorder.js';
+export { startRepl } from './repl.js';
diff --git a/packages/cli/bin/playwright-repl.mjs b/packages/cli/src/playwright-repl.ts
similarity index 65%
rename from packages/cli/bin/playwright-repl.mjs
rename to packages/cli/src/playwright-repl.ts
index 4ebf817..81c1bdb 100644
--- a/packages/cli/bin/playwright-repl.mjs
+++ b/packages/cli/src/playwright-repl.ts
@@ -11,18 +11,18 @@
*/
import { minimist } from '@playwright-repl/core';
-import { startRepl } from '../src/repl.mjs';
+import { startRepl } from './repl.js';
const args = minimist(process.argv.slice(2), {
- boolean: ['headed', 'persistent', 'extension', 'help', 'step', 'silent'],
- string: ['session', 'browser', 'profile', 'config', 'replay', 'record', 'connect'],
+ boolean: ['headed', 'persistent', 'extension', 'help', 'step', 'silent', 'spawn'],
+ string: ['session', 'browser', 'profile', 'config', 'replay', 'record', 'connect', 'port', 'cdp-port'],
alias: { s: 'session', h: 'help', b: 'browser', q: 'silent' },
default: { session: 'default' },
});
// --connect without a value → default port 9222
if (args.connect === '') args.connect = 9222;
-else if (args.connect) args.connect = parseInt(args.connect, 10) || 9222;
+else if (args.connect) args.connect = parseInt(args.connect as string, 10) || 9222;
if (args.help) {
console.log(`
@@ -38,6 +38,10 @@ Options:
--persistent Use persistent browser profile
--profile Persistent profile directory
--connect [port] Connect to existing Chrome via CDP (default: 9222)
+ --extension Connect to Chrome with side panel extension
+ --spawn Spawn Chrome automatically (default: connect to existing)
+ --port Extension server port (default: 6781)
+ --cdp-port Chrome CDP port (default: 9222)
--config Path to config file
--replay Replay a .pw session file
--record Start REPL with recording to file
@@ -62,6 +66,10 @@ Examples:
playwright-repl --headed # start with visible browser
playwright-repl --connect # connect to Chrome on port 9222
playwright-repl --connect 9333 # connect to Chrome on custom port
+ playwright-repl --extension # connect to existing Chrome + side panel
+ playwright-repl --extension --spawn # spawn Chrome automatically
+ playwright-repl --extension --port 7000 # custom server port
+ playwright-repl --extension --cdp-port 9333 # custom CDP port
playwright-repl --replay login.pw # replay a session
playwright-repl --replay login.pw --step # step through replay
echo "open https://example.com" | playwright-repl # pipe commands
@@ -70,18 +78,22 @@ Examples:
}
startRepl({
- session: args.session,
- headed: args.headed,
- browser: args.browser,
- persistent: args.persistent,
- profile: args.profile,
- connect: args.connect,
- config: args.config,
- replay: args.replay,
- record: args.record,
- step: args.step,
- silent: args.silent,
-}).catch((err) => {
+ session: args.session as string,
+ headed: args.headed as boolean,
+ browser: args.browser as string,
+ persistent: args.persistent as boolean,
+ profile: args.profile as string,
+ connect: args.connect as number | undefined,
+ extension: args.extension as boolean,
+ spawn: args.spawn === true,
+ port: args.port ? parseInt(args.port as string, 10) : undefined,
+ cdpPort: args['cdp-port'] ? parseInt(args['cdp-port'] as string, 10) : undefined,
+ config: args.config as string,
+ replay: args.replay as string,
+ record: args.record as string,
+ step: args.step as boolean,
+ silent: args.silent as boolean,
+}).catch((err: Error) => {
console.error(`Fatal: ${err.message}`);
process.exit(1);
});
diff --git a/packages/cli/src/recorder.mjs b/packages/cli/src/recorder.ts
similarity index 79%
rename from packages/cli/src/recorder.mjs
rename to packages/cli/src/recorder.ts
index de58ec0..9dad04e 100644
--- a/packages/cli/src/recorder.mjs
+++ b/packages/cli/src/recorder.ts
@@ -28,18 +28,15 @@ import path from 'node:path';
// ─── Session Recorder ────────────────────────────────────────────────────────
export class SessionRecorder {
- constructor() {
- this.commands = [];
- this.recording = false;
- this.filename = null;
- this.paused = false;
- }
+ commands: string[] = [];
+ recording = false;
+ filename: string | null = null;
+ paused = false;
/**
* Start recording commands.
- * @param {string} [filename] - Output file path. If not provided, uses a timestamp.
*/
- start(filename) {
+ start(filename?: string): string {
this.filename = filename || `session-${new Date().toISOString().replace(/[:.]/g, '-')}.pw`;
this.commands = [];
this.recording = true;
@@ -51,7 +48,7 @@ export class SessionRecorder {
* Record a command (called after each successful REPL command).
* Skips meta-commands (lines starting with .).
*/
- record(line) {
+ record(line: string): void {
if (!this.recording || this.paused) return;
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('.')) return;
@@ -61,16 +58,15 @@ export class SessionRecorder {
/**
* Pause recording (toggle).
*/
- pause() {
+ pause(): boolean {
this.paused = !this.paused;
return this.paused;
}
/**
* Stop recording and save to file.
- * @returns {{ filename: string, count: number }}
*/
- save() {
+ save(): { filename: string; count: number } {
if (!this.recording) throw new Error('Not recording');
const header = [
@@ -82,14 +78,14 @@ export class SessionRecorder {
const content = [...header, ...this.commands, ''].join('\n');
// Ensure directory exists
- const dir = path.dirname(this.filename);
+ const dir = path.dirname(this.filename!);
if (dir && dir !== '.') {
fs.mkdirSync(dir, { recursive: true });
}
- fs.writeFileSync(this.filename, content, 'utf-8');
+ fs.writeFileSync(this.filename!, content, 'utf-8');
- const result = { filename: this.filename, count: this.commands.length };
+ const result = { filename: this.filename!, count: this.commands.length };
this.recording = false;
this.commands = [];
@@ -102,20 +98,20 @@ export class SessionRecorder {
/**
* Discard recording without saving.
*/
- discard() {
+ discard(): void {
this.recording = false;
this.commands = [];
this.filename = null;
this.paused = false;
}
- get status() {
+ get status(): string {
if (!this.recording) return 'idle';
if (this.paused) return 'paused';
return 'recording';
}
- get commandCount() {
+ get commandCount(): number {
return this.commands.length;
}
}
@@ -123,12 +119,14 @@ export class SessionRecorder {
// ─── Session Player ──────────────────────────────────────────────────────────
export class SessionPlayer {
+ filename: string;
+ commands: string[];
+ index = 0;
+
/**
* Load commands from a .pw file.
- * @param {string} filename
- * @returns {string[]} Array of command lines
*/
- static load(filename) {
+ static load(filename: string): string[] {
if (!fs.existsSync(filename)) {
throw new Error(`File not found: ${filename}`);
}
@@ -144,30 +142,29 @@ export class SessionPlayer {
* Create a player that yields commands one at a time.
* Supports step-through mode where it pauses between commands.
*/
- constructor(filename) {
+ constructor(filename: string) {
this.filename = filename;
this.commands = SessionPlayer.load(filename);
- this.index = 0;
}
- get done() {
+ get done(): boolean {
return this.index >= this.commands.length;
}
- get current() {
+ get current(): string | null {
return this.commands[this.index] || null;
}
- get progress() {
+ get progress(): string {
return `[${this.index}/${this.commands.length}]`;
}
- next() {
+ next(): string | null {
if (this.done) return null;
return this.commands[this.index++];
}
- reset() {
+ reset(): void {
this.index = 0;
}
}
@@ -180,62 +177,62 @@ export class SessionPlayer {
export class SessionManager {
#recorder = new SessionRecorder();
- #player = null;
+ #player: SessionPlayer | null = null;
#step = false;
/** Current mode: 'idle' | 'recording' | 'paused' | 'replaying' */
- get mode() {
+ get mode(): string {
if (this.#player && !this.#player.done) return 'replaying';
return this.#recorder.status;
}
// ── Recording ──────────────────────────────────────────────────
- startRecording(filename) {
+ startRecording(filename?: string): string {
if (this.mode !== 'idle') throw new Error(`Cannot record while ${this.mode}`);
return this.#recorder.start(filename);
}
- save() {
+ save(): { filename: string; count: number } {
if (this.mode !== 'recording' && this.mode !== 'paused')
throw new Error('Not recording');
return this.#recorder.save();
}
- togglePause() {
+ togglePause(): boolean {
if (this.mode !== 'recording' && this.mode !== 'paused')
throw new Error('Not recording');
return this.#recorder.pause();
}
- discard() {
+ discard(): void {
if (this.mode !== 'recording' && this.mode !== 'paused')
throw new Error('Not recording');
this.#recorder.discard();
}
/** Called after each successful command — records if active. */
- record(line) {
+ record(line: string): void {
this.#recorder.record(line);
}
- get recordingFilename() { return this.#recorder.filename; }
- get recordedCount() { return this.#recorder.commandCount; }
+ get recordingFilename(): string | null { return this.#recorder.filename; }
+ get recordedCount(): number { return this.#recorder.commandCount; }
// ── Playback ───────────────────────────────────────────────────
- startReplay(filename, step = false) {
+ startReplay(filename: string, step = false): SessionPlayer {
if (this.mode !== 'idle') throw new Error(`Cannot replay while ${this.mode}`);
this.#player = new SessionPlayer(filename);
this.#step = step;
return this.#player;
}
- endReplay() {
+ endReplay(): void {
this.#player = null;
this.#step = false;
}
- get player() { return this.#player; }
- get step() { return this.#step; }
+ get player(): SessionPlayer | null { return this.#player; }
+ get step(): boolean { return this.#step; }
}
diff --git a/packages/cli/src/repl.mjs b/packages/cli/src/repl.ts
similarity index 73%
rename from packages/cli/src/repl.mjs
rename to packages/cli/src/repl.ts
index e3b7fab..0ecfea6 100644
--- a/packages/cli/src/repl.mjs
+++ b/packages/cli/src/repl.ts
@@ -14,13 +14,35 @@ import {
actionByText, fillByText, selectByText, checkByText, uncheckByText,
Engine,
} from '@playwright-repl/core';
-import { SessionManager } from './recorder.mjs';
+import type { EngineOpts, ParsedArgs } from '@playwright-repl/core';
+import { SessionManager } from './recorder.js';
+import type { CompletionItem } from '@playwright-repl/core';
+
+// ─── Types ──────────────────────────────────────────────────────────────────
+
+export interface ReplOpts extends EngineOpts {
+ session?: string;
+ replay?: string;
+ record?: string;
+ step?: boolean;
+ silent?: boolean;
+}
+
+export interface ReplContext {
+ conn: Engine;
+ session: SessionManager;
+ rl: readline.Interface | null;
+ opts: ReplOpts;
+ log: (...args: unknown[]) => void;
+ historyFile: string;
+ commandCount: number;
+}
// ─── Response filtering ─────────────────────────────────────────────────────
-export function filterResponse(text) {
+export function filterResponse(text: string, cmdName?: string): string | null {
const sections = text.split(/^### /m).slice(1);
- const kept = [];
+ const kept: string[] = [];
for (const section of sections) {
const newline = section.indexOf('\n');
if (newline === -1) continue;
@@ -28,7 +50,9 @@ export function filterResponse(text) {
const content = section.substring(newline + 1).trim();
if (title === 'Error')
kept.push(`${c.red}${content}${c.reset}`);
- else if (title === 'Result' || title === 'Modal state')
+ else if (title === 'Snapshot' && cmdName !== 'snapshot')
+ continue;
+ else if (title === 'Result' || title === 'Modal state' || title === 'Page' || title === 'Snapshot')
kept.push(content);
}
return kept.length > 0 ? kept.join('\n') : null;
@@ -36,9 +60,9 @@ export function filterResponse(text) {
// ─── Meta-command handlers ──────────────────────────────────────────────────
-export function showHelp() {
+export function showHelp(): void {
console.log(`\n${c.bold}Available commands:${c.reset}`);
- const categories = {
+ const categories: Record = {
'Navigation': ['open', 'goto', 'go-back', 'go-forward', 'reload'],
'Interaction': ['click', 'dblclick', 'fill', 'type', 'press', 'hover', 'select', 'check', 'uncheck', 'drag'],
'Inspection': ['snapshot', 'screenshot', 'eval', 'console', 'network', 'run-code'],
@@ -61,9 +85,9 @@ export function showHelp() {
console.log(` .exit Exit REPL\n`);
}
-export function showAliases() {
+export function showAliases(): void {
console.log(`\n${c.bold}Command aliases:${c.reset}`);
- const groups = {};
+ const groups: Record = {};
for (const [alias, cmd] of Object.entries(ALIASES)) {
if (!groups[cmd]) groups[cmd] = [];
groups[cmd].push(alias);
@@ -74,7 +98,7 @@ export function showAliases() {
console.log();
}
-export function showStatus(ctx) {
+export function showStatus(ctx: ReplContext): void {
const { conn, session } = ctx;
console.log(`Connected: ${conn.connected ? `${c.green}yes${c.reset}` : `${c.red}no${c.reset}`}`);
console.log(`Commands sent: ${ctx.commandCount}`);
@@ -86,41 +110,41 @@ export function showStatus(ctx) {
// ─── Session-level commands ─────────────────────────────────────────────────
-export async function handleKillAll(ctx) {
+export async function handleKillAll(ctx: ReplContext): Promise {
try {
await ctx.conn.close();
console.log(`${c.green}✓${c.reset} Browser closed`);
- } catch (err) {
- console.error(`${c.red}Error:${c.reset} ${err.message}`);
+ } catch (err: unknown) {
+ console.error(`${c.red}Error:${c.reset} ${(err as Error).message}`);
}
}
-export async function handleClose(ctx) {
+export async function handleClose(ctx: ReplContext): Promise {
try {
await ctx.conn.close();
console.log(`${c.green}✓${c.reset} Browser closed`);
- } catch (err) {
- console.error(`${c.red}Error:${c.reset} ${err.message}`);
+ } catch (err: unknown) {
+ console.error(`${c.red}Error:${c.reset} ${(err as Error).message}`);
}
}
// ─── Session meta-commands (.record, .save, .pause, .discard, .replay) ──────
-export function handleSessionCommand(ctx, line) {
+export function handleSessionCommand(ctx: ReplContext, line: string): boolean {
const { session } = ctx;
if (line.startsWith('.record')) {
const filename = line.split(/\s+/)[1] || undefined;
const file = session.startRecording(filename);
console.log(`${c.red}⏺${c.reset} Recording to ${c.bold}${file}${c.reset}`);
- ctx.rl.setPrompt(promptStr(ctx));
+ ctx.rl!.setPrompt(promptStr(ctx));
return true;
}
if (line === '.save') {
const { filename, count } = session.save();
console.log(`${c.green}✓${c.reset} Saved ${count} commands to ${c.bold}${filename}${c.reset}`);
- ctx.rl.setPrompt(promptStr(ctx));
+ ctx.rl!.setPrompt(promptStr(ctx));
return true;
}
@@ -133,7 +157,7 @@ export function handleSessionCommand(ctx, line) {
if (line === '.discard') {
session.discard();
console.log(`${c.yellow}Recording discarded${c.reset}`);
- ctx.rl.setPrompt(promptStr(ctx));
+ ctx.rl!.setPrompt(promptStr(ctx));
return true;
}
@@ -142,7 +166,7 @@ export function handleSessionCommand(ctx, line) {
// ─── Process a single line ──────────────────────────────────────────────────
-export async function processLine(ctx, line) {
+export async function processLine(ctx: ReplContext, line: string): Promise {
line = line.trim();
if (!line) return;
@@ -162,8 +186,8 @@ export async function processLine(ctx, line) {
try {
await ctx.conn.start(ctx.opts);
console.log(`${c.green}✓${c.reset} Reconnected`);
- } catch (err) {
- console.error(`${c.red}✗${c.reset} ${err.message}`);
+ } catch (err: unknown) {
+ console.error(`${c.red}✗${c.reset} ${(err as Error).message}`);
}
return;
}
@@ -173,8 +197,8 @@ export async function processLine(ctx, line) {
if (line.startsWith('.')) {
try {
if (handleSessionCommand(ctx, line)) return;
- } catch (err) {
- console.log(`${c.yellow}${err.message}${c.reset}`);
+ } catch (err: unknown) {
+ console.log(`${c.yellow}${(err as Error).message}${c.reset}`);
return;
}
}
@@ -191,14 +215,14 @@ export async function processLine(ctx, line) {
const player = ctx.session.startReplay(filename);
console.log(`${c.blue}▶${c.reset} Replaying ${c.bold}${filename}${c.reset} (${player.commands.length} commands)\n`);
while (!player.done) {
- const cmd = player.next();
+ const cmd = player.next()!;
console.log(`${c.dim}${player.progress}${c.reset} ${cmd}`);
await processLine(ctx, cmd);
}
ctx.session.endReplay();
console.log(`\n${c.green}✓${c.reset} Replay complete`);
- } catch (err) {
- console.error(`${c.red}Error:${c.reset} ${err.message}`);
+ } catch (err: unknown) {
+ console.error(`${c.red}Error:${c.reset} ${(err as Error).message}`);
ctx.session.endReplay();
}
return;
@@ -206,7 +230,7 @@ export async function processLine(ctx, line) {
// ── Regular command — parse and send ─────────────────────────────
- let args = parseInput(line);
+ let args: ParsedArgs | null = parseInput(line);
if (!args) return;
const cmdName = args._[0];
@@ -222,20 +246,21 @@ export async function processLine(ctx, line) {
}
// ── Session-level commands (not forwarded to daemon) ──────────
- if (cmdName === 'kill-all') return handleKillAll(ctx);
- if (cmdName === 'close' || cmdName === 'close-all') return handleClose(ctx);
+ if (cmdName === 'kill-all') return handleKillAll(ctx) as unknown as void;
+ if (cmdName === 'close' || cmdName === 'close-all') return handleClose(ctx) as unknown as void;
// ── Verify commands → run-code translation ──────────────────
- const verifyFns = {
- 'verify-text': verifyText,
- 'verify-element': verifyElement,
- 'verify-value': verifyValue,
- 'verify-list': verifyList,
+ type PageScriptFn = (...fnArgs: unknown[]) => Promise;
+ const verifyFns: Record = {
+ 'verify-text': verifyText as PageScriptFn,
+ 'verify-element': verifyElement as PageScriptFn,
+ 'verify-value': verifyValue as PageScriptFn,
+ 'verify-list': verifyList as PageScriptFn,
};
if (verifyFns[cmdName]) {
const pos = args._.slice(1);
const fn = verifyFns[cmdName];
- let translated = null;
+ let translated: ParsedArgs | null = null;
if (cmdName === 'verify-text') {
const text = pos.join(' ');
if (text) translated = buildRunCode(fn, text);
@@ -252,20 +277,22 @@ export async function processLine(ctx, line) {
}
// ── Auto-resolve text to native Playwright locator ─────────
- const textFns = {
- click: actionByText, dblclick: actionByText, hover: actionByText,
- fill: fillByText, select: selectByText, check: checkByText, uncheck: uncheckByText,
+ const textFns: Record = {
+ click: actionByText as PageScriptFn, dblclick: actionByText as PageScriptFn, hover: actionByText as PageScriptFn,
+ fill: fillByText as PageScriptFn, select: selectByText as PageScriptFn, check: checkByText as PageScriptFn, uncheck: uncheckByText as PageScriptFn,
};
if (textFns[cmdName] && args._[1] && !/^e\d+$/.test(args._[1])) {
const textArg = args._[1];
const extraArgs = args._.slice(2);
const fn = textFns[cmdName];
- let runCodeArgs;
- if (fn === actionByText) runCodeArgs = buildRunCode(fn, textArg, cmdName);
- else if (cmdName === 'fill' || cmdName === 'select') runCodeArgs = buildRunCode(fn, textArg, extraArgs[0] || '');
- else runCodeArgs = buildRunCode(fn, textArg);
+ const nth = args.nth !== undefined ? parseInt(String(args.nth), 10) : undefined;
+ let runCodeArgs: ParsedArgs;
+ if (fn === actionByText) runCodeArgs = buildRunCode(fn, textArg, cmdName, nth);
+ else if (cmdName === 'fill' || cmdName === 'select') runCodeArgs = buildRunCode(fn, textArg, extraArgs[0] || '', nth);
+ else runCodeArgs = buildRunCode(fn, textArg, nth);
const argsHint = extraArgs.length > 0 ? ` ${extraArgs.join(' ')}` : '';
- ctx.log(`${c.dim}→ ${cmdName} "${textArg}"${argsHint} (via run-code)${c.reset}`);
+ const nthHint = nth !== undefined ? ` --nth ${nth}` : '';
+ ctx.log(`${c.dim}→ ${cmdName} "${textArg}"${argsHint}${nthHint} (via run-code)${c.reset}`);
args = runCodeArgs;
}
@@ -284,17 +311,17 @@ export async function processLine(ctx, line) {
const result = await ctx.conn.run(args);
const elapsed = (performance.now() - startTime).toFixed(0);
if (result?.text) {
- const output = filterResponse(result.text);
- if (output) console.log(output);
+ const filtered = filterResponse(result.text, cmdName);
+ if (filtered !== null) console.log(filtered);
}
ctx.commandCount++;
ctx.session.record(line);
- if (elapsed > 500) {
+ if (Number(elapsed) > 500) {
ctx.log(`${c.dim}(${elapsed}ms)${c.reset}`);
}
- } catch (err) {
- console.error(`${c.red}Error:${c.reset} ${err.message}`);
+ } catch (err: unknown) {
+ console.error(`${c.red}Error:${c.reset} ${(err as Error).message}`);
if (!ctx.conn.connected) {
console.log(`${c.yellow}Browser disconnected. Trying to restart...${c.reset}`);
try {
@@ -309,17 +336,17 @@ export async function processLine(ctx, line) {
// ─── Replay mode (non-interactive, --replay flag) ───────────────────────────
-export async function runReplayMode(ctx, replayFile, step) {
+export async function runReplayMode(ctx: ReplContext, replayFile: string, step: boolean): Promise {
try {
const player = ctx.session.startReplay(replayFile, step);
console.log(`${c.blue}▶${c.reset} Replaying ${c.bold}${replayFile}${c.reset} (${player.commands.length} commands)\n`);
while (!player.done) {
- const cmd = player.next();
+ const cmd = player.next()!;
console.log(`${c.dim}${player.progress}${c.reset} ${cmd}`);
await processLine(ctx, cmd);
if (ctx.session.step && !player.done) {
- await new Promise((resolve) => {
+ await new Promise((resolve) => {
process.stdout.write(`${c.dim} Press Enter to continue...${c.reset}`);
process.stdin.once('data', () => {
process.stdout.write('\r\x1b[K');
@@ -332,8 +359,8 @@ export async function runReplayMode(ctx, replayFile, step) {
console.log(`\n${c.green}✓${c.reset} Replay complete`);
ctx.conn.close();
process.exit(0);
- } catch (err) {
- console.error(`${c.red}Error:${c.reset} ${err.message}`);
+ } catch (err: unknown) {
+ console.error(`${c.red}Error:${c.reset} ${(err as Error).message}`);
ctx.conn.close();
process.exit(1);
}
@@ -341,35 +368,35 @@ export async function runReplayMode(ctx, replayFile, step) {
// ─── Command loop (interactive) ─────────────────────────────────────────────
-export function startCommandLoop(ctx) {
+export function startCommandLoop(ctx: ReplContext): void {
let processing = false;
- const commandQueue = [];
+ const commandQueue: string[] = [];
async function processQueue() {
if (processing) return;
processing = true;
while (commandQueue.length > 0) {
- const line = commandQueue.shift();
+ const line = commandQueue.shift()!;
await processLine(ctx, line);
if (line.trim()) {
try {
fs.mkdirSync(path.dirname(ctx.historyFile), { recursive: true });
fs.appendFileSync(ctx.historyFile, line.trim() + '\n');
- } catch {}
+ } catch { /* ignore */ }
}
}
processing = false;
- ctx.rl.prompt();
+ ctx.rl!.prompt();
}
- ctx.rl.prompt();
+ ctx.rl!.prompt();
- ctx.rl.on('line', (line) => {
+ ctx.rl!.on('line', (line: string) => {
commandQueue.push(line);
processQueue();
});
- ctx.rl.on('close', async () => {
+ ctx.rl!.on('close', async () => {
while (processing || commandQueue.length > 0) {
await new Promise(r => setTimeout(r, 50));
}
@@ -379,7 +406,11 @@ export function startCommandLoop(ctx) {
});
let lastSigint = 0;
- ctx.rl.on('SIGINT', () => {
+ ctx.rl!.on('SIGINT', () => {
+ if (ctx.opts?.extension) {
+ ctx.conn.close();
+ process.exit(0);
+ }
const now = Date.now();
if (now - lastSigint < 500) {
ctx.conn.close();
@@ -387,13 +418,14 @@ export function startCommandLoop(ctx) {
}
lastSigint = now;
ctx.log(`\n${c.dim}(Ctrl+C again to exit, or type .exit)${c.reset}`);
- ctx.rl.prompt();
+ ctx.rl!.prompt();
});
}
// ─── Prompt string ──────────────────────────────────────────────────────────
-export function promptStr(ctx) {
+export function promptStr(ctx: ReplContext): string {
+ if (ctx.opts?.extension) return '';
const mode = ctx.session.mode;
const prefix = mode === 'recording' ? `${c.red}⏺${c.reset} `
: mode === 'paused' ? `${c.yellow}⏸${c.reset} `
@@ -403,22 +435,12 @@ export function promptStr(ctx) {
// ─── Ghost completion (inline suggestion) ───────────────────────────────────
-/**
- * Attaches ghost-text completion to a readline interface.
- * Shows dimmed inline suggestion after the cursor; Tab or Right Arrow accepts it.
- *
- * Uses _ttyWrite wrapper instead of _writeToOutput because Node 22+ optimizes
- * single-character appends and doesn't always trigger a full line refresh.
- *
- * @param {readline.Interface} rl
- * @param {Array<{cmd: string, desc: string}>} items - from buildCompletionItems()
- */
/**
* Returns matching commands for ghost completion.
* When the input exactly matches a command AND there are longer matches,
* the exact match is included so the user can cycle through all options.
*/
-export function getGhostMatches(cmds, input) {
+export function getGhostMatches(cmds: string[], input: string): string[] {
if (input.length > 0 && !input.includes(' ')) {
const longer = cmds.filter(cmd => cmd.startsWith(input) && cmd !== input);
if (longer.length > 0 && cmds.includes(input)) longer.push(input);
@@ -427,21 +449,22 @@ export function getGhostMatches(cmds, input) {
return [];
}
-function attachGhostCompletion(rl, items) {
+/* eslint-disable @typescript-eslint/no-explicit-any */
+function attachGhostCompletion(rl: any, items: CompletionItem[]): void {
if (!process.stdin.isTTY) return; // no ghost text for piped input
- const cmds = items.map(i => i.cmd);
+ const cmds = items.map((i: CompletionItem) => i.cmd);
let ghost = '';
- let matches = []; // all matching commands for current input
+ let matches: string[] = []; // all matching commands for current input
let matchIdx = 0; // which match is currently shown
- function renderGhost(suffix) {
+ function renderGhost(suffix: string) {
ghost = suffix;
rl.output.write(`\x1b[2m${ghost}\x1b[0m\x1b[${ghost.length}D`);
}
const origTtyWrite = rl._ttyWrite.bind(rl);
- rl._ttyWrite = function (s, key) {
+ rl._ttyWrite = function (s: string, key: { name: string }) {
// Tab handling — based on matches, not ghost text
if (key && key.name === 'tab') {
// Cycle through multiple matches
@@ -496,24 +519,34 @@ function attachGhostCompletion(rl, items) {
}
};
}
+/* eslint-enable @typescript-eslint/no-explicit-any */
// ─── REPL ────────────────────────────────────────────────────────────────────
-export async function startRepl(opts = {}) {
+export async function startRepl(opts: ReplOpts = {}): Promise {
const silent = opts.silent || false;
- const log = (...args) => { if (!silent) console.log(...args); };
+ const log = (...args: unknown[]) => { if (!silent) console.log(...args); };
log(`${c.bold}${c.magenta}🎭 Playwright REPL${c.reset} ${c.dim}v${replVersion}${c.reset}`);
- log(`${c.dim}Type .help for commands${c.reset}\n`);
// ─── Start engine ────────────────────────────────────────────────
+ if (opts.extension) {
+ log(`${c.dim}Extension mode: starting CDP relay server...${c.reset}`);
+ log('');
+ } else {
+ log(`${c.dim}Type .help for commands${c.reset}\n`);
+ }
+
const conn = new Engine();
try {
await conn.start(opts);
- log(`${c.green}✓${c.reset} Browser ready\n`);
- } catch (err) {
- console.error(`${c.red}✗${c.reset} Failed to start: ${err.message}`);
+ if (opts.extension)
+ log(`${c.green}✓${c.reset} Extension connected, ready for commands\n`);
+ else
+ log(`${c.green}✓${c.reset} Browser ready\n`);
+ } catch (err: unknown) {
+ console.error(`${c.red}✗${c.reset} Failed to start: ${(err as Error).message}`);
process.exit(1);
}
@@ -522,7 +555,7 @@ export async function startRepl(opts = {}) {
const session = new SessionManager();
const historyDir = path.join(os.homedir(), '.playwright-repl');
const historyFile = path.join(historyDir, '.repl-history');
- const ctx = { conn, session, rl: null, opts, log, historyFile, commandCount: 0 };
+ const ctx: ReplContext = { conn, session, rl: null, opts, log, historyFile, commandCount: 0 };
// Auto-start recording if --record was passed
if (opts.record) {
@@ -540,15 +573,15 @@ export async function startRepl(opts = {}) {
try {
const hist = fs.readFileSync(historyFile, 'utf-8').split('\n').filter(Boolean).reverse();
- for (const line of hist) rl.history.push(line);
- } catch {}
+ for (const line of hist) (rl as readline.Interface & { history: string[] }).history.push(line);
+ } catch { /* ignore */ }
attachGhostCompletion(rl, buildCompletionItems());
// ─── Start ───────────────────────────────────────────────────────
if (opts.replay) {
- await runReplayMode(ctx, opts.replay, opts.step);
+ await runReplayMode(ctx, opts.replay, opts.step || false);
} else {
startCommandLoop(ctx);
}
diff --git a/packages/cli/test/index.test.mjs b/packages/cli/test/index.test.ts
similarity index 86%
rename from packages/cli/test/index.test.mjs
rename to packages/cli/test/index.test.ts
index 31d9db2..f657327 100644
--- a/packages/cli/test/index.test.mjs
+++ b/packages/cli/test/index.test.ts
@@ -1,10 +1,11 @@
+// @ts-nocheck
/**
- * Tests that index.mjs re-exports all public API.
+ * Tests that index.ts re-exports all public API.
*/
import { describe, it, expect } from 'vitest';
-import * as api from '../src/index.mjs';
+import * as api from '../src/index.js';
-describe('index.mjs exports', () => {
+describe('index.ts exports', () => {
it('exports Engine', () => {
expect(api.Engine).toBeDefined();
expect(typeof api.Engine).toBe('function');
diff --git a/packages/cli/test/recorder.test.mjs b/packages/cli/test/recorder.test.ts
similarity index 99%
rename from packages/cli/test/recorder.test.mjs
rename to packages/cli/test/recorder.test.ts
index c909ccc..da492f0 100644
--- a/packages/cli/test/recorder.test.mjs
+++ b/packages/cli/test/recorder.test.ts
@@ -1,8 +1,9 @@
+// @ts-nocheck
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
-import { SessionRecorder, SessionPlayer, SessionManager } from '../src/recorder.mjs';
+import { SessionRecorder, SessionPlayer, SessionManager } from '../src/recorder.js';
// ─── SessionRecorder ────────────────────────────────────────────────────────
diff --git a/packages/cli/test/repl-helpers.test.mjs b/packages/cli/test/repl-helpers.test.ts
similarity index 87%
rename from packages/cli/test/repl-helpers.test.mjs
rename to packages/cli/test/repl-helpers.test.ts
index 14fc248..5d8e38a 100644
--- a/packages/cli/test/repl-helpers.test.mjs
+++ b/packages/cli/test/repl-helpers.test.ts
@@ -1,5 +1,6 @@
+// @ts-nocheck
import { describe, it, expect } from 'vitest';
-import { filterResponse, getGhostMatches } from '../src/repl.mjs';
+import { filterResponse, getGhostMatches } from '../src/repl.js';
import {
c, buildRunCode, actionByText,
fillByText, selectByText, checkByText, uncheckByText,
@@ -84,12 +85,12 @@ describe('text locator buildRunCode output', () => {
describe('filterResponse', () => {
it('extracts Result section', () => {
const text = '### Page\nhttp://example.com\n### Result\nClicked element';
- expect(filterResponse(text)).toBe('Clicked element');
+ expect(filterResponse(text)).toBe('http://example.com\nClicked element');
});
it('extracts Error section in red', () => {
const text = '### Page\nhttp://example.com\n### Error\nElement not found';
- expect(filterResponse(text)).toBe(`${c.red}Element not found${c.reset}`);
+ expect(filterResponse(text)).toBe(`http://example.com\n${c.red}Element not found${c.reset}`);
});
it('extracts Modal state section', () => {
@@ -97,14 +98,19 @@ describe('filterResponse', () => {
expect(filterResponse(text)).toBe('[Alert] Are you sure?');
});
- it('strips Page and Snapshot sections', () => {
+ it('includes Page and Snapshot sections for snapshot command', () => {
const text = '### Page\nhttp://example.com\n### Snapshot\n- element tree\n### Result\nDone';
- expect(filterResponse(text)).toBe('Done');
+ expect(filterResponse(text, 'snapshot')).toBe('http://example.com\n- element tree\nDone');
});
- it('returns null when no matching sections', () => {
+ it('suppresses Snapshot section for non-snapshot commands', () => {
+ const text = '### Page\nhttp://example.com\n### Snapshot\n- element tree\n### Result\nDone';
+ expect(filterResponse(text, 'goto')).toBe('http://example.com\nDone');
+ });
+
+ it('returns Page and Snapshot content when no Result section', () => {
const text = '### Page\nhttp://example.com\n### Snapshot\n- tree';
- expect(filterResponse(text)).toBeNull();
+ expect(filterResponse(text, 'snapshot')).toBe('http://example.com\n- tree');
});
it('returns null for text with no sections', () => {
diff --git a/packages/cli/test/repl-integration.test.mjs b/packages/cli/test/repl-integration.test.ts
similarity index 98%
rename from packages/cli/test/repl-integration.test.mjs
rename to packages/cli/test/repl-integration.test.ts
index 0e2c6ca..7f2bd02 100644
--- a/packages/cli/test/repl-integration.test.mjs
+++ b/packages/cli/test/repl-integration.test.ts
@@ -1,5 +1,6 @@
+// @ts-nocheck
/**
- * Integration-level tests for repl.mjs functions that need mocking
+ * Integration-level tests for repl.ts functions that need mocking
* (execSync, process.exit, etc.)
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
@@ -7,14 +8,14 @@ import { EventEmitter } from 'node:events';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
-import { SessionManager } from '../src/recorder.mjs';
+import { SessionManager } from '../src/recorder.js';
import {
handleKillAll,
handleClose,
startCommandLoop,
runReplayMode,
-} from '../src/repl.mjs';
+} from '../src/repl.js';
// ─── Helpers ────────────────────────────────────────────────────────────────
diff --git a/packages/cli/test/repl-processline.test.mjs b/packages/cli/test/repl-processline.test.ts
similarity index 99%
rename from packages/cli/test/repl-processline.test.mjs
rename to packages/cli/test/repl-processline.test.ts
index b17ee98..ee60dba 100644
--- a/packages/cli/test/repl-processline.test.mjs
+++ b/packages/cli/test/repl-processline.test.ts
@@ -1,5 +1,6 @@
+// @ts-nocheck
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
-import { SessionManager } from '../src/recorder.mjs';
+import { SessionManager } from '../src/recorder.js';
import {
processLine,
handleSessionCommand,
@@ -7,7 +8,7 @@ import {
showAliases,
showStatus,
promptStr,
-} from '../src/repl.mjs';
+} from '../src/repl.js';
// ─── Mock ctx factory ───────────────────────────────────────────────────────
diff --git a/packages/cli/test/repl-startrepl.test.mjs b/packages/cli/test/repl-startrepl.test.ts
similarity index 98%
rename from packages/cli/test/repl-startrepl.test.mjs
rename to packages/cli/test/repl-startrepl.test.ts
index b0487d8..2847bcf 100644
--- a/packages/cli/test/repl-startrepl.test.mjs
+++ b/packages/cli/test/repl-startrepl.test.ts
@@ -1,3 +1,4 @@
+// @ts-nocheck
/**
* Tests for startRepl() — the main orchestrator.
* Mocks Engine (from core) and readline.
@@ -38,7 +39,7 @@ vi.mock('node:readline', () => ({
}));
import { Engine } from '@playwright-repl/core';
-import { startRepl } from '../src/repl.mjs';
+import { startRepl } from '../src/repl.js';
// ─── Tests ──────────────────────────────────────────────────────────────────
diff --git a/packages/cli/tsconfig.build.json b/packages/cli/tsconfig.build.json
new file mode 100644
index 0000000..570f634
--- /dev/null
+++ b/packages/cli/tsconfig.build.json
@@ -0,0 +1,11 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "rootDir": "src",
+ "outDir": "dist"
+ },
+ "include": ["src"],
+ "references": [
+ { "path": "../core/tsconfig.build.json" }
+ ]
+}
diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json
new file mode 100644
index 0000000..35707f6
--- /dev/null
+++ b/packages/cli/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "rootDir": ".",
+ "noEmit": true
+ },
+ "include": ["src", "test"]
+}
diff --git a/packages/cli/vitest.config.mjs b/packages/cli/vitest.config.ts
similarity index 84%
rename from packages/cli/vitest.config.mjs
rename to packages/cli/vitest.config.ts
index 26c18fe..ecb2a09 100644
--- a/packages/cli/vitest.config.mjs
+++ b/packages/cli/vitest.config.ts
@@ -2,7 +2,7 @@ import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
- include: ['test/**/*.test.mjs'],
+ include: ['test/**/*.test.ts'],
coverage: {
provider: 'v8',
include: ['src/**'],
diff --git a/packages/core/.gitgnore b/packages/core/.gitgnore
new file mode 100644
index 0000000..849ddff
--- /dev/null
+++ b/packages/core/.gitgnore
@@ -0,0 +1 @@
+dist/
diff --git a/packages/core/package.json b/packages/core/package.json
index 4bd4a5c..fe4b298 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -1,18 +1,23 @@
{
"name": "@playwright-repl/core",
- "version": "0.3.0",
+ "version": "0.5.0",
"private": true,
"type": "module",
- "main": "./src/index.mjs",
+ "main": "./dist/index.js",
"exports": {
- ".": "./src/index.mjs"
+ ".": "./dist/index.js"
},
+ "types": "./dist/index.d.ts",
"scripts": {
+ "build": "tsc",
"test": "vitest run"
},
"dependencies": {
"minimist": "^1.2.8"
},
+ "peerDependencies": {
+ "playwright": ">=1.59.0-alpha-2026-02-01"
+ },
"devDependencies": {
"vitest": "^4.0.18"
}
diff --git a/packages/core/src/colors.mjs b/packages/core/src/colors.ts
similarity index 87%
rename from packages/core/src/colors.mjs
rename to packages/core/src/colors.ts
index d90c130..11f5a84 100644
--- a/packages/core/src/colors.mjs
+++ b/packages/core/src/colors.ts
@@ -3,7 +3,7 @@
* No dependency — just ANSI escape codes.
*/
-export const c = {
+export const c: Record = {
reset: '\x1b[0m',
bold: '\x1b[1m',
dim: '\x1b[2m',
diff --git a/packages/core/src/completion-data.mjs b/packages/core/src/completion-data.ts
similarity index 90%
rename from packages/core/src/completion-data.mjs
rename to packages/core/src/completion-data.ts
index 13897b7..b92334a 100644
--- a/packages/core/src/completion-data.mjs
+++ b/packages/core/src/completion-data.ts
@@ -1,10 +1,10 @@
/**
* Completion data — builds the list of items for ghost completion.
*
- * Sources: COMMANDS from resolve.mjs plus REPL meta-commands (.help, .exit, etc.).
+ * Sources: COMMANDS from resolve.ts plus REPL meta-commands (.help, .exit, etc.).
*/
-import { COMMANDS } from './resolve.mjs';
+import { COMMANDS } from './resolve.js';
// ─── Meta-commands ───────────────────────────────────────────────────────────
@@ -28,6 +28,11 @@ const EXTRA_COMMANDS = [
{ cmd: 'verify-list', desc: 'Assert list contains expected items' },
];
+export interface CompletionItem {
+ cmd: string;
+ desc: string;
+}
+
// ─── Build completion items ──────────────────────────────────────────────────
/**
diff --git a/packages/core/src/engine.mjs b/packages/core/src/engine.mjs
deleted file mode 100644
index 1135c84..0000000
--- a/packages/core/src/engine.mjs
+++ /dev/null
@@ -1,195 +0,0 @@
-/**
- * Engine — in-process Playwright backend.
- *
- * Wraps BrowserServerBackend directly, eliminating the daemon process.
- * Provides the same interface as DaemonConnection: run(args), connected, close().
- *
- * Three connection modes:
- * - launch: new browser via Playwright (default)
- * - connect: existing Chrome via CDP port (--connect [port])
- * - extension: Chrome extension CDP relay (--extension)
- */
-
-import { createRequire } from 'node:module';
-import path from 'node:path';
-import url from 'node:url';
-
-// ─── Lazy-loaded Playwright dependencies ────────────────────────────────────
-
-let _deps;
-
-function loadDeps() {
- if (_deps) return _deps;
- const require = createRequire(import.meta.url);
- // Resolve absolute paths to bypass Playwright's exports map.
- const pwDir = path.dirname(require.resolve('playwright/package.json'));
- const pwReq = (sub) => require(path.join(pwDir, sub));
- _deps = {
- BrowserServerBackend: pwReq('lib/mcp/browser/browserServerBackend.js').BrowserServerBackend,
- contextFactory: pwReq('lib/mcp/browser/browserContextFactory.js').contextFactory,
- resolveConfig: pwReq('lib/mcp/browser/config.js').resolveConfig,
- commands: pwReq('lib/cli/daemon/commands.js').commands,
- parseCommand: pwReq('lib/cli/daemon/command.js').parseCommand,
- };
- return _deps;
-}
-
-// ─── Engine ─────────────────────────────────────────────────────────────────
-
-export class Engine {
- /**
- * @param {object} [deps] — Playwright dependencies (injected for testing).
- */
- constructor(deps) {
- this._deps = deps;
- this._backend = null;
- this._close = null;
- this._connected = false;
- }
-
- get connected() {
- return this._connected;
- }
-
- /**
- * Start the engine with given options.
- * @param {object} opts - CLI options (headed, browser, connect, etc.)
- */
- async start(opts = {}) {
- const deps = this._deps || loadDeps();
- const config = await this._buildConfig(opts, deps);
- const factory = deps.contextFactory(config);
-
- const cwd = url.pathToFileURL(process.cwd()).href;
- const clientInfo = {
- name: 'playwright-repl',
- version: '0.4.0',
- roots: [{ uri: cwd, name: 'cwd' }],
- timestamp: Date.now(),
- };
-
- const { browserContext, close } = await factory.createContext(clientInfo, new AbortController().signal, {});
- this._close = close;
-
- // Wrap in an "existing context" factory so BrowserServerBackend reuses it.
- const existingContextFactory = {
- createContext: () => Promise.resolve({ browserContext, close }),
- };
-
- this._backend = new deps.BrowserServerBackend(config, existingContextFactory, { allTools: true });
- await this._backend.initialize?.(clientInfo);
- this._connected = true;
-
- // If the browser closes externally, update our state.
- browserContext.on('close', () => {
- this._connected = false;
- });
- }
-
- /**
- * Run a command given minimist-parsed args.
- * Returns { text, isError } matching DaemonConnection.run() shape.
- */
- async run(args) {
- if (!this._backend)
- throw new Error('Engine not started');
-
- const deps = this._deps || loadDeps();
- const command = deps.commands[args._[0]];
- if (!command)
- throw new Error(`Unknown command: ${args._[0]}`);
-
- const { toolName, toolParams } = deps.parseCommand(command, args);
-
- // Commands like "close", "list", "kill-all" have empty toolName.
- if (!toolName)
- return { text: `Command "${args._[0]}" is not supported in engine mode.` };
-
- toolParams._meta = { cwd: args.cwd || process.cwd() };
-
- const response = await this._backend.callTool(toolName, toolParams);
- return formatResult(response);
- }
-
- /**
- * Shut down the browser and backend.
- */
- async close() {
- this._connected = false;
- if (this._backend) {
- this._backend.serverClosed();
- this._backend = null;
- }
- if (this._close) {
- await this._close();
- this._close = null;
- }
- }
-
- // ─── Config builder ───────────────────────────────────────────────────────
-
- async _buildConfig(opts, deps) {
- const config = {
- browser: {
- browserName: 'chromium',
- launchOptions: {
- channel: 'chrome',
- headless: !opts.headed,
- },
- contextOptions: {
- viewport: null,
- },
- isolated: false,
- },
- server: {},
- network: {},
- timeouts: {
- action: 5000,
- navigation: 60000,
- },
- };
-
- // Browser selection
- if (opts.browser) {
- switch (opts.browser) {
- case 'firefox':
- config.browser.browserName = 'firefox';
- config.browser.launchOptions.channel = undefined;
- break;
- case 'webkit':
- config.browser.browserName = 'webkit';
- config.browser.launchOptions.channel = undefined;
- break;
- default:
- // chrome, msedge, chrome-beta, etc.
- config.browser.browserName = 'chromium';
- config.browser.launchOptions.channel = opts.browser;
- break;
- }
- }
-
- // Persistent profile
- if (opts.persistent || opts.profile) {
- config.browser.userDataDir = opts.profile || undefined;
- } else {
- config.browser.isolated = true;
- }
-
- // CDP connect mode
- if (opts.connect) {
- const port = typeof opts.connect === 'number' ? opts.connect : 9222;
- config.browser.cdpEndpoint = `http://localhost:${port}`;
- config.browser.isolated = false;
- }
-
- return await deps.resolveConfig(config);
- }
-}
-
-// ─── Helpers ────────────────────────────────────────────────────────────────
-
-function formatResult(result) {
- const isError = result.isError;
- const text = result.content[0].type === 'text' ? result.content[0].text : undefined;
- return { isError, text };
-}
diff --git a/packages/core/src/engine.ts b/packages/core/src/engine.ts
new file mode 100644
index 0000000..90c7bd5
--- /dev/null
+++ b/packages/core/src/engine.ts
@@ -0,0 +1,382 @@
+/**
+ * Engine — in-process Playwright backend.
+ *
+ * Wraps BrowserServerBackend directly, eliminating the daemon process.
+ * Provides the same interface as DaemonConnection: run(args), connected, close().
+ *
+ * Three connection modes:
+ * - launch: new browser via Playwright (default)
+ * - connect: existing Chrome via CDP port (--connect [port])
+ * - extension: DevTools extension CDP relay (--extension)
+ */
+
+import { createRequire } from 'node:module';
+import path from 'node:path';
+import url from 'node:url';
+import { replVersion } from './resolve.js';
+
+// ─── Types ───────────────────────────────────────────────────────────────────
+
+export interface EngineOpts {
+ headed?: boolean;
+ browser?: string;
+ connect?: number | boolean;
+ extension?: boolean;
+ spawn?: boolean;
+ port?: number;
+ cdpPort?: number;
+ persistent?: boolean;
+ profile?: string;
+ cwd?: string;
+ [key: string]: unknown;
+}
+
+export interface EngineResult {
+ text?: string;
+ image?: string;
+ isError?: boolean;
+}
+
+export interface ParsedArgs {
+ _: string[];
+ cwd?: string;
+ nth?: string | number;
+ [key: string]: unknown;
+}
+
+/* eslint-disable @typescript-eslint/no-explicit-any */
+interface PlaywrightDeps {
+ BrowserServerBackend: new (config: any, factory: any, opts: any) => any;
+ contextFactory: (config: any) => { createContext: (info: any, signal: AbortSignal, opts: any) => Promise<{ browserContext: any; close: () => Promise }> };
+ playwright: any;
+ registry: { findExecutable: (name: string) => { executablePath: () => string | undefined } | undefined };
+ resolveConfig: (config: any) => Promise | any;
+ commands: Record;
+ parseCommand: (command: any, args: any) => { toolName: string; toolParams: Record };
+}
+/* eslint-enable @typescript-eslint/no-explicit-any */
+
+// ─── Lazy-loaded Playwright dependencies ────────────────────────────────────
+
+let _deps: PlaywrightDeps | undefined;
+
+function loadDeps(): PlaywrightDeps {
+ if (_deps) return _deps;
+ const require = createRequire(import.meta.url);
+ // Resolve absolute paths to bypass Playwright's exports map.
+ const pwDir = path.dirname(require.resolve('playwright/package.json'));
+ const pwReq = (sub: string) => require(path.join(pwDir, sub));
+ const pwCoreDir = path.dirname(require.resolve('playwright-core/package.json'));
+ const pwCoreReq = (sub: string) => require(path.join(pwCoreDir, sub));
+ _deps = {
+ BrowserServerBackend: pwReq('lib/mcp/browser/browserServerBackend.js').BrowserServerBackend,
+ contextFactory: pwReq('lib/mcp/browser/browserContextFactory.js').contextFactory,
+ playwright: require('playwright-core'),
+ registry: pwCoreReq('lib/server/registry/index.js').registry,
+ resolveConfig: pwReq('lib/mcp/browser/config.js').resolveConfig,
+ commands: pwReq('lib/cli/daemon/commands.js').commands,
+ parseCommand: pwReq('lib/cli/daemon/command.js').parseCommand,
+ };
+ return _deps;
+}
+
+// ─── Engine ─────────────────────────────────────────────────────────────────
+
+export class Engine {
+ /* eslint-disable @typescript-eslint/no-explicit-any */
+ private _deps: PlaywrightDeps | undefined;
+ private _backend: any = null;
+ private _browserContext: any = null;
+ private _close: (() => Promise) | null = null;
+ private _connected = false;
+ private _commandServer: { close: () => Promise } | null = null;
+ private _chromeProc: { kill: () => void; unref: () => void } | null = null;
+ /* eslint-enable @typescript-eslint/no-explicit-any */
+
+ constructor(deps?: PlaywrightDeps) {
+ this._deps = deps;
+ }
+
+ get connected(): boolean {
+ return this._connected;
+ }
+
+ /**
+ * Start the engine with given options.
+ */
+ async start(opts: EngineOpts = {}): Promise {
+ const deps = this._deps || loadDeps();
+ const config = await this._buildConfig(opts, deps);
+
+ const cwd = url.pathToFileURL(process.cwd()).href;
+ const clientInfo = {
+ name: 'playwright-repl',
+ version: replVersion,
+ roots: [{ uri: cwd, name: 'cwd' }],
+ timestamp: Date.now(),
+ };
+
+ // Choose context factory based on mode.
+ if (opts.extension) {
+ const serverPort = opts.port || 6781;
+ const cdpPort = opts.cdpPort || 9222;
+
+ // 1. Start CommandServer for panel HTTP commands.
+ const { CommandServer } = await import('./extension-server.js');
+ const cmdServer = new CommandServer(this);
+ await cmdServer.start(serverPort);
+ this._commandServer = cmdServer;
+ console.log(`CommandServer listening on http://localhost:${serverPort}`);
+
+ // 2. Spawn Chrome (only with --spawn).
+ if (opts.spawn) {
+ const extPath = path.resolve(path.dirname(url.fileURLToPath(import.meta.url)), '../../extension');
+ const execInfo = deps.registry.findExecutable(opts.browser || 'chrome');
+ const execPath = execInfo?.executablePath();
+ if (!execPath)
+ throw new Error('Chrome executable not found. Make sure Chrome is installed.');
+
+ // Chrome 136+ requires --user-data-dir for CDP. Use a dedicated profile dir.
+ const os = await import('node:os');
+ const fs = await import('node:fs');
+ const userDataDir = opts.profile || path.join(os.default.homedir(), '.playwright-repl', 'chrome-profile');
+ fs.default.mkdirSync(userDataDir, { recursive: true });
+
+ const chromeArgs = [
+ `--remote-debugging-port=${cdpPort}`,
+ `--user-data-dir=${userDataDir}`,
+ `--load-extension=${extPath}`,
+ '--no-first-run',
+ '--no-default-browser-check',
+ ];
+
+ const { spawn } = await import('node:child_process');
+ const chromeProc = spawn(execPath, chromeArgs, {
+ detached: true, stdio: 'ignore',
+ });
+ chromeProc.unref();
+ this._chromeProc = chromeProc;
+ console.log(`Chrome profile: ${userDataDir}`);
+ } else {
+ console.log('Connecting to existing Chrome on port ' + cdpPort + ' (use --spawn to launch Chrome automatically)');
+ }
+
+ // 3. Wait for Chrome CDP to be ready (no timeout — waits until available).
+ console.log('Waiting for Chrome CDP...');
+ const cdpUrl = `http://localhost:${cdpPort}`;
+ while (true) {
+ try {
+ const res = await fetch(`${cdpUrl}/json/version`);
+ if (res.ok) break;
+ } catch { /* retry */ }
+ await new Promise(r => setTimeout(r, 500));
+ }
+ console.log('Chrome CDP ready. Connecting Playwright...');
+
+ // 4. Connect Playwright via CDP.
+ (config as any).browser.cdpEndpoint = cdpUrl;
+ const factory = deps.contextFactory(config);
+ const { browserContext, close } = await factory.createContext(
+ clientInfo, new AbortController().signal, {},
+ );
+ this._browserContext = browserContext;
+ this._close = close;
+
+ const existingContextFactory = {
+ createContext: () => Promise.resolve({ browserContext, close }),
+ };
+ this._backend = new deps.BrowserServerBackend(config, existingContextFactory, { allTools: true });
+ await this._backend.initialize?.(clientInfo);
+ this._connected = true;
+
+ // 5. Auto-select the first visible web page.
+ const pages = browserContext.pages();
+ const INTERNAL = /^(chrome|devtools|chrome-extension|about):/;
+ let selectedIdx = -1;
+ for (let i = 0; i < pages.length; i++) {
+ const pageUrl = pages[i].url();
+ if (!pageUrl || INTERNAL.test(pageUrl)) continue;
+ try {
+ const state = await pages[i].evaluate(() => document.visibilityState);
+ if (state === 'visible' && selectedIdx === -1) {
+ selectedIdx = i;
+ }
+ } catch { /* skip */ }
+ }
+ if (selectedIdx > 0) {
+ await this._backend.callTool('browser_tabs', { action: 'select', index: selectedIdx });
+ }
+
+ console.log('Ready! Side panel can send commands.');
+
+ browserContext.on('close', () => {
+ this._connected = false;
+ });
+ } else {
+ // Launch/connect mode: eagerly create context for immediate feedback.
+ const factory = deps.contextFactory(config);
+ const { browserContext, close } = await factory.createContext(
+ clientInfo, new AbortController().signal, {},
+ );
+ this._browserContext = browserContext;
+ this._close = close;
+
+ const existingContextFactory = {
+ createContext: () => Promise.resolve({ browserContext, close }),
+ };
+ this._backend = new deps.BrowserServerBackend(config, existingContextFactory, { allTools: true });
+ await this._backend.initialize?.(clientInfo);
+ this._connected = true;
+
+ browserContext.on('close', () => {
+ this._connected = false;
+ });
+ }
+ }
+
+ /**
+ * Run a command given minimist-parsed args.
+ * Returns { text, isError } matching DaemonConnection.run() shape.
+ */
+ async run(args: ParsedArgs): Promise {
+ if (!this._backend)
+ throw new Error('Engine not started');
+
+ const deps = this._deps || loadDeps();
+ const command = deps.commands[args._[0]];
+ if (!command)
+ throw new Error(`Unknown command: ${args._[0]}`);
+
+ const { toolName, toolParams } = deps.parseCommand(command, args);
+
+ // Commands like "close", "list", "kill-all" have empty toolName.
+ if (!toolName)
+ return { text: `Command "${args._[0]}" is not supported in engine mode.` };
+
+ toolParams._meta = { cwd: args.cwd || process.cwd() };
+
+ const response = await this._backend.callTool(toolName, toolParams);
+ return formatResult(response);
+ }
+
+ /**
+ * Select the Playwright page matching the given URL.
+ * Uses backend.callTool('browser_tabs') to properly update the tab tracker.
+ */
+ async selectPageByUrl(targetUrl: string): Promise {
+ if (!this._browserContext || !this._backend || !targetUrl) return;
+ const pages = this._browserContext.pages();
+ const normalize = (u: string) => u.replace(/\/+$/, '');
+ const target = normalize(targetUrl);
+ for (let i = 0; i < pages.length; i++) {
+ if (normalize(pages[i].url()) === target) {
+ try {
+ await this._backend.callTool('browser_tabs', { action: 'select', index: i });
+ } catch { /* ignore */ }
+ return;
+ }
+ }
+ }
+
+ /**
+ * Shut down the browser and backend.
+ */
+ async close(): Promise {
+ this._connected = false;
+ if (this._commandServer) {
+ await this._commandServer.close();
+ this._commandServer = null;
+ }
+ if (this._backend) {
+ this._backend.serverClosed();
+ this._backend = null;
+ }
+ if (this._close) {
+ await this._close();
+ this._close = null;
+ }
+ if (this._chromeProc) {
+ try { this._chromeProc.kill(); } catch { /* ignore */ }
+ this._chromeProc = null;
+ }
+ }
+
+ // ─── Config builder ───────────────────────────────────────────────────────
+
+ private async _buildConfig(opts: EngineOpts, deps: PlaywrightDeps) {
+ const config = {
+ browser: {
+ browserName: 'chromium',
+ launchOptions: {
+ channel: 'chrome' as string | undefined,
+ headless: !opts.headed,
+ },
+ contextOptions: {
+ viewport: null as null,
+ },
+ isolated: false,
+ userDataDir: undefined as string | undefined,
+ cdpEndpoint: undefined as string | undefined,
+ },
+ server: {},
+ network: {},
+ timeouts: {
+ action: opts.extension ? 30000 : 5000,
+ navigation: opts.extension ? 15000 : 60000,
+ },
+ };
+
+ // Browser selection
+ if (opts.browser) {
+ switch (opts.browser) {
+ case 'firefox':
+ config.browser.browserName = 'firefox';
+ config.browser.launchOptions.channel = undefined;
+ break;
+ case 'webkit':
+ config.browser.browserName = 'webkit';
+ config.browser.launchOptions.channel = undefined;
+ break;
+ default:
+ // chrome, msedge, chrome-beta, etc.
+ config.browser.browserName = 'chromium';
+ config.browser.launchOptions.channel = opts.browser;
+ break;
+ }
+ }
+
+ // Persistent profile
+ if (opts.persistent || opts.profile) {
+ config.browser.userDataDir = opts.profile || undefined;
+ } else if (!opts.extension) {
+ config.browser.isolated = true;
+ }
+
+ // CDP connect mode
+ if (opts.connect) {
+ const port = typeof opts.connect === 'number' ? opts.connect : 9222;
+ config.browser.cdpEndpoint = `http://localhost:${port}`;
+ config.browser.isolated = false;
+ }
+
+ return await deps.resolveConfig(config);
+ }
+}
+
+// ─── Helpers ────────────────────────────────────────────────────────────────
+
+interface ToolResult {
+ content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>;
+ isError?: boolean;
+}
+
+function formatResult(result: ToolResult): EngineResult {
+ const isError = result.isError;
+ let text: string | undefined;
+ let image: string | undefined;
+ for (const item of result.content) {
+ if (item.type === 'text' && !text) text = item.text;
+ if (item.type === 'image' && !image) image = `data:${item.mimeType || 'image/png'};base64,${item.data}`;
+ }
+ return { isError, text, image };
+}
diff --git a/packages/core/src/extension-server.ts b/packages/core/src/extension-server.ts
new file mode 100644
index 0000000..7221cf4
--- /dev/null
+++ b/packages/core/src/extension-server.ts
@@ -0,0 +1,203 @@
+/**
+ * CommandServer — HTTP server for the side panel extension.
+ *
+ * Endpoints:
+ * POST /run Panel commands → engine.run()
+ * GET /health Panel checks if server is running
+ */
+
+import { createServer, type IncomingMessage, type ServerResponse, type Server } from 'node:http';
+import { parseInput } from './parser.js';
+import { replVersion } from './resolve.js';
+import {
+ buildRunCode, verifyText, verifyElement, verifyValue, verifyList,
+ actionByText, fillByText, selectByText, checkByText, uncheckByText,
+} from './page-scripts.js';
+import type { ParsedArgs, EngineResult } from './engine.js';
+
+// ─── Types ──────────────────────────────────────────────────────────────────
+
+interface EngineInterface {
+ run: (args: ParsedArgs) => Promise;
+ selectPageByUrl: (url: string) => Promise;
+ connected: boolean;
+}
+
+// ─── CommandServer ──────────────────────────────────────────────────────────
+
+export class CommandServer {
+ private _engine: EngineInterface;
+ private _server: Server | null = null;
+ private _port: number | null = null;
+
+ constructor(engine: EngineInterface) {
+ this._engine = engine;
+ }
+
+ get port(): number | null { return this._port; }
+
+ async start(port = 6781): Promise {
+ this._server = createServer((req, res) => this._handleHttp(req, res));
+
+ return new Promise((resolve, reject) => {
+ this._server!.listen(port, () => {
+ this._port = (this._server!.address() as { port: number }).port;
+ resolve();
+ });
+ this._server!.on('error', reject);
+ });
+ }
+
+ async close(): Promise {
+ if (this._server) {
+ await new Promise((resolve) => this._server!.close(() => resolve()));
+ this._server = null;
+ }
+ }
+
+ // ─── HTTP handler ───────────────────────────────────────────────────────
+
+ private async _handleHttp(req: IncomingMessage, res: ServerResponse): Promise {
+ // CORS — allow chrome-extension:// origins
+ res.setHeader('Access-Control-Allow-Origin', '*');
+ res.setHeader('Access-Control-Allow-Methods', 'POST, GET');
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
+
+ if (req.method === 'OPTIONS') {
+ res.writeHead(204);
+ res.end();
+ return;
+ }
+
+ const urlPath = (req.url || '').replace(/\/+$/, '');
+
+ // Health check endpoint
+ if (req.method === 'GET' && urlPath === '/health') {
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ status: 'ok', version: replVersion }));
+ return;
+ }
+
+ // Panel command endpoint
+ if (req.method === 'POST' && urlPath === '/run') {
+ try {
+ const body = await readBody(req);
+ const { raw, activeTabUrl } = JSON.parse(body);
+ console.log(`[server] ${raw} | ${activeTabUrl || 'no-url'}`);
+
+ // Auto-select the tab matching the panel's active tab.
+ if (activeTabUrl) {
+ await withTimeout(this._engine.selectPageByUrl(activeTabUrl), 5000).catch(() => {});
+ }
+
+ let args = parseInput(raw);
+ if (!args) {
+ res.writeHead(400, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ text: `Unknown command: ${raw}`, isError: true }));
+ return;
+ }
+ args = resolveArgs(args);
+ const result = await withTimeout(this._engine.run(args), 30000);
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify(result));
+ } catch (e: unknown) {
+ const message = e instanceof Error ? e.message : String(e);
+ res.writeHead(500, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ text: message, isError: true }));
+ }
+ return;
+ }
+
+ res.writeHead(404);
+ res.end('Not found');
+ }
+}
+
+// ─── REPL-level argument transformations ────────────────────────────────────
+
+type PageScriptFn = (...args: unknown[]) => Promise;
+
+/**
+ * Apply the same transformations the CLI REPL does before engine.run():
+ * - Verify commands → run-code with page scripts
+ * - Text locators → run-code with actionByText/fillByText/etc.
+ * - Auto-wrap run-code body with async (page) => { ... }
+ */
+function resolveArgs(args: ParsedArgs): ParsedArgs {
+ const cmdName = args._[0];
+
+ // ── Verify commands → run-code translation ──────────────────
+ const verifyFns: Record = {
+ 'verify-text': verifyText as PageScriptFn,
+ 'verify-element': verifyElement as PageScriptFn,
+ 'verify-value': verifyValue as PageScriptFn,
+ 'verify-list': verifyList as PageScriptFn,
+ };
+ if (verifyFns[cmdName]) {
+ const pos = args._.slice(1);
+ const fn = verifyFns[cmdName];
+ let translated: ParsedArgs | null = null;
+ if (cmdName === 'verify-text') {
+ const text = pos.join(' ');
+ if (text) translated = buildRunCode(fn, text);
+ } else if (pos[0] && pos.length >= 2) {
+ const rest = cmdName === 'verify-list' ? pos.slice(1) : pos.slice(1).join(' ');
+ translated = buildRunCode(fn, pos[0], rest);
+ }
+ if (translated) args = translated;
+ }
+
+ // ── Auto-resolve text to native Playwright locator ─────────
+ const textFns: Record = {
+ click: actionByText as PageScriptFn, dblclick: actionByText as PageScriptFn, hover: actionByText as PageScriptFn,
+ fill: fillByText as PageScriptFn, select: selectByText as PageScriptFn, check: checkByText as PageScriptFn, uncheck: uncheckByText as PageScriptFn,
+ };
+ if (textFns[cmdName] && args._[1] && !/^e\d+$/.test(args._[1])) {
+ const textArg = args._[1];
+ const extraArgs = args._.slice(2);
+ const fn = textFns[cmdName];
+ const nth = args.nth !== undefined ? parseInt(String(args.nth), 10) : undefined;
+ if (fn === actionByText) args = buildRunCode(fn, textArg, cmdName, nth);
+ else if (cmdName === 'fill' || cmdName === 'select') args = buildRunCode(fn, textArg, extraArgs[0] || '', nth);
+ else args = buildRunCode(fn, textArg, nth);
+ }
+
+ // ── go-back / go-forward → evaluate history.back/forward ──
+ if (cmdName === 'go-back') {
+ args = { _: ['run-code', 'async (page) => { await page.evaluate(() => history.back()); return "Navigated back"; }'] };
+ }
+ if (cmdName === 'go-forward') {
+ args = { _: ['run-code', 'async (page) => { await page.evaluate(() => history.forward()); return "Navigated forward"; }'] };
+ }
+
+ // ── Auto-wrap run-code body with async (page) => { ... } ──
+ if (cmdName === 'run-code' && args._[1] && !args._[1].startsWith('async')) {
+ const STMT = /^(await|return|const|let|var|for|if|while|throw|try)\b/;
+ const body = !args._[1].includes(';') && !STMT.test(args._[1])
+ ? `return await ${args._[1]}`
+ : args._[1];
+ args = { _: ['run-code', `async (page) => { ${body} }`] };
+ }
+
+ return args;
+}
+
+// ─── Helpers ────────────────────────────────────────────────────────────────
+
+function readBody(req: IncomingMessage): Promise {
+ return new Promise((resolve) => {
+ let data = '';
+ req.on('data', (chunk: Buffer) => data += chunk);
+ req.on('end', () => resolve(data));
+ });
+}
+
+function withTimeout(promise: Promise, ms: number): Promise {
+ let timer: ReturnType;
+ return Promise.race([
+ promise,
+ new Promise((_, reject) => {
+ timer = setTimeout(() => reject(new Error(`Command timed out after ${ms / 1000}s`)), ms);
+ }),
+ ]).finally(() => clearTimeout(timer!));
+}
diff --git a/packages/core/src/index.mjs b/packages/core/src/index.mjs
deleted file mode 100644
index c0854a9..0000000
--- a/packages/core/src/index.mjs
+++ /dev/null
@@ -1,13 +0,0 @@
-/**
- * @playwright-repl/core — shared engine, parser, and utilities.
- */
-
-export { minimist, replVersion, packageLocation, COMMANDS } from './resolve.mjs';
-export { parseInput, ALIASES, ALL_COMMANDS, booleanOptions } from './parser.mjs';
-export { buildCompletionItems } from './completion-data.mjs';
-export { c } from './colors.mjs';
-export {
- buildRunCode, verifyText, verifyElement, verifyValue, verifyList,
- actionByText, fillByText, selectByText, checkByText, uncheckByText,
-} from './page-scripts.mjs';
-export { Engine } from './engine.mjs';
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
new file mode 100644
index 0000000..e60b761
--- /dev/null
+++ b/packages/core/src/index.ts
@@ -0,0 +1,17 @@
+/**
+ * @playwright-repl/core — shared engine, parser, and utilities.
+ */
+
+export { minimist, replVersion, packageLocation, COMMANDS } from './resolve.js';
+export { parseInput, ALIASES, ALL_COMMANDS, booleanOptions } from './parser.js';
+export { buildCompletionItems } from './completion-data.js';
+export { c } from './colors.js';
+export {
+ buildRunCode, verifyText, verifyElement, verifyValue, verifyList,
+ actionByText, fillByText, selectByText, checkByText, uncheckByText,
+} from './page-scripts.js';
+export { Engine } from './engine.js';
+export type { EngineOpts, EngineResult, ParsedArgs } from './engine.js';
+export { CommandServer } from './extension-server.js';
+export type { CompletionItem } from './completion-data.js';
+export type { CommandInfo } from './resolve.js';
diff --git a/packages/core/src/page-scripts.mjs b/packages/core/src/page-scripts.ts
similarity index 66%
rename from packages/core/src/page-scripts.mjs
rename to packages/core/src/page-scripts.ts
index 0073079..1613789 100644
--- a/packages/core/src/page-scripts.mjs
+++ b/packages/core/src/page-scripts.ts
@@ -1,9 +1,18 @@
+// @ts-nocheck — This file is intentionally untyped JavaScript.
+// Functions here are stringified via fn.toString() and sent to Playwright's
+// browser_run_code tool. Type annotations would appear in the stringified output.
+
/**
* Page-context functions for run-code commands.
*
* Each function is a real, testable async function that takes (page, ...args).
* buildRunCode() converts them to code strings via Function.toString(),
* following the same pattern as playwright-repl-extension/lib/page-scripts.js.
+ *
+ * IMPORTANT: This file must remain plain JavaScript — NOT TypeScript.
+ * These functions are stringified via fn.toString() and sent to Playwright's
+ * browser_run_code tool for evaluation. TypeScript annotations would break
+ * the stringified output.
*/
// ─── Helper ─────────────────────────────────────────────────────────────────
@@ -16,7 +25,8 @@
* So `code` must be a function expression, not an IIFE.
*/
export function buildRunCode(fn, ...args) {
- const serialized = args.map(a => JSON.stringify(a)).join(', ');
+ const filtered = args.filter(a => a !== undefined);
+ const serialized = filtered.map(a => JSON.stringify(a)).join(', ');
return { _: ['run-code', `async (page) => (${fn.toString()})(page, ${serialized})`] };
}
@@ -49,39 +59,52 @@ export async function verifyList(page, ref, items) {
// ─── Text locator actions ───────────────────────────────────────────────────
-export async function actionByText(page, text, action) {
+export async function actionByText(page, text, action, nth) {
let loc = page.getByText(text, { exact: true });
if (await loc.count() === 0) loc = page.getByRole('button', { name: text });
if (await loc.count() === 0) loc = page.getByRole('link', { name: text });
if (await loc.count() === 0) loc = page.getByText(text);
+ if (nth !== undefined) loc = loc.filter({ visible: true }).nth(nth);
await loc[action]();
}
-export async function fillByText(page, text, value) {
+export async function fillByText(page, text, value, nth) {
let loc = page.getByLabel(text);
if (await loc.count() === 0) loc = page.getByPlaceholder(text);
if (await loc.count() === 0) loc = page.getByRole('textbox', { name: text });
+ if (nth !== undefined) loc = loc.filter({ visible: true }).nth(nth);
await loc.fill(value);
}
-export async function selectByText(page, text, value) {
+export async function selectByText(page, text, value, nth) {
let loc = page.getByLabel(text);
if (await loc.count() === 0) loc = page.getByRole('combobox', { name: text });
+ if (nth !== undefined) loc = loc.filter({ visible: true }).nth(nth);
await loc.selectOption(value);
}
-export async function checkByText(page, text) {
+export async function checkByText(page, text, nth) {
const item = page.getByRole('listitem').filter({ hasText: text });
- if (await item.count() > 0) { await item.getByRole('checkbox').check(); return; }
+ if (await item.count() > 0) {
+ const target = nth !== undefined ? item.filter({ visible: true }).nth(nth) : item;
+ await target.getByRole('checkbox').check();
+ return;
+ }
let loc = page.getByLabel(text);
if (await loc.count() === 0) loc = page.getByRole('checkbox', { name: text });
+ if (nth !== undefined) loc = loc.filter({ visible: true }).nth(nth);
await loc.check();
}
-export async function uncheckByText(page, text) {
+export async function uncheckByText(page, text, nth) {
const item = page.getByRole('listitem').filter({ hasText: text });
- if (await item.count() > 0) { await item.getByRole('checkbox').uncheck(); return; }
+ if (await item.count() > 0) {
+ const target = nth !== undefined ? item.filter({ visible: true }).nth(nth) : item;
+ await target.getByRole('checkbox').uncheck();
+ return;
+ }
let loc = page.getByLabel(text);
if (await loc.count() === 0) loc = page.getByRole('checkbox', { name: text });
+ if (nth !== undefined) loc = loc.filter({ visible: true }).nth(nth);
await loc.uncheck();
}
diff --git a/packages/core/src/parser.mjs b/packages/core/src/parser.ts
similarity index 90%
rename from packages/core/src/parser.mjs
rename to packages/core/src/parser.ts
index f34d8a4..63ad252 100644
--- a/packages/core/src/parser.mjs
+++ b/packages/core/src/parser.ts
@@ -7,11 +7,12 @@
* parseCliCommand() which maps it to a tool call.
*/
-import { minimist, COMMANDS } from './resolve.mjs';
+import { minimist, COMMANDS } from './resolve.js';
+import type { ParsedArgs } from './engine.js';
// ─── Command aliases ─────────────────────────────────────────────────────────
-export const ALIASES = {
+export const ALIASES: Record = {
// Navigation
'o': 'open',
'g': 'goto',
@@ -65,7 +66,7 @@ export const booleanOptions = new Set([
// ─── All known commands ──────────────────────────────────────────────────────
-export const ALL_COMMANDS = Object.keys(COMMANDS);
+export const ALL_COMMANDS: string[] = Object.keys(COMMANDS);
// ─── Tokenizer ───────────────────────────────────────────────────────────────
@@ -73,10 +74,10 @@ export const ALL_COMMANDS = Object.keys(COMMANDS);
* Tokenize input respecting quoted strings.
* "fill e7 'hello world'" → ["fill", "e7", "hello world"]
*/
-function tokenize(line) {
- const tokens = [];
+function tokenize(line: string): string[] {
+ const tokens: string[] = [];
let current = '';
- let inQuote = null;
+ let inQuote: string | null = null;
for (let i = 0; i < line.length; i++) {
const ch = line[i];
@@ -110,7 +111,7 @@ function tokenize(line) {
// Commands where everything after the keyword is a single raw argument
const RAW_COMMANDS = new Set(['run-code', 'eval']);
-export function parseInput(line) {
+export function parseInput(line: string): ParsedArgs | null {
const tokens = tokenize(line);
if (tokens.length === 0) return null;
@@ -120,13 +121,13 @@ export function parseInput(line) {
// For run-code / eval, preserve the rest of the line as a single raw string
if (RAW_COMMANDS.has(tokens[0])) {
- const cmdLen = line.match(/^\s*\S+/)[0].length;
+ const cmdLen = line.match(/^\s*\S+/)![0].length;
const rest = line.slice(cmdLen).trim();
return rest ? { _: [tokens[0], rest] } : { _: [tokens[0]] };
}
// Parse with minimist (same lib and boolean set as playwright-cli)
- const args = minimist(tokens, { boolean: [...booleanOptions] });
+ const args = minimist(tokens, { boolean: [...booleanOptions] }) as ParsedArgs;
// Stringify non-boolean values (playwright-cli does this)
for (const key of Object.keys(args)) {
diff --git a/packages/core/src/resolve.mjs b/packages/core/src/resolve.ts
similarity index 89%
rename from packages/core/src/resolve.mjs
rename to packages/core/src/resolve.ts
index c6d2edf..7b55556 100644
--- a/packages/core/src/resolve.mjs
+++ b/packages/core/src/resolve.ts
@@ -3,7 +3,6 @@
* No @playwright/cli — we start the daemon ourselves via daemon-launcher.cjs.
*/
-import fs from 'node:fs';
import { fileURLToPath } from 'node:url';
import { createRequire } from 'node:module';
@@ -11,18 +10,24 @@ const require = createRequire(import.meta.url);
// ─── Own dependencies ────────────────────────────────────────────────────────
-export const minimist = require('minimist');
+// eslint-disable-next-line @typescript-eslint/no-require-imports
+export const minimist: (args: string[], opts?: Record) => Record & { _: string[] } = require('minimist');
const pkgUrl = new URL('../package.json', import.meta.url);
-const pkg = JSON.parse(fs.readFileSync(pkgUrl, 'utf-8'));
-export const replVersion = pkg.version;
+// eslint-disable-next-line @typescript-eslint/no-require-imports
+export const replVersion: string = require('../../cli/package.json').version;
// Must match what daemon-launcher.cjs computes via require.resolve('../package.json')
-export const packageLocation = fileURLToPath(pkgUrl);
+export const packageLocation: string = fileURLToPath(pkgUrl);
// ─── Command vocabulary ──────────────────────────────────────────────────────
-export const COMMANDS = {
+export interface CommandInfo {
+ desc: string;
+ options: string[];
+}
+
+export const COMMANDS: Record = {
'open': { desc: 'Open the browser', options: [] },
'close': { desc: 'Close the browser', options: [] },
'goto': { desc: 'Navigate to a URL', options: [] },
diff --git a/packages/core/test/completion-data.test.mjs b/packages/core/test/completion-data.test.ts
similarity index 92%
rename from packages/core/test/completion-data.test.mjs
rename to packages/core/test/completion-data.test.ts
index c1d314d..b68b440 100644
--- a/packages/core/test/completion-data.test.mjs
+++ b/packages/core/test/completion-data.test.ts
@@ -1,6 +1,7 @@
+// @ts-nocheck
import { describe, it, expect } from 'vitest';
-import { buildCompletionItems } from '../src/completion-data.mjs';
-import { COMMANDS } from '../src/resolve.mjs';
+import { buildCompletionItems } from '../src/completion-data.js';
+import { COMMANDS } from '../src/resolve.js';
describe('buildCompletionItems', () => {
const items = buildCompletionItems();
diff --git a/packages/core/test/engine.test.mjs b/packages/core/test/engine.test.ts
similarity index 99%
rename from packages/core/test/engine.test.mjs
rename to packages/core/test/engine.test.ts
index add5152..b7022e7 100644
--- a/packages/core/test/engine.test.mjs
+++ b/packages/core/test/engine.test.ts
@@ -1,5 +1,6 @@
+// @ts-nocheck
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
-import { Engine } from '../src/engine.mjs';
+import { Engine } from '../src/engine.js';
// ─── Mock Playwright dependencies (injected via Engine constructor) ──────────
diff --git a/packages/core/test/extension-server.test.ts b/packages/core/test/extension-server.test.ts
new file mode 100644
index 0000000..b60ca06
--- /dev/null
+++ b/packages/core/test/extension-server.test.ts
@@ -0,0 +1,146 @@
+// @ts-nocheck
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { CommandServer } from '../src/extension-server.js';
+
+// ─── Helper: create a mock engine ─────────────────────────────────────────────
+
+function createMockEngine() {
+ return {
+ run: vi.fn().mockResolvedValue({ text: 'Clicked', isError: false }),
+ connected: true,
+ };
+}
+
+// ─── Tests ────────────────────────────────────────────────────────────────────
+
+describe('CommandServer', () => {
+ let server;
+ let engine;
+
+ beforeEach(() => {
+ engine = createMockEngine();
+ server = new CommandServer(engine);
+ });
+
+ afterEach(async () => {
+ await server.close();
+ });
+
+ describe('lifecycle', () => {
+ it('starts and listens on the given port', async () => {
+ await server.start(0);
+ expect(server.port).toBeGreaterThan(0);
+ });
+
+ it('close() is idempotent', async () => {
+ await server.start(0);
+ await server.close();
+ await server.close(); // Should not throw
+ });
+
+ it('close() works when server was never started', async () => {
+ await server.close(); // Should not throw
+ });
+ });
+
+ describe('POST /run', () => {
+ it('runs command through engine and returns result', async () => {
+ await server.start(0);
+
+ const res = await fetch(`http://127.0.0.1:${server.port}/run`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ raw: 'click e5' }),
+ });
+
+ expect(res.status).toBe(200);
+ const data = await res.json();
+ expect(data.text).toBe('Clicked');
+ expect(data.isError).toBe(false);
+ expect(engine.run).toHaveBeenCalled();
+ });
+
+ it('returns 400 for unknown commands', async () => {
+ await server.start(0);
+
+ const res = await fetch(`http://127.0.0.1:${server.port}/run`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ raw: '' }),
+ });
+
+ expect(res.status).toBe(400);
+ const data = await res.json();
+ expect(data.isError).toBe(true);
+ });
+
+ it('returns 500 when engine throws', async () => {
+ engine.run.mockRejectedValueOnce(new Error('Engine exploded'));
+ await server.start(0);
+
+ const res = await fetch(`http://127.0.0.1:${server.port}/run`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ raw: 'click e5' }),
+ });
+
+ expect(res.status).toBe(500);
+ const data = await res.json();
+ expect(data.isError).toBe(true);
+ expect(data.text).toContain('Engine exploded');
+ });
+ });
+
+ describe('CORS', () => {
+ it('responds to OPTIONS preflight with CORS headers', async () => {
+ await server.start(0);
+
+ const res = await fetch(`http://127.0.0.1:${server.port}/run`, {
+ method: 'OPTIONS',
+ });
+
+ expect(res.status).toBe(204);
+ expect(res.headers.get('Access-Control-Allow-Origin')).toBe('*');
+ expect(res.headers.get('Access-Control-Allow-Methods')).toBe('POST, GET');
+ });
+
+ it('includes CORS headers on POST responses', async () => {
+ await server.start(0);
+
+ const res = await fetch(`http://127.0.0.1:${server.port}/run`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ raw: 'click e5' }),
+ });
+
+ expect(res.headers.get('Access-Control-Allow-Origin')).toBe('*');
+ });
+ });
+
+ describe('GET /health', () => {
+ it('returns 200 with status ok', async () => {
+ await server.start(0);
+
+ const res = await fetch(`http://127.0.0.1:${server.port}/health`);
+ expect(res.status).toBe(200);
+ const data = await res.json();
+ expect(data.status).toBe('ok');
+ });
+ });
+
+ describe('other routes', () => {
+ it('returns 404 for unknown paths', async () => {
+ await server.start(0);
+
+ const res = await fetch(`http://127.0.0.1:${server.port}/unknown`);
+ expect(res.status).toBe(404);
+ });
+
+ it('returns 404 for GET /run', async () => {
+ await server.start(0);
+
+ const res = await fetch(`http://127.0.0.1:${server.port}/run`);
+ expect(res.status).toBe(404);
+ });
+ });
+});
diff --git a/packages/core/test/page-scripts.test.mjs b/packages/core/test/page-scripts.test.ts
similarity index 82%
rename from packages/core/test/page-scripts.test.mjs
rename to packages/core/test/page-scripts.test.ts
index c0d9d50..3d690c4 100644
--- a/packages/core/test/page-scripts.test.mjs
+++ b/packages/core/test/page-scripts.test.ts
@@ -1,9 +1,10 @@
+// @ts-nocheck
import { describe, it, expect, vi } from 'vitest';
import {
buildRunCode, verifyText, verifyElement, verifyValue, verifyList,
actionByText, fillByText, selectByText,
checkByText, uncheckByText,
-} from '../src/page-scripts.mjs';
+} from '../src/page-scripts.js';
// ─── buildRunCode ───────────────────────────────────────────────────────────
@@ -35,6 +36,18 @@ describe('buildRunCode', () => {
expect(result._[1]).toContain('["item1","item2"]');
});
+ it('filters out undefined args', () => {
+ const result = buildRunCode(actionByText, 'Submit', 'click', undefined);
+ expect(result._[1]).toContain('(page, "Submit", "click")');
+ // Args should end with just the three values, no trailing undefined/comma
+ expect(result._[1]).toMatch(/\(page, "Submit", "click"\)$/);
+ });
+
+ it('includes nth arg when defined', () => {
+ const result = buildRunCode(actionByText, 'Submit', 'click', 2);
+ expect(result._[1]).toContain('(page, "Submit", "click", 2)');
+ });
+
it('produces code callable as (code)(page) by daemon', async () => {
const result = buildRunCode(verifyText, 'hello');
const code = result._[1];
@@ -155,6 +168,31 @@ describe('actionByText', () => {
await actionByText(page, 'Menu', 'hover');
expect(page._loc.hover).toHaveBeenCalled();
});
+
+ it('chains .nth() when nth is provided', async () => {
+ const nthLoc = mockLocator(1);
+ const loc = mockLocator(3);
+ loc.nth = vi.fn().mockReturnValue(nthLoc);
+ const page = {
+ getByText: vi.fn().mockReturnValue(loc),
+ getByRole: vi.fn().mockReturnValue(loc),
+ };
+ await actionByText(page, 'Learn more', 'click', 1);
+ expect(loc.nth).toHaveBeenCalledWith(1);
+ expect(nthLoc.click).toHaveBeenCalled();
+ });
+
+ it('does not chain .nth() when nth is undefined', async () => {
+ const loc = mockLocator(1);
+ loc.nth = vi.fn();
+ const page = {
+ getByText: vi.fn().mockReturnValue(loc),
+ getByRole: vi.fn().mockReturnValue(loc),
+ };
+ await actionByText(page, 'Submit', 'click');
+ expect(loc.nth).not.toHaveBeenCalled();
+ expect(loc.click).toHaveBeenCalled();
+ });
});
describe('fillByText', () => {
diff --git a/packages/core/test/parser.test.mjs b/packages/core/test/parser.test.ts
similarity index 98%
rename from packages/core/test/parser.test.mjs
rename to packages/core/test/parser.test.ts
index dcace77..4da5084 100644
--- a/packages/core/test/parser.test.mjs
+++ b/packages/core/test/parser.test.ts
@@ -1,5 +1,6 @@
+// @ts-nocheck
import { describe, it, expect } from 'vitest';
-import { parseInput, ALIASES, ALL_COMMANDS, booleanOptions } from '../src/parser.mjs';
+import { parseInput, ALIASES, ALL_COMMANDS, booleanOptions } from '../src/parser.js';
describe('parseInput', () => {
it('parses a basic command', () => {
@@ -102,7 +103,7 @@ describe('parseInput', () => {
describe('ALIASES', () => {
it('maps most aliases to known commands', () => {
- // verify-* aliases map to commands handled as knownExtras in repl.mjs,
+ // verify-* aliases map to commands handled as knownExtras in repl.ts,
// not in the COMMANDS vocabulary — that's intentional.
const extras = ['verify-text', 'verify-element', 'verify-value', 'verify-list'];
for (const [alias, cmd] of Object.entries(ALIASES)) {
diff --git a/packages/core/tsconfig.build.json b/packages/core/tsconfig.build.json
new file mode 100644
index 0000000..0496317
--- /dev/null
+++ b/packages/core/tsconfig.build.json
@@ -0,0 +1,9 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "rootDir": "src",
+ "outDir": "dist",
+ "composite": true
+ },
+ "include": ["src"]
+}
diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json
new file mode 100644
index 0000000..35707f6
--- /dev/null
+++ b/packages/core/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "rootDir": ".",
+ "noEmit": true
+ },
+ "include": ["src", "test"]
+}
diff --git a/packages/core/vitest.config.mjs b/packages/core/vitest.config.ts
similarity index 84%
rename from packages/core/vitest.config.mjs
rename to packages/core/vitest.config.ts
index 26c18fe..ecb2a09 100644
--- a/packages/core/vitest.config.mjs
+++ b/packages/core/vitest.config.ts
@@ -2,7 +2,7 @@ import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
- include: ['test/**/*.test.mjs'],
+ include: ['test/**/*.test.ts'],
coverage: {
provider: 'v8',
include: ['src/**'],
diff --git a/packages/extension/e2e/commands/commands.test.ts b/packages/extension/e2e/commands/commands.test.ts
new file mode 100644
index 0000000..51915f4
--- /dev/null
+++ b/packages/extension/e2e/commands/commands.test.ts
@@ -0,0 +1,332 @@
+/**
+ * Command integration tests — exercises the full stack:
+ * real Engine + CommandServer + Playwright browser.
+ *
+ * Each test sends commands via HTTP POST /run and asserts on actual results.
+ */
+
+import { test, expect } from './fixtures.js';
+
+// ─── Helper ─────────────────────────────────────────────────────────────────
+
+/**
+ * Extract an element ref (e.g. "e5") from snapshot text by matching a label.
+ * Handles formats like:
+ * - link "Learn more" [ref=e6] → label before ref
+ * - textbox "Name" [ref=e2] → label before ref
+ * - combobox [ref=e2]: → type before ref (no label)
+ */
+function findRef(snapshotText: string, labelPattern: string): string {
+ // Try: label appears before [ref=eN] on same line
+ const re1 = new RegExp(`${labelPattern}.*\\[ref=(e\\d+)\\]`, 'i');
+ const m1 = snapshotText.match(re1);
+ if (m1) return m1[1];
+
+ // Try: [ref=eN] appears before label on same line
+ const re2 = new RegExp(`\\[ref=(e\\d+)\\].*${labelPattern}`, 'i');
+ const m2 = snapshotText.match(re2);
+ if (m2) return m2[1];
+
+ throw new Error(`No ref found for "${labelPattern}" in snapshot:\n${snapshotText}`);
+}
+
+// ─── Navigation ─────────────────────────────────────────────────────────────
+
+test('goto navigates to a URL', async ({ run }) => {
+ const result = await run('goto https://example.com');
+ expect(result.isError).toBeFalsy();
+ expect(result.text).toContain('Page URL: https://example.com');
+});
+
+test('goto with alias g', async ({ run }) => {
+ const result = await run('g https://example.com');
+ expect(result.isError).toBeFalsy();
+ expect(result.text).toContain('Page URL: https://example.com');
+});
+
+test('go-back navigates to previous page', async ({ run }) => {
+ await run('goto https://example.com');
+ await run('goto https://www.iana.org');
+ const result = await run('go-back');
+ expect(result.isError).toBeFalsy();
+ expect(result.text).toContain('Page URL: https://example.com');
+});
+
+test('go-forward navigates to next page', async ({ run }) => {
+ await run('goto https://example.com');
+ await run('goto https://www.iana.org');
+ await run('go-back');
+ const result = await run('go-forward');
+ expect(result.isError).toBeFalsy();
+ expect(result.text).toContain('Page URL: https://www.iana.org');
+});
+
+// ─── Snapshot ───────────────────────────────────────────────────────────────
+
+test('snapshot returns accessibility tree with refs', async ({ run }) => {
+ await run('goto https://example.com');
+ const result = await run('snapshot');
+ expect(result.isError).toBeFalsy();
+ expect(result.text).toContain('Example Domain');
+ expect(result.text).toMatch(/\[ref=e\d+\]/);
+});
+
+test('snapshot with alias s', async ({ run }) => {
+ await run('goto https://example.com');
+ const result = await run('s');
+ expect(result.isError).toBeFalsy();
+ expect(result.text).toContain('Example Domain');
+ expect(result.text).toMatch(/\[ref=e\d+\]/);
+});
+
+// ─── Click ──────────────────────────────────────────────────────────────────
+
+test('click an element by ref', async ({ run }) => {
+ await run('goto https://example.com');
+ const snap = await run('snapshot');
+ const ref = findRef(snap.text, 'Learn more');
+ const result = await run(`click ${ref}`);
+ expect(result.isError).toBeFalsy();
+ expect(result.text).toContain('Page URL');
+});
+
+// ─── Fill ───────────────────────────────────────────────────────────────────
+
+test('fill an input field', async ({ run }) => {
+ await run('goto https://demo.playwright.dev/todomvc/');
+ const result = await run('fill "What needs to be done" "Buy groceries"');
+ expect(result.isError).toBeFalsy();
+});
+
+// ─── Press ──────────────────────────────────────────────────────────────────
+
+test('press a keyboard key', async ({ run }) => {
+ await run('goto https://example.com');
+ const result = await run('press Tab');
+ expect(result.isError).toBeFalsy();
+});
+
+// ─── Select ─────────────────────────────────────────────────────────────────
+
+test('select a dropdown option', async ({ run }) => {
+ await run('goto https://the-internet.herokuapp.com/dropdown');
+ const snap = await run('snapshot');
+ const ref = findRef(snap.text, 'combobox');
+ const result = await run(`select ${ref} "Option 1"`);
+ expect(result.isError).toBeFalsy();
+});
+
+// ─── Eval ───────────────────────────────────────────────────────────────────
+
+test('eval executes JavaScript', async ({ run }) => {
+ await run('goto https://example.com');
+ const result = await run('eval document.title');
+ expect(result.isError).toBeFalsy();
+ expect(result.text).toContain('Example Domain');
+});
+
+// ─── Screenshot ─────────────────────────────────────────────────────────────
+
+test('screenshot captures the page', async ({ run }) => {
+ await run('goto https://example.com');
+ const result = await run('screenshot');
+ expect(result.isError).toBeFalsy();
+ expect(result.text).toContain('.png');
+ expect(result.image).toMatch(/^data:image\/png;base64,/);
+});
+
+// ─── Check / Uncheck ────────────────────────────────────────────────────────
+
+test('check a todo item', async ({ run }) => {
+ await run('goto https://demo.playwright.dev/todomvc/');
+ await run('fill "What needs to be done" "Buy groceries"');
+ await run('press Enter');
+ const result = await run('check "Buy groceries"');
+ expect(result.isError).toBeFalsy();
+});
+
+test('uncheck a todo item', async ({ run }) => {
+ await run('goto https://demo.playwright.dev/todomvc/');
+ await run('fill "What needs to be done" "Clean house"');
+ await run('press Enter');
+ await run('check "Clean house"');
+ const result = await run('uncheck "Clean house"');
+ expect(result.isError).toBeFalsy();
+});
+
+// ─── Hover ──────────────────────────────────────────────────────────────────
+
+test('hover over an element', async ({ run }) => {
+ await run('goto https://example.com');
+ const result = await run('hover "Example Domain"');
+ expect(result.isError).toBeFalsy();
+});
+
+// ─── Run-code ───────────────────────────────────────────────────────────────
+
+test('run-code simple expression returns result', async ({ run }) => {
+ await run('goto https://example.com');
+ const result = await run('run-code page.title()');
+ expect(result.isError).toBeFalsy();
+ expect(result.text).toContain('### Result');
+ expect(result.text).toContain('Example Domain');
+ expect(result.text).toContain('return await page.title()');
+});
+
+test('run-code multi-statement with semicolons', async ({ run }) => {
+ await run('goto https://example.com');
+ const result = await run('run-code const t = await page.title(); return t');
+ expect(result.isError).toBeFalsy();
+ expect(result.text).toContain('### Result');
+ expect(result.text).toContain('Example Domain');
+ expect(result.text).toContain('const t = await page.title(); return t');
+});
+
+test('run-code with await statement', async ({ run }) => {
+ await run('goto https://example.com');
+ const result = await run('run-code await page.waitForTimeout(100)');
+ expect(result.isError).toBeFalsy();
+ expect(result.text).toContain('await page.waitForTimeout(100)');
+});
+
+test('run-code with async function passes through unchanged', async ({ run }) => {
+ await run('goto https://example.com');
+ const result = await run('run-code async (page) => { const t = await page.title(); return t }');
+ expect(result.isError).toBeFalsy();
+ expect(result.text).toContain('### Result');
+ expect(result.text).toContain('Example Domain');
+ expect(result.text).toContain('const t = await page.title(); return t');
+});
+
+// ─── Verify commands ────────────────────────────────────────────────────────
+
+test('verify-text passes when text exists', async ({ run }) => {
+ await run('goto https://example.com');
+ const result = await run('verify-text "Example Domain"');
+ expect(result.isError).toBeFalsy();
+});
+
+test('verify-text fails when text is missing', async ({ run }) => {
+ await run('goto https://example.com');
+ const result = await run('verify-text "nonexistent text xyz"');
+ expect(result.isError).toBe(true);
+ expect(result.text).toContain('Text not found');
+});
+
+test('verify-element passes when element exists', async ({ run }) => {
+ await run('goto https://example.com');
+ const result = await run('verify-element heading "Example Domain"');
+ expect(result.isError).toBeFalsy();
+});
+
+// ─── Aliases ────────────────────────────────────────────────────────────────
+
+test('alias c for click', async ({ run }) => {
+ await run('goto https://example.com');
+ const snap = await run('s');
+ const ref = findRef(snap.text, 'Learn more');
+ const result = await run(`c ${ref}`);
+ expect(result.isError).toBeFalsy();
+});
+
+// ─── Tab commands ────────────────────────────────────────────────────────────
+
+/**
+ * Count tab entries in tab-list output.
+ * Format: "- N: [Title](URL)" or "- N: (current) [Title](URL)"
+ */
+function countTabs(tabListText: string): number {
+ return tabListText.split('\n').filter(l => /^- \d+:/.test(l)).length;
+}
+
+test('tab-list shows open tabs', async ({ run }) => {
+ await run('goto https://example.com');
+ const result = await run('tab-list');
+ expect(result.isError).toBeFalsy();
+ expect(result.text).toContain('example.com');
+ expect(result.text).toMatch(/- 0:.*\(current\)/);
+});
+
+test('tab-new opens a new tab', async ({ run }) => {
+ await run('goto https://example.com');
+ const before = await run('tab-list');
+ const tabsBefore = countTabs(before.text);
+
+ const result = await run('tab-new');
+ expect(result.isError).toBeFalsy();
+
+ const after = await run('tab-list');
+ const tabsAfter = countTabs(after.text);
+ expect(tabsAfter).toBe(tabsBefore + 1);
+ // New tab becomes current
+ expect(after.text).toMatch(/- 1:.*\(current\)/);
+});
+
+test('tab-select switches to a tab by index', async ({ run }) => {
+ await run('goto https://example.com');
+ await run('tab-new');
+ // New tab is current — switch back to first tab
+ const result = await run('tab-select 0');
+ expect(result.isError).toBeFalsy();
+
+ // Verify first tab is now current
+ const list = await run('tab-list');
+ expect(list.text).toMatch(/- 0:.*\(current\).*example\.com/);
+});
+
+test('tab-close closes the current tab', async ({ run }) => {
+ await run('goto https://example.com');
+ await run('tab-new');
+ const before = await run('tab-list');
+ const tabsBefore = countTabs(before.text);
+
+ // Close the current tab (the new one)
+ const result = await run('tab-close');
+ expect(result.isError).toBeFalsy();
+
+ const after = await run('tab-list');
+ const tabsAfter = countTabs(after.text);
+ expect(tabsAfter).toBe(tabsBefore - 1);
+});
+
+test('tab-new then goto navigates in the new tab', async ({ run }) => {
+ await run('goto https://example.com');
+ await run('tab-new');
+ await run('goto https://www.iana.org');
+
+ const list = await run('tab-list');
+ expect(list.text).toContain('example.com');
+ expect(list.text).toContain('iana.org');
+});
+
+test('tab aliases tl, tn, tc, ts work', async ({ run }) => {
+ await run('goto https://example.com');
+
+ const list = await run('tl');
+ expect(list.isError).toBeFalsy();
+ expect(list.text).toContain('example.com');
+
+ const newTab = await run('tn');
+ expect(newTab.isError).toBeFalsy();
+
+ const select = await run('ts 0');
+ expect(select.isError).toBeFalsy();
+
+ const close = await run('ts 1');
+ expect(close.isError).toBeFalsy();
+ await run('tc');
+});
+
+// ─── Errors ─────────────────────────────────────────────────────────────────
+
+test('unknown command returns error', async ({ run }) => {
+ const result = await run('nonexistent');
+ expect(result.isError).toBe(true);
+ expect(result.text).toContain('Unknown command');
+});
+
+test('invalid ref returns error', async ({ run }) => {
+ await run('goto https://example.com');
+ const result = await run('click e9999');
+ expect(result.isError).toBe(true);
+});
diff --git a/packages/extension/e2e/commands/fixtures.ts b/packages/extension/e2e/commands/fixtures.ts
new file mode 100644
index 0000000..c45ae54
--- /dev/null
+++ b/packages/extension/e2e/commands/fixtures.ts
@@ -0,0 +1,50 @@
+/**
+ * Command integration test fixtures.
+ *
+ * Launches a real Engine + CommandServer, sends commands via HTTP,
+ * and asserts on actual Playwright browser behavior.
+ */
+
+import { test as base } from '@playwright/test';
+import { Engine, CommandServer } from '@playwright-repl/core';
+
+type EngineContext = { engine: Engine; server: CommandServer; port: number };
+type RunResult = { text: string; isError: boolean; image?: string };
+type RunFn = (command: string) => Promise;
+
+export const test = base.extend<
+ { run: RunFn },
+ { engineContext: EngineContext }
+>({
+ // Worker-scoped: one Engine + CommandServer per worker
+ engineContext: [async ({}, use) => {
+ const engine = new Engine();
+ await engine.start({ browser: 'chromium', headed: false });
+
+ const server = new CommandServer(engine);
+ await server.start(0); // OS-assigned port
+
+ await use({ engine, server, port: server.port as number });
+
+ await server.close();
+ await engine.close();
+ }, { scope: 'worker' }],
+
+ // Test-scoped: run() helper sends commands to the real server
+ run: async ({ engineContext }, use) => {
+ const { port } = engineContext;
+
+ const run = async (command: string): Promise => {
+ const res = await fetch(`http://localhost:${port}/run`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ raw: command }),
+ });
+ return res.json();
+ };
+
+ await use(run);
+ },
+});
+
+export { expect } from '@playwright/test';
diff --git a/packages/extension/e2e/panel/fixtures.ts b/packages/extension/e2e/panel/fixtures.ts
new file mode 100644
index 0000000..4c7215e
--- /dev/null
+++ b/packages/extension/e2e/panel/fixtures.ts
@@ -0,0 +1,84 @@
+/**
+ * E2E test fixtures — launches Chromium with the extension loaded,
+ * sets up page.route() mocking, and provides fixtures to tests.
+ */
+
+import { test as base, chromium, type BrowserContext, type Page } from '@playwright/test';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const EXTENSION_PATH = path.resolve(__dirname, '../../dist');
+
+type ExtensionContext = { context: BrowserContext; extensionId: string };
+type MockResponse = (response: { text: string; isError: boolean }) => void;
+
+/**
+ * Custom test fixtures for the extension panel.
+ *
+ * Worker-scoped: browser context is shared across all tests in a worker.
+ * Test-scoped: panelPage and mockResponse reset per test.
+ */
+export const test = base.extend<
+ { panelPage: Page; mockResponse: MockResponse },
+ { extensionContext: ExtensionContext }
+>({
+ // Worker-scoped: launch browser once, reuse across tests
+ extensionContext: [async ({}, use) => {
+ const context = await chromium.launchPersistentContext('', {
+ channel: 'chromium',
+ headless: !process.env.HEADED,
+ args: [
+ `--disable-extensions-except=${EXTENSION_PATH}`,
+ `--load-extension=${EXTENSION_PATH}`,
+ '--no-first-run',
+ '--no-default-browser-check',
+ ],
+ });
+
+ // Get extension ID from the service worker URL
+ let sw = context.serviceWorkers()[0];
+ if (!sw) sw = await context.waitForEvent('serviceworker');
+ const extensionId = sw.url().split('/')[2];
+
+ // Context-level route: applies to all pages, set up once
+ await context.route('**/health', (route) => {
+ route.fulfill({
+ contentType: 'application/json',
+ body: JSON.stringify({ status: 'ok', version: '0.4.0-test' }),
+ });
+ });
+
+ await use({ context, extensionId });
+ await context.close();
+ }, { scope: 'worker' }],
+
+ // Test-scoped: fresh panelPage with route mocking for each test
+ panelPage: async ({ extensionContext }, use) => {
+ const { context, extensionId } = extensionContext;
+ const page = await context.newPage();
+
+ (page as any)._runResponse = { text: 'OK', isError: false };
+
+ await page.route('**/run', async (route) => {
+ await route.fulfill({
+ contentType: 'application/json',
+ body: JSON.stringify((page as any)._runResponse),
+ });
+ });
+
+ await page.goto(`chrome-extension://${extensionId}/panel/panel.html`);
+ await page.waitForSelector('.line-info', { timeout: 10000 });
+
+ await use(page);
+ await page.close();
+ },
+
+ mockResponse: async ({ panelPage }, use) => {
+ await use((response: { text: string; isError: boolean }) => {
+ (panelPage as any)._runResponse = response;
+ });
+ },
+});
+
+export { expect } from '@playwright/test';
diff --git a/packages/extension/e2e/panel/panel.test.ts b/packages/extension/e2e/panel/panel.test.ts
new file mode 100644
index 0000000..2b5e62c
--- /dev/null
+++ b/packages/extension/e2e/panel/panel.test.ts
@@ -0,0 +1,312 @@
+/**
+ * E2E tests for the extension side panel UI.
+ *
+ * Launches Chromium with the extension loaded, navigates to panel.html,
+ * and uses page.route() to intercept HTTP calls to the CommandServer.
+ */
+
+import { test, expect } from './fixtures.js';
+
+// ─── Initialization ────────────────────────────────────────────────────────
+
+test('shows version from health endpoint', async ({ panelPage }) => {
+ const text = await panelPage.locator('#output').textContent();
+ expect(text).toContain('Playwright REPL v0.4.0-test');
+});
+
+test('shows connected status', async ({ panelPage }) => {
+ const text = await panelPage.locator('#output').textContent();
+ expect(text).toContain('Connected to server');
+});
+
+test('has record button enabled', async ({ panelPage }) => {
+ const enabled = await panelPage.locator('#record-btn').isEnabled();
+ expect(enabled).toBe(true);
+});
+
+test('has prompt visible', async ({ panelPage }) => {
+ const visible = await panelPage.locator('#prompt').isVisible();
+ expect(visible).toBe(true);
+});
+
+// ─── REPL Command Input ────────────────────────────────────────────────────
+
+test('displays success response after command', async ({ panelPage, mockResponse }) => {
+ mockResponse({ text: '### Result\nNavigated to https://example.com', isError: false });
+
+ const input = panelPage.locator('#command-input');
+ await input.fill('goto https://example.com');
+ await input.press('Enter');
+
+ await panelPage.waitForFunction(
+ () => document.querySelector('.line-success')?.textContent?.includes('Navigated'),
+ );
+});
+
+test('clears input after submit', async ({ panelPage }) => {
+ const input = panelPage.locator('#command-input');
+ await input.fill('snapshot');
+ await input.press('Enter');
+ await panelPage.waitForTimeout(200);
+ const value = await input.inputValue();
+ expect(value).toBe('');
+});
+
+test('does not send empty input', async ({ panelPage }) => {
+ const input = panelPage.locator('#command-input');
+ await input.fill(' ');
+ await input.press('Enter');
+ await panelPage.waitForTimeout(300);
+ const commands = panelPage.locator('.line-command');
+ expect(await commands.count()).toBe(0);
+});
+
+test('displays error responses with error styling', async ({ panelPage, mockResponse }) => {
+ mockResponse({ text: 'Element not found', isError: true });
+
+ const input = panelPage.locator('#command-input');
+ await input.fill('click missing');
+ await input.press('Enter');
+
+ await panelPage.waitForFunction(
+ () => document.querySelector('.line-error')?.textContent?.includes('Element not found'),
+ );
+});
+
+// ─── Command History ───────────────────────────────────────────────────────
+
+test('navigates history with ArrowUp/ArrowDown', async ({ panelPage }) => {
+ const input = panelPage.locator('#command-input');
+
+ await input.fill('goto https://a.com');
+ await input.press('Enter');
+ await panelPage.waitForTimeout(300);
+
+ await input.fill('goto https://b.com');
+ await input.press('Enter');
+ await panelPage.waitForTimeout(300);
+
+ await input.press('ArrowUp');
+ expect(await input.inputValue()).toBe('goto https://b.com');
+
+ await input.press('ArrowUp');
+ expect(await input.inputValue()).toBe('goto https://a.com');
+
+ await input.press('ArrowDown');
+ expect(await input.inputValue()).toBe('goto https://b.com');
+
+ await input.press('ArrowDown');
+ expect(await input.inputValue()).toBe('');
+});
+
+// ─── Local Commands ────────────────────────────────────────────────────────
+
+test('clear empties the output', async ({ panelPage }) => {
+ const input = panelPage.locator('#command-input');
+ await input.fill('snapshot');
+ await input.press('Enter');
+ await panelPage.waitForSelector('.line-command');
+
+ await input.fill('clear');
+ await input.press('Enter');
+
+ // Wait for the output to be cleared
+ await expect(panelPage.locator('#output .line')).toHaveCount(0);
+});
+
+test('comments display without server call', async ({ panelPage }) => {
+ const input = panelPage.locator('#command-input');
+ await input.fill('# this is a comment');
+ await input.press('Enter');
+
+ await panelPage.waitForSelector('.line-comment');
+ const text = await panelPage.locator('.line-comment').last().textContent();
+ expect(text).toContain('# this is a comment');
+});
+
+// ─── Editor ────────────────────────────────────────────────────────────────
+
+test('shows line numbers for content', async ({ panelPage }) => {
+ const editor = panelPage.locator('#editor');
+ await editor.fill('goto https://example.com\nclick OK\npress Enter');
+ await editor.dispatchEvent('input');
+ await panelPage.waitForTimeout(100);
+
+ const lineNums = panelPage.locator('#line-numbers div');
+ expect(await lineNums.count()).toBe(3);
+});
+
+test('enables buttons when editor has content', async ({ panelPage }) => {
+ const editor = panelPage.locator('#editor');
+ await editor.fill('goto https://example.com');
+ await editor.dispatchEvent('input');
+ await panelPage.waitForTimeout(100);
+
+ expect(await panelPage.locator('#copy-btn').isDisabled()).toBe(false);
+ expect(await panelPage.locator('#save-btn').isDisabled()).toBe(false);
+ expect(await panelPage.locator('#export-btn').isDisabled()).toBe(false);
+});
+
+test('disables buttons when editor is empty', async ({ panelPage }) => {
+ const editor = panelPage.locator('#editor');
+ await editor.fill('');
+ await editor.dispatchEvent('input');
+ await panelPage.waitForTimeout(100);
+
+ expect(await panelPage.locator('#copy-btn').isDisabled()).toBe(true);
+ expect(await panelPage.locator('#save-btn').isDisabled()).toBe(true);
+ expect(await panelPage.locator('#export-btn').isDisabled()).toBe(true);
+});
+
+// ─── Run Button ────────────────────────────────────────────────────────────
+
+test('executes all editor lines and shows Run complete', async ({ panelPage }) => {
+ const editor = panelPage.locator('#editor');
+ await editor.fill('goto https://example.com\nclick OK');
+ await editor.dispatchEvent('input');
+
+ await panelPage.locator('#run-btn').click();
+
+ await panelPage.waitForFunction(
+ () => document.getElementById('output')!.textContent!.includes('Run complete'),
+ { timeout: 15000 },
+ );
+});
+
+test('shows fail stats when command errors', async ({ panelPage, mockResponse }) => {
+ mockResponse({ text: 'Failed', isError: true });
+
+ const input = panelPage.locator('#command-input');
+ await input.fill('clear');
+ await input.press('Enter');
+
+ const editor = panelPage.locator('#editor');
+ await editor.fill('click missing');
+ await editor.dispatchEvent('input');
+
+ await panelPage.locator('#run-btn').click();
+
+ await panelPage.waitForFunction(
+ () => document.getElementById('output')!.textContent!.includes('Run complete'),
+ { timeout: 15000 },
+ );
+
+ const statsText = await panelPage.locator('#console-stats').textContent();
+ expect(statsText).toContain('1');
+});
+
+// ─── Recording UI ─────────────────────────────────────────────────────────
+
+test('record button toggles to Stop when recording starts', async ({ panelPage }) => {
+ // Mock chrome APIs so we don't need a real tab to inject into
+ await panelPage.evaluate(() => {
+ chrome.tabs.query = async () => [{ id: 999, title: "Test Page", url: "https://example.com" }] as chrome.tabs.Tab[];
+ const origSend = chrome.runtime.sendMessage.bind(chrome.runtime);
+ chrome.runtime.sendMessage = async (msg: any) => {
+ if (msg.type === 'pw-record-start' || msg.type === 'pw-record-stop') return { ok: true };
+ return origSend(msg);
+ };
+ });
+
+ const btn = panelPage.locator('#record-btn');
+
+ // Click to start recording
+ await btn.click();
+ await expect(btn).toHaveText('Stop');
+ const hasRecording = await btn.evaluate(el => el.classList.contains('recording'));
+ expect(hasRecording).toBe(true);
+
+ // Info message should appear
+ await panelPage.waitForFunction(
+ () => {
+ const infos = document.querySelectorAll('.line-info');
+ return [...infos].some(el => el.textContent!.includes('Recording on'));
+ },
+ );
+});
+
+test('record button toggles back to Record when stopped', async ({ panelPage }) => {
+ await panelPage.evaluate(() => {
+ chrome.tabs.query = async () => [{ id: 999, title: "Test Page", url: "https://example.com" }] as chrome.tabs.Tab[];
+ const origSend = chrome.runtime.sendMessage.bind(chrome.runtime);
+ chrome.runtime.sendMessage = async (msg: any) => {
+ if (msg.type === 'pw-record-start' || msg.type === 'pw-record-stop') return { ok: true };
+ return origSend(msg);
+ };
+ });
+
+ const btn = panelPage.locator('#record-btn');
+
+ // Start then stop
+ await btn.click();
+ await expect(btn).toHaveText('Stop');
+ await btn.click();
+ await expect(btn).toHaveText('Record');
+ const hasRecording = await btn.evaluate(el => el.classList.contains('recording'));
+ expect(hasRecording).toBe(false);
+
+ // Stop info message should appear
+ await panelPage.waitForFunction(
+ () => {
+ const infos = document.querySelectorAll('.line-info');
+ return [...infos].some(el => el.textContent!.includes('Recording stopped'));
+ },
+ );
+});
+
+test('record button shows error when injection fails', async ({ panelPage }) => {
+ await panelPage.evaluate(() => {
+ chrome.tabs.query = async () => [{ id: 999, title: "Test Page", url: "chrome://settings" }] as chrome.tabs.Tab[];
+ const origSend = chrome.runtime.sendMessage.bind(chrome.runtime);
+ chrome.runtime.sendMessage = async (msg: any) => {
+ if (msg.type === 'pw-record-start') return { ok: false, error: 'Cannot access chrome:// URLs' };
+ return origSend(msg);
+ };
+ });
+
+ const btn = panelPage.locator('#record-btn');
+ await btn.click();
+
+ // Should show error
+ await panelPage.waitForFunction(
+ () => document.querySelector('.line-error')?.textContent?.includes('Cannot access'),
+ );
+
+ // Button should NOT be in recording state
+ const hasRecording = await btn.evaluate(el => el.classList.contains('recording'));
+ expect(hasRecording).toBe(false);
+});
+
+test('received recorded commands appear in editor', async ({ panelPage }) => {
+ // Send a message from the service worker to simulate a recorded command
+ const context = panelPage.context();
+ const sw = context.serviceWorkers()[0];
+
+ await sw.evaluate(() => {
+ chrome.runtime.sendMessage({ type: 'pw-recorded-command', command: 'click "Submit"' });
+ });
+
+ // Verify command appears in the console output
+ await panelPage.waitForFunction(
+ () => document.querySelector('.line-command')?.textContent?.includes('click "Submit"'),
+ );
+
+ // Verify command is also appended to the editor
+ const editorValue = await panelPage.locator('#editor').inputValue();
+ expect(editorValue).toContain('click "Submit"');
+});
+
+// ─── Theme ─────────────────────────────────────────────────────────────────
+
+test('applies dark theme based on color scheme', async ({ panelPage }) => {
+ await panelPage.emulateMedia({ colorScheme: 'dark' });
+ await panelPage.reload();
+ await panelPage.waitForSelector('.line-info', { timeout: 10000 });
+
+ const hasDark = await panelPage.evaluate(
+ () => document.body.classList.contains('theme-dark'),
+ );
+ expect(hasDark).toBe(true);
+
+ await panelPage.emulateMedia({ colorScheme: 'light' });
+});
diff --git a/packages/extension/e2e/recording/fixtures.ts b/packages/extension/e2e/recording/fixtures.ts
new file mode 100644
index 0000000..11d602c
--- /dev/null
+++ b/packages/extension/e2e/recording/fixtures.ts
@@ -0,0 +1,85 @@
+/**
+ * Recording integration E2E fixtures.
+ *
+ * Launches Chromium with the real extension loaded, opens a panel page
+ * and a target page. Tests verify that user interactions on the target
+ * page produce recorded commands visible in the panel editor.
+ *
+ * Unlike the panel tests (which mock chrome APIs), these tests exercise
+ * the real background.js + recorder.js + panel.js message flow.
+ */
+
+import { test as base, chromium, type BrowserContext, type Page, type Worker } from '@playwright/test';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const EXTENSION_PATH = path.resolve(__dirname, '../../dist');
+
+type ExtensionContext = { context: BrowserContext; extensionId: string; sw: Worker };
+type RecordingPages = { panelPage: Page; targetPage: Page; extensionId: string; sw: Worker; context: BrowserContext };
+
+export const test = base.extend<
+ { recordingPages: RecordingPages },
+ { extensionContext: ExtensionContext }
+>({
+ // Worker-scoped: browser context with extension loaded
+ extensionContext: [async ({}, use) => {
+ const context = await chromium.launchPersistentContext('', {
+ channel: 'chromium',
+ headless: !process.env.HEADED,
+ args: [
+ `--disable-extensions-except=${EXTENSION_PATH}`,
+ `--load-extension=${EXTENSION_PATH}`,
+ '--no-first-run',
+ '--no-default-browser-check',
+ ],
+ });
+
+ let sw = context.serviceWorkers()[0];
+ if (!sw) sw = await context.waitForEvent('serviceworker');
+ const extensionId = sw.url().split('/')[2];
+
+ await use({ context, extensionId, sw });
+ await context.close();
+ }, { scope: 'worker' }],
+
+ // Test-scoped: panel page + target page pair
+ recordingPages: async ({ extensionContext }, use) => {
+ const { context, extensionId, sw } = extensionContext;
+
+ // Mock /health and /run so panel initializes (recording is extension-side only)
+ const panelPage = await context.newPage();
+
+ await panelPage.route('**/health', (route) => {
+ route.fulfill({
+ contentType: 'application/json',
+ body: JSON.stringify({ status: 'ok', version: '0.4.0-test' }),
+ });
+ });
+ await panelPage.route('**/run', async (route) => {
+ await route.fulfill({
+ contentType: 'application/json',
+ body: JSON.stringify({ text: 'OK', isError: false }),
+ });
+ });
+
+ await panelPage.goto(`chrome-extension://${extensionId}/panel/panel.html`);
+ await panelPage.waitForSelector('.line-info', { timeout: 10000 });
+
+ // Open a target page with a simple test form
+ const targetPage = await context.newPage();
+ await targetPage.goto('https://example.com');
+ await targetPage.waitForLoadState('domcontentloaded');
+
+ // Bring target page to front so it's the "active tab" for chrome.tabs.query
+ await targetPage.bringToFront();
+
+ await use({ panelPage, targetPage, extensionId, sw, context });
+
+ await targetPage.close();
+ await panelPage.close();
+ },
+});
+
+export { expect } from '@playwright/test';
diff --git a/packages/extension/e2e/recording/recording.test.ts b/packages/extension/e2e/recording/recording.test.ts
new file mode 100644
index 0000000..933679c
--- /dev/null
+++ b/packages/extension/e2e/recording/recording.test.ts
@@ -0,0 +1,248 @@
+/**
+ * Recording integration E2E tests.
+ *
+ * Tests the full recording pipeline with the real extension:
+ * recorder.js (content script) → chrome.runtime.sendMessage → panel.js
+ *
+ * Key challenge: In E2E tests the panel runs as a regular tab
+ * (not a real side panel), so we trigger recording via chrome.runtime.sendMessage
+ * from the panel page (an extension page that can message the service worker).
+ */
+
+import { test, expect } from './fixtures.js';
+import type { Page } from '@playwright/test';
+
+// ─── Helpers ────────────────────────────────────────────────────────────────
+
+/**
+ * Start recording on the target page via chrome.runtime.sendMessage
+ * sent from the panel page (an extension page), the same way the
+ * panel triggers recording in production.
+ */
+async function startRecordingOn(panelPage: Page, targetPage: Page): Promise<{ ok: boolean; error?: string }> {
+ const targetUrl = targetPage.url();
+ return await panelPage.evaluate(async (url: string) => {
+ const tabs: any[] = await (chrome.tabs.query as any)({});
+ const tab = tabs.find((t: any) => t.url && t.url.startsWith(url));
+ if (!tab) return { ok: false, error: 'Target tab not found for ' + url };
+ return await (chrome.runtime.sendMessage as any)({ type: 'pw-record-start', tabId: tab.id });
+ }, targetUrl);
+}
+
+/**
+ * Stop recording on the target page.
+ */
+async function stopRecordingOn(panelPage: Page, targetPage: Page): Promise<{ ok: boolean }> {
+ const targetUrl = targetPage.url();
+ return await panelPage.evaluate(async (url: string) => {
+ const tabs: any[] = await (chrome.tabs.query as any)({});
+ const tab = tabs.find((t: any) => t.url && t.url.startsWith(url));
+ if (!tab) return { ok: true };
+ return await (chrome.runtime.sendMessage as any)({ type: 'pw-record-stop', tabId: tab.id });
+ }, targetUrl);
+}
+
+/**
+ * Wait for a command matching a pattern to appear in the panel's console output.
+ */
+async function waitForConsoleCommand(panelPage: Page, pattern: string, timeoutMs: number = 10000): Promise {
+ await panelPage.waitForFunction(
+ ([pat]) => {
+ const cmds = document.querySelectorAll('.line-command');
+ return [...cmds].some(el => new RegExp(pat).test(el.textContent!));
+ },
+ [pattern],
+ { timeout: timeoutMs },
+ );
+}
+
+// ─── Tests ──────────────────────────────────────────────────────────────────
+
+test('recorder injects and captures a click on a link', async ({ recordingPages }) => {
+ const { panelPage, targetPage } = recordingPages;
+
+ // Start recording on the target page
+ const result = await startRecordingOn(panelPage, targetPage);
+ expect(result.ok).toBe(true);
+
+ // Bring target page to front and click the link
+ await targetPage.bringToFront();
+ await targetPage.locator('a').first().click();
+
+ // The recorder should capture the click and send it to the panel
+ await panelPage.bringToFront();
+ await waitForConsoleCommand(panelPage, 'click');
+
+ // Verify it also appeared in the editor
+ const editorValue = await panelPage.locator('#editor').inputValue();
+ expect(editorValue).toContain('click');
+
+ await stopRecordingOn(panelPage, targetPage);
+});
+
+test('recorder appends --nth for ambiguous locators', async ({ recordingPages }) => {
+ const { panelPage, targetPage } = recordingPages;
+
+ // Navigate to a page with multiple duplicate tab buttons (npm/yarn tabs)
+ await targetPage.goto('https://playwright.dev/docs/intro');
+ await targetPage.waitForLoadState('domcontentloaded');
+
+ const result = await startRecordingOn(panelPage, targetPage);
+ expect(result.ok).toBe(true);
+
+ // Click the second "npm" tab — multiple tab buttons share the same "npm" text.
+ // The recorder's nthSuffix only counts interactive elements (role=tab, button, a, etc.)
+ // so non-clickable "npm" text in code blocks is ignored.
+ await targetPage.bringToFront();
+ const npmTabs = targetPage.locator('role=tab', { hasText: /^npm$/ });
+ const count = await npmTabs.count();
+ expect(count).toBeGreaterThan(1); // confirm duplicates exist
+ await npmTabs.nth(1).click();
+
+ await panelPage.bringToFront();
+ await waitForConsoleCommand(panelPage, 'click.*--nth 1');
+
+ const editorValue = await panelPage.locator('#editor').inputValue();
+ expect(editorValue).toContain('--nth 1');
+ await stopRecordingOn(panelPage, targetPage);
+});
+
+test('recorder ignores clicks on non-interactive elements', async ({ recordingPages }) => {
+ const { panelPage, targetPage } = recordingPages;
+
+ await targetPage.goto('https://playwright.dev/docs/intro');
+ await targetPage.waitForLoadState('domcontentloaded');
+
+ const result = await startRecordingOn(panelPage, targetPage);
+ expect(result.ok).toBe(true);
+
+ // Click a heading — non-interactive, recorder should ignore it
+ await targetPage.bringToFront();
+ await targetPage.locator('h1').first().click();
+
+ // Wait and verify no command was recorded
+ await panelPage.bringToFront();
+ await panelPage.waitForTimeout(2000);
+ const editorValue = await panelPage.locator('#editor').inputValue();
+ expect(editorValue).not.toContain('click');
+
+ await stopRecordingOn(panelPage, targetPage);
+});
+
+test('recorder captures input/fill events', async ({ recordingPages }) => {
+ const { panelPage, targetPage } = recordingPages;
+
+ // Navigate to a page with an input field
+ await targetPage.goto('https://demo.playwright.dev/todomvc/');
+ await targetPage.waitForLoadState('domcontentloaded');
+
+ const result = await startRecordingOn(panelPage, targetPage);
+ expect(result.ok).toBe(true);
+
+ // Type into the input field — recorder debounces fill after 1500ms
+ await targetPage.bringToFront();
+ const input = targetPage.locator('.new-todo');
+ await input.fill('Buy groceries');
+
+ // Wait for the debounced fill to flush (1500ms debounce + buffer)
+ await targetPage.waitForTimeout(2500);
+
+ await panelPage.bringToFront();
+ await waitForConsoleCommand(panelPage, 'fill.*Buy groceries');
+
+ const editorValue = await panelPage.locator('#editor').inputValue();
+ expect(editorValue).toContain('fill');
+ expect(editorValue).toContain('Buy groceries');
+
+ await stopRecordingOn(panelPage, targetPage);
+});
+
+test('recorder captures keyboard press events', async ({ recordingPages }) => {
+ const { panelPage, targetPage } = recordingPages;
+
+ // Navigate to a page with an input
+ await targetPage.goto('https://demo.playwright.dev/todomvc/');
+ await targetPage.waitForLoadState('domcontentloaded');
+
+ const result = await startRecordingOn(panelPage, targetPage);
+ expect(result.ok).toBe(true);
+
+ // Type and press Enter
+ await targetPage.bringToFront();
+ const input = targetPage.locator('.new-todo');
+ await input.fill('Test item');
+ await input.press('Enter');
+
+ await panelPage.bringToFront();
+ await waitForConsoleCommand(panelPage, 'press Enter');
+
+ const editorValue = await panelPage.locator('#editor').inputValue();
+ expect(editorValue).toContain('press Enter');
+
+ await stopRecordingOn(panelPage, targetPage);
+});
+
+test('stop recording cleans up — no more commands captured', async ({ recordingPages }) => {
+ const { panelPage, targetPage } = recordingPages;
+
+ const result = await startRecordingOn(panelPage, targetPage);
+ expect(result.ok).toBe(true);
+
+ // Click while recording — should be captured
+ await targetPage.bringToFront();
+ await targetPage.locator('a').first().click();
+ await panelPage.bringToFront();
+ await waitForConsoleCommand(panelPage, 'click');
+
+ // Stop recording
+ await stopRecordingOn(panelPage, targetPage);
+
+ // Clear editor to check for new commands
+ await panelPage.evaluate(() => {
+ (document.getElementById('editor') as HTMLTextAreaElement).value = '';
+ });
+
+ // Navigate back and click again — should NOT be captured
+ await targetPage.bringToFront();
+ await targetPage.goBack();
+ await targetPage.waitForLoadState('domcontentloaded');
+ await targetPage.locator('a').first().click();
+
+ // Wait a bit and verify no new commands appeared in editor
+ await panelPage.bringToFront();
+ await panelPage.waitForTimeout(2000);
+ const editorValue = await panelPage.locator('#editor').inputValue();
+ expect(editorValue).toBe('');
+});
+
+test('recorder re-injects after page navigation', async ({ recordingPages }) => {
+ const { panelPage, targetPage } = recordingPages;
+
+ const result = await startRecordingOn(panelPage, targetPage);
+ expect(result.ok).toBe(true);
+
+ // Click a link that navigates to a new page
+ await targetPage.bringToFront();
+ await targetPage.locator('a').first().click();
+ await targetPage.waitForLoadState('domcontentloaded');
+
+ // Wait for re-injection (tabs.onUpdated fires on 'complete')
+ await targetPage.waitForTimeout(1000);
+
+ // Clear the editor to isolate new commands
+ await panelPage.bringToFront();
+ await panelPage.evaluate(() => {
+ (document.getElementById('editor') as HTMLTextAreaElement).value = '';
+ });
+
+ // Now interact with the new page — recorder should still work
+ await targetPage.bringToFront();
+
+ // Press Tab on the new page to trigger a keyboard event
+ await targetPage.keyboard.press('Tab');
+
+ await panelPage.bringToFront();
+ await waitForConsoleCommand(panelPage, 'press Tab');
+
+ await stopRecordingOn(panelPage, targetPage);
+});
diff --git a/packages/extension/eslint.config.js b/packages/extension/eslint.config.js
new file mode 100644
index 0000000..918bf76
--- /dev/null
+++ b/packages/extension/eslint.config.js
@@ -0,0 +1,24 @@
+import eslint from "@eslint/js";
+import tseslint from "typescript-eslint";
+
+export default tseslint.config(
+ eslint.configs.recommended,
+ ...tseslint.configs.recommended,
+ {
+ ignores: ["dist/**", "node_modules/**"],
+ },
+ {
+ files: ["src/**/*.ts", "test/**/*.ts", "e2e/**/*.ts"],
+ rules: {
+ "@typescript-eslint/no-explicit-any": "off",
+ "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_", caughtErrorsIgnorePattern: "^_" }],
+ "no-empty-pattern": "off",
+ },
+ },
+ {
+ files: ["src/content/recorder.ts"],
+ rules: {
+ "no-var": "off",
+ },
+ },
+);
diff --git a/packages/extension/package.json b/packages/extension/package.json
index f2e7856..33cba3e 100644
--- a/packages/extension/package.json
+++ b/packages/extension/package.json
@@ -1,9 +1,28 @@
{
"name": "@playwright-repl/extension",
- "version": "0.9.3",
+ "version": "1.1.0",
"private": true,
"description": "Chrome DevTools panel extension for Playwright REPL",
+ "type": "module",
"scripts": {
- "test": "echo \"no tests yet\""
+ "build": "vite build",
+ "typecheck": "tsc",
+ "test": "vitest run",
+ "test:e2e": "npx playwright test",
+ "test:coverage": "vitest run --coverage",
+ "lint": "eslint ."
+ },
+ "devDependencies": {
+ "@eslint/js": "^10.0.1",
+ "@playwright/test": ">=1.59.0-alpha-2026-02-01",
+ "@types/chrome": "^0.0.304",
+ "@vitest/coverage-v8": "^4.0.18",
+ "eslint": "^10.0.1",
+ "happy-dom": "^20.6.1",
+ "typescript": "^5.7.3",
+ "typescript-eslint": "^8.56.0",
+ "vite": "^6.1.0",
+ "vitest": "^4.0.18",
+ "vitest-chrome": "^0.1.0"
}
}
diff --git a/packages/extension/playwright.config.ts b/packages/extension/playwright.config.ts
new file mode 100644
index 0000000..a8ef72f
--- /dev/null
+++ b/packages/extension/playwright.config.ts
@@ -0,0 +1,7 @@
+import { defineConfig } from '@playwright/test';
+
+export default defineConfig({
+ testDir: './e2e',
+ timeout: 60000,
+ retries: 0,
+});
diff --git a/packages/extension/public/icons/icon128.png b/packages/extension/public/icons/icon128.png
new file mode 100644
index 0000000..248b994
Binary files /dev/null and b/packages/extension/public/icons/icon128.png differ
diff --git a/packages/extension/public/icons/icon16.png b/packages/extension/public/icons/icon16.png
new file mode 100644
index 0000000..ff1e120
Binary files /dev/null and b/packages/extension/public/icons/icon16.png differ
diff --git a/packages/extension/public/icons/icon48.png b/packages/extension/public/icons/icon48.png
new file mode 100644
index 0000000..ff3fb89
Binary files /dev/null and b/packages/extension/public/icons/icon48.png differ
diff --git a/packages/extension/public/manifest.json b/packages/extension/public/manifest.json
new file mode 100644
index 0000000..ec30116
--- /dev/null
+++ b/packages/extension/public/manifest.json
@@ -0,0 +1,36 @@
+{
+ "manifest_version": 3,
+ "name": "Playwright REPL",
+ "version": "1.0.0",
+ "description": "A side panel for Playwright-style browser automation commands.",
+ "permissions": [
+ "activeTab",
+ "tabs",
+ "sidePanel",
+ "scripting",
+ "webNavigation"
+ ],
+ "host_permissions": [
+ ""
+ ],
+ "action": {
+ "default_title": "Playwright REPL",
+ "default_icon": {
+ "16": "icons/icon16.png",
+ "48": "icons/icon48.png",
+ "128": "icons/icon128.png"
+ }
+ },
+ "side_panel": {
+ "default_path": "panel/panel.html"
+ },
+ "background": {
+ "service_worker": "background.js",
+ "type": "module"
+ },
+ "icons": {
+ "16": "icons/icon16.png",
+ "48": "icons/icon48.png",
+ "128": "icons/icon128.png"
+ }
+}
diff --git a/packages/extension/src/background.ts b/packages/extension/src/background.ts
new file mode 100644
index 0000000..710a82d
--- /dev/null
+++ b/packages/extension/src/background.ts
@@ -0,0 +1,163 @@
+// background.js — Opens side panel when extension icon is clicked + recording handlers.
+chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true });
+
+// ─── Recording State ───────────────────────────────────────────────────────
+
+let recordingTabId: number | null = null;
+let tabUpdateListener: ((tabId: number, changeInfo: chrome.tabs.TabChangeInfo) => void) | null = null;
+let navCommittedListener: ((details: chrome.webNavigation.WebNavigationTransitionCallbackDetails) => void) | null = null;
+let lastRecordedUrl: string | null = null;
+let urlStack: string[] = [];
+let stackIndex: number = -1;
+
+// ─── Message Handler ────────────────────────────────────────────────────────
+
+chrome.runtime.onMessage.addListener((msg: { type: string; tabId?: number }, sender: chrome.runtime.MessageSender, sendResponse: (response?: unknown) => void) => {
+ if (msg.type === "pw-record-start") {
+ startRecording(msg.tabId!).then(sendResponse);
+ return true;
+ }
+ if (msg.type === "pw-record-stop") {
+ stopRecording(msg.tabId!).then(sendResponse);
+ return true;
+ }
+});
+
+// ─── Tab Activation (follow active tab) ─────────────────────────────────────
+
+chrome.tabs.onActivated.addListener((activeInfo: chrome.tabs.TabActiveInfo) => {
+ if (recordingTabId === null) return;
+ recordingTabId = activeInfo.tabId;
+ injectRecorder(activeInfo.tabId).catch(() => {});
+ // Notify panel about tab switch
+ chrome.tabs.get(activeInfo.tabId, (tab) => {
+ if (chrome.runtime.lastError) return;
+ chrome.runtime.sendMessage({
+ type: "pw-recorded-command",
+ command: "# tab: " + (tab.title || tab.url || "unknown"),
+ }).catch(() => {});
+ });
+});
+
+// ─── Recording Functions ────────────────────────────────────────────────────
+
+async function startRecording(tabId: number): Promise<{ ok: boolean; error?: string }> {
+ try {
+ await injectRecorder(tabId);
+ recordingTabId = tabId;
+
+ // Store the initial URL and initialize history stack
+ const tab = await chrome.tabs.get(tabId);
+ lastRecordedUrl = tab.url ?? null;
+ urlStack = [tab.url ?? ""];
+ stackIndex = 0;
+
+ // Listen for navigation commits to detect back/forward vs typed URLs
+ navCommittedListener = (details: chrome.webNavigation.WebNavigationTransitionCallbackDetails) => {
+ if (details.tabId !== recordingTabId) return;
+ if (details.frameId !== 0) return; // main frame only
+ if (details.url === lastRecordedUrl) return;
+
+ const qualifiers = details.transitionQualifiers || [];
+
+ if (qualifiers.includes("forward_back")) {
+ // Back or forward — compare to history stack
+ if (stackIndex > 0 && details.url === urlStack[stackIndex - 1]) {
+ stackIndex--;
+ chrome.runtime.sendMessage({
+ type: "pw-recorded-command",
+ command: "go-back",
+ }).catch(() => {});
+ } else if (stackIndex < urlStack.length - 1 && details.url === urlStack[stackIndex + 1]) {
+ stackIndex++;
+ chrome.runtime.sendMessage({
+ type: "pw-recorded-command",
+ command: "go-forward",
+ }).catch(() => {});
+ } else {
+ // Can't determine direction — default to go-back
+ chrome.runtime.sendMessage({
+ type: "pw-recorded-command",
+ command: "go-back",
+ }).catch(() => {});
+ }
+ } else if (details.transitionType === "typed" || qualifiers.includes("from_address_bar")) {
+ // User typed URL in address bar
+ chrome.runtime.sendMessage({
+ type: "pw-recorded-command",
+ command: "goto " + details.url,
+ }).catch(() => {});
+ // Push to history stack (truncate any forward entries)
+ urlStack = urlStack.slice(0, stackIndex + 1);
+ urlStack.push(details.url);
+ stackIndex = urlStack.length - 1;
+ } else {
+ // Link click, form submit, etc. — don't emit goto (click already recorded)
+ // But do update the history stack
+ urlStack = urlStack.slice(0, stackIndex + 1);
+ urlStack.push(details.url);
+ stackIndex = urlStack.length - 1;
+ }
+
+ lastRecordedUrl = details.url;
+ };
+ chrome.webNavigation.onCommitted.addListener(navCommittedListener);
+
+ // Listen for page load completion to re-inject recorder
+ tabUpdateListener = (updatedTabId: number, changeInfo: chrome.tabs.TabChangeInfo) => {
+ if (updatedTabId !== recordingTabId) return;
+ if (changeInfo.status === "complete") {
+ injectRecorder(updatedTabId).catch((err: unknown) => {
+ console.warn("[recorder] re-injection failed:", (err as Error).message);
+ });
+ }
+ };
+ chrome.tabs.onUpdated.addListener(tabUpdateListener);
+
+ return { ok: true };
+ } catch (err: unknown) {
+ return { ok: false, error: (err as Error).message };
+ }
+}
+
+async function stopRecording(tabId: number): Promise<{ ok: boolean }> {
+ try {
+ // Remove navigation listeners
+ if (navCommittedListener) {
+ chrome.webNavigation.onCommitted.removeListener(navCommittedListener);
+ navCommittedListener = null;
+ }
+ if (tabUpdateListener) {
+ chrome.tabs.onUpdated.removeListener(tabUpdateListener);
+ tabUpdateListener = null;
+ }
+ recordingTabId = null;
+ urlStack = [];
+ stackIndex = -1;
+
+ // Run cleanup on the tab
+ await chrome.scripting.executeScript({
+ target: { tabId },
+ func: () => {
+ if (typeof window.__pwRecorderCleanup === "function") {
+ window.__pwRecorderCleanup();
+ }
+ },
+ });
+
+ return { ok: true };
+ } catch (_err) {
+ // Cleanup failure is non-fatal (tab may have closed)
+ return { ok: true };
+ }
+}
+
+async function injectRecorder(tabId: number): Promise {
+ await chrome.scripting.executeScript({
+ target: { tabId },
+ files: ["content/recorder.js"],
+ });
+}
+
+export { startRecording, stopRecording, injectRecorder };
+
diff --git a/packages/extension/src/content/recorder.ts b/packages/extension/src/content/recorder.ts
new file mode 100644
index 0000000..ee2f22e
--- /dev/null
+++ b/packages/extension/src/content/recorder.ts
@@ -0,0 +1,276 @@
+// Content recorder — injected into pages via chrome.scripting.executeScript.
+// Captures user interactions and sends .pw commands via chrome.runtime.sendMessage.
+
+(() => {
+ if (document.documentElement.dataset.pwRecorderActive) return;
+ document.documentElement.dataset.pwRecorderActive = "true";
+
+ function getLocator(el: Element): string {
+ const ariaLabel = el.getAttribute && el.getAttribute("aria-label");
+ if (ariaLabel) return quote(ariaLabel);
+
+ if (el.id) {
+ const label = document.querySelector('label[for="' + CSS.escape(el.id) + '"]');
+ if (label && label.textContent!.trim()) return quote(label.textContent!.trim());
+ }
+ const parentLabel = el.closest && el.closest("label");
+ if (parentLabel && parentLabel.textContent!.trim()) {
+ return quote(parentLabel.textContent!.trim());
+ }
+
+ if ((el as HTMLInputElement).placeholder) return quote((el as HTMLInputElement).placeholder);
+
+ const text = el.textContent ? el.textContent.trim() : "";
+ if (text && text.length < 80 && el.children.length === 0) return quote(text);
+
+ if ((el.tagName === "BUTTON" || el.tagName === "A") && text && text.length < 80) {
+ return quote(text);
+ }
+
+ if ((el as HTMLElement).title) return quote((el as HTMLElement).title);
+
+ const tag = el.tagName ? el.tagName.toLowerCase() : "unknown";
+ return quote(tag);
+ }
+
+ // Get the primary text of a container (e.g., a list item's label)
+ function getItemContext(el: Element): string | null {
+ const item = el.closest && el.closest("li, tr, [role=listitem], [role=row], article");
+ if (!item) return null;
+ const primary = item.querySelector("label, h1, h2, h3, h4, [class*=title], [class*=text], p, span");
+ if (primary && primary !== el && primary.textContent!.trim()) {
+ const t = primary.textContent!.trim();
+ if (t.length < 80) return t;
+ }
+ return null;
+ }
+
+ function quote(s: string): string {
+ return '"' + s.replace(/"/g, '\\"') + '"';
+ }
+
+ // Check if the locator text matches multiple interactive elements on the page.
+ // Returns ' --nth N' suffix if ambiguous, '' if unique.
+ function nthSuffix(el: Element, locator: string): string {
+ var selector = 'a, button, input, textarea, select, [role=button], [role=link], [role=tab], [role=menuitem], [role=checkbox], [role=option], [aria-label]';
+ var candidates = document.querySelectorAll(selector);
+ var matches: Element[] = [];
+ for (var i = 0; i < candidates.length; i++) {
+ if (getLocator(candidates[i]) === locator) matches.push(candidates[i]);
+ }
+ if (matches.length <= 1) return '';
+ var idx = matches.indexOf(el);
+ if (idx === -1) {
+ for (var j = 0; j < matches.length; j++) {
+ if (matches[j].contains(el)) { idx = j; break; }
+ }
+ }
+ return idx >= 0 ? ' --nth ' + idx : '';
+ }
+
+ function send(command: string): void {
+ chrome.runtime.sendMessage({ type: "pw-recorded-command", command });
+ }
+
+ let fillTimer: ReturnType | null = null;
+ let fillTarget: HTMLInputElement | HTMLTextAreaElement | null = null;
+ let fillValue: string = "";
+
+ function flushFill(): void {
+ if (fillTarget && fillValue) {
+ const locator = getLocator(fillTarget);
+ send('fill ' + locator + nthSuffix(fillTarget, locator) + ' "' + fillValue.replace(/"/g, '\\"') + '"');
+ }
+ fillTimer = null;
+ fillTarget = null;
+ fillValue = "";
+ }
+
+ // Only record clicks on interactive elements (or their children)
+ var clickableTags: Set = new Set(["A", "BUTTON", "INPUT", "SELECT", "TEXTAREA", "SUMMARY", "DETAILS"]);
+ var clickableRoles: Set = new Set(["button", "link", "tab", "menuitem", "checkbox", "option", "switch", "radio", "treeitem"]);
+
+ function isClickable(el: Element): boolean {
+ var node: Element | null = el;
+ while (node && node !== document.body) {
+ if (clickableTags.has(node.tagName)) return true;
+ var role = node.getAttribute && node.getAttribute("role");
+ if (role && clickableRoles.has(role)) return true;
+ if (node.getAttribute && node.getAttribute("onclick")) return true;
+ node = node.parentElement;
+ }
+ return false;
+ }
+
+ function findCheckbox(el: Element): HTMLInputElement | null {
+ if (el.tagName === "INPUT" && (el as HTMLInputElement).type === "checkbox") return el as HTMLInputElement;
+ if (el.tagName === "LABEL") {
+ var input = el.querySelector('input[type="checkbox"]') as HTMLInputElement | null;
+ if (input) return input;
+ if ((el as HTMLLabelElement).htmlFor) {
+ var target = document.getElementById((el as HTMLLabelElement).htmlFor) as HTMLInputElement | null;
+ if (target && target.type === "checkbox") return target;
+ }
+ }
+ var parentLabel = el.closest("label");
+ if (parentLabel) {
+ var cb = parentLabel.querySelector('input[type="checkbox"]') as HTMLInputElement | null;
+ if (cb) return cb;
+ }
+ return null;
+ }
+
+ let clickTimer: ReturnType | null = null;
+
+ function handleClick(e: MouseEvent): void {
+ try {
+ var el = e.target as Element;
+ if (!el || !el.tagName) return;
+
+ // Skip text inputs and textareas — handled by handleInput
+ if ((el.tagName === "INPUT" && (el as HTMLInputElement).type !== "checkbox" && (el as HTMLInputElement).type !== "radio") || el.tagName === "TEXTAREA") return;
+
+ // Skip clicks on non-interactive elements
+ if (!isClickable(el)) return;
+
+ // Links: emit immediately (page navigates before debounce timer fires)
+ if (el.closest && el.closest("a[href]")) {
+ if (clickTimer) { clearTimeout(clickTimer); clickTimer = null; }
+ if (fillTimer) { clearTimeout(fillTimer); flushFill(); }
+ emitClick(e);
+ return;
+ }
+
+ // Delay other clicks to allow dblclick dedup
+ if (clickTimer) { clearTimeout(clickTimer); clickTimer = null; }
+ clickTimer = setTimeout(() => {
+ clickTimer = null;
+ emitClick(e);
+ }, 250);
+ } catch(err) {
+ send("# click recording error: " + (err as Error).message);
+ }
+ }
+
+ function emitClick(e: MouseEvent): void {
+ try {
+ if (fillTimer) { clearTimeout(fillTimer); flushFill(); }
+ var el = e.target as Element;
+
+ // Check for checkbox (direct or via label/parent)
+ var checkbox = findCheckbox(el);
+ if (checkbox) {
+ var cbLabel = getItemContext(checkbox) || "";
+ if (cbLabel) {
+ var cbLocator = quote(cbLabel);
+ var cbNth = nthSuffix(checkbox, cbLocator);
+ send(checkbox.checked ? 'check ' + cbLocator + cbNth : 'uncheck ' + cbLocator + cbNth);
+ } else {
+ var loc = getLocator(checkbox);
+ var locNth = nthSuffix(checkbox, loc);
+ send(checkbox.checked ? 'check ' + loc + locNth : 'uncheck ' + loc + locNth);
+ }
+ return;
+ }
+
+ var locator = getLocator(el);
+ var nth = nthSuffix(el, locator);
+ var actionWords = new Set(["delete", "remove", "edit", "close", "destroy", "\u00d7", "\u2715", "\u2716", "\u2717", "\u2718", "x"]);
+ var elText = (el.textContent || "").trim().toLowerCase();
+ var elClass = ((el as HTMLElement).className || "").toLowerCase();
+ var elAriaLabel = (el.getAttribute && el.getAttribute("aria-label") || "").toLowerCase();
+ var isAction = actionWords.has(elText)
+ || [...actionWords].some(function(w) { return elClass.includes(w); })
+ || [...actionWords].some(function(w) { return elAriaLabel.includes(w); })
+ || (el.tagName === "BUTTON" && !elText && el.closest && el.closest("li, tr, [role=listitem]"));
+ try {
+ var ctx = getItemContext(el);
+ if (ctx && isAction) {
+ send('click ' + locator + ' "' + ctx.replace(/"/g, '\\"') + '"');
+ } else {
+ send('click ' + locator + nth);
+ }
+ } catch(_ce) {
+ send('click ' + locator + nth);
+ }
+ } catch(err) {
+ send("# click recording error: " + (err as Error).message);
+ }
+ }
+
+ function handleDblClick(e: MouseEvent): void {
+ try {
+ // Cancel pending single click — dblclick supersedes it
+ if (clickTimer) { clearTimeout(clickTimer); clickTimer = null; }
+ if (fillTimer) { clearTimeout(fillTimer); flushFill(); }
+ var el = e.target as Element;
+ if (!el || !el.tagName) return;
+ if (!isClickable(el)) return;
+ var locator = getLocator(el);
+ send('dblclick ' + locator + nthSuffix(el, locator));
+ } catch(err) {
+ send("# dblclick recording error: " + (err as Error).message);
+ }
+ }
+
+ function handleContextMenu(e: MouseEvent): void {
+ try {
+ var el = e.target as Element;
+ if (!el || !el.tagName) return;
+ if (!isClickable(el)) return;
+ var locator = getLocator(el);
+ send('click ' + locator + nthSuffix(el, locator) + ' --button right');
+ } catch(err) {
+ send("# contextmenu recording error: " + (err as Error).message);
+ }
+ }
+
+ function handleInput(e: Event): void {
+ const el = e.target as HTMLInputElement | HTMLTextAreaElement;
+ if (el.tagName !== "INPUT" && el.tagName !== "TEXTAREA") return;
+ if ((el as HTMLInputElement).type === "checkbox" || (el as HTMLInputElement).type === "radio") return;
+ fillTarget = el;
+ fillValue = el.value;
+ if (fillTimer) clearTimeout(fillTimer);
+ fillTimer = setTimeout(flushFill, 1500);
+ }
+
+ function handleChange(e: Event): void {
+ const el = e.target as Element;
+ if (el.tagName === "SELECT") {
+ const selEl = el as HTMLSelectElement;
+ const opt = selEl.options[selEl.selectedIndex];
+ const optText = opt ? opt.text.trim() : selEl.value;
+ const locator = getLocator(el);
+ send('select ' + locator + nthSuffix(el, locator) + ' "' + optText.replace(/"/g, '\\"') + '"');
+ }
+ }
+
+ function handleKeydown(e: KeyboardEvent): void {
+ const specialKeys = ["Enter", "Tab", "Escape"];
+ if (specialKeys.includes(e.key)) {
+ if (fillTimer) { clearTimeout(fillTimer); flushFill(); }
+ send('press ' + e.key);
+ }
+ }
+
+ document.addEventListener("click", handleClick, true);
+ document.addEventListener("dblclick", handleDblClick, true);
+ document.addEventListener("contextmenu", handleContextMenu, true);
+ document.addEventListener("input", handleInput, true);
+ document.addEventListener("change", handleChange, true);
+ document.addEventListener("keydown", handleKeydown, true);
+
+ window.__pwRecorderCleanup = () => {
+ if (fillTimer) { clearTimeout(fillTimer); flushFill(); }
+ if (clickTimer) { clearTimeout(clickTimer); clickTimer = null; }
+ document.removeEventListener("click", handleClick, true);
+ document.removeEventListener("dblclick", handleDblClick, true);
+ document.removeEventListener("contextmenu", handleContextMenu, true);
+ document.removeEventListener("input", handleInput, true);
+ document.removeEventListener("change", handleChange, true);
+ document.removeEventListener("keydown", handleKeydown, true);
+ delete document.documentElement.dataset.pwRecorderActive;
+ delete window.__pwRecorderCleanup;
+ };
+})();
diff --git a/packages/extension/src/lib/converter.ts b/packages/extension/src/lib/converter.ts
new file mode 100644
index 0000000..9d8505e
--- /dev/null
+++ b/packages/extension/src/lib/converter.ts
@@ -0,0 +1,132 @@
+/**
+ * Tokenizes a raw .pw command string, respecting quoted arguments.
+ * Returns an empty array for comments and empty lines.
+ */
+export function tokenize(raw: string): string[] {
+ const trimmed = raw.trim();
+ if (!trimmed || trimmed.startsWith("#")) return [];
+ const tokens = [];
+ let current = "";
+ let inQuote = false;
+ let quoteChar = "";
+ for (let i = 0; i < trimmed.length; i++) {
+ const ch = trimmed[i];
+ if (inQuote) {
+ if (ch === quoteChar) { inQuote = false; tokens.push(current); current = ""; }
+ else current += ch;
+ } else if (ch === '"' || ch === "'") { inQuote = true; quoteChar = ch; }
+ else if (ch === " " || ch === "\t") { if (current) { tokens.push(current); current = ""; } }
+ else current += ch;
+ }
+ if (current) tokens.push(current);
+ return tokens;
+}
+
+/**
+ * Converts a .pw REPL command to Playwright TypeScript code.
+ * Returns a code string, or null if the command is invalid.
+ */
+export function pwToPlaywright(cmd: string): string | null {
+ const tokens = tokenize(cmd);
+ if (!tokens.length) return null;
+ const command = tokens[0].toLowerCase();
+ const args = tokens.slice(1);
+
+ switch (command) {
+ case "goto":
+ case "open": {
+ if (!args[0]) return null;
+ let url = args[0];
+ if (!/^https?:\/\//i.test(url)) url = "https://" + url;
+ return `await page.goto(${JSON.stringify(url)});`;
+ }
+ case "click":
+ case "c": {
+ if (!args[0]) return null;
+ const t = args[0];
+ if (/^e\d+$/.test(t)) return `// click ${t} — snapshot ref, use a locator instead`;
+ if (args[1]) {
+ return `await page.getByText(${JSON.stringify(args[1])}).getByText(${JSON.stringify(t)}).click();`;
+ }
+ return `await page.getByText(${JSON.stringify(t)}).click();`;
+ }
+ case "dblclick": {
+ if (!args[0]) return null;
+ return `await page.getByText(${JSON.stringify(args[0])}).dblclick();`;
+ }
+ case "fill":
+ case "f": {
+ if (args.length < 2) return null;
+ return `await page.getByLabel(${JSON.stringify(args[0])}).fill(${JSON.stringify(args[1])});`;
+ }
+ case "select": {
+ if (args.length < 2) return null;
+ return `await page.getByLabel(${JSON.stringify(args[0])}).selectOption(${JSON.stringify(args[1])});`;
+ }
+ case "check": {
+ if (!args[0]) return null;
+ return `await page.getByLabel(${JSON.stringify(args[0])}).check();`;
+ }
+ case "uncheck": {
+ if (!args[0]) return null;
+ return `await page.getByLabel(${JSON.stringify(args[0])}).uncheck();`;
+ }
+ case "hover": {
+ if (!args[0]) return null;
+ return `await page.getByText(${JSON.stringify(args[0])}).hover();`;
+ }
+ case "press":
+ case "p": {
+ if (!args[0]) return null;
+ const key = args[0].charAt(0).toUpperCase() + args[0].slice(1);
+ return `await page.keyboard.press(${JSON.stringify(key)});`;
+ }
+ case "screenshot": {
+ if (args[0] === "full") {
+ return `await page.screenshot({ path: 'screenshot.png', fullPage: true });`;
+ }
+ return `await page.screenshot({ path: 'screenshot.png' });`;
+ }
+ case "snapshot":
+ case "s":
+ return `// snapshot — no Playwright equivalent (use Playwright Inspector)`;
+ case "eval": {
+ const expr = args.join(" ");
+ return `await page.evaluate(() => ${expr});`;
+ }
+ case "go-back":
+ case "back":
+ return `await page.goBack();`;
+ case "go-forward":
+ case "forward":
+ return `await page.goForward();`;
+ case "reload":
+ return `await page.reload();`;
+ case "verify-text": {
+ if (!args[0]) return null;
+ return `await expect(page.getByText(${JSON.stringify(args[0])})).toBeVisible();`;
+ }
+ case "verify-no-text": {
+ if (!args[0]) return null;
+ return `await expect(page.getByText(${JSON.stringify(args[0])})).not.toBeVisible();`;
+ }
+ case "verify-element": {
+ if (!args[0]) return null;
+ return `await expect(page.getByText(${JSON.stringify(args[0])})).toBeVisible();`;
+ }
+ case "verify-no-element": {
+ if (!args[0]) return null;
+ return `await expect(page.getByText(${JSON.stringify(args[0])})).not.toBeVisible();`;
+ }
+ case "verify-url": {
+ if (!args[0]) return null;
+ return `await expect(page).toHaveURL(/${args[0].replace(/[.*+?^${}()|[\]\\/]/g, '\\$&')}/);`;
+ }
+ case "verify-title": {
+ if (!args[0]) return null;
+ return `await expect(page).toHaveTitle(/${args[0].replace(/[.*+?^${}()|[\]\\/]/g, '\\$&')}/);`;
+ }
+ default:
+ return `// unknown command: ${cmd}`;
+ }
+}
diff --git a/packages/extension/src/panel/panel.css b/packages/extension/src/panel/panel.css
new file mode 100644
index 0000000..6d5adf8
--- /dev/null
+++ b/packages/extension/src/panel/panel.css
@@ -0,0 +1,690 @@
+/* === Theme variables === */
+
+:root {
+ /* Light theme (default — matches Chrome DevTools) */
+ --bg-body: #ffffff;
+ --bg-toolbar: #f3f3f3;
+ --bg-editor: #ffffff;
+ --bg-line-highlight: #e8e8e8;
+ --bg-button: #e0e0e0;
+ --bg-button-hover: #d0d0d0;
+ --bg-splitter: #f3f3f3;
+ --bg-splitter-hover: #e8e8e8;
+ --bg-scrollbar-track: #ffffff;
+ --bg-scrollbar-thumb: #c1c1c1;
+ --bg-scrollbar-thumb-hover: #a8a8a8;
+
+ --text-default: #1e1e1e;
+ --text-line-numbers: #858585;
+ --text-dim: #6e6e6e;
+ --text-placeholder: #a0a0a0;
+
+ --border-primary: #d4d4d4;
+ --border-button: #c8c8c8;
+ --border-screenshot: #d4d4d4;
+
+ --color-run-bg: #2ea043;
+ --color-run-bg-hover: #3fb950;
+ --color-recording-bg: #fde8e8;
+ --color-recording-text: #d32f2f;
+ --color-recording-border: #d32f2f;
+ --color-error: #d32f2f;
+ --color-success: #2e7d32;
+ --color-command: #0451a5;
+ --color-snapshot: #0070c1;
+ --color-comment: #6a9955;
+ --color-active-line: #795e26;
+ --color-line-pass: #2e7d32;
+ --color-line-fail: #d32f2f;
+ --color-prompt: #2e7d32;
+ --color-splitter-handle: #a0a0a0;
+ --color-splitter-handle-hover: #007acc;
+ --color-toolbar-sep: #d4d4d4;
+ --color-caret: #1e1e1e;
+}
+
+.theme-dark {
+ --bg-body: #1e1e1e;
+ --bg-toolbar: #252526;
+ --bg-editor: #1e1e1e;
+ --bg-line-highlight: #2a2d2e;
+ --bg-button: #333333;
+ --bg-button-hover: #3c3c3c;
+ --bg-splitter: #252526;
+ --bg-splitter-hover: #2a2d2e;
+ --bg-scrollbar-track: #1e1e1e;
+ --bg-scrollbar-thumb: #444444;
+ --bg-scrollbar-thumb-hover: #555555;
+
+ --text-default: #d4d4d4;
+ --text-line-numbers: #858585;
+ --text-dim: #888888;
+ --text-placeholder: #555555;
+
+ --border-primary: #333333;
+ --border-button: #444444;
+ --border-screenshot: #444444;
+
+ --color-run-bg: #2ea043;
+ --color-run-bg-hover: #3fb950;
+ --color-recording-bg: #5c2020;
+ --color-recording-text: #f44747;
+ --color-recording-border: #f44747;
+ --color-error: #f44747;
+ --color-success: #6a9955;
+ --color-command: #569cd6;
+ --color-snapshot: #9cdcfe;
+ --color-comment: #6a9955;
+ --color-active-line: #dcdcaa;
+ --color-line-pass: #6a9955;
+ --color-line-fail: #f44747;
+ --color-prompt: #6a9955;
+ --color-splitter-handle: #555555;
+ --color-splitter-handle-hover: #007acc;
+ --color-toolbar-sep: #444444;
+ --color-caret: #d4d4d4;
+}
+
+/* === Reset === */
+
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+html, body {
+ height: 100%;
+ background: var(--bg-body);
+ color: var(--text-default);
+ font-family: "Cascadia Code", "Fira Code", "Consolas", "Courier New", monospace;
+ font-size: 13px;
+ display: flex;
+ flex-direction: column;
+}
+
+/* === Toolbar (top) === */
+
+#toolbar {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 4px 8px;
+ background: var(--bg-toolbar);
+ border-bottom: 1px solid var(--border-primary);
+ flex-shrink: 0;
+}
+
+#toolbar-left {
+ display: flex;
+ gap: 4px;
+ align-items: center;
+}
+
+#toolbar-right {
+ display: flex;
+ align-items: center;
+}
+
+.toolbar-sep {
+ width: 1px;
+ height: 18px;
+ background: var(--color-toolbar-sep);
+ margin: 0 4px;
+}
+
+#toolbar button {
+ background: var(--bg-button);
+ color: var(--text-default);
+ border: 1px solid var(--border-button);
+ border-radius: 4px;
+ padding: 4px 10px;
+ font-family: inherit;
+ font-size: 11px;
+ cursor: pointer;
+}
+
+#toolbar button:hover {
+ background: var(--bg-button-hover);
+}
+
+#toolbar button:disabled {
+ opacity: 0.4;
+ cursor: default;
+}
+
+#run-btn,
+#step-btn {
+ min-width: 28px;
+ padding: 4px 8px;
+ font-size: 14px;
+ text-align: center;
+}
+
+#run-btn {
+ background: var(--color-run-bg) !important;
+ color: white !important;
+ border-color: var(--color-run-bg) !important;
+ font-weight: bold;
+}
+
+#run-btn:hover {
+ background: var(--color-run-bg-hover) !important;
+}
+
+#record-btn.recording {
+ background: var(--color-recording-bg) !important;
+ color: var(--color-recording-text) !important;
+ border-color: var(--color-recording-border) !important;
+}
+
+#file-info {
+ color: var(--text-dim);
+ font-size: 11px;
+}
+
+/* === Editor pane === */
+
+#editor-pane {
+ display: flex;
+ flex: 1;
+ min-height: 80px;
+ overflow: hidden;
+ background: var(--bg-editor);
+}
+
+#line-numbers {
+ width: 40px;
+ flex-shrink: 0;
+ padding: 8px 4px 8px 8px;
+ text-align: right;
+ color: var(--text-line-numbers);
+ background: var(--bg-editor);
+ font-family: inherit;
+ font-size: inherit;
+ line-height: 18px;
+ overflow: hidden;
+ user-select: none;
+ border-right: 1px solid var(--border-primary);
+}
+
+#line-numbers div {
+ height: 18px;
+}
+
+#line-numbers .line-active {
+ color: var(--color-active-line);
+ background: var(--bg-line-highlight);
+}
+
+#line-numbers .line-pass {
+ color: var(--color-line-pass);
+}
+
+#line-numbers .line-pass::before {
+ content: "\2713";
+ margin-right: 2px;
+ font-size: 10px;
+}
+
+#line-numbers .line-fail {
+ color: var(--color-line-fail);
+}
+
+#line-numbers .line-fail::before {
+ content: "\2717";
+ margin-right: 2px;
+ font-size: 10px;
+}
+
+#editor-wrapper {
+ flex: 1;
+ position: relative;
+ overflow: hidden;
+}
+
+#line-highlight {
+ position: absolute;
+ left: 0;
+ right: 0;
+ height: 18px;
+ background: var(--bg-line-highlight);
+ display: none;
+ pointer-events: none;
+}
+
+#editor {
+ width: 100%;
+ height: 100%;
+ resize: none;
+ background: transparent;
+ color: var(--text-default);
+ border: none;
+ outline: none;
+ padding: 8px;
+ font-family: inherit;
+ font-size: inherit;
+ line-height: 18px;
+ white-space: pre;
+ overflow-y: auto;
+ overflow-x: auto;
+ tab-size: 2;
+ caret-color: var(--color-caret);
+ position: relative;
+}
+
+#editor::placeholder {
+ color: var(--text-placeholder);
+}
+
+/* === Splitter === */
+
+#splitter {
+ height: 6px;
+ background: var(--bg-splitter);
+ cursor: row-resize;
+ flex-shrink: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-top: 1px solid var(--border-primary);
+ border-bottom: 1px solid var(--border-primary);
+}
+
+#splitter-handle {
+ width: 40px;
+ height: 2px;
+ background: var(--color-splitter-handle);
+ border-radius: 1px;
+}
+
+#splitter:hover {
+ background: var(--bg-splitter-hover);
+}
+
+#splitter:hover #splitter-handle {
+ background: var(--color-splitter-handle-hover);
+}
+
+/* === Console pane === */
+
+#console-pane {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ min-height: 80px;
+ overflow: hidden;
+}
+
+#console-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 4px 12px;
+ background: var(--bg-toolbar);
+ border-bottom: 1px solid var(--border-primary);
+ flex-shrink: 0;
+ font-size: 11px;
+}
+
+#console-title {
+ color: var(--text-default);
+ font-size: 12px;
+ font-weight: 600;
+}
+
+#console-header-left {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+#console-stats {
+ color: var(--text-dim);
+}
+
+#console-stats .pass-count {
+ color: var(--color-success);
+}
+
+#console-stats .fail-count {
+ color: var(--color-error);
+}
+
+#console-clear-btn {
+ background: transparent;
+ border: none;
+ color: var(--text-dim);
+ font-family: inherit;
+ font-size: 11px;
+ cursor: pointer;
+ padding: 1px 6px;
+ border-radius: 3px;
+}
+
+#console-clear-btn:hover {
+ color: var(--text-default);
+ background: var(--bg-button);
+}
+
+/* Output area */
+#output {
+ flex: 1;
+ overflow-y: auto;
+ padding: 8px 12px;
+ white-space: pre-wrap;
+ word-break: break-word;
+}
+
+/* Individual output lines */
+.line {
+ padding: 1px 0;
+}
+
+.line-command {
+ color: var(--color-command);
+}
+
+.line-command::before {
+ content: "> ";
+ color: var(--color-success);
+}
+
+.line-success {
+ color: var(--color-success);
+}
+
+.line-success::before {
+ content: "\2713 ";
+}
+
+.line-error {
+ color: var(--color-error);
+}
+
+.line-error::before {
+ content: "\2717 ";
+}
+
+.line-info {
+ color: var(--text-default);
+}
+
+.line-comment {
+ color: var(--color-comment);
+ font-style: italic;
+}
+
+.line-snapshot {
+ color: var(--color-snapshot);
+ font-size: 12px;
+}
+
+/* Screenshot block */
+.screenshot-block {
+ position: relative;
+ display: inline-block;
+ margin: 6px 0;
+}
+
+.screenshot-block img {
+ max-width: 400px;
+ border: 1px solid var(--border-screenshot);
+ border-radius: 4px 4px 0 0;
+ display: block;
+ cursor: zoom-in;
+}
+
+.screenshot-block:hover img {
+ opacity: 0.85;
+}
+
+.screenshot-zoom-hint {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ background: rgba(0, 0, 0, 0.6);
+ color: #fff;
+ font-size: 11px;
+ padding: 4px 10px;
+ border-radius: 4px;
+ pointer-events: none;
+ opacity: 0;
+ transition: opacity 0.15s;
+}
+
+.screenshot-block:hover .screenshot-zoom-hint {
+ opacity: 1;
+}
+
+.screenshot-actions {
+ display: flex;
+ justify-content: flex-end;
+ padding: 4px 6px;
+ background: var(--bg-toolbar);
+ border: 1px solid var(--border-screenshot);
+ border-top: none;
+ border-radius: 0 0 4px 4px;
+}
+
+.screenshot-btn {
+ background: var(--bg-button);
+ color: var(--text-default);
+ border: 1px solid var(--border-button);
+ border-radius: 3px;
+ padding: 2px 8px;
+ font-family: inherit;
+ font-size: 10px;
+ cursor: pointer;
+}
+
+.screenshot-btn:hover {
+ background: var(--bg-button-hover);
+}
+
+/* Lightbox */
+#lightbox {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background: rgba(0, 0, 0, 0.8);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 100;
+ cursor: pointer;
+}
+
+#lightbox[hidden] {
+ display: none;
+}
+
+#lightbox img {
+ max-width: 95%;
+ max-height: 95%;
+ border-radius: 4px;
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
+ cursor: default;
+}
+
+#lightbox-close-btn {
+ position: absolute;
+ top: 10px;
+ right: 10px;
+ background: rgba(255, 255, 255, 0.15);
+ color: #fff;
+ border: 1px solid rgba(255, 255, 255, 0.3);
+ border-radius: 4px;
+ padding: 4px 12px;
+ font-family: inherit;
+ font-size: 18px;
+ line-height: 1;
+ cursor: pointer;
+}
+
+#lightbox-close-btn:hover {
+ background: rgba(255, 255, 255, 0.3);
+}
+
+#lightbox-save-btn {
+ position: absolute;
+ top: 10px;
+ right: 50px;
+ background: rgba(255, 255, 255, 0.15);
+ color: #fff;
+ border: 1px solid rgba(255, 255, 255, 0.3);
+ border-radius: 4px;
+ padding: 4px 14px;
+ font-family: inherit;
+ font-size: 12px;
+ cursor: pointer;
+}
+
+#lightbox-save-btn:hover {
+ background: rgba(255, 255, 255, 0.3);
+}
+
+/* Code block (export output) */
+.code-block {
+ position: relative;
+ border: 1px solid var(--border-primary);
+ border-radius: 4px;
+ margin: 6px 0;
+ background: var(--bg-editor);
+}
+
+.code-block .code-content {
+ margin: 0;
+ padding: 8px 12px;
+ color: var(--color-snapshot);
+ font-family: inherit;
+ font-size: 12px;
+ line-height: 16px;
+ white-space: pre-wrap;
+ word-break: break-word;
+}
+
+.code-block .code-copy-btn {
+ position: absolute;
+ top: 4px;
+ right: 4px;
+ background: var(--bg-button);
+ color: var(--text-default);
+ border: 1px solid var(--border-button);
+ border-radius: 3px;
+ padding: 2px 8px;
+ font-family: inherit;
+ font-size: 10px;
+ cursor: pointer;
+}
+
+.code-block .code-copy-btn:hover {
+ background: var(--bg-button-hover);
+}
+
+/* Input bar */
+#input-bar {
+ display: flex;
+ align-items: center;
+ border-top: 1px solid var(--border-primary);
+ padding: 6px 12px;
+ background: var(--bg-toolbar);
+ gap: 8px;
+ flex-shrink: 0;
+}
+
+#prompt {
+ color: var(--color-prompt);
+ font-weight: bold;
+ flex-shrink: 0;
+}
+
+#input-wrapper {
+ flex: 1;
+ position: relative;
+}
+
+#ghost-text {
+ position: absolute;
+ top: 0;
+ left: 0;
+ color: var(--text-placeholder);
+ font-family: inherit;
+ font-size: inherit;
+ line-height: normal;
+ pointer-events: none;
+ white-space: pre;
+}
+
+#autocomplete-dropdown {
+ position: absolute;
+ bottom: 100%;
+ left: 0;
+ background: var(--bg-toolbar);
+ border: 1px solid var(--border-primary);
+ border-radius: 4px;
+ padding: 4px 0;
+ margin-bottom: 4px;
+ max-height: 200px;
+ overflow-y: auto;
+ z-index: 50;
+ box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.15);
+}
+
+#autocomplete-dropdown[hidden] {
+ display: none;
+}
+
+.autocomplete-item {
+ padding: 3px 12px;
+ cursor: pointer;
+ font-family: inherit;
+ font-size: 12px;
+ color: var(--text-default);
+}
+
+.autocomplete-item:hover,
+.autocomplete-item.active {
+ background: var(--bg-button);
+}
+
+#command-input {
+ width: 100%;
+ background: transparent;
+ border: none;
+ outline: none;
+ color: var(--text-default);
+ font-family: inherit;
+ font-size: inherit;
+ caret-color: var(--color-caret);
+ position: relative;
+}
+
+#command-input::placeholder {
+ color: var(--text-placeholder);
+}
+
+/* Scrollbar styling */
+#output::-webkit-scrollbar,
+#editor::-webkit-scrollbar {
+ width: 8px;
+}
+
+#output::-webkit-scrollbar-track,
+#editor::-webkit-scrollbar-track {
+ background: var(--bg-scrollbar-track);
+}
+
+#output::-webkit-scrollbar-thumb,
+#editor::-webkit-scrollbar-thumb {
+ background: var(--bg-scrollbar-thumb);
+ border-radius: 4px;
+}
+
+#output::-webkit-scrollbar-thumb:hover,
+#editor::-webkit-scrollbar-thumb:hover {
+ background: var(--bg-scrollbar-thumb-hover);
+}
diff --git a/packages/extension/src/panel/panel.html b/packages/extension/src/panel/panel.html
new file mode 100644
index 0000000..6179583
--- /dev/null
+++ b/packages/extension/src/panel/panel.html
@@ -0,0 +1,68 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
![]()
+
+
+
+
+
diff --git a/packages/extension/src/panel/panel.ts b/packages/extension/src/panel/panel.ts
new file mode 100644
index 0000000..e23c48e
--- /dev/null
+++ b/packages/extension/src/panel/panel.ts
@@ -0,0 +1,1066 @@
+import { pwToPlaywright } from "../lib/converter.js";
+
+// --- Interfaces ---
+
+interface RunResult {
+ text: string;
+ isError: boolean;
+ image?: string;
+}
+
+interface ResponseEntry {
+ command: string;
+ text: string;
+ timestamp: number;
+}
+
+// --- Chrome MV3 promise-based API helpers ---
+// @types/chrome only declares callback overloads; MV3 supports promises at runtime.
+// These are functions (not captured references) so E2E tests can mock chrome.tabs/runtime.
+function tabsQuery(q: chrome.tabs.QueryInfo): Promise {
+ return (chrome.tabs.query as any)(q);
+}
+function rtSendMessage(message: unknown): Promise {
+ return (chrome.runtime.sendMessage as any)(message);
+}
+
+// --- DOM references ---
+
+const output = document.getElementById("output") as HTMLDivElement;
+const input = document.getElementById("command-input") as HTMLInputElement;
+const editor = document.getElementById("editor") as HTMLTextAreaElement;
+const lineNumbers = document.getElementById("line-numbers") as HTMLDivElement;
+const editorPane = document.getElementById("editor-pane") as HTMLDivElement;
+const splitter = document.getElementById("splitter") as HTMLDivElement;
+const consoleStats = document.getElementById("console-stats") as HTMLSpanElement;
+const fileInfo = document.getElementById("file-info") as HTMLSpanElement;
+const runBtn = document.getElementById("run-btn") as HTMLButtonElement;
+const openBtn = document.getElementById("open-btn") as HTMLButtonElement;
+const saveBtn = document.getElementById("save-btn") as HTMLButtonElement;
+const exportBtn = document.getElementById("export-btn") as HTMLButtonElement;
+const recordBtn = document.getElementById("record-btn") as HTMLButtonElement;
+const copyBtn = document.getElementById("copy-btn") as HTMLButtonElement;
+const stepBtn = document.getElementById("step-btn") as HTMLButtonElement;
+const consoleClearBtn = document.getElementById("console-clear-btn") as HTMLButtonElement;
+const lineHighlight = document.getElementById("line-highlight") as HTMLDivElement;
+const lightbox = document.getElementById("lightbox") as HTMLDivElement;
+const lightboxImg = document.getElementById("lightbox-img") as HTMLImageElement;
+const lightboxSaveBtn = document.getElementById("lightbox-save-btn") as HTMLButtonElement;
+const lightboxCloseBtn = document.getElementById("lightbox-close-btn") as HTMLButtonElement;
+const ghostText = document.getElementById("ghost-text") as HTMLSpanElement;
+const dropdown = document.getElementById("autocomplete-dropdown") as HTMLDivElement;
+
+// --- Server config ---
+const SERVER_PORT = 6781;
+const SERVER_URL = `http://localhost:${SERVER_PORT}`;
+
+// --- Response history (full responses stored for reference) ---
+const responseHistory: ResponseEntry[] = [];
+
+/**
+ * Filter verbose response for panel display.
+ *
+ * Playwright MCP responses follow a consistent structure:
+ * ← the actual outcome (e.g. "Clicked", "Navigated to...")
+ * ### Section 1 ← supplementary context (code, tabs, page info, etc.)
+ * ### Section 2
+ * ...
+ *
+ * Generic strategy:
+ * - Always store the full response in responseHistory
+ * - For "snapshot": show the ### Snapshot section (that's the whole point)
+ * - For everything else: show only the result text (before the first ### header)
+ */
+function filterResponse(command: string, text: string): string {
+ responseHistory.push({ command, text, timestamp: Date.now() });
+
+ const cmdName = command.trim().split(/\s+/)[0];
+
+ // snapshot: return full response (the whole point is to see the tree)
+ if (cmdName === 'snapshot') return text;
+
+ // Extract text before the first ### header (if any)
+ const firstSectionIdx = text.indexOf('### ');
+ const preamble = firstSectionIdx > 0 ? text.slice(0, firstSectionIdx).trim() : '';
+
+ // Extract content from useful ### sections (Result, Error, Snapshot)
+ const sections = text.split(/^### /m).slice(1);
+ const kept: string[] = [];
+ for (const section of sections) {
+ const nl = section.indexOf('\n');
+ if (nl === -1) continue;
+ const title = section.substring(0, nl).trim();
+ const content = section.substring(nl + 1).trim();
+ if (title === 'Result' || title === 'Error') kept.push(content);
+ }
+
+ if (preamble) return preamble;
+ if (kept.length > 0) return kept.join('\n');
+ return 'Done';
+}
+
+// --- Theme detection ---
+if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
+ document.body.classList.add("theme-dark");
+}
+
+// --- State ---
+
+// Command history (for REPL input up/down)
+const history: string[] = [];
+let historyIndex: number = -1;
+
+// Run state
+let isRunning: boolean = false;
+let currentRunLine: number = -1;
+let runPassCount: number = 0;
+let runFailCount: number = 0;
+let lineResults: (string | null)[] = []; // "pass" | "fail" | null per line
+
+// Step state
+let stepLine: number = -1; // Next line to execute when stepping, -1 = not stepping
+
+// File state
+let currentFilename: string = "";
+
+// Commands handled locally (not added to history, not sent to background)
+const LOCAL_COMMANDS = new Set(["history", "clear", "reset"]);
+
+// All commands for autocomplete
+const COMMANDS = [
+ "goto", "open", "click", "dblclick", "fill", "select",
+ "check", "uncheck", "hover", "press", "snapshot",
+ "screenshot", "eval", "go-back", "back", "go-forward", "forward",
+ "reload", "verify-text", "verify-no-text", "verify-element",
+ "verify-no-element", "verify-url", "verify-title",
+ "export", "help", "history", "clear", "reset"
+];
+
+// --- Autocomplete ---
+
+function updateGhostText(): void {
+ const val = input.value.toLowerCase();
+ if (!val || val.includes(" ")) {
+ ghostText.textContent = "";
+ return;
+ }
+ const match = COMMANDS.find(cmd => cmd.startsWith(val) && cmd !== val);
+ if (match) {
+ ghostText.style.paddingLeft = val.length + "ch";
+ ghostText.textContent = match.slice(val.length);
+ } else {
+ ghostText.textContent = "";
+ }
+}
+
+function clearGhostText(): void {
+ ghostText.textContent = "";
+}
+
+let dropdownItems: string[] = [];
+let dropdownIndex: number = -1;
+
+function showDropdown(matches: string[]): void {
+ dropdown.innerHTML = "";
+ dropdownItems = matches;
+ dropdownIndex = -1;
+ for (let i = 0; i < matches.length; i++) {
+ const div = document.createElement("div");
+ div.className = "autocomplete-item";
+ div.textContent = matches[i];
+ div.addEventListener("mousedown", (e: MouseEvent) => {
+ e.preventDefault();
+ input.value = matches[i] + " ";
+ hideDropdown();
+ clearGhostText();
+ input.focus();
+ });
+ dropdown.appendChild(div);
+ }
+ dropdown.hidden = false;
+}
+
+function hideDropdown(): void {
+ dropdown.hidden = true;
+ dropdown.innerHTML = "";
+ dropdownItems = [];
+ dropdownIndex = -1;
+}
+
+function updateDropdownHighlight(): void {
+ const items = dropdown.querySelectorAll(".autocomplete-item");
+ items.forEach((el, i) => {
+ el.classList.toggle("active", i === dropdownIndex);
+ });
+}
+
+// --- Output helpers ---
+
+function addLine(text: string, className: string): void {
+ const div = document.createElement("div");
+ div.className = `line ${className}`;
+ div.textContent = text;
+ output.appendChild(div);
+ output.scrollTop = output.scrollHeight;
+}
+
+function addCommand(text: string): void {
+ addLine(text, "line-command");
+}
+
+function addSuccess(text: string): void {
+ addLine(text, "line-success");
+}
+
+function addError(text: string): void {
+ addLine(text, "line-error");
+}
+
+function addInfo(text: string): void {
+ addLine(text, "line-info");
+}
+
+function addSnapshot(text: string): void {
+ addLine(text, "line-snapshot");
+}
+
+function addScreenshot(base64: string): void {
+ const dataUrl = "data:image/png;base64," + base64;
+
+ const wrapper = document.createElement("div");
+ wrapper.className = "screenshot-block";
+
+ const img = document.createElement("img");
+ img.src = dataUrl;
+ img.addEventListener("click", () => {
+ lightboxImg.src = dataUrl;
+ lightbox.hidden = false;
+ });
+ wrapper.appendChild(img);
+
+ const zoomHint = document.createElement("span");
+ zoomHint.className = "screenshot-zoom-hint";
+ zoomHint.textContent = "Click to enlarge";
+ wrapper.appendChild(zoomHint);
+
+ const actions = document.createElement("div");
+ actions.className = "screenshot-actions";
+
+ const saveBtnEl = document.createElement("button");
+ saveBtnEl.className = "screenshot-btn";
+ saveBtnEl.textContent = "Save";
+ saveBtnEl.addEventListener("click", async () => {
+ try {
+ const handle = await window.showSaveFilePicker({
+ suggestedName: "screenshot-" + new Date().toISOString().slice(0, 19).replace(/:/g, "-") + ".png",
+ types: [{ description: "PNG images", accept: { "image/png": [".png"] } }],
+ });
+ const res = await fetch(dataUrl);
+ const blob = await res.blob();
+ const writable = await handle.createWritable();
+ await writable.write(blob);
+ await writable.close();
+ } catch (e: unknown) {
+ if (e instanceof Error && e.name !== "AbortError") console.error("Save failed:", e);
+ }
+ });
+ actions.appendChild(saveBtnEl);
+
+ wrapper.appendChild(actions);
+
+ output.appendChild(wrapper);
+ output.scrollTop = output.scrollHeight;
+}
+
+function addComment(text: string): void {
+ addLine(text, "line-comment");
+}
+
+function addCodeBlock(code: string): void {
+ const wrapper = document.createElement("div");
+ wrapper.className = "code-block";
+
+ const copyBtn = document.createElement("button");
+ copyBtn.className = "code-copy-btn";
+ copyBtn.textContent = "Copy";
+ copyBtn.addEventListener("click", () => {
+ if (copyToClipboard(code)) {
+ copyBtn.textContent = "Copied!";
+ setTimeout(() => { copyBtn.textContent = "Copy"; }, 1500);
+ }
+ });
+ wrapper.appendChild(copyBtn);
+
+ const pre = document.createElement("pre");
+ pre.className = "code-content";
+ pre.textContent = code;
+ wrapper.appendChild(pre);
+
+ output.appendChild(wrapper);
+ output.scrollTop = output.scrollHeight;
+}
+
+// --- Clipboard helper (navigator.clipboard blocked in DevTools panels) ---
+
+function copyToClipboard(text: string): boolean {
+ const textarea = document.createElement("textarea");
+ textarea.value = text;
+ textarea.style.position = "fixed";
+ textarea.style.opacity = "0";
+ document.body.appendChild(textarea);
+ textarea.select();
+ try {
+ document.execCommand("copy");
+ return true;
+ } catch (_e) {
+ return false;
+ } finally {
+ document.body.removeChild(textarea);
+ }
+}
+
+// --- Editor helpers ---
+
+function updateLineNumbers(): void {
+ const lines = editor.value.split("\n");
+ let html = "";
+ for (let i = 0; i < lines.length; i++) {
+ let cls = "";
+ if (i === currentRunLine) {
+ cls = "line-active";
+ } else if (lineResults[i] === "pass") {
+ cls = "line-pass";
+ } else if (lineResults[i] === "fail") {
+ cls = "line-fail";
+ }
+ html += `${i + 1}
`;
+ }
+ lineNumbers.innerHTML = html;
+ updateLineHighlight();
+}
+
+function updateLineHighlight(): void {
+ if (currentRunLine >= 0) {
+ // 8px padding + line index * 18px line-height, offset by scroll
+ lineHighlight.style.top = (8 + currentRunLine * 18 - editor.scrollTop) + "px";
+ lineHighlight.style.display = "block";
+ } else {
+ lineHighlight.style.display = "none";
+ }
+}
+
+function syncScroll(): void {
+ lineNumbers.scrollTop = editor.scrollTop;
+ updateLineHighlight();
+}
+
+function updateFileInfo(): void {
+ const lines = editor.value.split("\n");
+ const count = lines.length;
+ const name = currentFilename || "untitled.pw";
+ fileInfo.textContent = `${name} \u2014 ${count} line${count !== 1 ? "s" : ""}`;
+}
+
+function updateButtonStates(): void {
+ const hasContent = editor.value.trim().length > 0;
+ copyBtn.disabled = !hasContent;
+ saveBtn.disabled = !hasContent;
+ exportBtn.disabled = !hasContent;
+}
+
+function appendToEditor(text: string): void {
+ const current = editor.value;
+ if (current && !current.endsWith("\n")) {
+ editor.value += "\n";
+ }
+ editor.value += text;
+ updateLineNumbers();
+ updateFileInfo();
+ updateButtonStates();
+ editor.scrollTop = editor.scrollHeight;
+}
+
+function clearConsole(): void {
+ output.innerHTML = "";
+ runPassCount = 0;
+ runFailCount = 0;
+ updateConsoleStats();
+}
+
+function findNextExecutableLine(startFrom: number, lines: string[]): number {
+ for (let i = startFrom; i < lines.length; i++) {
+ const trimmed = lines[i].trim();
+ if (trimmed && !trimmed.startsWith("#")) return i;
+ }
+ return -1;
+}
+
+function updateConsoleStats(): void {
+ if (runPassCount === 0 && runFailCount === 0) {
+ consoleStats.textContent = "";
+ return;
+ }
+ consoleStats.innerHTML =
+ `${runPassCount} passed / ` +
+ `${runFailCount} failed`;
+}
+
+// Editor input/scroll listeners
+editor.addEventListener("input", () => {
+ // Reset execution state when editor content changes
+ stepLine = -1;
+ currentRunLine = -1;
+ lineResults = [];
+ updateLineNumbers();
+ updateFileInfo();
+ updateButtonStates();
+});
+editor.addEventListener("scroll", syncScroll);
+
+// --- Export helper ---
+
+function exportFromLines(cmds: string[]): void {
+ const lines = [
+ `import { test, expect } from '@playwright/test';`,
+ ``,
+ `test('recorded session', async ({ page }) => {`,
+ ];
+ for (const cmd of cmds) {
+ if (cmd.startsWith("#")) {
+ lines.push(` ${cmd.replace("#", "//")}`);
+ continue;
+ }
+ const converted = pwToPlaywright(cmd);
+ if (converted) {
+ lines.push(` ${converted}`);
+ }
+ }
+ lines.push(`});`);
+
+ const code = lines.join("\n");
+ addCodeBlock(code);
+}
+
+// --- Active tab detection ---
+
+async function getActiveTabUrl(): Promise {
+ try {
+ const tabs = await tabsQuery({ active: true, currentWindow: true });
+ const tab = tabs[0];
+ console.log('[panel] activeTab:', tab?.id, tab?.url);
+ return tab?.url || null;
+ } catch (_e) { console.error('[panel] tabs.query failed:', _e); return null; }
+}
+
+// --- Command execution (REPL ad-hoc) ---
+
+async function executeCommand(raw: string): Promise {
+ const trimmed = raw.trim();
+ if (!trimmed) return;
+
+ // Skip comments
+ if (trimmed.startsWith("#")) {
+ addComment(trimmed);
+ return;
+ }
+
+ // Handle local console commands (no background needed, not added to history)
+ if (trimmed === "history") {
+ if (history.length === 0) {
+ addInfo("No command history.");
+ } else {
+ for (const cmd of history) {
+ addInfo(cmd);
+ }
+ }
+ return;
+ }
+
+ if (trimmed === "clear") {
+ clearConsole();
+ return;
+ }
+
+ if (trimmed === "reset") {
+ history.length = 0;
+ historyIndex = -1;
+ clearConsole();
+ addInfo("History and terminal cleared.");
+ return;
+ }
+
+ // Handle export command locally (no CDP needed)
+ if (trimmed === "export") {
+ const editorLines = editor.value.split("\n").filter((l) => l.trim());
+ if (editorLines.length === 0) {
+ addInfo("Nothing to export. Add commands to the editor first.");
+ } else {
+ exportFromLines(editorLines);
+ }
+ return;
+ }
+ if (trimmed.startsWith("export ")) {
+ const subCmd = trimmed.slice(7).trim();
+ const converted = pwToPlaywright(subCmd);
+ if (converted) {
+ addCommand(trimmed);
+ addLine(converted, "line-snapshot");
+ } else {
+ addError("Cannot convert: " + subCmd);
+ }
+ return;
+ }
+
+ addCommand(trimmed);
+
+ try {
+ const activeTabUrl = await getActiveTabUrl();
+ const res = await fetch(`${SERVER_URL}/run`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ raw: trimmed, activeTabUrl }),
+ });
+ const result: RunResult = await res.json();
+
+ if (result.isError) {
+ addError(result.text);
+ } else {
+ if (result.image) {
+ addScreenshot(result.image.replace(/^data:image\/\w+;base64,/, ''));
+ }
+ const text = filterResponse(trimmed, result.text);
+ if (text.includes("[ref=") || text.startsWith("- ")) {
+ for (const line of text.split("\n")) {
+ addSnapshot(line);
+ }
+ } else {
+ addSuccess(text);
+ }
+ }
+ } catch (_e) {
+ addError("Not connected to server. Run: playwright-repl --extension");
+ }
+}
+
+// --- Command execution for Run (with pass/fail tracking) ---
+
+async function executeCommandForRun(raw: string): Promise {
+ const trimmed = raw.trim();
+ if (!trimmed) return;
+
+ // Handle comments
+ if (trimmed.startsWith("#")) {
+ addComment(trimmed);
+ return;
+ }
+
+ addCommand(trimmed);
+
+ try {
+ const activeTabUrl = await getActiveTabUrl();
+ const res = await fetch(`${SERVER_URL}/run`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ raw: trimmed, activeTabUrl }),
+ });
+ const result: RunResult = await res.json();
+
+ if (result.isError) {
+ addError(result.text);
+ lineResults[currentRunLine] = "fail";
+ runFailCount++;
+ } else {
+ if (result.image) {
+ addScreenshot(result.image.replace(/^data:image\/\w+;base64,/, ''));
+ }
+ const text = filterResponse(trimmed, result.text);
+ if (text.includes("[ref=") || text.startsWith("- ")) {
+ for (const line of text.split("\n")) {
+ addSnapshot(line);
+ }
+ } else {
+ addSuccess(text);
+ }
+ lineResults[currentRunLine] = "pass";
+ runPassCount++;
+ }
+ } catch (_e) {
+ addError("Not connected to server. Run: playwright-repl --extension");
+ lineResults[currentRunLine] = "fail";
+ runFailCount++;
+ }
+ updateConsoleStats();
+ updateLineNumbers();
+}
+
+// --- REPL input handling ---
+
+function selectDropdownItem(cmd: string): void {
+ input.value = cmd + " ";
+ hideDropdown();
+ clearGhostText();
+}
+
+input.addEventListener("keydown", (e: KeyboardEvent) => {
+ // When dropdown is visible, arrow keys navigate it
+ if (!dropdown.hidden && dropdownItems.length > 0) {
+ if (e.key === "ArrowDown") {
+ e.preventDefault();
+ dropdownIndex = (dropdownIndex + 1) % dropdownItems.length;
+ updateDropdownHighlight();
+ return;
+ }
+ if (e.key === "ArrowUp") {
+ e.preventDefault();
+ dropdownIndex = dropdownIndex <= 0 ? dropdownItems.length - 1 : dropdownIndex - 1;
+ updateDropdownHighlight();
+ return;
+ }
+ if (e.key === "Enter" && dropdownIndex >= 0) {
+ e.preventDefault();
+ selectDropdownItem(dropdownItems[dropdownIndex]);
+ return;
+ }
+ if (e.key === "Tab") {
+ e.preventDefault();
+ if (dropdownIndex >= 0) {
+ selectDropdownItem(dropdownItems[dropdownIndex]);
+ } else if (dropdownItems.length > 0) {
+ selectDropdownItem(dropdownItems[0]);
+ }
+ return;
+ }
+ if (e.key === "Escape") {
+ e.preventDefault();
+ e.stopPropagation();
+ hideDropdown();
+ clearGhostText();
+ return;
+ }
+ }
+
+ if (e.key === "Enter") {
+ const value = input.value;
+ if (value.trim()) {
+ const cmd = value.trim().toLowerCase();
+ if (!LOCAL_COMMANDS.has(cmd)) {
+ history.push(value);
+ historyIndex = history.length;
+ }
+ executeCommand(value);
+ }
+ input.value = "";
+ hideDropdown();
+ clearGhostText();
+ } else if (e.key === "Tab") {
+ e.preventDefault();
+ const val = input.value.toLowerCase().trim();
+ if (val.includes(" ")) return;
+ if (!val) {
+ showDropdown(COMMANDS);
+ return;
+ }
+ const matches = COMMANDS.filter(cmd => cmd.startsWith(val) && cmd !== val);
+ if (matches.length === 1) {
+ selectDropdownItem(matches[0]);
+ } else if (matches.length > 1) {
+ showDropdown(matches);
+ }
+ } else if (e.key === "ArrowUp") {
+ e.preventDefault();
+ if (historyIndex > 0) {
+ historyIndex--;
+ input.value = history[historyIndex];
+ }
+ hideDropdown();
+ clearGhostText();
+ } else if (e.key === "ArrowDown") {
+ e.preventDefault();
+ if (historyIndex < history.length - 1) {
+ historyIndex++;
+ input.value = history[historyIndex];
+ } else {
+ historyIndex = history.length;
+ input.value = "";
+ }
+ hideDropdown();
+ clearGhostText();
+ } else if (e.key === "Escape") {
+ hideDropdown();
+ clearGhostText();
+ } else if (e.key === " " && e.ctrlKey) {
+ e.preventDefault();
+ const val = input.value.toLowerCase().trim();
+ if (val.includes(" ")) return;
+ const matches = val
+ ? COMMANDS.filter(cmd => cmd.startsWith(val) && cmd !== val)
+ : COMMANDS;
+ if (matches.length > 0) showDropdown(matches);
+ }
+});
+
+input.addEventListener("input", () => {
+ updateGhostText();
+ // Auto-show dropdown while typing
+ const val = input.value.toLowerCase().trim();
+ if (!val || val.includes(" ")) {
+ hideDropdown();
+ return;
+ }
+ const matches = COMMANDS.filter(cmd => cmd.startsWith(val) && cmd !== val);
+ if (matches.length > 1) {
+ showDropdown(matches);
+ } else {
+ hideDropdown();
+ }
+});
+
+// --- Editor keyboard shortcut ---
+
+editor.addEventListener("keydown", (e: KeyboardEvent) => {
+ if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
+ e.preventDefault();
+ runBtn.click();
+ }
+});
+
+// --- Run button ---
+
+runBtn.addEventListener("click", async () => {
+ if (isRunning) {
+ isRunning = false;
+ runBtn.textContent = "\u25B6";
+ addInfo("Run stopped.");
+ currentRunLine = -1;
+ updateLineNumbers();
+ return;
+ }
+
+ const content = editor.value;
+ if (!content.trim()) {
+ addInfo("Editor is empty. Open a .pw file or type commands.");
+ return;
+ }
+
+ const lines = content.split("\n");
+ const startLine = stepLine > 0 ? stepLine : 0;
+
+ isRunning = true;
+ stepBtn.disabled = true;
+ runBtn.textContent = "\u25A0";
+
+ // Only reset stats if starting fresh (not continuing from step)
+ if (startLine === 0) {
+ runPassCount = 0;
+ runFailCount = 0;
+ lineResults = new Array(lines.length).fill(null);
+ updateConsoleStats();
+ }
+
+ addInfo(startLine > 0 ? "Continuing run from step..." : "Running script...");
+
+ for (let i = startLine; i < lines.length; i++) {
+ if (!isRunning) break;
+ const line = lines[i].trim();
+
+ // Skip empty lines and comments (comments shown but not counted)
+ if (!line) continue;
+
+ currentRunLine = i;
+ updateLineNumbers();
+
+ await executeCommandForRun(line);
+
+ if (isRunning) await new Promise((r) => setTimeout(r, 300));
+ }
+
+ isRunning = false;
+ stepBtn.disabled = false;
+ runBtn.textContent = "\u25B6";
+ currentRunLine = -1;
+ stepLine = -1;
+ updateLineNumbers();
+ addInfo("Run complete.");
+});
+
+// --- Step button ---
+
+stepBtn.addEventListener("click", async () => {
+ if (isRunning) return;
+
+ const content = editor.value;
+ if (!content.trim()) {
+ addInfo("Editor is empty. Open a .pw file or type commands.");
+ return;
+ }
+
+ const lines = content.split("\n");
+
+ // Initialize step state on first click
+ if (stepLine === -1) {
+ stepLine = 0;
+ lineResults = new Array(lines.length).fill(null);
+ runPassCount = 0;
+ runFailCount = 0;
+ updateConsoleStats();
+ }
+
+ // Find next executable line from current step position
+ const nextLine = findNextExecutableLine(stepLine, lines);
+
+ if (nextLine === -1) {
+ addInfo("Step complete. No more lines to execute.");
+ stepLine = -1;
+ currentRunLine = -1;
+ updateLineNumbers();
+ return;
+ }
+
+ // Execute the line
+ stepBtn.disabled = true;
+ currentRunLine = nextLine;
+ updateLineNumbers();
+
+ await executeCommandForRun(lines[nextLine]);
+
+ stepLine = nextLine + 1;
+ currentRunLine = nextLine; // Keep highlight on last stepped line
+ updateLineNumbers();
+ stepBtn.disabled = false;
+
+ // Check if there are more executable lines
+ if (findNextExecutableLine(stepLine, lines) === -1) {
+ addInfo("Step complete. All lines executed.");
+ stepLine = -1;
+ currentRunLine = -1;
+ updateLineNumbers();
+ }
+});
+
+// --- Open button ---
+
+openBtn.addEventListener("click", () => {
+ const fileInput = document.createElement("input");
+ fileInput.type = "file";
+ fileInput.accept = ".pw,.txt";
+ fileInput.addEventListener("change", () => {
+ const file = fileInput.files?.[0];
+ if (!file) return;
+ const reader = new FileReader();
+ reader.onload = () => {
+ editor.value = reader.result as string;
+ currentFilename = file.name;
+ lineResults = [];
+ updateLineNumbers();
+ updateFileInfo();
+ updateButtonStates();
+ addInfo(`Opened ${file.name}`);
+ };
+ reader.readAsText(file);
+ });
+ fileInput.click();
+});
+
+// --- Save button ---
+
+saveBtn.addEventListener("click", async () => {
+ const content = editor.value;
+ if (!content.trim()) {
+ addInfo("Nothing to save.");
+ return;
+ }
+ const defaultName =
+ currentFilename || "commands-" + new Date().toISOString().slice(0, 10) + ".pw";
+ try {
+ const handle = await window.showSaveFilePicker({
+ suggestedName: defaultName,
+ types: [{ description: "PW command files", accept: { "text/plain": [".pw"] } }],
+ });
+ const writable = await handle.createWritable();
+ await writable.write(content);
+ await writable.close();
+ currentFilename = handle.name;
+ updateFileInfo();
+ addSuccess("Saved as " + handle.name);
+ } catch (e: unknown) {
+ if (e instanceof Error && e.name !== "AbortError") addError("Save failed: " + e.message);
+ }
+});
+
+// --- Export button ---
+
+exportBtn.addEventListener("click", () => {
+ const content = editor.value;
+ if (!content.trim()) {
+ addInfo("Nothing to export.");
+ return;
+ }
+ const editorLines = content.split("\n").filter((l) => l.trim());
+ exportFromLines(editorLines);
+});
+
+// --- Copy button ---
+
+copyBtn.addEventListener("click", () => {
+ const content = editor.value;
+ if (!content.trim()) {
+ addInfo("Nothing to copy.");
+ return;
+ }
+ if (copyToClipboard(content)) {
+ addSuccess("Editor content copied to clipboard.");
+ } else {
+ addError("Failed to copy to clipboard.");
+ }
+});
+
+// --- Console header clear button ---
+
+consoleClearBtn.addEventListener("click", clearConsole);
+
+// --- Record button ---
+
+let isRecording: boolean = false;
+
+recordBtn.addEventListener("click", async () => {
+ if (isRecording) {
+ // Stop recording
+ const tabs = await tabsQuery({ active: true, currentWindow: true });
+ const tab = tabs[0];
+ if (tab) {
+ chrome.runtime.sendMessage({ type: "pw-record-stop", tabId: tab.id });
+ }
+ recordBtn.classList.remove("recording");
+ recordBtn.textContent = "Record";
+ recordBtn.title = "Start recording user interactions";
+ isRecording = false;
+ addInfo("Recording stopped");
+ } else {
+ // Start recording
+ const tabs = await tabsQuery({ active: true, currentWindow: true });
+ const tab = tabs[0];
+ if (!tab) {
+ addError("No active tab found");
+ return;
+ }
+ const result = await rtSendMessage({ type: "pw-record-start", tabId: tab.id }) as { ok?: boolean; error?: string } | undefined;
+ if (result && !result.ok) {
+ addError("Recording failed: " + (result.error || "unknown error"));
+ return;
+ }
+ recordBtn.classList.add("recording");
+ recordBtn.textContent = "Stop";
+ recordBtn.title = "Stop recording";
+ isRecording = true;
+ addInfo("Recording on " + (tab.title || tab.url));
+ }
+});
+
+// --- Receive recorded commands from content script ---
+
+chrome.runtime.onMessage.addListener((msg) => {
+ if (msg.type === "pw-recorded-command" && msg.command) {
+ addCommand(msg.command);
+ appendToEditor(msg.command);
+ }
+});
+
+// --- Draggable splitter ---
+
+let isDragging: boolean = false;
+let dragStartY: number = 0;
+let dragStartHeight: number = 0;
+
+splitter.addEventListener("mousedown", (e: MouseEvent) => {
+ isDragging = true;
+ dragStartY = e.clientY;
+ dragStartHeight = editorPane.offsetHeight;
+ document.body.style.cursor = "row-resize";
+ document.body.style.userSelect = "none";
+ e.preventDefault();
+});
+
+document.addEventListener("mousemove", (e: MouseEvent) => {
+ if (!isDragging) return;
+ const delta = e.clientY - dragStartY;
+ const newHeight = dragStartY === 0 ? dragStartHeight : dragStartHeight + delta;
+ const minEditor = 80;
+ const bodyHeight = document.body.offsetHeight;
+ const fixedHeight =
+ (document.getElementById("toolbar") as HTMLDivElement).offsetHeight +
+ splitter.offsetHeight +
+ (document.getElementById("console-header") as HTMLDivElement).offsetHeight +
+ (document.getElementById("input-bar") as HTMLDivElement).offsetHeight;
+ const maxEditor = bodyHeight - fixedHeight - 80;
+ editorPane.style.flex = `0 0 ${Math.max(minEditor, Math.min(maxEditor, newHeight))}px`;
+});
+
+document.addEventListener("mouseup", () => {
+ if (!isDragging) return;
+ isDragging = false;
+ document.body.style.cursor = "";
+ document.body.style.userSelect = "";
+});
+
+// --- Lightbox ---
+
+lightbox.addEventListener("click", (e: MouseEvent) => {
+ // Only close when clicking the backdrop, not the image or save button
+ if (e.target === lightbox) {
+ lightbox.hidden = true;
+ lightboxImg.src = "";
+ }
+});
+
+lightboxCloseBtn.addEventListener("click", () => {
+ lightbox.hidden = true;
+ lightboxImg.src = "";
+});
+
+lightboxSaveBtn.addEventListener("click", async () => {
+ try {
+ const handle = await window.showSaveFilePicker({
+ suggestedName: "screenshot-" + new Date().toISOString().slice(0, 19).replace(/:/g, "-") + ".png",
+ types: [{ description: "PNG images", accept: { "image/png": [".png"] } }],
+ });
+ const res = await fetch(lightboxImg.src);
+ const blob = await res.blob();
+ const writable = await handle.createWritable();
+ await writable.write(blob);
+ await writable.close();
+ } catch (e: unknown) {
+ if (e instanceof Error && e.name !== "AbortError") console.error("Save failed:", e);
+ }
+});
+
+document.addEventListener("keydown", (e: KeyboardEvent) => {
+ if (e.key === "Escape" && !lightbox.hidden) {
+ e.stopPropagation();
+ e.preventDefault();
+ lightbox.hidden = true;
+ lightboxImg.src = "";
+ }
+});
+
+// --- Init ---
+
+updateLineNumbers();
+updateFileInfo();
+updateButtonStates();
+editor.focus();
+
+// Health check — verify server is running and show version
+(async () => {
+ try {
+ const res = await fetch(`${SERVER_URL}/health`);
+ if (res.ok) {
+ const data = await res.json();
+ addInfo(`Playwright REPL v${data.version || '?'}`);
+ addInfo("Connected to server on port " + SERVER_PORT);
+ } else {
+ addError("Server returned status " + res.status);
+ }
+ } catch {
+ addError("Server not running. Start with: playwright-repl --extension");
+ }
+})();
diff --git a/packages/extension/test-results/.last-run.json b/packages/extension/test-results/.last-run.json
new file mode 100644
index 0000000..cbcc1fb
--- /dev/null
+++ b/packages/extension/test-results/.last-run.json
@@ -0,0 +1,4 @@
+{
+ "status": "passed",
+ "failedTests": []
+}
\ No newline at end of file
diff --git a/packages/extension/test/background.test.ts b/packages/extension/test/background.test.ts
new file mode 100644
index 0000000..a39970b
--- /dev/null
+++ b/packages/extension/test/background.test.ts
@@ -0,0 +1,255 @@
+import { describe, it, expect, vi, beforeEach, type Mock } from "vitest";
+
+describe("background.js recording handlers", () => {
+ let startRecording: (tabId: number) => Promise<{ ok: boolean; error?: string }>;
+ let stopRecording: (tabId: number) => Promise<{ ok: boolean }>;
+ let injectRecorder: (tabId: number) => Promise;
+
+ // Aliases for frequently-used mocks
+ let executeScript: Mock;
+ let onUpdatedAdd: Mock;
+ let onCommittedAdd: Mock;
+ let rtSendMessage: Mock;
+
+ beforeEach(async () => {
+ vi.restoreAllMocks();
+ vi.resetModules();
+
+ executeScript = vi.fn().mockResolvedValue([]);
+ (chrome as any).scripting = { executeScript };
+
+ onUpdatedAdd = vi.fn();
+ (chrome.tabs as any).onUpdated = {
+ addListener: onUpdatedAdd,
+ removeListener: vi.fn(),
+ };
+
+ (chrome.tabs as any).onActivated = { addListener: vi.fn() };
+ (chrome.tabs as any).get = vi.fn().mockResolvedValue({ id: 42, url: "https://example.com", title: "Example" });
+
+ onCommittedAdd = vi.fn();
+ (chrome as any).webNavigation = {
+ onCommitted: {
+ addListener: onCommittedAdd,
+ removeListener: vi.fn(),
+ },
+ };
+
+ rtSendMessage = vi.fn().mockResolvedValue(undefined);
+ (chrome.runtime as any).sendMessage = rtSendMessage;
+
+ const bg: any = await import("../src/background.js");
+ startRecording = bg.startRecording;
+ stopRecording = bg.stopRecording;
+ injectRecorder = bg.injectRecorder;
+ });
+
+ // ─── startRecording ─────────────────────────────────────────────────────
+
+ it("injects recorder.js into the tab", async () => {
+ const result = await startRecording(42);
+ expect(result).toEqual({ ok: true });
+ expect(executeScript).toHaveBeenCalledWith({
+ target: { tabId: 42 },
+ files: ["content/recorder.js"],
+ });
+ });
+
+ it("adds tabs.onUpdated and webNavigation.onCommitted listeners", async () => {
+ await startRecording(42);
+ expect(onUpdatedAdd).toHaveBeenCalledWith(expect.any(Function));
+ expect(onCommittedAdd).toHaveBeenCalledWith(expect.any(Function));
+ });
+
+ it("returns error on injection failure", async () => {
+ executeScript.mockRejectedValue(new Error("Cannot access chrome:// URLs"));
+ const result = await startRecording(42);
+ expect(result).toEqual({ ok: false, error: "Cannot access chrome:// URLs" });
+ });
+
+ // ─── stopRecording ──────────────────────────────────────────────────────
+
+ it("runs cleanup on the tab", async () => {
+ await startRecording(42);
+ const result = await stopRecording(42);
+ expect(result).toEqual({ ok: true });
+ expect(executeScript).toHaveBeenCalledTimes(2);
+ const cleanupCall = executeScript.mock.calls[1][0];
+ expect(cleanupCall.target).toEqual({ tabId: 42 });
+ expect(typeof cleanupCall.func).toBe("function");
+ });
+
+ it("removes tabs.onUpdated and webNavigation.onCommitted listeners", async () => {
+ await startRecording(42);
+ await stopRecording(42);
+ expect(chrome.tabs.onUpdated.removeListener).toHaveBeenCalledWith(expect.any(Function));
+ expect(chrome.webNavigation.onCommitted.removeListener).toHaveBeenCalledWith(expect.any(Function));
+ });
+
+ it("handles cleanup failure gracefully", async () => {
+ await startRecording(42);
+ executeScript.mockRejectedValueOnce(new Error("Tab closed"));
+ const result = await stopRecording(42);
+ expect(result).toEqual({ ok: true });
+ });
+
+ // ─── onTabUpdated re-injection ──────────────────────────────────────────
+
+ it("re-injects recorder on tab navigation (status complete)", async () => {
+ await startRecording(42);
+ const listener = onUpdatedAdd.mock.calls[0][0];
+ executeScript.mockClear();
+ listener(42, { status: "complete" });
+ await vi.waitFor(() => {
+ expect(executeScript).toHaveBeenCalledWith({
+ target: { tabId: 42 },
+ files: ["content/recorder.js"],
+ });
+ });
+ });
+
+ it("does not re-inject for non-complete status", async () => {
+ await startRecording(42);
+ const listener = onUpdatedAdd.mock.calls[0][0];
+ executeScript.mockClear();
+ listener(42, { status: "loading" });
+ expect(executeScript).not.toHaveBeenCalled();
+ });
+
+ it("does not re-inject for a different tab", async () => {
+ await startRecording(42);
+ const listener = onUpdatedAdd.mock.calls[0][0];
+ executeScript.mockClear();
+ listener(99, { status: "complete" });
+ expect(executeScript).not.toHaveBeenCalled();
+ });
+
+ // ─── webNavigation.onCommitted — goto, go-back, go-forward ─────────
+
+ it("emits goto when user types URL in address bar", async () => {
+ await startRecording(42);
+ const navListener = onCommittedAdd.mock.calls[0][0];
+
+ navListener({
+ tabId: 42, frameId: 0, url: "https://new-url.com",
+ transitionType: "typed", transitionQualifiers: ["from_address_bar"],
+ });
+
+ expect(rtSendMessage).toHaveBeenCalledWith({
+ type: "pw-recorded-command",
+ command: "goto https://new-url.com",
+ });
+ });
+
+ it("does not emit goto for link click navigations", async () => {
+ await startRecording(42);
+ const navListener = onCommittedAdd.mock.calls[0][0];
+
+ navListener({
+ tabId: 42, frameId: 0, url: "https://linked-page.com",
+ transitionType: "link", transitionQualifiers: [],
+ });
+
+ expect(rtSendMessage).not.toHaveBeenCalledWith(
+ expect.objectContaining({ type: "pw-recorded-command" }),
+ );
+ });
+
+ it("does not emit for same URL", async () => {
+ await startRecording(42);
+ const navListener = onCommittedAdd.mock.calls[0][0];
+
+ navListener({
+ tabId: 42, frameId: 0, url: "https://example.com",
+ transitionType: "typed", transitionQualifiers: [],
+ });
+
+ expect(rtSendMessage).not.toHaveBeenCalledWith(
+ expect.objectContaining({ type: "pw-recorded-command" }),
+ );
+ });
+
+ it("ignores navigations from a different tab", async () => {
+ await startRecording(42);
+ const navListener = onCommittedAdd.mock.calls[0][0];
+
+ navListener({
+ tabId: 99, frameId: 0, url: "https://other.com",
+ transitionType: "typed", transitionQualifiers: [],
+ });
+
+ expect(rtSendMessage).not.toHaveBeenCalledWith(
+ expect.objectContaining({ type: "pw-recorded-command" }),
+ );
+ });
+
+ it("ignores subframe navigations", async () => {
+ await startRecording(42);
+ const navListener = onCommittedAdd.mock.calls[0][0];
+
+ navListener({
+ tabId: 42, frameId: 1, url: "https://iframe.com",
+ transitionType: "typed", transitionQualifiers: [],
+ });
+
+ expect(rtSendMessage).not.toHaveBeenCalledWith(
+ expect.objectContaining({ type: "pw-recorded-command" }),
+ );
+ });
+
+ it("emits go-back on back button navigation", async () => {
+ await startRecording(42);
+ const navListener = onCommittedAdd.mock.calls[0][0];
+
+ navListener({
+ tabId: 42, frameId: 0, url: "https://page2.com",
+ transitionType: "typed", transitionQualifiers: ["from_address_bar"],
+ });
+ rtSendMessage.mockClear();
+
+ navListener({
+ tabId: 42, frameId: 0, url: "https://example.com",
+ transitionType: "link", transitionQualifiers: ["forward_back"],
+ });
+
+ expect(rtSendMessage).toHaveBeenCalledWith({
+ type: "pw-recorded-command",
+ command: "go-back",
+ });
+ });
+
+ it("emits go-forward on forward button navigation", async () => {
+ await startRecording(42);
+ const navListener = onCommittedAdd.mock.calls[0][0];
+
+ navListener({
+ tabId: 42, frameId: 0, url: "https://page2.com",
+ transitionType: "typed", transitionQualifiers: ["from_address_bar"],
+ });
+ navListener({
+ tabId: 42, frameId: 0, url: "https://example.com",
+ transitionType: "link", transitionQualifiers: ["forward_back"],
+ });
+ rtSendMessage.mockClear();
+
+ navListener({
+ tabId: 42, frameId: 0, url: "https://page2.com",
+ transitionType: "link", transitionQualifiers: ["forward_back"],
+ });
+
+ expect(rtSendMessage).toHaveBeenCalledWith({
+ type: "pw-recorded-command",
+ command: "go-forward",
+ });
+ });
+
+ // ─── injectRecorder ────────────────────────────────────────────────────
+
+ it("calls chrome.scripting.executeScript with correct args", async () => {
+ await injectRecorder(99);
+ expect(executeScript).toHaveBeenCalledWith({
+ target: { tabId: 99 },
+ files: ["content/recorder.js"],
+ });
+ });
+});
diff --git a/packages/extension/test/converter.test.ts b/packages/extension/test/converter.test.ts
new file mode 100644
index 0000000..d1e1f94
--- /dev/null
+++ b/packages/extension/test/converter.test.ts
@@ -0,0 +1,289 @@
+import { describe, it, expect } from "vitest";
+import { tokenize, pwToPlaywright } from "../src/lib/converter.js";
+
+describe("tokenize", () => {
+ it("tokenizes simple words", () => {
+ expect(tokenize("click Submit")).toEqual(["click", "Submit"]);
+ });
+
+ it("tokenizes double-quoted strings", () => {
+ expect(tokenize('fill "Email" "test@example.com"')).toEqual([
+ "fill",
+ "Email",
+ "test@example.com",
+ ]);
+ });
+
+ it("tokenizes single-quoted strings", () => {
+ expect(tokenize("fill 'Username' 'alice'")).toEqual([
+ "fill",
+ "Username",
+ "alice",
+ ]);
+ });
+
+ it("returns empty array for empty string", () => {
+ expect(tokenize("")).toEqual([]);
+ });
+
+ it("returns empty array for whitespace", () => {
+ expect(tokenize(" ")).toEqual([]);
+ });
+
+ it("returns empty array for comments", () => {
+ expect(tokenize("# a comment")).toEqual([]);
+ });
+
+ it("handles mixed quoted and unquoted", () => {
+ expect(tokenize('click "destroy" costco')).toEqual([
+ "click",
+ "destroy",
+ "costco",
+ ]);
+ });
+
+ it("handles tabs as separators", () => {
+ expect(tokenize("goto\thttps://example.com")).toEqual([
+ "goto",
+ "https://example.com",
+ ]);
+ });
+
+ it("handles quoted string with spaces", () => {
+ expect(tokenize('click "Sign In"')).toEqual(["click", "Sign In"]);
+ });
+});
+
+describe("pwToPlaywright", () => {
+ it("returns null for empty string", () => {
+ expect(pwToPlaywright("")).toBeNull();
+ });
+
+ it("returns null for comments", () => {
+ expect(pwToPlaywright("# comment")).toBeNull();
+ });
+
+ // goto / open
+ it("converts goto with URL", () => {
+ expect(pwToPlaywright("goto https://example.com")).toBe(
+ 'await page.goto("https://example.com");'
+ );
+ });
+
+ it("converts goto without protocol", () => {
+ expect(pwToPlaywright("goto example.com")).toBe(
+ 'await page.goto("https://example.com");'
+ );
+ });
+
+ it("converts open alias", () => {
+ expect(pwToPlaywright("open https://example.com")).toBe(
+ 'await page.goto("https://example.com");'
+ );
+ });
+
+ it("returns null for goto without URL", () => {
+ expect(pwToPlaywright("goto")).toBeNull();
+ });
+
+ // click
+ it("converts click with text", () => {
+ expect(pwToPlaywright('click "Submit"')).toBe(
+ 'await page.getByText("Submit").click();'
+ );
+ });
+
+ it("converts click with scope (second arg)", () => {
+ expect(pwToPlaywright('click "destroy" "costco"')).toBe(
+ 'await page.getByText("costco").getByText("destroy").click();'
+ );
+ });
+
+ it("converts click with snapshot ref to comment", () => {
+ expect(pwToPlaywright("click e5")).toContain("snapshot ref");
+ });
+
+ it("converts c alias", () => {
+ expect(pwToPlaywright('c "Submit"')).toBe(
+ 'await page.getByText("Submit").click();'
+ );
+ });
+
+ it("returns null for click without target", () => {
+ expect(pwToPlaywright("click")).toBeNull();
+ });
+
+ // dblclick
+ it("converts dblclick", () => {
+ expect(pwToPlaywright('dblclick "Item"')).toBe(
+ 'await page.getByText("Item").dblclick();'
+ );
+ });
+
+ // fill
+ it("converts fill with label and value", () => {
+ expect(pwToPlaywright('fill "Email" "test@example.com"')).toBe(
+ 'await page.getByLabel("Email").fill("test@example.com");'
+ );
+ });
+
+ it("converts f alias", () => {
+ expect(pwToPlaywright('f "Name" "Alice"')).toBe(
+ 'await page.getByLabel("Name").fill("Alice");'
+ );
+ });
+
+ it("returns null for fill with missing value", () => {
+ expect(pwToPlaywright('fill "Email"')).toBeNull();
+ });
+
+ // select
+ it("converts select", () => {
+ expect(pwToPlaywright('select "Country" "US"')).toBe(
+ 'await page.getByLabel("Country").selectOption("US");'
+ );
+ });
+
+ // check / uncheck
+ it("converts check", () => {
+ expect(pwToPlaywright('check "Remember me"')).toBe(
+ 'await page.getByLabel("Remember me").check();'
+ );
+ });
+
+ it("converts uncheck", () => {
+ expect(pwToPlaywright('uncheck "Terms"')).toBe(
+ 'await page.getByLabel("Terms").uncheck();'
+ );
+ });
+
+ // hover
+ it("converts hover", () => {
+ expect(pwToPlaywright('hover "Menu"')).toBe(
+ 'await page.getByText("Menu").hover();'
+ );
+ });
+
+ // press
+ it("converts press with capitalization", () => {
+ expect(pwToPlaywright("press enter")).toBe(
+ 'await page.keyboard.press("Enter");'
+ );
+ });
+
+ it("converts p alias", () => {
+ expect(pwToPlaywright("p tab")).toBe(
+ 'await page.keyboard.press("Tab");'
+ );
+ });
+
+ // screenshot
+ it("converts screenshot", () => {
+ expect(pwToPlaywright("screenshot")).toBe(
+ "await page.screenshot({ path: 'screenshot.png' });"
+ );
+ });
+
+ it("converts screenshot full", () => {
+ expect(pwToPlaywright("screenshot full")).toBe(
+ "await page.screenshot({ path: 'screenshot.png', fullPage: true });"
+ );
+ });
+
+ // snapshot
+ it("converts snapshot to comment", () => {
+ expect(pwToPlaywright("snapshot")).toContain("// snapshot");
+ });
+
+ it("converts s alias to comment", () => {
+ expect(pwToPlaywright("s")).toContain("// snapshot");
+ });
+
+ // eval
+ it("converts eval", () => {
+ expect(pwToPlaywright("eval document.title")).toBe(
+ "await page.evaluate(() => document.title);"
+ );
+ });
+
+ // navigation
+ it("converts go-back", () => {
+ expect(pwToPlaywright("go-back")).toBe("await page.goBack();");
+ });
+
+ it("converts back alias", () => {
+ expect(pwToPlaywright("back")).toBe("await page.goBack();");
+ });
+
+ it("converts go-forward", () => {
+ expect(pwToPlaywright("go-forward")).toBe("await page.goForward();");
+ });
+
+ it("converts forward alias", () => {
+ expect(pwToPlaywright("forward")).toBe("await page.goForward();");
+ });
+
+ it("converts reload", () => {
+ expect(pwToPlaywright("reload")).toBe("await page.reload();");
+ });
+
+ // verify commands
+ it("converts verify-text", () => {
+ expect(pwToPlaywright('verify-text "Hello"')).toBe(
+ 'await expect(page.getByText("Hello")).toBeVisible();'
+ );
+ });
+
+ it("converts verify-no-text", () => {
+ expect(pwToPlaywright('verify-no-text "Gone"')).toBe(
+ 'await expect(page.getByText("Gone")).not.toBeVisible();'
+ );
+ });
+
+ it("converts verify-element", () => {
+ expect(pwToPlaywright('verify-element "Submit"')).toBe(
+ 'await expect(page.getByText("Submit")).toBeVisible();'
+ );
+ });
+
+ it("converts verify-no-element", () => {
+ expect(pwToPlaywright('verify-no-element "Deleted"')).toBe(
+ 'await expect(page.getByText("Deleted")).not.toBeVisible();'
+ );
+ });
+
+ it("converts verify-url", () => {
+ expect(pwToPlaywright('verify-url "dashboard"')).toBe(
+ "await expect(page).toHaveURL(/dashboard/);"
+ );
+ });
+
+ it("converts verify-url with regex special chars", () => {
+ expect(pwToPlaywright('verify-url "example.com/path"')).toBe(
+ "await expect(page).toHaveURL(/example\\.com\\/path/);"
+ );
+ });
+
+ it("converts verify-title", () => {
+ expect(pwToPlaywright('verify-title "My App"')).toBe(
+ "await expect(page).toHaveTitle(/My App/);"
+ );
+ });
+
+ it("returns null for verify-text without arg", () => {
+ expect(pwToPlaywright("verify-text")).toBeNull();
+ });
+
+ it("returns null for verify-url without arg", () => {
+ expect(pwToPlaywright("verify-url")).toBeNull();
+ });
+
+ it("returns null for verify-title without arg", () => {
+ expect(pwToPlaywright("verify-title")).toBeNull();
+ });
+
+ // unknown
+ it("converts unknown command to comment", () => {
+ expect(pwToPlaywright("foobar")).toBe("// unknown command: foobar");
+ });
+});
diff --git a/packages/extension/test/panel.test.ts b/packages/extension/test/panel.test.ts
new file mode 100644
index 0000000..fd570f4
--- /dev/null
+++ b/packages/extension/test/panel.test.ts
@@ -0,0 +1,771 @@
+import { describe, it, expect, vi, beforeEach, type Mock } from "vitest";
+
+describe("panel.js", () => {
+ let fetchMock: Mock;
+
+ beforeEach(() => {
+ vi.restoreAllMocks();
+ vi.resetModules();
+
+ // Set up the DOM that panel.js expects (split editor/REPL layout)
+ document.body.innerHTML = `
+
+
+
+
+ ![]()
+ `;
+
+ // Remove any theme class from previous test
+ document.body.classList.remove("theme-dark");
+
+ // Mock window.matchMedia for theme detection
+ (window as any).matchMedia = vi.fn().mockReturnValue({ matches: false });
+
+ // Mock fetch — route-aware: /health returns version, /run returns command result
+ fetchMock = vi.fn((url) => {
+ if (url && url.includes("/health")) {
+ return Promise.resolve({
+ ok: true,
+ json: () => Promise.resolve({ status: "ok", version: "1.0.0" }),
+ });
+ }
+ return Promise.resolve({
+ ok: true,
+ json: () => Promise.resolve({ text: "OK", isError: false }),
+ });
+ });
+ (global as any).fetch = fetchMock;
+ });
+
+ async function loadPanel() {
+ await import("../src/panel/panel.js");
+ }
+
+ // --- Init ---
+
+ it("renders welcome message on load", async () => {
+ await loadPanel();
+ const output = document.getElementById("output")!;
+ expect(output.textContent).toContain("Playwright REPL v1.0.0");
+ });
+
+ it("performs health check on load", async () => {
+ await loadPanel();
+ await vi.waitFor(() => {
+ expect(fetchMock).toHaveBeenCalledWith(
+ expect.stringContaining("/health")
+ );
+ });
+ });
+
+ it("shows connected message when health check succeeds", async () => {
+ await loadPanel();
+ await vi.waitFor(() => {
+ expect(document.getElementById("output")!.textContent).toContain(
+ "Connected to server"
+ );
+ });
+ });
+
+ it("shows error when health check fails", async () => {
+ fetchMock = (global as any).fetch = vi.fn().mockRejectedValue(new Error("Connection refused"));
+ await loadPanel();
+ await vi.waitFor(() => {
+ expect(document.getElementById("output")!.textContent).toContain(
+ "Server not running"
+ );
+ });
+ });
+
+ it("focuses the editor on load", async () => {
+ const editor = document.getElementById("editor")!;
+ const focusSpy = vi.spyOn(editor, "focus");
+ await loadPanel();
+ expect(focusSpy).toHaveBeenCalled();
+ });
+
+ it("has disabled copy, save, and export buttons initially", async () => {
+ await loadPanel();
+ expect((document.getElementById("copy-btn") as HTMLButtonElement).disabled).toBe(true);
+ expect((document.getElementById("save-btn") as HTMLButtonElement).disabled).toBe(true);
+ expect((document.getElementById("export-btn") as HTMLButtonElement).disabled).toBe(true);
+ });
+
+ it("has enabled open button", async () => {
+ await loadPanel();
+ expect((document.getElementById("open-btn") as HTMLButtonElement).disabled).toBe(false);
+ });
+
+ it("record button is enabled", async () => {
+ await loadPanel();
+ expect((document.getElementById("record-btn") as HTMLButtonElement).disabled).toBe(false);
+ });
+
+ // --- Theme ---
+
+ it("defaults to light theme", async () => {
+ (window as any).matchMedia = vi.fn().mockReturnValue({ matches: false });
+ await loadPanel();
+ expect(document.body.classList.contains("theme-dark")).toBe(false);
+ });
+
+ it("applies dark theme when prefers-color-scheme: dark", async () => {
+ (window as any).matchMedia = vi.fn().mockReturnValue({ matches: true });
+ await loadPanel();
+ expect(document.body.classList.contains("theme-dark")).toBe(true);
+ });
+
+ // --- Line numbers ---
+
+ it("renders line numbers for editor content", async () => {
+ await loadPanel();
+ const editor = document.getElementById("editor") as HTMLTextAreaElement;
+ editor.value = "goto https://example.com\nclick \"OK\"\npress Enter";
+ editor.dispatchEvent(new Event("input"));
+ const lineNums = document.getElementById("line-numbers")!;
+ const divs = lineNums.querySelectorAll("div");
+ expect(divs.length).toBe(3);
+ expect(divs[0].textContent).toBe("1");
+ expect(divs[1].textContent).toBe("2");
+ expect(divs[2].textContent).toBe("3");
+ });
+
+ it("shows file info with line count", async () => {
+ await loadPanel();
+ const editor = document.getElementById("editor") as HTMLTextAreaElement;
+ editor.value = "goto https://example.com\nclick \"OK\"";
+ editor.dispatchEvent(new Event("input"));
+ const fileInfo = document.getElementById("file-info")!;
+ expect(fileInfo.textContent).toContain("2 lines");
+ });
+
+ // --- REPL input ---
+
+ it("sends command via fetch on Enter key", async () => {
+ await loadPanel();
+ const input = document.getElementById("command-input") as HTMLInputElement;
+ input.value = "click e5";
+ input.dispatchEvent(
+ new KeyboardEvent("keydown", { key: "Enter", bubbles: true })
+ );
+ await vi.waitFor(() => {
+ expect(fetchMock).toHaveBeenCalledWith(
+ expect.stringContaining("/run"),
+ expect.objectContaining({
+ method: "POST",
+ body: JSON.stringify({ raw: "click e5", activeTabUrl: null }),
+ })
+ );
+ });
+ });
+
+ it("clears input after Enter", async () => {
+ await loadPanel();
+ const input = document.getElementById("command-input") as HTMLInputElement;
+ input.value = "snapshot";
+ input.dispatchEvent(
+ new KeyboardEvent("keydown", { key: "Enter", bubbles: true })
+ );
+ expect(input.value).toBe("");
+ });
+
+ it("does not send empty commands", async () => {
+ await loadPanel();
+ // Clear the health check fetch call
+ fetchMock.mockClear();
+ const input = document.getElementById("command-input") as HTMLInputElement;
+ input.value = " ";
+ input.dispatchEvent(
+ new KeyboardEvent("keydown", { key: "Enter", bubbles: true })
+ );
+ expect(fetchMock).not.toHaveBeenCalledWith(
+ expect.stringContaining("/run"),
+ expect.anything()
+ );
+ });
+
+ // --- Response display ---
+
+ it("displays success response in output", async () => {
+ fetchMock = (global as any).fetch = vi.fn((url) => {
+ if (url && url.includes("/health")) {
+ return Promise.resolve({
+ ok: true,
+ json: () => Promise.resolve({ status: "ok", version: "1.0.0" }),
+ });
+ }
+ return Promise.resolve({
+ ok: true,
+ json: () =>
+ Promise.resolve({
+ text: "Navigated\n### Page\n- Page URL: https://example.com",
+ isError: false,
+ }),
+ });
+ });
+ await loadPanel();
+ const input = document.getElementById("command-input") as HTMLInputElement;
+ input.value = "goto https://example.com";
+ input.dispatchEvent(
+ new KeyboardEvent("keydown", { key: "Enter", bubbles: true })
+ );
+ await vi.waitFor(() => {
+ expect(document.getElementById("output")!.textContent).toContain(
+ "Navigated"
+ );
+ });
+ });
+
+ it("displays error response in output", async () => {
+ fetchMock = (global as any).fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ json: () =>
+ Promise.resolve({ text: "Element not found", isError: true }),
+ });
+ await loadPanel();
+ const input = document.getElementById("command-input") as HTMLInputElement;
+ input.value = 'click "Missing"';
+ input.dispatchEvent(
+ new KeyboardEvent("keydown", { key: "Enter", bubbles: true })
+ );
+ await vi.waitFor(() => {
+ expect(document.getElementById("output")!.textContent).toContain(
+ "Element not found"
+ );
+ });
+ });
+
+ it("displays snapshot lines in output", async () => {
+ fetchMock = (global as any).fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ json: () =>
+ Promise.resolve({
+ text: '- button "OK" [ref=e1]\n- link "Home" [ref=e2]',
+ isError: false,
+ }),
+ });
+ await loadPanel();
+ const input = document.getElementById("command-input") as HTMLInputElement;
+ input.value = "snapshot";
+ input.dispatchEvent(
+ new KeyboardEvent("keydown", { key: "Enter", bubbles: true })
+ );
+ await vi.waitFor(() => {
+ expect(document.getElementById("output")!.textContent).toContain(
+ "button"
+ );
+ expect(document.getElementById("output")!.textContent).toContain("link");
+ });
+ });
+
+ it("displays screenshot as image in output", async () => {
+ fetchMock = (global as any).fetch = vi.fn((url) => {
+ if (url && url.includes("/health")) {
+ return Promise.resolve({
+ ok: true,
+ json: () => Promise.resolve({ status: "ok", version: "1.0.0" }),
+ });
+ }
+ return Promise.resolve({
+ ok: true,
+ json: () =>
+ Promise.resolve({
+ text: "Screenshot saved\n### Page\n- Page URL: https://example.com",
+ image: "data:image/png;base64,fakebase64",
+ isError: false,
+ }),
+ });
+ });
+ await loadPanel();
+ const input = document.getElementById("command-input") as HTMLInputElement;
+ input.value = "screenshot";
+ input.dispatchEvent(
+ new KeyboardEvent("keydown", { key: "Enter", bubbles: true })
+ );
+ await vi.waitFor(() => {
+ const img = document.querySelector("img:not(#lightbox-img)") as HTMLImageElement;
+ expect(img).not.toBeNull();
+ expect(img.src).toContain("fakebase64");
+ });
+ });
+
+ it("shows server not running on fetch failure", async () => {
+ fetchMock = (global as any).fetch = vi.fn().mockRejectedValue(new Error("Connection refused"));
+ await loadPanel();
+ const input = document.getElementById("command-input") as HTMLInputElement;
+ input.value = "snapshot";
+ input.dispatchEvent(
+ new KeyboardEvent("keydown", { key: "Enter", bubbles: true })
+ );
+ await vi.waitFor(() => {
+ expect(document.getElementById("output")!.textContent).toContain(
+ "Not connected to server"
+ );
+ });
+ });
+
+ it("displays comments without sending to server", async () => {
+ await loadPanel();
+ fetchMock.mockClear();
+ const input = document.getElementById("command-input") as HTMLInputElement;
+ input.value = "# this is a comment";
+ input.dispatchEvent(
+ new KeyboardEvent("keydown", { key: "Enter", bubbles: true })
+ );
+ expect(document.getElementById("output")!.textContent).toContain(
+ "# this is a comment"
+ );
+ expect(fetchMock).not.toHaveBeenCalledWith(
+ expect.stringContaining("/run"),
+ expect.anything()
+ );
+ });
+
+ // --- History ---
+
+ it("navigates command history with ArrowUp/ArrowDown", async () => {
+ await loadPanel();
+ const input = document.getElementById("command-input") as HTMLInputElement;
+
+ input.value = "help";
+ input.dispatchEvent(
+ new KeyboardEvent("keydown", { key: "Enter", bubbles: true })
+ );
+ input.value = "snapshot";
+ input.dispatchEvent(
+ new KeyboardEvent("keydown", { key: "Enter", bubbles: true })
+ );
+
+ input.dispatchEvent(
+ new KeyboardEvent("keydown", { key: "ArrowUp", bubbles: true })
+ );
+ expect(input.value).toBe("snapshot");
+
+ input.dispatchEvent(
+ new KeyboardEvent("keydown", { key: "ArrowUp", bubbles: true })
+ );
+ expect(input.value).toBe("help");
+
+ input.dispatchEvent(
+ new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true })
+ );
+ expect(input.value).toBe("snapshot");
+
+ input.dispatchEvent(
+ new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true })
+ );
+ expect(input.value).toBe("");
+ });
+
+ // --- Copy/Save/Export ---
+
+ it("enables copy, save, export when editor has content", async () => {
+ await loadPanel();
+ const editor = document.getElementById("editor") as HTMLTextAreaElement;
+ editor.value = "goto https://example.com";
+ editor.dispatchEvent(new Event("input"));
+
+ expect((document.getElementById("copy-btn") as HTMLButtonElement).disabled).toBe(false);
+ expect((document.getElementById("save-btn") as HTMLButtonElement).disabled).toBe(false);
+ expect((document.getElementById("export-btn") as HTMLButtonElement).disabled).toBe(false);
+ });
+
+ it("copy button copies editor content to clipboard", async () => {
+ (document as any).execCommand = vi.fn().mockReturnValue(true);
+ await loadPanel();
+ const editor = document.getElementById("editor") as HTMLTextAreaElement;
+ editor.value = 'goto https://example.com\nclick "OK"';
+ editor.dispatchEvent(new Event("input"));
+
+ document.getElementById("copy-btn")!.click();
+ expect(document.execCommand).toHaveBeenCalledWith("copy");
+ expect(document.getElementById("output")!.textContent).toContain("copied");
+ });
+
+ it("export button converts editor to Playwright code", async () => {
+ await loadPanel();
+ (document as any).execCommand = vi.fn().mockReturnValue(true);
+ const editor = document.getElementById("editor") as HTMLTextAreaElement;
+ editor.value = 'goto https://example.com\nclick "Submit"';
+ editor.dispatchEvent(new Event("input"));
+
+ document.getElementById("export-btn")!.click();
+ const output = document.getElementById("output")!;
+ const codeBlock = output.querySelector(".code-block");
+ expect(codeBlock).not.toBeNull();
+ expect(codeBlock!.textContent).toContain("@playwright/test");
+ });
+
+ // --- Console commands (history, clear, reset) ---
+
+ it("history command displays history in terminal", async () => {
+ await loadPanel();
+ const input = document.getElementById("command-input") as HTMLInputElement;
+
+ input.value = "help";
+ input.dispatchEvent(
+ new KeyboardEvent("keydown", { key: "Enter", bubbles: true })
+ );
+ input.value = "snapshot";
+ input.dispatchEvent(
+ new KeyboardEvent("keydown", { key: "Enter", bubbles: true })
+ );
+
+ await vi.waitFor(() => {
+ // Wait for the fetch calls to complete
+ expect(fetchMock).toHaveBeenCalledWith(
+ expect.stringContaining("/run"),
+ expect.anything()
+ );
+ });
+
+ input.value = "history";
+ input.dispatchEvent(
+ new KeyboardEvent("keydown", { key: "Enter", bubbles: true })
+ );
+
+ const output = document.getElementById("output")!;
+ expect(output.textContent).toContain("help");
+ expect(output.textContent).toContain("snapshot");
+ });
+
+ it("clear command clears console output", async () => {
+ await loadPanel();
+ const input = document.getElementById("command-input") as HTMLInputElement;
+
+ input.value = "clear";
+ input.dispatchEvent(
+ new KeyboardEvent("keydown", { key: "Enter", bubbles: true })
+ );
+
+ expect(document.getElementById("output")!.innerHTML).toBe("");
+ });
+
+ it("reset command clears history and console", async () => {
+ await loadPanel();
+ const input = document.getElementById("command-input") as HTMLInputElement;
+
+ input.value = "help";
+ input.dispatchEvent(
+ new KeyboardEvent("keydown", { key: "Enter", bubbles: true })
+ );
+
+ input.value = "reset";
+ input.dispatchEvent(
+ new KeyboardEvent("keydown", { key: "Enter", bubbles: true })
+ );
+
+ expect(document.getElementById("output")!.textContent).toContain(
+ "History and terminal cleared"
+ );
+
+ input.dispatchEvent(
+ new KeyboardEvent("keydown", { key: "ArrowUp", bubbles: true })
+ );
+ expect(input.value).toBe("");
+ });
+
+ // --- Run button ---
+
+ it("run button executes editor lines via fetch", async () => {
+ await loadPanel();
+ const editor = document.getElementById("editor") as HTMLTextAreaElement;
+ editor.value = 'goto https://example.com\nclick "OK"';
+ editor.dispatchEvent(new Event("input"));
+
+ document.getElementById("run-btn")!.click();
+ await vi.waitFor(() => {
+ expect(document.getElementById("output")!.textContent).toContain(
+ "Running script..."
+ );
+ expect(document.getElementById("output")!.textContent).toContain(
+ "Run complete."
+ );
+ });
+ });
+
+ it("run button shows pass/fail stats", async () => {
+ let callCount = 0;
+ fetchMock = (global as any).fetch = vi.fn().mockImplementation((url: string) => {
+ if (url.includes("/run")) {
+ callCount++;
+ if (callCount === 1) {
+ return Promise.resolve({
+ ok: true,
+ json: () => Promise.resolve({ text: "OK", isError: false }),
+ });
+ }
+ return Promise.resolve({
+ ok: true,
+ json: () =>
+ Promise.resolve({ text: "Not found", isError: true }),
+ });
+ }
+ return Promise.resolve({
+ ok: true,
+ json: () => Promise.resolve({ status: "ok" }),
+ });
+ });
+ await loadPanel();
+ const editor = document.getElementById("editor") as HTMLTextAreaElement;
+ editor.value = 'goto https://example.com\nclick "Missing"';
+ editor.dispatchEvent(new Event("input"));
+
+ document.getElementById("run-btn")!.click();
+ await vi.waitFor(() => {
+ const stats = document.getElementById("console-stats")!;
+ expect(stats.textContent).toContain("1 passed");
+ expect(stats.textContent).toContain("1 failed");
+ });
+ });
+
+ it("run button shows message for empty editor", async () => {
+ await loadPanel();
+ document.getElementById("run-btn")!.click();
+ expect(document.getElementById("output")!.textContent).toContain(
+ "Editor is empty"
+ );
+ });
+
+ // --- Ctrl+Enter ---
+
+ it("Ctrl+Enter in editor triggers run", async () => {
+ await loadPanel();
+ const editor = document.getElementById("editor") as HTMLTextAreaElement;
+ editor.value = "goto https://example.com";
+ editor.dispatchEvent(new Event("input"));
+
+ editor.dispatchEvent(
+ new KeyboardEvent("keydown", {
+ key: "Enter",
+ ctrlKey: true,
+ bubbles: true,
+ })
+ );
+
+ await vi.waitFor(() => {
+ expect(document.getElementById("output")!.textContent).toContain(
+ "Running script..."
+ );
+ });
+ });
+
+ // --- Step button ---
+
+ it("step button executes the first executable line", async () => {
+ await loadPanel();
+ const editor = document.getElementById("editor") as HTMLTextAreaElement;
+ editor.value = 'goto https://example.com\nclick "OK"';
+ editor.dispatchEvent(new Event("input"));
+
+ document.getElementById("step-btn")!.click();
+ await vi.waitFor(() => {
+ expect(fetchMock).toHaveBeenCalledWith(
+ expect.stringContaining("/run"),
+ expect.objectContaining({
+ body: JSON.stringify({ raw: "goto https://example.com", activeTabUrl: null }),
+ })
+ );
+ });
+ });
+
+ it("step button shows message for empty editor", async () => {
+ await loadPanel();
+ document.getElementById("step-btn")!.click();
+ expect(document.getElementById("output")!.textContent).toContain(
+ "Editor is empty"
+ );
+ });
+
+ // --- Autocomplete ---
+
+ it("ghost text shows completion hint while typing", async () => {
+ await loadPanel();
+ const input = document.getElementById("command-input") as HTMLInputElement;
+ const ghost = document.getElementById("ghost-text")!;
+
+ input.value = "go";
+ input.dispatchEvent(new Event("input", { bubbles: true }));
+ expect(ghost.textContent).toBe("to");
+ });
+
+ it("Tab completes single matching command", async () => {
+ await loadPanel();
+ const input = document.getElementById("command-input") as HTMLInputElement;
+
+ input.value = "scr";
+ input.dispatchEvent(new Event("input", { bubbles: true }));
+ input.dispatchEvent(
+ new KeyboardEvent("keydown", { key: "Tab", bubbles: true })
+ );
+ expect(input.value).toBe("screenshot ");
+ });
+
+ it("dropdown shows for multiple matches", async () => {
+ await loadPanel();
+ const input = document.getElementById("command-input") as HTMLInputElement;
+ const dd = document.getElementById("autocomplete-dropdown")!;
+
+ input.value = "go";
+ input.dispatchEvent(new Event("input", { bubbles: true }));
+ expect(dd.hidden).toBe(false);
+ expect(dd.querySelectorAll(".autocomplete-item").length).toBeGreaterThan(1);
+ });
+
+ it("Escape closes dropdown", async () => {
+ await loadPanel();
+ const input = document.getElementById("command-input") as HTMLInputElement;
+ const dd = document.getElementById("autocomplete-dropdown")!;
+
+ input.value = "go";
+ input.dispatchEvent(new Event("input", { bubbles: true }));
+ expect(dd.hidden).toBe(false);
+
+ input.dispatchEvent(
+ new KeyboardEvent("keydown", { key: "Escape", bubbles: true })
+ );
+ expect(dd.hidden).toBe(true);
+ });
+
+ // --- Lightbox ---
+
+ it("lightbox closes when clicking backdrop", async () => {
+ await loadPanel();
+ const lightbox = document.getElementById("lightbox")!;
+ lightbox.hidden = false;
+
+ lightbox.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ expect(lightbox.hidden).toBe(true);
+ });
+
+ it("lightbox close button works", async () => {
+ await loadPanel();
+ const lightbox = document.getElementById("lightbox")!;
+ lightbox.hidden = false;
+
+ document.getElementById("lightbox-close-btn")!.click();
+ expect(lightbox.hidden).toBe(true);
+ });
+
+ it("Escape key closes lightbox when visible", async () => {
+ await loadPanel();
+ const lightbox = document.getElementById("lightbox")!;
+ lightbox.hidden = false;
+
+ document.dispatchEvent(
+ new KeyboardEvent("keydown", { key: "Escape", bubbles: true })
+ );
+ expect(lightbox.hidden).toBe(true);
+ });
+
+ // --- Splitter ---
+
+ it("splitter drag resizes editor pane", async () => {
+ await loadPanel();
+ const splitter = document.getElementById("splitter")!;
+
+ splitter.dispatchEvent(
+ new MouseEvent("mousedown", { clientY: 200, bubbles: true })
+ );
+ expect(document.body.style.cursor).toBe("row-resize");
+
+ document.dispatchEvent(
+ new MouseEvent("mousemove", { clientY: 250, bubbles: true })
+ );
+
+ document.dispatchEvent(new MouseEvent("mouseup", { bubbles: true }));
+ expect(document.body.style.cursor).toBe("");
+ });
+
+ // --- Save button ---
+
+ it("save button saves editor content", async () => {
+ const mockWritable = { write: vi.fn(), close: vi.fn() };
+ const mockHandle = { name: "test.pw", createWritable: vi.fn().mockResolvedValue(mockWritable) };
+ (window as any).showSaveFilePicker = vi.fn().mockResolvedValue(mockHandle);
+ await loadPanel();
+ const editor = document.getElementById("editor") as HTMLTextAreaElement;
+ editor.value = "goto https://example.com";
+ editor.dispatchEvent(new Event("input"));
+
+ document.getElementById("save-btn")!.click();
+ await vi.waitFor(() => {
+ expect(document.getElementById("output")!.textContent).toContain(
+ "Saved as test.pw"
+ );
+ });
+ expect(mockWritable.write).toHaveBeenCalledWith("goto https://example.com");
+ expect(mockWritable.close).toHaveBeenCalled();
+ });
+
+ // --- Open button ---
+
+ it("open button loads file content into editor", async () => {
+ await loadPanel();
+
+ const realCreateElement = document.createElement.bind(document);
+ vi.spyOn(document, "createElement").mockImplementation((tag: string) => {
+ const el = realCreateElement(tag);
+ if (tag === "input") {
+ el.click = () => {
+ const file = new File(
+ ['goto https://example.com\nclick "OK"'],
+ "test.pw",
+ { type: "text/plain" }
+ );
+ Object.defineProperty(el, "files", { value: [file] });
+ el.dispatchEvent(new Event("change"));
+ };
+ }
+ return el;
+ });
+
+ document.getElementById("open-btn")!.click();
+
+ await vi.waitFor(() => {
+ expect((document.getElementById("editor") as HTMLTextAreaElement).value).toContain(
+ "goto https://example.com"
+ );
+ });
+
+ (document.createElement as any).mockRestore();
+ });
+});
diff --git a/packages/extension/test/recorder.test.ts b/packages/extension/test/recorder.test.ts
new file mode 100644
index 0000000..e834637
--- /dev/null
+++ b/packages/extension/test/recorder.test.ts
@@ -0,0 +1,597 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+
+describe("recorder.js", () => {
+ let sendMessageSpy: ReturnType;
+
+ beforeEach(() => {
+ vi.restoreAllMocks();
+ vi.resetModules();
+ vi.useFakeTimers();
+
+ // Clear recorder state
+ delete document.documentElement.dataset.pwRecorderActive;
+ delete window.__pwRecorderCleanup;
+
+ // Mock chrome.runtime.sendMessage
+ sendMessageSpy = vi.fn();
+ (globalThis as any).chrome = {
+ runtime: { sendMessage: sendMessageSpy },
+ };
+ });
+
+ afterEach(() => {
+ // Clean up recorder if still active
+ if (typeof window.__pwRecorderCleanup === "function") {
+ window.__pwRecorderCleanup();
+ }
+ vi.useRealTimers();
+ });
+
+ async function loadRecorder() {
+ // @ts-expect-error recorder.ts is an IIFE script with no exports; vitest can still execute it
+ await import("../src/content/recorder.ts");
+ }
+
+ // ─── Initialization ─────────────────────────────────────────────────────
+
+ it("sets dataset.pwRecorderActive on load", async () => {
+ await loadRecorder();
+ expect(document.documentElement.dataset.pwRecorderActive).toBe("true");
+ });
+
+ it("provides __pwRecorderCleanup function", async () => {
+ await loadRecorder();
+ expect(typeof window.__pwRecorderCleanup).toBe("function");
+ });
+
+ it("is idempotent — does not run twice", async () => {
+ await loadRecorder();
+ const firstCleanup = window.__pwRecorderCleanup;
+ // @ts-expect-error recorder.ts is an IIFE with no exports
+ await import("../src/content/recorder.ts");
+ expect(window.__pwRecorderCleanup).toBe(firstCleanup);
+ });
+
+ // ─── Click ──────────────────────────────────────────────────────────────
+
+ it("records click on a button", async () => {
+ document.body.innerHTML = '';
+ await loadRecorder();
+ document.querySelector("button")!.click();
+ vi.advanceTimersByTime(300);
+ expect(sendMessageSpy).toHaveBeenCalledWith({
+ type: "pw-recorded-command",
+ command: 'click "Submit"',
+ });
+ });
+
+ it("records click on a link", async () => {
+ document.body.innerHTML = 'Learn more';
+ await loadRecorder();
+ document.querySelector("a")!.click();
+ vi.advanceTimersByTime(300);
+ expect(sendMessageSpy).toHaveBeenCalledWith({
+ type: "pw-recorded-command",
+ command: 'click "Learn more"',
+ });
+ });
+
+ it("skips clicks on non-interactive containers (div, section)", async () => {
+ document.body.innerHTML = 'content
';
+ await loadRecorder();
+ (document.querySelector("#container") as HTMLElement).click();
+ vi.advanceTimersByTime(300);
+ expect(sendMessageSpy).not.toHaveBeenCalled();
+ });
+
+ it("uses aria-label for locator when available", async () => {
+ document.body.innerHTML = '';
+ await loadRecorder();
+ document.querySelector("button")!.click();
+ vi.advanceTimersByTime(300);
+ expect(sendMessageSpy).toHaveBeenCalledWith({
+ type: "pw-recorded-command",
+ command: 'click "Close dialog"',
+ });
+ });
+
+ it("uses label[for] for locator", async () => {
+ document.body.innerHTML = '';
+ await loadRecorder();
+ document.querySelector("input")!.click();
+ vi.advanceTimersByTime(300);
+ expect(sendMessageSpy).toHaveBeenCalledWith(
+ expect.objectContaining({ type: "pw-recorded-command" }),
+ );
+ });
+
+ it("uses placeholder for locator", async () => {
+ document.body.innerHTML = '';
+ await loadRecorder();
+ // placeholder is not standard on buttons, so textContent takes priority
+ document.querySelector("button")!.click();
+ vi.advanceTimersByTime(300);
+ expect(sendMessageSpy).toHaveBeenCalledWith({
+ type: "pw-recorded-command",
+ command: 'click "Search..."',
+ });
+ });
+
+ it("uses title as fallback locator", async () => {
+ document.body.innerHTML = '';
+ await loadRecorder();
+ document.querySelector("span")!.click();
+ vi.advanceTimersByTime(300);
+ expect(sendMessageSpy).toHaveBeenCalledWith({
+ type: "pw-recorded-command",
+ command: 'click "Info tooltip"',
+ });
+ });
+
+ it("falls back to tagName locator", async () => {
+ document.body.innerHTML = '';
+ await loadRecorder();
+ document.querySelector("span")!.click();
+ vi.advanceTimersByTime(300);
+ expect(sendMessageSpy).toHaveBeenCalledWith({
+ type: "pw-recorded-command",
+ command: 'click "span"',
+ });
+ });
+
+ it("skips click on text input (handled by fill)", async () => {
+ document.body.innerHTML = '';
+ await loadRecorder();
+ document.querySelector("input")!.click();
+ vi.advanceTimersByTime(300);
+ expect(sendMessageSpy).not.toHaveBeenCalled();
+ });
+
+ it("skips click on textarea (handled by fill)", async () => {
+ document.body.innerHTML = '';
+ await loadRecorder();
+ document.querySelector("textarea")!.click();
+ vi.advanceTimersByTime(300);
+ expect(sendMessageSpy).not.toHaveBeenCalled();
+ });
+
+ it("records click on div with role attribute", async () => {
+ document.body.innerHTML = 'Custom Button
';
+ await loadRecorder();
+ document.querySelector("div")!.click();
+ vi.advanceTimersByTime(300);
+ expect(sendMessageSpy).toHaveBeenCalledWith({
+ type: "pw-recorded-command",
+ command: 'click "Custom Button"',
+ });
+ });
+
+ it("escapes quotes in locator text", async () => {
+ document.body.innerHTML = '';
+ await loadRecorder();
+ document.querySelector("button")!.click();
+ vi.advanceTimersByTime(300);
+ expect(sendMessageSpy).toHaveBeenCalledWith({
+ type: "pw-recorded-command",
+ command: 'click "Say \\"Hello\\""',
+ });
+ });
+
+ it("falls through long text (>80 chars) to title", async () => {
+ const longText = "A".repeat(100);
+ document.body.innerHTML = `${longText}`;
+ await loadRecorder();
+ document.querySelector("span")!.click();
+ vi.advanceTimersByTime(300);
+ expect(sendMessageSpy).toHaveBeenCalledWith({
+ type: "pw-recorded-command",
+ command: 'click "Short title"',
+ });
+ });
+
+ it("uses parent label text for locator", async () => {
+ document.body.innerHTML = '';
+ await loadRecorder();
+ document.querySelector("span")!.click();
+ vi.advanceTimersByTime(300);
+ expect(sendMessageSpy).toHaveBeenCalledWith({
+ type: "pw-recorded-command",
+ command: 'click "Username icon"',
+ });
+ });
+
+ // ─── Non-interactive elements (isClickable) ────────────────────────────
+
+ it("skips clicks on plain span without role", async () => {
+ document.body.innerHTML = 'just text';
+ await loadRecorder();
+ document.querySelector("span")!.click();
+ vi.advanceTimersByTime(300);
+ expect(sendMessageSpy).not.toHaveBeenCalled();
+ });
+
+ it("skips clicks on paragraph text", async () => {
+ document.body.innerHTML = 'paragraph content
';
+ await loadRecorder();
+ document.querySelector("p")!.click();
+ vi.advanceTimersByTime(300);
+ expect(sendMessageSpy).not.toHaveBeenCalled();
+ });
+
+ it("skips clicks on heading text", async () => {
+ document.body.innerHTML = 'Section Title
';
+ await loadRecorder();
+ document.querySelector("h2")!.click();
+ vi.advanceTimersByTime(300);
+ expect(sendMessageSpy).not.toHaveBeenCalled();
+ });
+
+ it("records click on child of a link", async () => {
+ document.body.innerHTML = 'inner text';
+ await loadRecorder();
+ document.querySelector("span")!.click();
+ vi.advanceTimersByTime(300);
+ expect(sendMessageSpy).toHaveBeenCalledWith(
+ expect.objectContaining({ type: "pw-recorded-command" }),
+ );
+ });
+
+ it("records click on child of a button", async () => {
+ document.body.innerHTML = '';
+ await loadRecorder();
+ document.querySelector("span")!.click();
+ vi.advanceTimersByTime(300);
+ expect(sendMessageSpy).toHaveBeenCalledWith(
+ expect.objectContaining({ type: "pw-recorded-command" }),
+ );
+ });
+
+ it("records click on element with onclick attribute", async () => {
+ document.body.innerHTML = 'clickable div
';
+ await loadRecorder();
+ document.querySelector("div")!.click();
+ vi.advanceTimersByTime(300);
+ expect(sendMessageSpy).toHaveBeenCalledWith(
+ expect.objectContaining({ type: "pw-recorded-command" }),
+ );
+ });
+
+ // ─── Nth suffix (uniqueness check) ────────────────────────────────────
+
+ it("appends --nth when multiple buttons share the same text", async () => {
+ document.body.innerHTML = '';
+ await loadRecorder();
+ const buttons = document.querySelectorAll("button");
+ buttons[1].click();
+ vi.advanceTimersByTime(300);
+ expect(sendMessageSpy).toHaveBeenCalledWith({
+ type: "pw-recorded-command",
+ command: 'click "OK" --nth 1',
+ });
+ });
+
+ it("appends --nth 0 for the first of multiple matching elements", async () => {
+ document.body.innerHTML = '';
+ await loadRecorder();
+ document.querySelectorAll("button")[0].click();
+ vi.advanceTimersByTime(300);
+ expect(sendMessageSpy).toHaveBeenCalledWith({
+ type: "pw-recorded-command",
+ command: 'click "Save" --nth 0',
+ });
+ });
+
+ it("does not append --nth when text is unique", async () => {
+ document.body.innerHTML = '';
+ await loadRecorder();
+ document.querySelector("button")!.click();
+ vi.advanceTimersByTime(300);
+ expect(sendMessageSpy).toHaveBeenCalledWith({
+ type: "pw-recorded-command",
+ command: 'click "Submit"',
+ });
+ });
+
+ it("appends --nth for duplicate links", async () => {
+ document.body.innerHTML = 'Read moreRead more';
+ await loadRecorder();
+ document.querySelectorAll("a")[1].click();
+ vi.advanceTimersByTime(300);
+ expect(sendMessageSpy).toHaveBeenCalledWith({
+ type: "pw-recorded-command",
+ command: 'click "Read more" --nth 1',
+ });
+ });
+
+ // ─── Checkbox ───────────────────────────────────────────────────────────
+
+ it("records check command on checkbox", async () => {
+ document.body.innerHTML = '';
+ await loadRecorder();
+ const cb = document.querySelector("input")!;
+ // click() toggles unchecked → checked, handler reads checked=true
+ cb.click();
+ vi.advanceTimersByTime(300);
+ expect(sendMessageSpy).toHaveBeenCalledWith({
+ type: "pw-recorded-command",
+ command: 'check "Accept terms"',
+ });
+ });
+
+ it("records uncheck command on checkbox", async () => {
+ document.body.innerHTML = '';
+ await loadRecorder();
+ const cb = document.querySelector("input")!;
+ // click() toggles checked → unchecked, handler reads checked=false
+ cb.click();
+ vi.advanceTimersByTime(300);
+ expect(sendMessageSpy).toHaveBeenCalledWith({
+ type: "pw-recorded-command",
+ command: 'uncheck "Accept terms"',
+ });
+ });
+
+ it("detects checkbox via parent label", async () => {
+ document.body.innerHTML = '';
+ await loadRecorder();
+ const cb = document.querySelector("input")!;
+ cb.checked = true;
+ document.querySelector("label")!.click();
+ vi.advanceTimersByTime(300);
+ expect(sendMessageSpy).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: "pw-recorded-command",
+ command: expect.stringContaining("check"),
+ }),
+ );
+ });
+
+ // ─── Input / Fill ───────────────────────────────────────────────────────
+
+ it("records debounced fill command on input", async () => {
+ document.body.innerHTML = '';
+ await loadRecorder();
+ const el = document.querySelector("input")!;
+ el.value = "alice";
+ el.dispatchEvent(new Event("input", { bubbles: true }));
+ // Should not fire immediately
+ expect(sendMessageSpy).not.toHaveBeenCalled();
+ // After debounce
+ vi.advanceTimersByTime(1500);
+ expect(sendMessageSpy).toHaveBeenCalledWith({
+ type: "pw-recorded-command",
+ command: 'fill "Username" "alice"',
+ });
+ });
+
+ it("click flushes pending fill immediately", async () => {
+ document.body.innerHTML = '';
+ await loadRecorder();
+ const el = document.querySelector("input")!;
+ el.value = "test@example.com";
+ el.dispatchEvent(new Event("input", { bubbles: true }));
+ // Click before debounce timeout
+ document.querySelector("button")!.click();
+ vi.advanceTimersByTime(300);
+ const calls = sendMessageSpy.mock.calls.map((c) => c[0]);
+ const fillCall = calls.find((c) => c.command && c.command.startsWith("fill"));
+ const clickCall = calls.find((c) => c.command && c.command.startsWith("click"));
+ expect(fillCall).toBeDefined();
+ expect(clickCall).toBeDefined();
+ });
+
+ it("records fill on textarea", async () => {
+ document.body.innerHTML = '';
+ await loadRecorder();
+ const el = document.querySelector("textarea")!;
+ el.value = "Great product!";
+ el.dispatchEvent(new Event("input", { bubbles: true }));
+ vi.advanceTimersByTime(1500);
+ expect(sendMessageSpy).toHaveBeenCalledWith({
+ type: "pw-recorded-command",
+ command: 'fill "Comments" "Great product!"',
+ });
+ });
+
+ it("resets debounce timer on continued typing", async () => {
+ document.body.innerHTML = '';
+ await loadRecorder();
+ const el = document.querySelector("input")!;
+ el.value = "hel";
+ el.dispatchEvent(new Event("input", { bubbles: true }));
+ vi.advanceTimersByTime(1000);
+ // Still typing — should not have flushed yet
+ expect(sendMessageSpy).not.toHaveBeenCalled();
+ el.value = "hello";
+ el.dispatchEvent(new Event("input", { bubbles: true }));
+ vi.advanceTimersByTime(1000);
+ // Timer reset — still waiting
+ expect(sendMessageSpy).not.toHaveBeenCalled();
+ vi.advanceTimersByTime(500);
+ // Now 1500ms since last input
+ expect(sendMessageSpy).toHaveBeenCalledWith({
+ type: "pw-recorded-command",
+ command: 'fill "Search" "hello"',
+ });
+ });
+
+ it("escapes quotes in fill value", async () => {
+ document.body.innerHTML = '';
+ await loadRecorder();
+ const el = document.querySelector("input")!;
+ el.value = 'John "Doe"';
+ el.dispatchEvent(new Event("input", { bubbles: true }));
+ vi.advanceTimersByTime(1500);
+ expect(sendMessageSpy).toHaveBeenCalledWith({
+ type: "pw-recorded-command",
+ command: 'fill "Name" "John \\"Doe\\""',
+ });
+ });
+
+ it("does not record fill for radio inputs", async () => {
+ document.body.innerHTML = '';
+ await loadRecorder();
+ const el = document.querySelector("input")!;
+ el.dispatchEvent(new Event("input", { bubbles: true }));
+ vi.advanceTimersByTime(1500);
+ const fillCalls = sendMessageSpy.mock.calls.filter(
+ (c) => c[0].command && c[0].command.startsWith("fill"),
+ );
+ expect(fillCalls).toHaveLength(0);
+ });
+
+ it("does not record fill for checkbox inputs", async () => {
+ document.body.innerHTML = '';
+ await loadRecorder();
+ const el = document.querySelector("input")!;
+ el.dispatchEvent(new Event("input", { bubbles: true }));
+ vi.advanceTimersByTime(1500);
+ const fillCalls = sendMessageSpy.mock.calls.filter(
+ (c) => c[0].command && c[0].command.startsWith("fill"),
+ );
+ expect(fillCalls).toHaveLength(0);
+ });
+
+ // ─── Select ─────────────────────────────────────────────────────────────
+
+ it("records select command on dropdown", async () => {
+ document.body.innerHTML = `
+ `;
+ await loadRecorder();
+ const sel = document.querySelector("select")!;
+ sel.selectedIndex = 1;
+ sel.dispatchEvent(new Event("change", { bubbles: true }));
+ expect(sendMessageSpy).toHaveBeenCalledWith({
+ type: "pw-recorded-command",
+ command: 'select "Color" "Blue"',
+ });
+ });
+
+ // ─── Keydown ────────────────────────────────────────────────────────────
+
+ it("records press Enter", async () => {
+ document.body.innerHTML = '';
+ await loadRecorder();
+ document.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
+ expect(sendMessageSpy).toHaveBeenCalledWith({
+ type: "pw-recorded-command",
+ command: "press Enter",
+ });
+ });
+
+ it("records press Tab", async () => {
+ await loadRecorder();
+ document.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab", bubbles: true }));
+ expect(sendMessageSpy).toHaveBeenCalledWith({
+ type: "pw-recorded-command",
+ command: "press Tab",
+ });
+ });
+
+ it("records press Escape", async () => {
+ await loadRecorder();
+ document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
+ expect(sendMessageSpy).toHaveBeenCalledWith({
+ type: "pw-recorded-command",
+ command: "press Escape",
+ });
+ });
+
+ it("does not record regular key presses", async () => {
+ await loadRecorder();
+ document.dispatchEvent(new KeyboardEvent("keydown", { key: "a", bubbles: true }));
+ expect(sendMessageSpy).not.toHaveBeenCalled();
+ });
+
+ it("keydown flushes pending fill", async () => {
+ document.body.innerHTML = '';
+ await loadRecorder();
+ const el = document.querySelector("input")!;
+ el.value = "hello";
+ el.dispatchEvent(new Event("input", { bubbles: true }));
+ document.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
+ const calls = sendMessageSpy.mock.calls.map((c) => c[0]);
+ expect(calls.find((c) => c.command === 'fill "Search" "hello"')).toBeDefined();
+ expect(calls.find((c) => c.command === "press Enter")).toBeDefined();
+ });
+
+ // ─── Double-click ───────────────────────────────────────────────────────
+
+ it("records dblclick and suppresses single click", async () => {
+ document.body.innerHTML = 'Edit';
+ await loadRecorder();
+ const el = document.querySelector("span")!;
+ // Simulate browser's click → dblclick sequence
+ el.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ el.dispatchEvent(new MouseEvent("dblclick", { bubbles: true }));
+ vi.advanceTimersByTime(300);
+ const commands = sendMessageSpy.mock.calls.map((c) => c[0].command);
+ expect(commands).toContain('dblclick "Edit"');
+ expect(commands).not.toContain('click "Edit"');
+ });
+
+ // ─── Context menu (right-click) ─────────────────────────────────────────
+
+ it("records right-click as click --button right", async () => {
+ document.body.innerHTML = 'Item';
+ await loadRecorder();
+ const el = document.querySelector("span")!;
+ el.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true }));
+ expect(sendMessageSpy).toHaveBeenCalledWith({
+ type: "pw-recorded-command",
+ command: 'click "Item" --button right',
+ });
+ });
+
+ // ─── Action button with item context ────────────────────────────────────
+
+ it("includes item context for action buttons in a list", async () => {
+ document.body.innerHTML = `
+ `;
+ await loadRecorder();
+ document.querySelector("button")!.click();
+ vi.advanceTimersByTime(300);
+ expect(sendMessageSpy).toHaveBeenCalledWith({
+ type: "pw-recorded-command",
+ command: 'click "delete" "Buy milk"',
+ });
+ });
+
+ // ─── Cleanup ────────────────────────────────────────────────────────────
+
+ it("cleanup removes listeners and dataset", async () => {
+ document.body.innerHTML = '';
+ await loadRecorder();
+ expect(document.documentElement.dataset.pwRecorderActive).toBe("true");
+
+ window.__pwRecorderCleanup();
+
+ expect(document.documentElement.dataset.pwRecorderActive).toBeUndefined();
+ expect(window.__pwRecorderCleanup).toBeUndefined();
+
+ // Events should no longer be captured
+ sendMessageSpy.mockClear();
+ document.querySelector("button")!.click();
+ vi.advanceTimersByTime(300);
+ expect(sendMessageSpy).not.toHaveBeenCalled();
+ });
+
+ it("cleanup flushes pending fill", async () => {
+ document.body.innerHTML = '';
+ await loadRecorder();
+ const el = document.querySelector("input")!;
+ el.value = "Bob";
+ el.dispatchEvent(new Event("input", { bubbles: true }));
+ // Cleanup before debounce
+ window.__pwRecorderCleanup();
+ expect(sendMessageSpy).toHaveBeenCalledWith({
+ type: "pw-recorded-command",
+ command: 'fill "Name" "Bob"',
+ });
+ });
+});
diff --git a/packages/extension/test/setup.ts b/packages/extension/test/setup.ts
new file mode 100644
index 0000000..2e2c3f3
--- /dev/null
+++ b/packages/extension/test/setup.ts
@@ -0,0 +1,28 @@
+import * as chrome from "vitest-chrome/lib/index.esm.js";
+
+// Add chrome object to global scope so imported modules can use it
+Object.assign(global, chrome);
+
+// vitest-chrome doesn't include chrome.scripting — add it manually
+if (!globalThis.chrome.scripting) {
+ (globalThis.chrome as any).scripting = {
+ executeScript: async () => [],
+ };
+}
+
+// vitest-chrome doesn't include chrome.sidePanel — add it manually
+if (!globalThis.chrome.sidePanel) {
+ (globalThis.chrome as any).sidePanel = {
+ setPanelBehavior: () => {},
+ };
+}
+
+// vitest-chrome doesn't include chrome.webNavigation — add it manually
+if (!globalThis.chrome.webNavigation) {
+ (globalThis.chrome as any).webNavigation = {
+ onCommitted: {
+ addListener: () => {},
+ removeListener: () => {},
+ },
+ };
+}
diff --git a/packages/extension/tsconfig.json b/packages/extension/tsconfig.json
new file mode 100644
index 0000000..7ddb8d0
--- /dev/null
+++ b/packages/extension/tsconfig.json
@@ -0,0 +1,14 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "strict": true,
+ "skipLibCheck": true,
+ "noEmit": true,
+ "isolatedModules": true,
+ "resolveJsonModule": true
+ },
+ "include": ["src", "test", "e2e", "types.d.ts"]
+}
diff --git a/packages/extension/types.d.ts b/packages/extension/types.d.ts
new file mode 100644
index 0000000..15e7fd1
--- /dev/null
+++ b/packages/extension/types.d.ts
@@ -0,0 +1,48 @@
+// ─── Window extensions ───────────────────────────────────────────────────────
+
+interface Window {
+ __pwRecorderCleanup?: () => void;
+ showSaveFilePicker(options?: {
+ suggestedName?: string;
+ types?: Array<{
+ description?: string;
+ accept: Record;
+ }>;
+ }): Promise;
+}
+
+// ─── File System Access API (not in default DOM types) ───────────────────────
+
+interface FileSystemFileHandle {
+ name: string;
+ createWritable(): Promise;
+}
+
+interface FileSystemWritableFileStream extends WritableStream {
+ write(data: Blob | string | ArrayBuffer): Promise;
+ close(): Promise;
+}
+
+// ─── Chrome Extension APIs (missing from @types/chrome) ─────────────────────
+
+declare namespace chrome {
+ namespace sidePanel {
+ function setPanelBehavior(options: { openPanelOnActionClick: boolean }): void;
+ }
+
+ namespace scripting {
+ interface ScriptInjection {
+ target: { tabId: number };
+ files?: string[];
+ func?: () => void;
+ }
+ function executeScript(injection: ScriptInjection): Promise;
+ }
+}
+
+// ─── vitest-chrome (no published types) ──────────────────────────────────────
+
+declare module "vitest-chrome/lib/index.esm.js" {
+ const chrome: typeof globalThis.chrome;
+ export = chrome;
+}
diff --git a/packages/extension/vite.config.ts b/packages/extension/vite.config.ts
new file mode 100644
index 0000000..cb5019a
--- /dev/null
+++ b/packages/extension/vite.config.ts
@@ -0,0 +1,26 @@
+import { resolve } from "path";
+import { defineConfig } from "vite";
+
+export default defineConfig({
+ root: "src",
+ base: "",
+ build: {
+ outDir: resolve(__dirname, "dist"),
+ emptyOutDir: true,
+ minify: false,
+ sourcemap: true,
+ rollupOptions: {
+ input: {
+ background: resolve(__dirname, "src/background.ts"),
+ "content/recorder": resolve(__dirname, "src/content/recorder.ts"),
+ "panel/panel": resolve(__dirname, "src/panel/panel.html"),
+ },
+ output: {
+ entryFileNames: "[name].js",
+ chunkFileNames: "[name].js",
+ assetFileNames: "[name].[ext]",
+ },
+ },
+ },
+ publicDir: resolve(__dirname, "public"),
+});
diff --git a/packages/extension/vitest.config.ts b/packages/extension/vitest.config.ts
new file mode 100644
index 0000000..17f39af
--- /dev/null
+++ b/packages/extension/vitest.config.ts
@@ -0,0 +1,14 @@
+import { defineConfig } from "vitest/config";
+
+export default defineConfig({
+ test: {
+ setupFiles: "./test/setup.ts",
+ environment: "happy-dom",
+ exclude: ["e2e/**", "node_modules/**", "dist/**"],
+ coverage: {
+ provider: "v8",
+ include: ["src/**/*.ts"],
+ reporter: ["text", "html"],
+ },
+ },
+});
diff --git a/tsconfig.base.json b/tsconfig.base.json
new file mode 100644
index 0000000..09de5f9
--- /dev/null
+++ b/tsconfig.base.json
@@ -0,0 +1,15 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "NodeNext",
+ "moduleResolution": "NodeNext",
+ "declaration": true,
+ "declarationMap": true,
+ "sourceMap": true,
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "forceConsistentCasingInFileNames": true,
+ "resolveJsonModule": true
+ }
+}