diff --git a/.gitignore b/.gitignore index 03014b6..24f876a 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,9 @@ node_modules lib +build + +.trace settings.local.json *.tgz diff --git a/README.md b/README.md index 669f3f1..6ceed70 100644 --- a/README.md +++ b/README.md @@ -797,6 +797,44 @@ The generated script reconstructs the full session — including capabilities, n standalone `import { remote } from 'webdriverio'` file. For BrowserStack sessions it includes the full try/catch/finally with automatic session result marking. +### Trace Recording + +Passing `trace: true` to `start_session` produces a Playwright-compatible `.trace` zip in the `.trace/` directory when +the session closes. The zip is playable at [player.vibium.dev](https://player.vibium.dev) and shows a filmstrip of +screenshots alongside the action timeline. + +**How screenshots are timed** + +Appium's `takeScreenshot` round-trip takes 700–1300 ms on a local emulator, which is long enough for the previous +action's animations to settle. We exploit this: each screenshot is captured **before** the next action fires, so what +the Appium server returns is already the settled result of the prior action. + +The tricky part is making the trace player show that screenshot under the right action. The player associates a +`screencast-frame` event with whichever action's time window contains the frame's `timestamp` field. If the timestamp +is set to "now" (capture time), it falls before the current action's `startTime` and the player labels it as the +*before* state of the next action — one action out of sync. + +The fix: stamp each `screencast-frame` with `lastAfterEndTime` — the `endTime` of the action that just completed. That +places the frame inside the previous action's window, so the player shows it as the result of that action, not the +precursor to the next one. + +``` +Timeline (monotonic ms): + + prev.endTime ← frame timestamp stamped here + │ + │ [screenshot captured here — shows settled state after prev action] + │ + curr.startTime + │ + │ [action executes] + │ + curr.endTime ← next frame will be stamped here +``` + +The final screenshot at session close is stamped with the last action's `endTime`, so it renders under that action +rather than appearing as an orphaned frame after the timeline ends. + ## Troubleshooting **Browser automation not working?** diff --git a/ROADMAP.md b/ROADMAP.md index 13318b7..19eb477 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -3,24 +3,39 @@ > This roadmap reflects current thinking and priorities. It is not a commitment to deliver specific features by specific > dates. Priorities may shift based on community feedback, contributor interest, and real-world usage patterns. -## Current (v2.x) +## Current What's shipped and stable today. -| Area | Capabilities | -|--------------------|-----------------------------------------------------------------------------------------------| -| Browser automation | Chrome, Firefox, Edge, Safari; headed/headless; navigation, clicks, form filling, screenshots | -| Mobile automation | iOS (XCUITest) and Android (UiAutomator2) via Appium; native + hybrid app support | -| Element detection | Platform-aware classification, multi-strategy locator generation, viewport filtering | -| Session model | Single active session (browser or mobile), state preservation, detach mode | -| Data format | TOON output for efficient LLM token usage | +| Area | Capabilities | +|-----------------------|-----------------------------------------------------------------------------------------------| +| Browser automation | Chrome, Firefox, Edge, Safari; headed/headless; navigation, clicks, form filling, screenshots | +| Mobile automation | iOS (XCUITest) and Android (UiAutomator2) via Appium; native + hybrid app support | +| Element detection | Platform-aware classification, multi-strategy locator generation, viewport filtering | +| Session model | Single active session (browser or mobile), state preservation, detach mode | +| Cloud providers | BrowserStack browser + App Automate; provider abstraction ready for SauceLabs / LambdaTest | +| Test infrastructure | Vitest unit tests, ESLint + TypeScript checks, CI pipeline on every PR | +| Trace recording (v1) | Synchronous Playwright-compatible trace zip; screenshot per action; auto-saved on session close; playable at player.vibium.dev | --- ## Next -High-value features actively being designed. Architecture proposals available in [ -`docs/architecture/`](docs/architecture/). +High-value features actively being designed or in early implementation. + +### Trace: Mobile / Appium Support + +**Goal:** Extend trace recording to iOS and Android sessions. + +The current synchronous model (capture screenshot after each traced tool call) maps cleanly to Appium — no architectural change needed. Mobile sessions just need to opt in via `trace: true` in `start_session` and have their tools wrapped with `withTrace`. + +| What | Why it matters | +|--------------------------------|-------------------------------------------------------------------| +| `trace: true` for ios/android | Same flag, same zip output, same player — no new concepts | +| Screenshot capture via Appium | `browser.takeScreenshot()` works identically on mobile | +| Mobile-aware tool mapping | `tap_element`, `swipe`, `scroll` already in `TOOL_MAP`; just enable the guard | + +**Dependency:** None — the synchronous model works without BiDi. ### Interaction Sequencing @@ -38,20 +53,30 @@ gets back a state delta showing what changed. See: [`docs/architecture/interaction-sequencing-proposal.md`](docs/architecture/interaction-sequencing-proposal.md) -### Testing and Quality - -| What | Why it matters | -|--------------------------|-----------------------------------------------------------| -| Unit test infrastructure | No tests exist today — foundation for confident iteration | -| CI pipeline | Automated linting, type checking, and tests on every PR | -| Tool contract tests | Verify each tool's input validation and error handling | - --- ## Later Features with clear use cases and initial designs, but dependent on foundational work landing first. +### Trace: WebDriver BiDi Mode + +**Goal:** Opt-in async trace recording driven by WebDriver BiDi events instead of synchronous post-action capture. + +The v1 trace records a screenshot after each tool call completes. This is simple and works, but it misses events that happen between tool calls (network requests, console errors, intermediate renders) and adds latency on every action. + +BiDi mode subscribes to browser events asynchronously — screenshots, network activity, and console output arrive as they happen, decoupled from the tool call cycle. + +| What | Why it matters | +|-------------------------------|-------------------------------------------------------------------------| +| Async screenshot capture | Screenshots taken on page state changes, not on tool boundaries — more accurate filmstrip | +| `trace.network` population | Real HAR-format network log from BiDi `network.*` events | +| Console event recording | Browser console errors/warnings captured as trace events | +| Lower per-action latency | No synchronous `takeScreenshot()` blocking each tool call | +| Graceful fallback | Sessions on browsers without BiDi support fall back to v1 synchronous mode | + +**Dependency:** Requires Chrome/Edge with BiDi enabled (WebdriverIO `webSocketUrl: true`). Safari and Firefox BiDi support is partial — fallback needed. Not applicable to mobile/Appium sessions (which keep the synchronous model). + ### Multi-Session Support **Goal:** Enable parallel automation sessions for sub-agent coordination and cross-platform testing. @@ -70,22 +95,6 @@ tools, allowing named sessions to run in parallel. See: [`docs/architecture/multi-session-proposal.md`](docs/architecture/multi-session-proposal.md) -### Session Configuration / Provider Pattern - -**Goal:** Make session creation extensible for cloud providers (BrowserStack, SauceLabs, custom Selenium Grids). - -| What | Why it matters | -|------------------------------|--------------------------------------------------------------------| -| Provider abstraction | Swap between local and cloud execution without changing tool calls | -| Unified `start_session` tool | Single entry point replaces `start_browser` + `start_app_session` | -| Cloud provider integrations | BrowserStack, SauceLabs, LambdaTest — run on real device farms | -| Credential management | Environment variables with parameter overrides for cloud auth | - -**Dependency:** Refactors session creation — should land before or alongside Multi-Session to avoid double-refactoring -`getBrowser()`. - -See: [`docs/architecture/session-configuration-proposal.md`](docs/architecture/session-configuration-proposal.md) - --- ## Ideas @@ -96,7 +105,6 @@ Not committed. Exploring feasibility and demand. |----------------------|-------------------------------------------------------------------| | Visual regression | Screenshot comparison with diff highlighting | | Record and replay | Capture interaction sequences for deterministic re-execution | -| Network interception | Monitor/mock API calls during automation | | File upload/download | Handle file dialogs and download verification | | iframe support | Navigate and interact within nested frames | | Assertion helpers | Built-in verification tools (element visible, text matches, etc.) | @@ -115,20 +123,25 @@ Not committed. Exploring feasibility and demand. ## Dependency Map ``` - ┌─────────────────────────┐ - │ Interaction Sequencing │ - │ (execute_sequence) │ - └────────────┬────────────┘ - │ enhances - ▼ -┌───────────────────┐ ┌──────────────────┐ -│ Session Config / │───▶│ Multi-Session │ -│ Provider Pattern │ │ (sessionId) │ -└───────────────────┘ └──────────────────┘ - │ │ - ▼ ▼ -┌─────────────────────────────────────────┐ -│ Cloud Provider Support │ -│ (BrowserStack, SauceLabs, Grids) │ -└─────────────────────────────────────────┘ -``` \ No newline at end of file +┌─────────────────────────────────────────────────┐ +│ Trace Recording (v1) ✓ │ +│ synchronous, screenshot-per-action, browser │ +└──────────┬──────────────────────┬───────────────┘ + │ │ + ▼ ▼ +┌──────────────────┐ ┌─────────────────────────┐ +│ Mobile Tracing │ │ Trace: BiDi Mode │ +│ (Appium, easy) │ │ (async, browser only) │ +└──────────────────┘ └─────────────────────────┘ + +┌─────────────────────────┐ +│ Interaction Sequencing │ +│ (execute_sequence) │ +└────────────┬────────────┘ + │ enhances + ▼ +┌──────────────────────┐ +│ Multi-Session │ +│ (sessionId) │ +└──────────────────────┘ +``` diff --git a/docs/architecture/trace-extraction-proposal.md b/docs/architecture/trace-extraction-proposal.md new file mode 100644 index 0000000..d33c4db --- /dev/null +++ b/docs/architecture/trace-extraction-proposal.md @@ -0,0 +1,390 @@ +# `@wdio/trace` — Extracting Trace Recording into a Standalone Package + +**Status:** Proposal — not yet implemented. + +--- + +## Problem + +Trace recording currently lives inside the MCP server (`src/trace/`) and is coupled to MCP-specific concepts: + +- `withTrace()` wraps `ToolCallback` (an MCP SDK type) — tracing is only possible for MCP tool calls +- `captureScreenshot()` reaches into `getBrowser()` and `getState()` — MCP session singletons +- The tool-mapping layer (`tool-mapping.ts`) maps MCP tool names to Playwright actions — not WebdriverIO command names +- Only the 10 tools explicitly registered in `TOOL_MAP` get traced; any other browser command is invisible + +This means tracing is unavailable to plain WebdriverIO scripts, standalone `remote()` usage, and any testrunner suite. The trace format itself (Playwright v8 NDJSON zip, playable at player.vibium.dev) is completely generic — only the recording layer is unnecessarily MCP-specific. + +--- + +## Goal + +Extract tracing into a standalone `@wdio/trace` package that hooks into WebdriverIO's native `beforeCommand`/`afterCommand` mechanism. Any WebdriverIO script — testrunner, standalone `remote()`, or MCP — gets Playwright-compatible trace recording without manual wrapping. + +```ts +// Plain WebdriverIO script — tracing enabled with three lines +import { createTracer } from '@wdio/trace' +import { sharpEncoder } from '@wdio/trace/sharp' +import { remote } from 'webdriverio' + +const tracer = createTracer({ outputDir: '.trace', encoder: sharpEncoder() }) +const browser = await remote({ + capabilities: { browserName: 'chrome' }, + ...tracer.hooks, +}) + +await browser.url('https://example.com') +await browser.$('button=Accept').click() +await browser.deleteSession() +// → .trace/2026-05-14T09-30-00-000Z-abc123.zip written automatically +``` + +--- + +## WebdriverIO Hook Mechanism + +WebdriverIO wraps every command with `wrapCommand()` (in `@wdio/utils`), which fires `beforeCommand`/`afterCommand` hooks around every execution. These are available in both testrunner and standalone `remote()` mode by passing them to the options object: + +```ts +const browser = await remote({ + capabilities: { ... }, + beforeCommand(commandName, args) { /* fires before every command */ }, + afterCommand(commandName, args, result, error) { /* fires after every command */ }, +}) +``` + +A guard (`inCommandHook`) prevents recursive invocations when hooks themselves call browser commands. + +This replaces the need for `withTrace()` entirely. Every `browser.url()`, `element.click()`, `element.setValue()`, `browser.execute()` — and any future command — fires these hooks automatically. + +--- + +## Public API + +### `createTracer(options)` + +```ts +import { createTracer } from '@wdio/trace' + +const tracer = createTracer({ + outputDir: '.trace', // where to write the zip (default: '.trace') + sessionType: 'browser', // 'browser' | 'ios' | 'android' (default: 'browser') + title: 'My test session', // optional — shown in trace viewer header + viewport: { width: 1920, height: 1080 }, // optional override + encoder: sharpEncoder({ quality: 60 }), // optional — see Screenshot Encoding below +}) +``` + +Returns a `Tracer` object: + +```ts +interface Tracer { + hooks: { + beforeCommand(commandName: string, args: unknown[]): Promise + afterCommand(commandName: string, args: unknown[], result: unknown, error?: Error): Promise + } + stop(): Promise // finalize and return zip buffer (without writing to disk) +} +``` + +### Usage with `remote()` + +```ts +const browser = await remote({ + capabilities: { browserName: 'chrome' }, + ...tracer.hooks, +}) +``` + +### Usage in testrunner (`wdio.conf.ts`) + +```ts +const tracer = createTracer({ outputDir: '.trace' }) + +export const config = { + capabilities: [{ browserName: 'chrome' }], + beforeCommand: tracer.hooks.beforeCommand, + afterCommand: tracer.hooks.afterCommand, +} +``` + +--- + +## Session Lifecycle + +**Auto-start:** The tracer lazily initializes on the first `beforeCommand` call. At that point `this` (the browser or element context) provides `this.capabilities` — `browserName`, platform, viewport. No explicit `startTrace()` call needed. + +**Auto-finalize:** When `commandName === 'deleteSession'` is seen in `beforeCommand`, the tracer captures a final screenshot. In `afterCommand` for `deleteSession`, it drains the screenshot chain, builds the zip, and writes it to `outputDir`. The filename is `{ISO-timestamp}-{sessionId-prefix}.zip`. + +**Manual stop:** `tracer.stop()` is available for cases where the caller wants the zip in memory (e.g., the MCP server writing to a custom location, or a test that uploads the zip to a reporting service). Calling `stop()` also prevents the auto-finalize from writing a second zip. + +**Ungraceful exits:** If `deleteSession` never fires (crash, test timeout), the trace is lost. Users should call `tracer.stop()` in a `finally` block for critical traces. + +--- + +## Command Mapping + +The current `tool-mapping.ts` maps MCP tool names (`click_element`, `set_value`) to Playwright trace actions. The new package maps raw WebdriverIO command names instead. + +Whether a command is a "browser" or "element" command is determined from `this.elementId` in the hook context — if it is truthy, it is an element command and `this.selector` gives the selector string. + +```ts +// command-mapping.ts + +const BROWSER_COMMANDS: Record = { + url: { class: 'Page', method: 'navigate' }, + execute: { class: 'Page', method: 'evaluate' }, + executeAsync: { class: 'Page', method: 'evaluate' }, + scroll: { class: 'Page', method: 'scroll' }, + newWindow: { class: 'Browser', method: 'newContext' }, +} + +const ELEMENT_COMMANDS: Record = { + click: { class: 'Element', method: 'click' }, + doubleClick: { class: 'Element', method: 'dblclick' }, + setValue: { class: 'Element', method: 'fill' }, + addValue: { class: 'Element', method: 'type' }, + clearValue: { class: 'Element', method: 'clear' }, + dragAndDrop: { class: 'Element', method: 'dragTo' }, + selectByVisibleText: { class: 'Element', method: 'selectOption' }, + touchAction: { class: 'Element', method: 'tap' }, +} +``` + +The mapping acts as an allowlist — only commands present in either map produce `before`/`after` trace events. Read-only commands (`getTitle`, `getText`, `isDisplayed`, `takeScreenshot`, etc.) are silently skipped. This keeps the action timeline clean. + +--- + +## Screenshot Encoding + +`sharp` is a native binary (~30 MB installed). Making it a hard dependency would be a significant cost for consumers who only need the trace format, not JPEG compression. + +**Strategy: pluggable encoder, `sharp` as optional.** + +```ts +// No encoder (default) — stores raw PNG, no compression +const tracer = createTracer({ outputDir: '.trace' }) + +// With sharp encoder — JPEG at quality 60 (current MCP behavior) +import { sharpEncoder } from '@wdio/trace/sharp' +const tracer = createTracer({ + outputDir: '.trace', + encoder: sharpEncoder({ quality: 60 }), +}) +``` + +The encoder interface: + +```ts +interface ScreenshotEncoder { + encode(pngBase64: string): Promise<{ + buffer: Buffer + width: number + height: number + ext: 'jpeg' | 'png' + }> +} +``` + +`@wdio/trace/sharp` is a separate package export entry point. It has `sharp` as a peer dependency (optional). Importing it when `sharp` is not installed throws a clear actionable error. + +--- + +## Timestamp Trick (Preserved) + +The `lastAfterEndTime` pattern from the current implementation is preserved. Each `screencast-frame` event is stamped at the *previous* action's `endTime`, not the current time. This causes the Vibium player to associate each screenshot with the action that produced the screen state, rather than the action about to fire. + +``` +prev.endTime ← screencast-frame timestamp stamped here + │ + │ [screenshot captured — shows settled state after prev action] + │ +curr.startTime + │ + │ [action executes] + │ +curr.endTime ← next frame will be stamped here +``` + +With hooks this works naturally: `afterCommand` writes `endTime` before the next `beforeCommand` fires (WDIO awaits hooks sequentially). + +--- + +## Package Structure + +``` +packages/trace/ + package.json # name: @wdio/trace + tsconfig.json + tsup.config.ts + src/ + index.ts # Public API: createTracer, types + tracer.ts # Core: beforeCommand/afterCommand logic, lifecycle + command-mapping.ts # WDIO command names → Playwright trace actions + types.ts # TraceSession, TraceEvent, etc. (moved from src/trace/) + state.ts # Session factory, monotonic clock (moved, decoupled) + zip-writer.ts # buildTraceZip (moved as-is) + sharp.ts # sharpEncoder() — subpath export, sharp as peer dep + tests/ + tracer.test.ts + command-mapping.test.ts + state.test.ts + zip-writer.test.ts +``` + +**`package.json` key fields:** + +```json +{ + "name": "@wdio/trace", + "type": "module", + "exports": { + ".": { "import": "./lib/index.js", "types": "./lib/index.d.ts" }, + "./sharp": { "import": "./lib/sharp.js", "types": "./lib/sharp.d.ts" } + }, + "dependencies": { + "yazl": "^3.3.1" + }, + "peerDependencies": { + "webdriverio": "^9.27.0", + "sharp": "^0.34.0" + }, + "peerDependenciesMeta": { + "sharp": { "optional": true }, + "webdriverio": { "optional": true } + } +} +``` + +`webdriverio` is a peer dep for types only — the package does not import from it at runtime. + +--- + +## Monorepo Setup + +The current repo is not a monorepo. To host both the MCP server and `@wdio/trace`: + +1. Add `packages: ['packages/*']` to `pnpm-workspace.yaml` +2. Create `packages/trace/` with the structure above +3. The MCP server stays at the repo root (or moves to `packages/mcp/`) +4. Add `"@wdio/trace": "workspace:*"` to the MCP server's `package.json` + +--- + +## MCP Server Migration + +After extraction, the MCP server changes are surgical: + +### What gets deleted + +| File | Reason | +|------|--------| +| `src/trace/recorder.ts` | Replaced by `@wdio/trace` hooks | +| `src/trace/tool-mapping.ts` | Replaced by `command-mapping.ts` in `@wdio/trace` | +| `withTrace` call in `src/server.ts` | `instrument()` simplifies to just `withRecording()` | + +### What changes + +**`src/tools/session.tool.ts`** — replace `startTrace()` / `recordInitialNavigation()` with: + +```ts +import { createTracer } from '@wdio/trace' +import { sharpEncoder } from '@wdio/trace/sharp' + +// Inside startBrowserSession / startMobileSession / attachBrowserSession: +const tracer = args.trace + ? createTracer({ + outputDir: join(process.cwd(), '.trace'), + sessionType, // 'browser' | 'ios' | 'android' + title: browserDisplayNames[browser], + encoder: sharpEncoder({ quality: 60 }), + }) + : undefined + +const wdioBrowser = await remote({ + ...connectionConfig, + capabilities: mergedCapabilities, + ...(tracer?.hooks ?? {}), +}) + +// Store on metadata so lifecycle can call tracer.stop() if needed +metadata.tracer = tracer +``` + +**`src/session/lifecycle.ts`** — remove the manual trace export block (lines 88–106). The tracer auto-finalizes on `deleteSession`. If manual control is needed (e.g., custom output path): + +```ts +if (metadata?.tracer) { + const zipBuffer = await metadata.tracer.stop() + const outPath = join(process.cwd(), '.trace', `${timestamp}-${sessionId.slice(0, 8)}.zip`) + writeFileSync(outPath, zipBuffer) +} +``` + +**`src/server.ts`** — `instrument()` simplifies: + +```ts +// Before +const instrument = (name, cb) => withTrace(name, withRecording(name, cb)) + +// After +const instrument = (name, cb) => withRecording(name, cb) +``` + +### What stays in MCP server + +| File | Notes | +|------|-------| +| `src/trace/types.ts` | Re-export from `@wdio/trace` or keep as alias | +| `src/trace/state.ts` | Re-export from `@wdio/trace` or keep as alias | +| `src/trace/zip-writer.ts` | Re-export from `@wdio/trace` or keep as alias | +| `src/show-trace.ts` | Trace viewer launcher — stays in MCP, could move later | +| `src/trace.ts` barrel | Update to re-export from `@wdio/trace` | + +--- + +## What a Plain Script Gets + +With this extraction, a developer running a vanilla WebdriverIO script gets full Playwright-compatible traces with zero MCP involvement: + +```ts +import { createTracer } from '@wdio/trace' +import { sharpEncoder } from '@wdio/trace/sharp' +import { remote } from 'webdriverio' + +const tracer = createTracer({ + outputDir: '.trace', + sessionType: 'android', + encoder: sharpEncoder({ quality: 60 }), +}) + +const browser = await remote({ + hostname: '127.0.0.1', + port: 4723, + capabilities: { + platformName: 'Android', + 'appium:deviceName': 'emulator-5554', + 'appium:app': '/path/to/app.apk', + }, + ...tracer.hooks, +}) + +await browser.$('~loginButton').click() +await browser.$('~usernameField').setValue('testuser') +await browser.deleteSession() +// → .trace/2026-05-14T09-30-00-000Z-session.zip +``` + +Open the zip at player.vibium.dev — full action timeline, screenshots, error annotations. + +--- + +## Risks + +| Risk | Mitigation | +|------|-----------| +| Hook composition — user also needs `beforeCommand` | Document: compose manually or use an array if WDIO supports it | +| `deleteSession` not firing (crash) | `tracer.stop()` escape hatch; document `finally` pattern | +| Command mapping gaps | Allowlist approach — unmapped commands silently skipped; debug log for unrecognized names | +| `sharp` not installed when `sharpEncoder` imported | Clear error message with install instructions | +| Mobile `browserName` must be `'chromium'` for Vibium | Handled inside `createTracer` when `sessionType` is `'ios'` or `'android'` | diff --git a/docs/architecture/trace-recording-and-replay.md b/docs/architecture/trace-recording-and-replay.md new file mode 100644 index 0000000..e6433e2 --- /dev/null +++ b/docs/architecture/trace-recording-and-replay.md @@ -0,0 +1,169 @@ +# Trace Recording and Replay + +**Status:** In progress — recording (v1) shipped; replay and .vibium spec are future work. + +--- + +## Vision + +Turn the WebdriverIO MCP server into a test automation product, not just a browser control tool. + +The goal is a two-file workflow that any tester can use: + +1. Write a `.vibium` file describing what the test should do (natural language spec + assertions) +2. Run it once — an agent drives the browser, records every step, and produces a trace +3. Review and check in both files +4. On subsequent runs, replay the trace deterministically — no AI required +5. If replay breaks (selector changed, element missing), the agent uses the trace context to self-heal and update the + trace + +This is a "Claude Code for testing" — an agent that bootstraps a test from a spec and a deterministic runner that +executes it cheaply thereafter. + +--- + +## The Two Files + +### `.vibium` — the spec + +A human-readable description of the test: what application, what flow, what to assert. Think of it as the test's source +of truth — the thing a tester writes and maintains. + +``` +# Search and add to basket + +App: https://www.worldofbooks.com +Flow: + - Search for "harry potter" + - Add the first result to the basket + - Navigate to checkout + +Assert: + - The checkout order summary contains "Harry Potter" + - Total is non-zero +``` + +The exact format is TBD and should be aligned with what Jason (VibiumDev) has defined. Reference: +[vibium recording format](https://raw.githubusercontent.com/VibiumDev/vibium/refs/heads/main/docs/explanation/recording-format.md) + +### `.trace` — the recorded execution + +A zip (Playwright-compatible format) produced by running the `.vibium` spec through the MCP agent for the first time. +Contains: + +- Every automation step (before/after events with params and timing) +- A screenshot per step (filmstrip) +- Enough application context to self-heal on replay failure + +The trace is the "compiled" form of the spec. It is checked in alongside the `.vibium` file. + +--- + +## Workflow + +``` +First run (agent-driven): + + .vibium spec + │ + ▼ + MCP agent reads spec, drives browser via wdio-mcp tools + │ + ▼ + Trace recorded (steps + screenshots + DOM context) + │ + ▼ + Tester reviews, updates spec if needed, checks in both files + +Subsequent runs (deterministic replay): + + .trace file + │ + ▼ + Replay runner executes recorded steps directly — no AI + │ + ├── All steps pass → test passes + │ + └── Step fails (selector stale, element missing, assertion wrong) + │ + ▼ + Agent invoked with: + - failing step + - trace context (screenshots, DOM snapshots at point of failure) + - current page state + │ + ▼ + Agent corrects the step, updates the trace, continues +``` + +--- + +## Two Separate Components + +### 1. Automation Agent Harness + +Reads a `.vibium` file, uses the WebdriverIO MCP server to drive the browser, and records the session as a trace. + +- Input: `.vibium` spec +- Output: `.trace` zip +- AI/LLM: required (interprets spec, decides which tools to call, generates assertions) +- Model: likely an autonomous agent loop (LangChain DeepAgent or similar) wrapping the MCP server + +This is where `wdio-mcp` lives. The trace recording infrastructure in `src/trace/` is the foundation. + +### 2. Replay Runner + +Reads a `.trace` zip and re-executes the recorded steps against the live application. + +- Input: `.trace` zip +- Output: pass / fail with diff +- AI/LLM: not required for the happy path; invoked only on failure for self-healing +- Candidate home: `@wdio/trace` (extractable standalone package) + +--- + +## Self-Healing on Failure + +When replay fails, the agent gets: + +- The recorded step that failed (selector, action, params) +- The screenshot and page state at the point of failure +- The original trace context (what the page looked like when the test was first recorded) + +From this context the agent can: + +- Try an alternative selector (text-based, aria label, structural) +- Update the trace with the corrected step +- Continue replay from that point +- Report an unrecoverable failure if the page structure has fundamentally changed (feature removed, flow redesigned) + +The agent does not re-run from scratch. It patches the trace in place and continues. + +--- + +## Non-Goals (for now) + +- **AI is not required for deterministic replay.** The replay runner is a straightforward step executor. +- **The trace format is not MCP-specific.** `src/trace/` is intentionally extractable — it has no MCP dependencies. A + standard WebdriverIO test run should be able to produce the same trace format. +- **Network interception (`trace.network`) and WebDriver BiDi** are on the roadmap alongside mobile tracing — not + deferred indefinitely, just not part of the initial replay implementation. +- **Mobile/Appium** replay follows the same model but depends on trace recording being extended to Appium sessions + first (see roadmap). + +--- + +## Open Questions + +1. **`.vibium` format** — what exactly does Jason's spec define? Should we adopt it directly or define a subset? The + format needs to be expressive enough for assertions, not just navigation steps. + +2. **Replay runner location** — standalone `@wdio/trace` package vs. built into `wdio-mcp` vs. a new `@wdio/replay` + package? + +3. **Trace versioning** — if the format evolves (e.g. BiDi adds network events), how do we keep old traces replayable? + +4. **Selector strategy on self-heal** — when a selector fails, what order do we try alternatives? Should the trace + record multiple fallback selectors at record time? + +5. **Assertion format** — are assertions in the `.vibium` file? In the trace? Both? diff --git a/package.json b/package.json index 2b57ad8..0eb2a4a 100644 --- a/package.json +++ b/package.json @@ -19,10 +19,15 @@ "./snapshot": { "import": "./lib/snapshot.js", "types": "./lib/snapshot.d.ts" + }, + "./trace": { + "import": "./lib/trace.js", + "types": "./lib/trace.d.ts" } }, "bin": { - "wdio-mcp": "lib/server.js" + "wdio-mcp": "lib/server.js", + "wdio-show-trace": "lib/show-trace.js" }, "license": "MIT", "publishConfig": { @@ -55,11 +60,14 @@ "sharp": "^0.34.5", "webdriverio": "^9.27.0", "xpath": "^0.0.34", + "yazl": "^3.3.1", "zod": "^4.3.6" }, "devDependencies": { "@release-it/conventional-changelog": "^10.0.6", "@types/node": "^20.19.37", + "@types/yauzl": "^2.10.3", + "@types/yazl": "^3.3.1", "@wdio/eslint": "^0.1.3", "@wdio/types": "^9.27.0", "eslint": "^9.39.4", @@ -71,7 +79,8 @@ "tsup": "^8.5.1", "tsx": "^4.21.0", "typescript": "~5.9.3", - "vitest": "^4.1.2" + "vitest": "^4.1.2", + "yauzl": "^3.3.0" }, "packageManager": "pnpm@10.32.1", "pnpm": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5d7092e..32a4ef6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -46,6 +46,9 @@ importers: xpath: specifier: ^0.0.34 version: 0.0.34 + yazl: + specifier: ^3.3.1 + version: 3.3.1 zod: specifier: ^4.3.6 version: 4.4.3 @@ -56,6 +59,12 @@ importers: '@types/node': specifier: ^20.19.37 version: 20.19.37 + '@types/yauzl': + specifier: ^2.10.3 + version: 2.10.3 + '@types/yazl': + specifier: ^3.3.1 + version: 3.3.1 '@wdio/eslint': specifier: ^0.1.3 version: 0.1.3(@typescript-eslint/eslint-plugin@8.57.2(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) @@ -92,6 +101,9 @@ importers: vitest: specifier: ^4.1.2 version: 4.1.6(@types/node@20.19.37)(happy-dom@20.9.0)(vite@7.3.1(@types/node@20.19.37)(jiti@2.7.0)(tsx@4.21.0)) + yauzl: + specifier: ^3.3.0 + version: 3.3.0 packages: @@ -1235,6 +1247,9 @@ packages: '@types/yauzl@2.10.3': resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} + '@types/yazl@3.3.1': + resolution: {integrity: sha512-DIWfCKpsTp6hE5BDBHV3+fIL/bLUF9Bv13iDrWnMlmhQpH67buNvI291ZauQ1xcccxK3FqQ9honnXpq4R8NMuQ==} + '@typescript-eslint/eslint-plugin@8.57.2': resolution: {integrity: sha512-NZZgp0Fm2IkD+La5PR81sd+g+8oS6JwJje+aRWsDocxHkjyRw0J5L5ZTlN3LI1LlOcGL7ph3eaIUmTXMIjLk0w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -3799,6 +3814,13 @@ packages: yauzl@2.10.0: resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} + yauzl@3.3.0: + resolution: {integrity: sha512-PtGEvEP30p7sbIBJKUBjUnqgTVOyMURc4dLo9iNyAJnNIEz9pm88cCXF21w94Kg3k6RXkeZh5DHOGS0qEONvNQ==} + engines: {node: '>=12'} + + yazl@3.3.1: + resolution: {integrity: sha512-BbETDVWG+VcMUle37k5Fqp//7SDOK2/1+T7X8TD96M3D9G8jK5VLUdQVdVjGi8im7FGkazX7kk5hkU8X4L5Bng==} + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -4656,7 +4678,10 @@ snapshots: '@types/yauzl@2.10.3': dependencies: '@types/node': 20.19.37 - optional: true + + '@types/yazl@3.3.1': + dependencies: + '@types/node': 20.19.37 '@typescript-eslint/eslint-plugin@8.57.2(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)': dependencies: @@ -7456,6 +7481,15 @@ snapshots: buffer-crc32: 0.2.13 fd-slicer: 1.1.0 + yauzl@3.3.0: + dependencies: + buffer-crc32: 0.2.13 + pend: 1.2.0 + + yazl@3.3.1: + dependencies: + buffer-crc32: 1.0.0 + yocto-queue@0.1.0: {} yoctocolors-cjs@2.1.3: {} diff --git a/src/server.ts b/src/server.ts index 0cb3d5c..9940a31 100644 --- a/src/server.ts +++ b/src/server.ts @@ -41,6 +41,7 @@ import { getElementsTool, getElementsToolDefinition } from './tools/get-elements import { launchChromeTool, launchChromeToolDefinition } from './tools/launch-chrome.tool'; import { emulateDeviceTool, emulateDeviceToolDefinition } from './tools/emulate-device.tool'; import { withRecording } from './recording/step-recorder'; +import { withTrace } from './trace/recorder.js'; import { accessibilityResource, appStateResource, @@ -120,26 +121,29 @@ function createServer(): McpServer { } }; + const instrument = (name: string, cb: ToolCallback): ToolCallback => + withTrace(name, withRecording(name, cb)); + registerTool(startSessionToolDefinition, withRecording('start_session', startSessionTool)); registerTool(closeSessionToolDefinition, closeSessionTool); - registerTool(launchChromeToolDefinition, withRecording('launch_chrome', launchChromeTool)); + registerTool(launchChromeToolDefinition, instrument('launch_chrome', launchChromeTool)); registerTool(emulateDeviceToolDefinition, emulateDeviceTool); - registerTool(navigateToolDefinition, withRecording('navigate', navigateTool)); + registerTool(navigateToolDefinition, instrument('navigate', navigateTool)); registerTool(switchTabToolDefinition, switchTabTool); registerTool(switchFrameToolDefinition, switchFrameTool); - registerTool(scrollToolDefinition, withRecording('scroll', scrollTool)); + registerTool(scrollToolDefinition, instrument('scroll', scrollTool)); - registerTool(clickToolDefinition, withRecording('click_element', clickTool)); - registerTool(setValueToolDefinition, withRecording('set_value', setValueTool)); + registerTool(clickToolDefinition, instrument('click_element', clickTool)); + registerTool(setValueToolDefinition, instrument('set_value', setValueTool)); registerTool(setCookieToolDefinition, setCookieTool); registerTool(deleteCookiesToolDefinition, deleteCookiesTool); - registerTool(tapElementToolDefinition, withRecording('tap_element', tapElementTool)); - registerTool(swipeToolDefinition, withRecording('swipe', swipeTool)); - registerTool(dragAndDropToolDefinition, withRecording('drag_and_drop', dragAndDropTool)); + registerTool(tapElementToolDefinition, instrument('tap_element', tapElementTool)); + registerTool(swipeToolDefinition, instrument('swipe', swipeTool)); + registerTool(dragAndDropToolDefinition, instrument('drag_and_drop', dragAndDropTool)); registerTool(switchContextToolDefinition, switchContextTool); @@ -147,7 +151,7 @@ function createServer(): McpServer { registerTool(hideKeyboardToolDefinition, hideKeyboardTool); registerTool(setGeolocationToolDefinition, setGeolocationTool); - registerTool(executeScriptToolDefinition, withRecording('execute_script', executeScriptTool)); + registerTool(executeScriptToolDefinition, instrument('execute_script', executeScriptTool)); registerTool(getElementsToolDefinition, getElementsTool); registerTool(listAppsToolDefinition, listAppsTool); diff --git a/src/session/lifecycle.ts b/src/session/lifecycle.ts index db421fe..5bb505f 100644 --- a/src/session/lifecycle.ts +++ b/src/session/lifecycle.ts @@ -1,8 +1,34 @@ +import { mkdirSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; import type { SessionHistory } from '../types/recording'; import type { SessionResult } from '../providers/types'; import type { SessionMetadata } from './state'; import { getState } from './state'; import { getProvider } from '../providers/registry'; +import { captureTraceScreenshot, endTrace } from '../trace/recorder.js'; +import { deleteTraceSession, getTraceSession } from '../trace/state.js'; +import { buildTraceZip } from '../trace/zip-writer.js'; + +async function finalizeTrace(sessionId: string, browser: WebdriverIO.Browser): Promise { + endTrace(sessionId); + captureTraceScreenshot(sessionId, browser); + const traceSession = getTraceSession(sessionId); + if (!traceSession) return; + try { + await traceSession.screenshotChain; + const traceDir = join(process.cwd(), '.trace'); + mkdirSync(traceDir, { recursive: true }); + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const outPath = join(traceDir, `${timestamp}-${sessionId.slice(0, 8)}.zip`); + const zipBuffer = await buildTraceZip(traceSession); + writeFileSync(outPath, zipBuffer); + console.error(`[TRACE] Saved to ${outPath}`); + } catch (e) { + console.error('[TRACE] Failed to save trace:', e); + } finally { + deleteTraceSession(sessionId); + } +} function getSessionResult(history: SessionHistory | undefined): SessionResult { const errorStep = history?.steps.find(s => s.status === 'error'); @@ -52,6 +78,9 @@ export function registerSession( if (oldBrowser) { // Fire and forget — don't block registration on close void (async () => { + if (oldMetadata?.trace) { + await finalizeTrace(oldSessionId, oldBrowser); + } if (oldMetadata?.provider) { const oldHistory = state.sessionHistory.get(oldSessionId); const provider = getProvider(oldMetadata.provider, oldMetadata.type); @@ -80,6 +109,10 @@ export async function closeSession(sessionId: string, detach: boolean, isAttache const metadata = state.sessionMetadata.get(sessionId); + if (metadata?.trace) { + await finalizeTrace(sessionId, browser); + } + // Terminate the WebDriver session if: // - force is true (override), OR // - detach is false AND isAttached is false (normal close) diff --git a/src/session/state.ts b/src/session/state.ts index dab722e..92b677b 100644 --- a/src/session/state.ts +++ b/src/session/state.ts @@ -6,6 +6,7 @@ export interface SessionMetadata { isAttached: boolean; provider?: 'local' | 'browserstack'; tunnelHandle?: unknown; + trace?: boolean; } const state = { diff --git a/src/show-trace.ts b/src/show-trace.ts new file mode 100644 index 0000000..695a960 --- /dev/null +++ b/src/show-trace.ts @@ -0,0 +1,86 @@ +#!/usr/bin/env node +import { createServer } from 'node:http'; +import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs'; +import { resolve, basename, join } from 'node:path'; +import { exec } from 'node:child_process'; + +function openBrowser(url: string): void { + const cmd = + process.platform === 'darwin' + ? `open "${url}"` + : process.platform === 'win32' + ? `start "" "${url}"` + : `xdg-open "${url}"`; + exec(cmd); +} + +function findLatestTrace(): string | null { + const traceDir = join(process.cwd(), '.trace'); + if (!existsSync(traceDir)) return null; + + const zips = readdirSync(traceDir) + .filter((f) => f.endsWith('.zip')) + .map((f) => ({ name: f, mtime: statSync(join(traceDir, f)).mtimeMs })) + .sort((a, b) => b.mtime - a.mtime); + + return zips.length > 0 ? join(traceDir, zips[0].name) : null; +} + +const argPath = process.argv[2]; +let absolutePath: string; + +if (argPath) { + absolutePath = resolve(argPath); + if (!existsSync(absolutePath)) { + console.error(`File not found: ${absolutePath}`); + process.exit(1); + } +} else { + const latest = findLatestTrace(); + if (!latest) { + console.error('No trace found. Run a session with trace enabled, or pass a zip path.'); + process.exit(1); + } + absolutePath = latest; + console.error(`Using latest trace: ${absolutePath}`); +} + +const fileName = basename(absolutePath); +const fileData = readFileSync(absolutePath); + +const server = createServer((req, res) => { + if (req.method === 'OPTIONS') { + res.writeHead(204, { + 'Access-Control-Allow-Origin': 'https://player.vibium.dev', + 'Access-Control-Allow-Methods': 'GET', + }); + res.end(); + return; + } + + if (req.url === `/${fileName}`) { + res.writeHead(200, { + 'Content-Type': 'application/zip', + 'Content-Length': String(fileData.length), + 'Access-Control-Allow-Origin': 'https://player.vibium.dev', + }); + res.end(fileData); + return; + } + + res.writeHead(404); + res.end(); +}); + +server.listen(0, '127.0.0.1', () => { + const addr = server.address(); + const port = typeof addr === 'object' && addr ? addr.port : 0; + const traceUrl = `http://localhost:${port}/${fileName}`; + const viewerUrl = `https://player.vibium.dev/?record=${encodeURIComponent(traceUrl)}`; + + console.error(`Serving ${fileName} on ${traceUrl}`); + console.error(`Opening ${viewerUrl}`); + console.error('Press Ctrl+C to stop.'); + + openBrowser(viewerUrl); +}); \ No newline at end of file diff --git a/src/tools/session.tool.ts b/src/tools/session.tool.ts index 78deabf..35cba5d 100644 --- a/src/tools/session.tool.ts +++ b/src/tools/session.tool.ts @@ -8,6 +8,7 @@ import { getBrowser, getState } from '../session/state'; import { closeSession, registerSession } from '../session/lifecycle'; import { getProvider } from '../providers/registry'; import { coerceBoolean } from '../utils/zod-helpers'; +import { startTrace, recordInitialNavigation } from '../trace/recorder.js'; const platformEnum = z.enum(['browser', 'ios', 'android']); const browserEnum = z.enum(['chrome', 'firefox', 'edge', 'safari']); @@ -45,6 +46,7 @@ export const startSessionToolDefinition: ToolDefinition = { noReset: coerceBoolean.optional().describe('Preserve app data between sessions'), fullReset: coerceBoolean.optional().describe('Uninstall app before/after session'), newCommandTimeout: z.number().min(0).optional().default(300).describe('Appium command timeout in seconds'), + trace: coerceBoolean.optional().default(false).describe('Enable trace recording — produces a Playwright-compatible zip saved to .trace/ on close_session, playable at player.vibium.dev.'), attach: coerceBoolean.optional().default(false).describe('Attach to existing Chrome instead of launching'), attachConfig: z.object({ port: z.number().optional().default(9222), @@ -86,6 +88,7 @@ type StartSessionArgs = { noReset?: boolean; fullReset?: boolean; newCommandTimeout?: number; + trace?: boolean; attach?: boolean; attachConfig?: { port?: number; host?: string }; appiumConfig?: { host?: string; port?: number; path?: string; protocol?: string }; @@ -202,6 +205,7 @@ async function startBrowserSession(args: StartSessionArgs): Promise, sessionType: 'browser' | 'ios' | 'android' = 'browser', browserViewport?: { width: number; height: number }): void { + let browserName: string; + let viewport: { width: number; height: number }; + let title: string; + + if (sessionType === 'browser') { + browserName = String(capabilities.browserName ?? 'chromium'); + viewport = browserViewport ?? { width: 1920, height: 1080 }; + title = String(capabilities.browserName ?? browserName); + } else { + // Vibium player is Playwright-derived and expects a known browserName; use 'chromium' as safe fallback + browserName = 'chromium'; + const deviceName = String(capabilities['appium:deviceName'] ?? capabilities.deviceName ?? 'device'); + const platformVersion = capabilities['appium:platformVersion'] ?? capabilities.platformVersion ?? ''; + title = `${sessionType} - ${deviceName}${platformVersion ? ` (${platformVersion})` : ''}`; + viewport = sessionType === 'ios' ? { width: 390, height: 844 } : { width: 412, height: 915 }; + } + + createTraceSession(sessionId, browserName, viewport, title, sessionType); +} + +export function endTrace(_sessionId: string): void { + // TraceSession stays in state until exported +} + +// Records the initial page load that happens inside start_session (navigationUrl). +// The navigation is done directly via wdioBrowser.url(), bypassing withTrace, so we +// record it here as a synthetic trace event after the fact. +export async function recordInitialNavigation(sessionId: string, url: string): Promise { + const traceSession = getTraceSession(sessionId); + if (!traceSession) return; + + const callId = `call@${++traceSession.callCounter}`; + const startTime = getMonotonicMs(traceSession); + + traceSession.events.push({ + type: 'before', + callId, + startTime, + class: 'Page', + method: 'navigate', + pageId: traceSession.pageId, + params: { url }, + title: `Page.navigate("${url.slice(0, 80)}")`, + }); + + await captureScreenshot(traceSession); + + const navEndTime = getMonotonicMs(traceSession); + traceSession.events.push({ + type: 'after', + callId, + endTime: navEndTime, + }); + traceSession.lastAfterEndTime = navEndTime; +} + +export function withTrace(toolName: string, callback: ToolCallback): ToolCallback { + return async (params, extra) => { + const state = getState(); + const sessionId = state.currentSession; + + if (!sessionId) return callback(params, extra); + + const metadata = state.sessionMetadata.get(sessionId); + if (!metadata?.trace) return callback(params, extra); + + const traceSession = getTraceSession(sessionId); + if (!traceSession) return callback(params, extra); + + const action = mapToolToTraceAction(toolName); + if (!action) return callback(params, extra); + + // Capture pre-action screenshot synchronously (what the agent sees before acting). + // The 200–1300ms screenshot duration also acts as a natural settle window, + // letting the previous action's animations clear before the next tap fires. + await captureScreenshot(traceSession); + + // Appium round-trips are slow; add a static settle delay so animations + // from the previous action finish before the next one fires. + if (traceSession.sessionType !== 'browser') { + await new Promise((r) => setTimeout(r, 50)); + } + + const callId = `call@${++traceSession.callCounter}`; + const startTime = getMonotonicMs(traceSession); + + traceSession.events.push({ + type: 'before', + callId, + startTime, + class: action.class, + method: action.method, + pageId: traceSession.pageId, + params: params as Record, + title: formatActionTitle(action, params as Record), + }); + + let result: Awaited>; + let actionError: string | undefined; + + try { + result = await callback(params, extra); + if ((result as { isError?: boolean }).isError) { + const text = result.content?.find((c) => c.type === 'text')?.text; + actionError = text ? String(text) : 'unknown error'; + } + } catch (e) { + actionError = String(e); + const errorEndTime = getMonotonicMs(traceSession); + traceSession.events.push({ + type: 'after', + callId, + endTime: errorEndTime, + error: { message: actionError }, + }); + traceSession.lastAfterEndTime = errorEndTime; + throw e; + } + + const endTime = getMonotonicMs(traceSession); + traceSession.events.push({ + type: 'after', + callId, + endTime, + ...(actionError ? { error: { message: actionError } } : {}), + }); + traceSession.lastAfterEndTime = endTime; + + return result; + }; +} + +// Captures a final screenshot at session end (shows the last screen state). +export function captureTraceScreenshot(sessionId: string, browser?: WebdriverIO.Browser): void { + const traceSession = getTraceSession(sessionId); + if (!traceSession) return; + const p = captureScreenshot(traceSession, browser); + traceSession.screenshotChain = traceSession.screenshotChain.then(() => p); +} + +async function captureScreenshot(traceSession: TraceSession, browser?: WebdriverIO.Browser): Promise { + try { + const b = browser ?? getBrowser(); + const base64 = await b.takeScreenshot(); + const inputBuffer = Buffer.from(base64, 'base64'); + const image = sharp(inputBuffer); + const metadata = await image.metadata(); + const width = metadata.width ?? 1280; + const height = metadata.height ?? 720; + const jpegBuffer = await image.jpeg({ quality: 60 }).toBuffer(); + const wallTimestamp = traceSession.startWallTime + getMonotonicMs(traceSession); + const resourceName = `${traceSession.pageId}-${wallTimestamp}.jpeg`; + + traceSession.screenshots.push({ resourceName, data: jpegBuffer, width, height }); + traceSession.events.push({ + type: 'screencast-frame', + pageId: traceSession.pageId, + sha1: resourceName, + width, + height, + // Stamp at the previous action's endTime so the player shows this frame + // as the result of that action, not as the "before" state of the next one. + timestamp: traceSession.lastAfterEndTime, + }); + } catch { + // Screenshot failures must not mask the action result + } +} diff --git a/src/trace/state.ts b/src/trace/state.ts new file mode 100644 index 0000000..32000ed --- /dev/null +++ b/src/trace/state.ts @@ -0,0 +1,63 @@ +import { createRequire } from 'node:module'; +import type { TraceSession } from './types.js'; + +const require = createRequire(import.meta.url); +const { version: LIBRARY_VERSION } = require('../../package.json') as { version: string }; + +const traceSessions = new Map(); + +export function createTraceSession( + sessionId: string, + browserName: string, + viewport: { width: number; height: number }, + title: string, + sessionType: 'browser' | 'ios' | 'android' = 'browser', +): TraceSession { + const prefix = sessionId.slice(0, 8); + const session: TraceSession = { + sessionId, + startWallTime: Date.now(), + startHrTime: process.hrtime.bigint(), + pageId: `page@${prefix}`, + contextId: `context@${prefix}`, + callCounter: 0, + events: [], + screenshots: [], + browserName, + viewport, + sessionType, + lastAfterEndTime: 0, + screenshotChain: Promise.resolve(), + }; + + session.events.push({ + version: 8, + type: 'context-options', + origin: 'library', + libraryName: '@wdio/mcp', + libraryVersion: LIBRARY_VERSION, + browserName, + platform: process.platform === 'darwin' ? 'darwin' : process.platform === 'win32' ? 'windows' : 'linux', + wallTime: session.startWallTime, + monotonicTime: 0, + sdkLanguage: 'javascript', + title, + contextId: session.contextId, + options: { viewport }, + }); + + traceSessions.set(sessionId, session); + return session; +} + +export function getTraceSession(sessionId: string): TraceSession | undefined { + return traceSessions.get(sessionId); +} + +export function deleteTraceSession(sessionId: string): void { + traceSessions.delete(sessionId); +} + +export function getMonotonicMs(session: TraceSession): number { + return Number((process.hrtime.bigint() - session.startHrTime) / 1_000_000n); +} diff --git a/src/trace/tool-mapping.ts b/src/trace/tool-mapping.ts new file mode 100644 index 0000000..a48a68b --- /dev/null +++ b/src/trace/tool-mapping.ts @@ -0,0 +1,50 @@ +export interface TraceAction { + class: string; + method: string; +} + +const TOOL_MAP: Record = { + navigate: { class: 'Page', method: 'navigate' }, + click_element: { class: 'Element', method: 'click' }, + set_value: { class: 'Element', method: 'fill' }, + scroll: { class: 'Page', method: 'scroll' }, + tap_element: { class: 'Element', method: 'tap' }, + swipe: { class: 'Page', method: 'swipe' }, + drag_and_drop: { class: 'Element', method: 'dragTo' }, + execute_script: { class: 'Page', method: 'evaluate' }, + launch_chrome: { class: 'Browser', method: 'launch' }, +}; + +export function mapToolToTraceAction(toolName: string): TraceAction | null { + return TOOL_MAP[toolName] ?? null; +} + +function extractSelectorLabel(selector: string): string { + // UiAutomator: android=new UiSelector().text("Label") or .description("Label") + const uiautomator = selector.match(/\.(?:text|description|textContains)\("([^"]+)"\)/); + if (uiautomator) return uiautomator[1]; + + // Accessibility ID: ~label + if (selector.startsWith('~')) return selector.slice(1); + + // iOS predicate: -ios predicate string:label == "X" or name == "X" + const predicate = selector.match(/(?:label|name|value)\s*==\s*"([^"]+)"/); + if (predicate) return predicate[1]; + + // XPath attribute: [@text="X"] [@label="X"] [@name="X"] [@content-desc="X"] + const xpath = selector.match(/@(?:text|label|name|content-desc)="([^"]+)"/); + if (xpath) return xpath[1]; + + return selector; +} + +export function formatActionTitle(action: TraceAction, params: Record): string { + const { class: cls, method } = action; + const firstKey = Object.keys(params)[0]; + const firstVal = Object.values(params)[0]; + if (firstVal === undefined) return `${cls}.${method}()`; + + const raw = String(firstVal); + const label = firstKey === 'selector' ? extractSelectorLabel(raw) : raw; + return `${cls}.${method}("${label.slice(0, 80)}")`; +} diff --git a/src/trace/types.ts b/src/trace/types.ts new file mode 100644 index 0000000..2f917c6 --- /dev/null +++ b/src/trace/types.ts @@ -0,0 +1,72 @@ +export interface ContextOptionsEvent { + version: 8; + type: 'context-options'; + origin: 'library'; + libraryName: string; + libraryVersion: string; + browserName: string; + platform: 'darwin' | 'linux' | 'windows'; + wallTime: number; + monotonicTime: 0; + sdkLanguage: 'javascript'; + title: string; + contextId: string; + options: { viewport: { width: number; height: number } }; +} + +export interface ScreencastFrameEvent { + type: 'screencast-frame'; + pageId: string; + sha1: string; + width: number; + height: number; + timestamp: number; +} + +export interface BeforeActionEvent { + type: 'before'; + callId: string; + startTime: number; + class: string; + method: string; + pageId: string; + params: Record; + title: string; +} + +export interface AfterActionEvent { + type: 'after'; + callId: string; + endTime: number; + error?: { message: string }; +} + +export type TraceEvent = + | ContextOptionsEvent + | ScreencastFrameEvent + | BeforeActionEvent + | AfterActionEvent; + +export interface TraceScreenshot { + resourceName: string; + data: Buffer; + width: number; + height: number; +} + +export interface TraceSession { + sessionId: string; + startWallTime: number; + startHrTime: bigint; + pageId: string; + contextId: string; + callCounter: number; + events: TraceEvent[]; + screenshots: TraceScreenshot[]; + browserName: string; + viewport: { width: number; height: number }; + sessionType: 'browser' | 'ios' | 'android'; + lastAfterEndTime: number; + // Sequential chain of pending screenshot captures — awaited before zip export + screenshotChain: Promise; +} diff --git a/src/trace/zip-writer.ts b/src/trace/zip-writer.ts new file mode 100644 index 0000000..fea4456 --- /dev/null +++ b/src/trace/zip-writer.ts @@ -0,0 +1,24 @@ +import yazl from 'yazl'; +import type { TraceSession } from './types.js'; + +export function buildTraceZip(session: TraceSession): Promise { + return new Promise((resolve, reject) => { + const zipFile = new yazl.ZipFile(); + + const traceNdjson = session.events.map((e) => JSON.stringify(e)).join('\n'); + const traceBuffer = Buffer.from(traceNdjson, 'utf8'); + zipFile.addBuffer(traceBuffer, 'trace.trace'); + zipFile.addBuffer(Buffer.alloc(0), 'trace.network'); + + for (const screenshot of session.screenshots) { + zipFile.addBuffer(screenshot.data, `resources/${screenshot.resourceName}`); + } + + zipFile.end(); + + const chunks: Buffer[] = []; + zipFile.outputStream.on('data', (chunk: Buffer) => chunks.push(chunk)); + zipFile.outputStream.on('end', () => resolve(Buffer.concat(chunks))); + zipFile.outputStream.on('error', reject); + }); +} diff --git a/tests/session/lifecycle.test.ts b/tests/session/lifecycle.test.ts index 5073607..3fe5017 100644 --- a/tests/session/lifecycle.test.ts +++ b/tests/session/lifecycle.test.ts @@ -1,9 +1,13 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'; +import { mkdtempSync, readdirSync, rmSync, existsSync, statSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import type { SessionMetadata } from '../../src/session/state'; import { getState } from '../../src/session/state'; import { closeSession, registerSession } from '../../src/session/lifecycle'; import type { SessionHistory } from '../../src/types/recording'; import type { SessionResult } from '../../src/providers/types'; +import { createTraceSession, getTraceSession } from '../../src/trace/state'; // Mock the provider registry so lifecycle tests don't depend on real providers const mockOnSessionClose = vi.fn().mockResolvedValue(undefined); @@ -11,10 +15,23 @@ vi.mock('../../src/providers/registry', () => ({ getProvider: vi.fn(() => ({ onSessionClose: mockOnSessionClose })), })); +const TINY_PNG = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; + function makeBrowser(overrides: Record = {}) { return { deleteSession: vi.fn().mockResolvedValue(undefined), ...overrides } as unknown as WebdriverIO.Browser; } +function setupTracedSession(sessionId: string) { + const state = getState(); + const browser = makeBrowser({ takeScreenshot: vi.fn().mockResolvedValue(TINY_PNG) }); + state.browsers.set(sessionId, browser); + state.currentSession = sessionId; + state.sessionMetadata.set(sessionId, { type: 'browser', capabilities: {}, isAttached: false, trace: true }); + state.sessionHistory.set(sessionId, { sessionId, type: 'browser', startedAt: new Date().toISOString(), capabilities: {}, steps: [] }); + createTraceSession(sessionId, 'chromium', { width: 1920, height: 1080 }, 'test'); + return browser; +} + function makeTunnel(overrides: Partial<{ stop: ReturnType }> = {}) { return { stop: vi.fn((cb: () => void) => cb()), @@ -23,12 +40,12 @@ function makeTunnel(overrides: Partial<{ stop: ReturnType }> = {}) } beforeEach(() => { + vi.clearAllMocks(); const state = getState(); state.browsers.clear(); state.sessionMetadata.clear(); state.sessionHistory.clear(); state.currentSession = null; - mockOnSessionClose.mockReset(); mockOnSessionClose.mockResolvedValue(undefined); }); @@ -225,3 +242,70 @@ describe('closeSession', () => { expect(mockOnSessionClose).toHaveBeenCalledWith('s5', 'browser', { status: 'failed', reason: 'page not found' }, undefined); }); }); + +describe('closeSession trace lifecycle', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'trace-test-')); + vi.spyOn(process, 'cwd').mockReturnValue(tempDir); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + it('writes a non-empty zip to .trace/ and removes the trace session from memory', async () => { + setupTracedSession('s-trace-close'); + await closeSession('s-trace-close', false, false); + + const traceDir = join(tempDir, '.trace'); + const files = readdirSync(traceDir); + expect(files).toHaveLength(1); + expect(files[0]).toMatch(/\.zip$/); + expect(statSync(join(traceDir, files[0])).size).toBeGreaterThan(0); + expect(getTraceSession('s-trace-close')).toBeUndefined(); + }); + + it('does not create .trace/ when trace is disabled', async () => { + const state = getState(); + state.browsers.set('s-no-trace', makeBrowser()); + state.currentSession = 's-no-trace'; + state.sessionMetadata.set('s-no-trace', { type: 'browser', capabilities: {}, isAttached: false }); + state.sessionHistory.set('s-no-trace', { sessionId: 's-no-trace', type: 'browser', startedAt: new Date().toISOString(), capabilities: {}, steps: [] }); + + await closeSession('s-no-trace', false, false); + + expect(existsSync(join(tempDir, '.trace'))).toBe(false); + }); +}); + +describe('registerSession orphaned trace cleanup', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'trace-orphan-')); + vi.spyOn(process, 'cwd').mockReturnValue(tempDir); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + it('finalizes trace zip for an orphaned session when a new session starts', async () => { + setupTracedSession('s-orphan'); + + const newMeta: SessionMetadata = { type: 'browser', capabilities: {}, isAttached: false }; + const newHistory: SessionHistory = { sessionId: 's-new', type: 'browser', startedAt: new Date().toISOString(), capabilities: {}, steps: [] }; + registerSession('s-new', makeBrowser(), newMeta, newHistory); + + // The orphan cleanup is fire-and-forget; wait for it to settle + await vi.waitFor(() => { + expect(getTraceSession('s-orphan')).toBeUndefined(); + }); + + const files = readdirSync(join(tempDir, '.trace')); + expect(files).toHaveLength(1); + expect(files[0]).toMatch(/\.zip$/); + }); +}); diff --git a/tests/trace/recorder.test.ts b/tests/trace/recorder.test.ts new file mode 100644 index 0000000..a84b777 --- /dev/null +++ b/tests/trace/recorder.test.ts @@ -0,0 +1,179 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ToolCallback } from '@modelcontextprotocol/sdk/server/mcp'; +import { getState } from '../../src/session/state'; +import { withTrace, recordInitialNavigation } from '../../src/trace/recorder'; +import { createTraceSession, getTraceSession } from '../../src/trace/state'; + +const extra = {} as Parameters[1]; +type AnyToolFn = (params: Record, extra: unknown) => Promise; + +const SUCCESS_RESULT = { content: [{ type: 'text', text: 'ok' }] }; +const ERROR_RESULT = { isError: true, content: [{ type: 'text', text: 'something failed' }] }; + +const TINY_PNG = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; + +function setupTracedSession(sessionId: string) { + const state = getState(); + const mockBrowser = { + takeScreenshot: vi.fn().mockResolvedValue(TINY_PNG), + } as unknown as WebdriverIO.Browser; + + state.browsers.set(sessionId, mockBrowser); + state.currentSession = sessionId; + state.sessionMetadata.set(sessionId, { type: 'browser', capabilities: {}, isAttached: false, trace: true }); + state.sessionHistory.set(sessionId, { sessionId, type: 'browser', startedAt: new Date().toISOString(), capabilities: {}, steps: [] }); + + createTraceSession(sessionId, 'chromium', { width: 1280, height: 720 }, 'test'); +} + +beforeEach(() => { + const state = getState(); + state.browsers.clear(); + state.sessionMetadata.clear(); + state.sessionHistory.clear(); + state.currentSession = null; +}); + +describe('withTrace', () => { + it('no-ops when tracing is not enabled', async () => { + const state = getState(); + state.currentSession = 'sess-no-trace'; + state.sessionMetadata.set('sess-no-trace', { type: 'browser', capabilities: {}, isAttached: false, trace: false }); + + const tool = vi.fn().mockResolvedValue(SUCCESS_RESULT) as unknown as ToolCallback; + const wrapped = withTrace('navigate', tool) as AnyToolFn; + await wrapped({ url: 'https://example.com' }, extra); + + expect(tool).toHaveBeenCalledOnce(); + expect(getTraceSession('sess-no-trace')).toBeUndefined(); + }); + + it('no-ops for unmapped tools', async () => { + const sessionId = 'sess-unmapped'; + setupTracedSession(sessionId); + + const tool = vi.fn().mockResolvedValue(SUCCESS_RESULT) as unknown as ToolCallback; + const wrapped = withTrace('get_elements', tool) as AnyToolFn; + await wrapped({}, extra); + + expect(tool).toHaveBeenCalledOnce(); + const session = getTraceSession(sessionId)!; + // Only context-options event, no before/after + expect(session.events.filter((e) => e.type === 'before')).toHaveLength(0); + }); + + it('emits before and after events for mapped tools', async () => { + const sessionId = 'sess-mapped'; + setupTracedSession(sessionId); + + const tool = vi.fn().mockResolvedValue(SUCCESS_RESULT) as unknown as ToolCallback; + const wrapped = withTrace('navigate', tool) as AnyToolFn; + await wrapped({ url: 'https://example.com' }, extra); + + const session = getTraceSession(sessionId)!; + const before = session.events.find((e) => e.type === 'before') as { callId: string; title: string }; + const after = session.events.find((e) => e.type === 'after') as { callId: string; error?: unknown }; + + expect(before).toBeDefined(); + expect(before.callId).toBe('call@1'); + expect(before.title).toContain('Page.navigate'); + expect(after).toBeDefined(); + expect(after.callId).toBe('call@1'); + expect(after.error).toBeUndefined(); + }); + + it('captures a screenshot and emits screencast-frame on success', async () => { + const sessionId = 'sess-screenshot'; + setupTracedSession(sessionId); + + const tool = vi.fn().mockResolvedValue(SUCCESS_RESULT) as unknown as ToolCallback; + await (withTrace('navigate', tool) as AnyToolFn)({ url: 'https://x.com' }, extra); + + const session = getTraceSession(sessionId)!; + await session.screenshotChain; + const frame = session.events.find((e) => e.type === 'screencast-frame'); + expect(frame).toBeDefined(); + expect(session.screenshots).toHaveLength(1); + }); + + it('increments call counter per action', async () => { + const sessionId = 'sess-counter'; + setupTracedSession(sessionId); + + const tool = vi.fn().mockResolvedValue(SUCCESS_RESULT) as unknown as ToolCallback; + await (withTrace('navigate', tool) as AnyToolFn)({ url: 'https://a.com' }, extra); + await (withTrace('click_element', tool) as AnyToolFn)({ selector: '#btn' }, extra); + + const session = getTraceSession(sessionId)!; + const befores = session.events.filter((e) => e.type === 'before') as { callId: string }[]; + expect(befores[0].callId).toBe('call@1'); + expect(befores[1].callId).toBe('call@2'); + }); + + it('marks after event with error when tool returns isError', async () => { + const sessionId = 'sess-error'; + setupTracedSession(sessionId); + + const tool = vi.fn().mockResolvedValue(ERROR_RESULT) as unknown as ToolCallback; + await (withTrace('navigate', tool) as AnyToolFn)({ url: 'https://fail.com' }, extra); + + const session = getTraceSession(sessionId)!; + const after = session.events.find((e) => e.type === 'after') as { error?: { message: string } }; + expect(after?.error?.message).toBe('something failed'); + }); + + it('traces mobile sessions when trace is enabled', async () => { + const state = getState(); + const sessionId = 'sess-mobile'; + const mockBrowser = { + takeScreenshot: vi.fn().mockResolvedValue(TINY_PNG), + } as unknown as WebdriverIO.Browser; + + state.browsers.set(sessionId, mockBrowser); + state.currentSession = sessionId; + state.sessionMetadata.set(sessionId, { type: 'ios', capabilities: {}, isAttached: false, trace: true }); + + createTraceSession(sessionId, 'chromium', { width: 390, height: 844 }, 'ios - iPhone 15', 'ios'); + + const tool = vi.fn().mockResolvedValue(SUCCESS_RESULT) as unknown as ToolCallback; + const wrapped = withTrace('tap_element', tool) as AnyToolFn; + await wrapped({ selector: '~btn' }, extra); + + expect(tool).toHaveBeenCalledOnce(); + const session = getTraceSession(sessionId)!; + await session.screenshotChain; + expect(session.events.filter((e) => e.type === 'before')).toHaveLength(1); + expect(session.events.filter((e) => e.type === 'after')).toHaveLength(1); + expect(session.screenshots).toHaveLength(1); + }); + +}); + +describe('recordInitialNavigation', () => { + it('emits before, screencast-frame, and after events', async () => { + const sessionId = 'sess-init-nav'; + setupTracedSession(sessionId); + + await recordInitialNavigation(sessionId, 'https://example.com'); + + const session = getTraceSession(sessionId)!; + expect(session.events.find((e) => e.type === 'before')).toBeDefined(); + expect(session.events.find((e) => e.type === 'after')).toBeDefined(); + expect(session.events.find((e) => e.type === 'screencast-frame')).toBeDefined(); + }); + + it('includes the URL in the before event title', async () => { + const sessionId = 'sess-init-nav-title'; + setupTracedSession(sessionId); + + await recordInitialNavigation(sessionId, 'https://example.com/path'); + + const session = getTraceSession(sessionId)!; + const before = session.events.find((e) => e.type === 'before') as { title: string }; + expect(before.title).toContain('https://example.com/path'); + }); + + it('is a no-op when no trace session exists', async () => { + await expect(recordInitialNavigation('nonexistent', 'https://x.com')).resolves.toBeUndefined(); + }); +}); diff --git a/tests/trace/state.test.ts b/tests/trace/state.test.ts new file mode 100644 index 0000000..417c694 --- /dev/null +++ b/tests/trace/state.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest'; +import { createTraceSession, getTraceSession, deleteTraceSession, getMonotonicMs } from '../../src/trace/state'; + +describe('createTraceSession', () => { + it('creates a session with context-options as first event', () => { + const session = createTraceSession('test-session-id', 'chromium', { width: 1280, height: 720 }, 'test'); + expect(session.events).toHaveLength(1); + expect(session.events[0].type).toBe('context-options'); + }); + + it('sets context-options fields correctly', () => { + const session = createTraceSession('abcdef12-xyz', 'firefox', { width: 1920, height: 1080 }, 'my test'); + const ctxOpts = session.events[0] as { type: string; version: number; browserName: string; libraryName: string; title: string; contextId: string; monotonicTime: number }; + expect(ctxOpts.version).toBe(8); + expect(ctxOpts.browserName).toBe('firefox'); + expect(ctxOpts.libraryName).toBe('@wdio/mcp'); + expect(ctxOpts.title).toBe('my test'); + expect(ctxOpts.contextId).toMatch(/^context@/); + expect(ctxOpts.monotonicTime).toBe(0); + }); + + it('stores session in state and retrieves it', () => { + createTraceSession('retrieve-me', 'chromium', { width: 1280, height: 720 }, 'x'); + expect(getTraceSession('retrieve-me')).toBeDefined(); + }); + + it('derives pageId and contextId from first 8 chars of sessionId', () => { + const session = createTraceSession('abcdef1234', 'chromium', { width: 1280, height: 720 }, 'x'); + expect(session.pageId).toBe('page@abcdef12'); + expect(session.contextId).toBe('context@abcdef12'); + }); + + it('starts with empty events array after context-options', () => { + const session = createTraceSession('empty-test', 'chromium', { width: 1280, height: 720 }, 'x'); + expect(session.callCounter).toBe(0); + expect(session.screenshots).toHaveLength(0); + }); +}); + +describe('getTraceSession', () => { + it('returns undefined for unknown session', () => { + expect(getTraceSession('nonexistent')).toBeUndefined(); + }); +}); + +describe('deleteTraceSession', () => { + it('removes session from state', () => { + createTraceSession('to-delete', 'chromium', { width: 1280, height: 720 }, 'x'); + deleteTraceSession('to-delete'); + expect(getTraceSession('to-delete')).toBeUndefined(); + }); +}); + +describe('getMonotonicMs', () => { + it('returns a non-negative number', () => { + const session = createTraceSession('monotonic-test', 'chromium', { width: 1280, height: 720 }, 'x'); + const ms = getMonotonicMs(session); + expect(ms).toBeGreaterThanOrEqual(0); + }); +}); diff --git a/tests/trace/tool-mapping.test.ts b/tests/trace/tool-mapping.test.ts new file mode 100644 index 0000000..4fad08f --- /dev/null +++ b/tests/trace/tool-mapping.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; +import { mapToolToTraceAction, formatActionTitle } from '../../src/trace/tool-mapping'; + +describe('mapToolToTraceAction', () => { + it('maps navigate to Page.navigate', () => { + expect(mapToolToTraceAction('navigate')).toEqual({ + class: 'Page', + method: 'navigate', + }); + }); + + it('maps click_element to Element.click', () => { + expect(mapToolToTraceAction('click_element')).toEqual({ + class: 'Element', + method: 'click', + }); + }); + + it('maps set_value to Element.fill', () => { + const action = mapToolToTraceAction('set_value'); + expect(action?.class).toBe('Element'); + expect(action?.method).toBe('fill'); + }); + + it('maps all expected tools without returning null', () => { + const mapped = ['navigate', 'click_element', 'set_value', 'scroll', 'tap_element', 'swipe', 'drag_and_drop', 'execute_script', 'launch_chrome']; + for (const tool of mapped) { + expect(mapToolToTraceAction(tool), `expected ${tool} to be mapped`).not.toBeNull(); + } + }); + + it('returns null for unmapped tools', () => { + expect(mapToolToTraceAction('get_elements')).toBeNull(); + expect(mapToolToTraceAction('get_screenshot')).toBeNull(); + expect(mapToolToTraceAction('unknown_tool')).toBeNull(); + }); +}); + +describe('formatActionTitle', () => { + it('includes first param in title', () => { + const action = { class: 'Page', method: 'navigate' }; + expect(formatActionTitle(action, { url: 'https://example.com' })).toBe('Page.navigate("https://example.com")'); + }); + + it('truncates long params to 80 chars', () => { + const action = { class: 'Page', method: 'evaluate' }; + const longScript = 'a'.repeat(100); + const title = formatActionTitle(action, { script: longScript }); + expect(title.length).toBeLessThan(100); + }); + + it('omits parens content when no params', () => { + const action = { class: 'Browser', method: 'launch' }; + expect(formatActionTitle(action, {})).toBe('Browser.launch()'); + }); +}); diff --git a/tests/trace/zip-writer.test.ts b/tests/trace/zip-writer.test.ts new file mode 100644 index 0000000..d931d47 --- /dev/null +++ b/tests/trace/zip-writer.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest'; +import yauzl from 'yauzl'; +import { createTraceSession } from '../../src/trace/state'; +import { buildTraceZip } from '../../src/trace/zip-writer'; +import type { TraceScreenshot } from '../../src/trace/types'; + +async function readZipEntries(zipBuffer: Buffer): Promise> { + return new Promise((resolve, reject) => { + yauzl.fromBuffer(zipBuffer, { lazyEntries: true }, (err, zipfile) => { + if (err || !zipfile) return reject(err); + const entries: Record = {}; + zipfile.readEntry(); + zipfile.on('entry', (entry) => { + zipfile.openReadStream(entry, (streamErr, readStream) => { + if (streamErr || !readStream) return reject(streamErr); + const chunks: Buffer[] = []; + readStream.on('data', (chunk: Buffer) => chunks.push(chunk)); + readStream.on('end', () => { + entries[entry.fileName] = Buffer.concat(chunks).toString('utf8'); + zipfile.readEntry(); + }); + }); + }); + zipfile.on('end', () => resolve(entries)); + zipfile.on('error', reject); + }); + }); +} + +describe('buildTraceZip', () => { + it('produces a zip with trace.trace and trace.network', async () => { + const session = createTraceSession('zip-test-1', 'chromium', { width: 1280, height: 720 }, 'test'); + const zipBuffer = await buildTraceZip(session); + + expect(zipBuffer).toBeInstanceOf(Buffer); + expect(zipBuffer.length).toBeGreaterThan(0); + + const entries = await readZipEntries(zipBuffer); + expect(Object.keys(entries)).toContain('trace.trace'); + expect(Object.keys(entries)).toContain('trace.network'); + }); + + it('trace.trace is valid NDJSON with context-options as first line', async () => { + const session = createTraceSession('zip-test-2', 'chromium', { width: 1280, height: 720 }, 'my session'); + const zipBuffer = await buildTraceZip(session); + const entries = await readZipEntries(zipBuffer); + + const lines = entries['trace.trace'].trim().split('\n'); + expect(lines.length).toBeGreaterThanOrEqual(1); + + const firstEvent = JSON.parse(lines[0]); + expect(firstEvent.type).toBe('context-options'); + expect(firstEvent.version).toBe(8); + expect(firstEvent.browserName).toBe('chromium'); + expect(firstEvent.libraryName).toBe('@wdio/mcp'); + }); + + it('includes screenshot resources in the zip', async () => { + const session = createTraceSession('zip-test-3', 'chromium', { width: 1280, height: 720 }, 'test'); + const screenshot: TraceScreenshot = { + resourceName: 'page@zip-test3-1000.jpeg', + data: Buffer.from('fake-jpeg'), + width: 1280, + height: 720, + }; + session.screenshots.push(screenshot); + + const zipBuffer = await buildTraceZip(session); + const entries = await readZipEntries(zipBuffer); + expect(Object.keys(entries)).toContain(`resources/${screenshot.resourceName}`); + }); +}); diff --git a/tsup.config.ts b/tsup.config.ts index 6071b67..1ce8ad3 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -4,6 +4,8 @@ export default defineConfig({ entry: { server: 'src/server.ts', snapshot: 'src/snapshot.ts', + trace: 'src/trace.ts', + 'show-trace': 'src/show-trace.ts', }, format: ['esm'], dts: true,