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 @@ ![playwright-repl](cover-image.png) -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 -![Architecture](architecture-diagram.png) +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 -![Command Flow](flow-diagram.png) +### 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 @@ + + + + + + + + +
+
+ + + + + + + + +
+
+ +
+
+ + +
+
+
+
+ +
+
+ + +
+ + +
+
+ + Terminal + + + +
+
+
+ pw> +
+ + + +
+
+
+ + + + + + + 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 = ` +
+
+ + + + + + + + +
+
+ +
+
+
+
+
+
+ +
+
+
+
+
+ + Terminal + + + +
+
+
+ pw> +
+ + + +
+
+
+ + `; + + // 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 = ` +
    +
  • Buy milk
  • +
`; + 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 + } +}