diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..9d12996c --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,70 @@ + + +## What + + + +## Why + + + +## How + + + +--- + +## Architecture self-check + +> Required for every non-trivial PR. If a box is unchecked, explain why. + +- [ ] **No new duplication.** This PR does not add a type, constant, enum, or contract that already exists in another package. (If it consolidates one, note which item from `CLAUDE.md` §7 is being resolved.) +- [ ] **No cross-adapter imports.** No code in `service`, `nightwatch-devtools`, or `selenium-devtools` imports from another adapter. +- [ ] **No adapter imports in `backend` / `app`.** Neither package reaches into adapter internals. +- [ ] **Typed contracts at boundaries.** Any new `fetch(...)`, `ws.send(...)`, or HTTP route has a typed request/response shape in `shared` (or in `service` types if `shared` doesn't exist yet, with a TODO to move). +- [ ] **No `if (framework === '...')` outside an adapter.** Framework branching uses a typed `FrameworkId`. +- [ ] **No new `any` at package boundaries.** Internal `any` is acceptable only at a documented framework-edge with a one-line comment. + +### Multi-adapter changes + +- [ ] This PR touches **more than one** adapter package. + +> If checked: **why isn't this in `core`?** Answer here: +> +> __ + +--- + +## Debt scoreboard + +> List the `CLAUDE.md` §7 debt items this PR resolves, partially resolves, or extends. Delete this section only if the PR genuinely affects no debt items. + +- Resolved: __ +- Partially resolved: __ +- New debt introduced: __ + +If new debt is introduced, it must be added to `CLAUDE.md` §7 in this PR. + +--- + +## Testing + +- [ ] Unit tests for new logic in `shared` / `core` (required per `CLAUDE.md` §4). +- [ ] Regression test for any bug fix (required per `CLAUDE.md` §4). +- [ ] `pnpm build` passes. +- [ ] `pnpm test` passes. +- [ ] `pnpm lint` passes. +- [ ] For UI/runtime changes: verified in `example/` (or `example` for the framework I changed). + +If any required item is skipped, say so here with the reason: + +__ + +--- + +## Screenshots / recordings (UI changes only) + + diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 00000000..d41da176 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,267 @@ +# Architecture + +Companion to [CLAUDE.md](./CLAUDE.md). CLAUDE.md defines the **rules**; this file describes **how the pieces fit together** so you can apply those rules without guessing. + +If the rules in CLAUDE.md and the descriptions here conflict, CLAUDE.md wins — and one of the files is out of date. + +--- + +## 1. One sentence + +A user's test suite is instrumented by a thin framework **adapter**, which sends a normalized event stream through **core** to the **backend**, which broadcasts it over WebSocket to the **app** (a browser UI), with shared types and contracts living in **shared**. + +``` +[user's test framework] + │ + ▼ + [adapter] ◀── thin: hooks + framework specifics + │ + ▼ + [core] ◀── all framework-agnostic capture/reporting logic + │ + ▼ (WS frames typed by shared) + [backend] ◀── Fastify + WS gateway + baseline store + runner + │ + ▼ (WS frames + HTTP, both typed by shared) + [app] ◀── Lit UI, framework-agnostic +``` + +Plus one out-of-band piece: **`packages/script`** is injected into the browser under test (not Node) to capture DOM mutations from the page's own JS context. It talks to the adapter, not directly to backend. + +--- + +## 2. Package responsibilities + +> Packages marked **[future]** do not exist yet. Their absence is the highest-priority debt in [CLAUDE.md §7](./CLAUDE.md#7-known-debt). + +### `packages/shared` + +**Owns:** Types, constants, enums, HTTP/WS contract definitions. Pure TypeScript, no runtime dependencies on other packages in this monorepo. Workspace-internal (`"private": true`) — never published; bundled into each consumer at build time. See [CLAUDE.md §2.6](./CLAUDE.md#26-workspace-internal-packages-must-stay-inlined-at-build-time). + +**Contains (target):** +- Domain types: `CommandLog`, `ConsoleLog`, `NetworkRequest`, `Mutation`, `Metadata`, `TestNode`, `TestStatus`, `PreservedAttempt`, `PreservedStep`, etc. +- The `FrameworkId` type: `'wdio' | 'nightwatch' | 'selenium'`. +- HTTP request/response schemas for every backend route. +- WS frame schemas (event name + payload type, for both directions). +- Cross-package constants: API paths, WS scopes, default values, status enums. + +**Imports from:** nothing (pure leaf package). + +**Imported by:** every other package. + +### `packages/core` + +**Owns:** All framework-agnostic logic that today is duplicated across adapter packages. Workspace-internal (`"private": true`); inlined into each adapter at build time. + +**Contains (target):** +- `SessionCapturer` — orchestrates capture for one test session. +- `ReporterBase` — common reporter behavior (suite/test lifecycle, ID generation, output formatting). +- `generateStableUid()` — single canonical UID generator. +- Console/stream capture — patches `console.*`, intercepts stdout/stderr, strips ANSI, classifies log levels. +- Command-log builder — stack trace parsing, source file loading, sourcemap resolution. +- WS client — connects to the backend, serializes frames per `shared` contracts, handles reconnect. +- Network/performance capture pipeline. +- Sourcemap loader. + +**Imports from:** `shared`. + +**Imported by:** all adapter packages (`service`, `nightwatch-devtools`, `selenium-devtools`). + +### `packages/service` (WebdriverIO adapter) + +**Owns:** WebdriverIO-specific glue only. + +**Contains (target):** +- WDIO service hooks: `beforeCommand`, `afterCommand`, `beforeTest`, `afterTest`, `beforeSession`, `afterSession`. +- WDIO reporter implementation that extends `core`'s `ReporterBase`. +- WDIO-specific config defaults. +- The launcher entry point (`@wdio/devtools-service`). + +**Imports from:** `@wdio/types`, `@wdio/reporter`, `@wdio/logger`, `@wdio/protocols`, `core`, `shared`. + +**Must not import:** other adapter packages, `backend`, `app`. + +### `packages/nightwatch-devtools` (Nightwatch adapter) + +**Owns:** Nightwatch-specific glue only. + +**Contains (target):** +- Nightwatch lifecycle hooks (`before`, `cucumberBefore`, `cucumberAfter`, etc.). +- BrowserProxy that wraps Nightwatch's browser API and forwards command events into `core`. +- Nightwatch + Cucumber test discovery. + +**Imports from:** `core`, `shared`, `@wdio/logger`. + +**Must not import:** other adapter packages, `backend`, `app`. + +### `packages/selenium-devtools` (Selenium adapter) + +**Owns:** Selenium-specific glue only. + +**Contains (target):** +- Driver patching (`driverPatcher.ts`) that wraps `selenium-webdriver`. +- Runner hooks (`runnerHooks.ts`) for Mocha/Jest/Vitest/Cucumber. +- BiDi event handling. + +**Imports from:** `core`, `shared`, `selenium-webdriver` (peer). + +**Must not import:** other adapter packages, `backend`, `app`. + +### `packages/backend` + +**Owns:** The server that adapters connect to and the app talks to. + +**Contains:** +- Fastify HTTP server. +- WebSocket gateway (one connection per adapter session, one connection per app client). +- Baseline store (in-memory) for preserve-and-rerun. +- Video registry (per-session WebM files). +- Test runner spawner (`runner.ts`) — spawns the user's `wdio` / `nightwatch` / `selenium` binary with rerun filters. + +**Framework-awareness:** Only in `runner.ts`, only for building CLI args. Must branch on a typed `FrameworkId` from `shared`, never magic strings. + +**Imports from:** `shared`. **Must not import:** any adapter package, `app`, `core` (backend doesn't need core; core is for adapters). + +### `packages/app` + +**Owns:** The browser UI. + +**Contains:** +- Lit web components (sidebar, workbench, compare, console, network, etc.). +- WebSocket client for receiving the live event stream. +- Context providers (`@lit/context`) for the various data streams. +- DataManager-level orchestration (today a single god-file, target: split per concern). + +**Imports from:** `shared`. **Must not import:** any adapter package, `backend` directly (only via WS/HTTP), `core`. + +### `packages/script` + +**Owns:** Browser-injected runtime — runs **inside the page under test**, not in Node. + +**Contains:** +- DOM mutation observers. +- Page-side trace collection. +- Communication channel back to the adapter (via the WebDriver bridge). + +**Why it's separate:** Different execution environment (browser, not Node). It cannot import from `core` (which assumes Node) or `shared` directly unless `shared` stays strictly browser-safe. + +### `examples/wdio/`, `examples/nightwatch/`, `examples/selenium/` + +**Owns:** Per-framework demo projects, used for manual verification per [CLAUDE.md §4](./CLAUDE.md#4-testing). Run via `pnpm demo:wdio` / `pnpm demo:nightwatch` / `pnpm demo:selenium` from the repo root. Selenium has multiple runners (`mocha-test/`, `jest-test/`, `cucumber-test/`); the default `demo:selenium` script runs mocha, and `selenium-devtools` exposes per-runner variants via `pnpm --filter @wdio/selenium-devtools example:`. + +--- + +## 3. Data flow + +### A test run, end to end + +1. User runs `wdio` / `nightwatch test` / `mocha + selenium` — their normal command. +2. The framework loads its adapter (via service/plugin config). +3. Adapter calls `core.startSession()`, which: + - Spawns a connection to `backend` over WS. + - Patches `console.*`, stdout, stderr. + - Installs sourcemap loader. +4. Framework fires lifecycle hooks (suite start, test start, command, etc.). Adapter translates each hook into a `core` call. +5. `core` builds the typed event (per `shared` schema) and sends it through the WS client. +6. `backend` receives, optionally persists (baseline store, video registry), and broadcasts to all connected `app` clients. +7. `app` updates its Lit components reactively. + +### Preserve-and-rerun + +1. User clicks the bug-play icon on a failed test in `app`. +2. `app` POSTs to `/api/baseline/preserve` (typed contract in `shared`). +3. `backend` snapshots the failing attempt into the baseline store, then spawns a rerun via `runner.ts`. +4. The rerun goes through the normal flow above. +5. `app` receives both attempts and renders the side-by-side compare view. + +### Rerun mechanics (framework-specific, but contained) + +`backend/src/runner.ts` is the **only** place outside an adapter that knows about specific frameworks. It branches on `FrameworkId` to build: +- WDIO: `wdio run config.ts --spec ` or `--mochaOpts.grep`. +- Nightwatch: `nightwatch ` or `--cucumberOpts.name `. +- Selenium + Mocha/Jest/etc.: depends on detected runner. + +Every other piece of the system sees only normalized events. + +--- + +## 4. Boundaries and contracts + +Every place data crosses a package boundary, there must be a typed contract in `shared`. The boundaries are: + +| Boundary | Direction | Transport | Contract lives in | +|---|---|---|---| +| Adapter → backend | One-way events (command, console, mutation, etc.) | WebSocket frames | `shared/ws-frames.ts` | +| App → backend | API requests (preserve, clear, get baseline, run, stop) | HTTP (Fastify) | `shared/api-routes.ts` | +| Backend → app | Live event broadcast + API responses | WebSocket + HTTP | `shared/ws-frames.ts`, `shared/api-routes.ts` | +| Script → adapter | Mutation events from the page | Via WebDriver bridge (executeScript + log channel) | `shared/script-protocol.ts` | + +A new boundary contract is a `shared` change. Adding a new event type or HTTP route without updating `shared` is a CLAUDE.md §2.5 violation. + +--- + +## 5. Where do I add new code? + +A decision tree for the most common cases. Answer top-down — the first match wins. + +**Are you adding or changing a type, constant, enum, schema, or contract used by more than one package?** +→ `packages/shared`. + +**Are you adding logic that captures, parses, normalizes, formats, or transports test-event data, and it doesn't depend on a specific framework's API?** +→ `packages/core`. Create it if it doesn't exist. + +**Are you wiring a specific framework's hook, event, or driver to the event pipeline?** +→ The matching adapter package. Adapter code should call `core` for the actual work and only own the hook registration. + +**Are you adding a backend HTTP route, WS handler, or runner behavior?** +→ `packages/backend`. Add the contract to `shared` first. + +**Are you adding UI?** +→ `packages/app`. Consume contracts from `shared` only; never reach into adapter or backend internals. + +**Are you adding code that runs inside the browser under test (DOM observer, page-side hook)?** +→ `packages/script`. + +**You're still not sure.** +→ Ask. Ambiguity here is the most expensive kind of mistake — putting something in the wrong package now means migrating it later, and migrations across this many consumers are painful. + +--- + +## 6. Current reality vs. target + +This is a snapshot of where the codebase diverges from the architecture above. As debt is resolved, update this section **and** delete the matching entry from [CLAUDE.md §7](./CLAUDE.md#7-known-debt). + +### Populated packages and what's still in adapters +- `packages/shared` contains baseline API constants, `TestRunnerId`, and the core test-event types (`CommandLog`, `ConsoleLog`, `NetworkRequest`, `Metadata`, `TraceLog`, `TraceType`, `PreservedAttempt`, `PreservedStep`, `TestStatus`, `TestError`, `PerformanceData`, `DocumentInfo`, `Viewport`, `ScreencastInfo`, `LogLevel`). Adapter `types.ts` files re-export shared types for backwards compatibility. +- `packages/core` contains console-capture constants and pure helpers (`CONSOLE_METHODS`, `ANSI_REGEX`, `LOG_LEVEL_PATTERNS`, `LOG_SOURCES`, `ERROR_INDICATORS`, `SPINNER_RE`, `stripAnsi`, `detectLogLevel`, `createConsoleLogEntry`, `isInternalStreamLine`), stable-UID helpers (`generateStableUid`, `deterministicUid`, `resetSignatureCounters`), stack-frame helpers (`isUserCodeFrame`, `normalizeFilePath`, `getCallSourceFromStack`), `serializeError`, net helpers (`isPortInUse`, `findFreePort`, `getRequestType`), `chromeLogLevelToLogLevel`, and the `SessionCapturerBase` abstract class. All three adapter `SessionCapturer`s now extend it. Command-log builder, reporter base, and the sourcemap loader remain in adapters. + +### Misplaced logic +- `packages/service` currently contains framework-agnostic logic (UID generation, console capture, sourcemap resolution, reporter base) that belongs in `core`. The other two adapters re-implement the same logic instead of importing it. + +### Misplaced state and concerns +- `packages/app/src/controller/DataManager.ts` (~986 lines) bundles WS connection, 11 context providers, business logic, and baseline coordination into one file. Target: one module per concern behind a thin façade. +- `packages/app/src/components/sidebar/explorer.ts` (~670 lines) is a Lit component that also makes HTTP calls — UI and I/O mixed. +- `packages/app/src/components/workbench/compare.ts` (~888 lines) mixes data fetching, diff logic, popup window management, and rendering. +- `packages/backend/src/index.ts` (~387 lines) bundles server wiring, WS gateway, video registry, baseline API, and runner lifecycle. + +### Missing contracts +- App-to-backend `fetch()` calls have no shared request/response types. +- The reporter in `packages/service/src/reporter.ts` uses `as any` for inputs instead of typed shapes. + +--- + +## 7. Migration order (suggested) + +Not a hard sequence — just the order that minimizes churn. Each step is intended to be one or a small handful of PRs, not a giant rewrite. + +1. ~~**Create `packages/shared`.** Empty workspace package with proper `package.json`, `tsconfig`, exports.~~ ✅ Done. +2. ~~**Move duplicated cross-package types into `shared`.**~~ ✅ Done for the 6 app-imported types and their dependencies. +3. ~~**Move duplicated constants and status types into `shared`.**~~ ✅ Done. `BASELINE_API`, `BASELINE_WS_SCOPE`, `TestStatus`, `TestRunnerId` all live in shared. Sidebar `TestState` is a value-only enum-style accessor backed by `TestStatus`. +4. ~~**Create `packages/core`.**~~ ✅ Done. +5. ~~**Extract one duplicated logic block into `core`.**~~ ✅ Done for pure console helpers and UID helpers (constants, `stripAnsi`, `detectLogLevel`, `createConsoleLogEntry`, `generateStableUid`, `deterministicUid`, `resetSignatureCounters`). The `SessionCapturer` class itself still owns the patching logic in each adapter. +6. ~~**Extract `SessionCapturer` into `core`.**~~ ✅ Done — `SessionCapturerBase` lives in core; service, nightwatch, and selenium all extend it. See [`SESSIONCAPTURER_EXTRACTION_PLAN.md`](./SESSIONCAPTURER_EXTRACTION_PLAN.md) for what stayed framework-specific and the design choices the migration locked in. Remaining: command-log builder, reporter base, sourcemap loader — smaller individual pieces than the SessionCapturer migration. +7. **Type the HTTP/WS contracts in `shared`.** Backend and app start importing them at the boundary. +8. ~~**Replace string-based framework checks in `runner.ts` with `FrameworkId`.**~~ ✅ Done via `TestRunnerId` in shared (typed `FRAMEWORK_FILTERS` map key). +9. **Split god-files opportunistically as their sections are edited** (boy-scout rule from CLAUDE.md §5). + +Steps 1–3 alone resolve roughly half of the known debt and unlock the rest. Steps 5–6 are where the per-feature productivity gains compound — once console capture is in core, the next feature touching console logs is one change instead of three. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..fe87f1d9 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,290 @@ +# CLAUDE.md + +This file is the contract for working in this repository. It applies to **all code in this repo** — existing and new alike. There is no "legacy carve-out": code that does not yet comply is debt, and every change must move the repo closer to compliance, never further from it. + +Both human contributors and AI agents (Claude Code) must follow it. When a rule here conflicts with what looks easier in the moment, the rule wins. + +If you are an AI agent: read this file in full before making any non-trivial change. When in doubt, ask the user. + +--- + +## 1. What this repo is + +A devtools UI for end-to-end browser tests, supporting three frameworks (WebdriverIO, Nightwatch, Selenium) with **one backend and one UI**. The frameworks are adapters that feed the same backend the same event stream. + +Packages (pnpm workspace): + +| Package | Role | +|---|---| +| `packages/app` | Lit-based browser UI. Framework-agnostic. | +| `packages/backend` | Fastify server, WebSocket gateway, baseline store, test runner spawner. Framework-agnostic at the API layer; framework-aware only via a typed `FrameworkId`. | +| `packages/shared` | Types, constants, HTTP/WS contracts. Pure, no runtime deps on other packages. Single source of truth. Workspace-internal (`"private": true`); inlined into each consumer at build time. | +| `packages/core` | Framework-agnostic capture/reporter logic. Currently houses console-capture constants and helpers, UID gen, error serialization, stack helpers, net helpers, `SessionCapturerBase` (extended by all three adapters), and `TestReporterBase` (extended by nightwatch + selenium reporters). Workspace-internal (`"private": true`); inlined into each adapter at build time. | +| `packages/service` | WebdriverIO adapter. Hook registration + WDIO-specific config. | +| `packages/nightwatch-devtools` | Nightwatch adapter. Hook registration + lifecycle binding. | +| `packages/selenium-devtools` | Selenium adapter. Driver patching + runner hooks. | +| `packages/script` | Browser-injected runtime. Runs **inside the page under test** (not in Node), captures DOM mutations and page-side traces. Not a home for shared Node-side logic — that belongs in `core`. | +| `examples/wdio/`, `examples/nightwatch/`, `examples/selenium/` | Per-framework demo projects, used for manual verification (§4). | + +Both `packages/shared` and `packages/core` exist and host the shared types, contracts, and adapter scaffolding. The `SessionCapturerBase` class in `core` owns console/stream patching, WS connection, command id bookkeeping, and upstream-send guard/try-catch (with an `onUpstreamDrop` hook subclasses can override for diagnostics); all three adapters extend it. `TestReporterBase` is shared by the nightwatch + selenium reporters (service uses `@wdio/reporter` from WDIO). Remaining `core` candidate is a handful of partially-shared `TIMING`/`DEFAULTS` constants. + +### Commands + +Run from repo root unless noted: + +| Command | What it does | +|---|---| +| `pnpm install` | Install workspace dependencies. | +| `pnpm build` | Build all packages (`pnpm -r build`). | +| `pnpm test` | Run vitest suite once. | +| `pnpm test:watch` | Run vitest in watch mode. | +| `pnpm lint` | Lint all packages in parallel. | +| `pnpm demo:wdio` | Run the WebdriverIO example. | +| `pnpm demo:nightwatch` | Run the Nightwatch example. | +| `pnpm demo:selenium` | Run the Selenium example (mocha runner by default; selenium-devtools also exposes `example:mocha` / `example:jest` / `example:cucumber` for per-runner variants). | +| `pnpm dev` | Run all packages in parallel dev mode. | + +Before any UI/runtime change is claimed done: `pnpm build && pnpm test && pnpm demo:wdio` (or `demo:nightwatch` / `demo:selenium` if your change targets that framework). + +### Path aliases (TypeScript) + +Defined in root `tsconfig.json`. Use these in imports — do **not** use long relative paths like `../../../components/...`: + +| Alias | Resolves to | +|---|---| +| `@/*` | `packages/app/src/*` | +| `@components/*` | `packages/app/src/components/*` | +| `@core/*` | `packages/app/src/core/*` (app-internal, not the future `packages/core`) | +| `@wdio/devtools-backend` / `@wdio/devtools-backend/*` | `packages/backend/src/...` | +| `@wdio/devtools-script` / `@wdio/devtools-script/*` | `packages/script/src/...` | +| `@wdio/devtools-service` / `@wdio/devtools-service/*` | `packages/service/src/...` | +| `@wdio/selenium-devtools` / `@wdio/selenium-devtools/*` | `packages/selenium-devtools/src/...` | + +`packages/shared` and `packages/core` are both wired in (`@wdio/devtools-shared`, `@wdio/devtools-core`). + +> ⚠️ Note: `@core/*` today points to `packages/app/src/core/` (app-internal). The future framework-agnostic `packages/core` will need a different alias (e.g. `@wdio/devtools-core`) to avoid collision. Resolve this when `packages/core` is created. + +--- + +## 2. Architecture rules + +These apply to every file in the repo. Code that doesn't comply is debt to be fixed (§7), not an exception. + +### 2.1 One source of truth per concept + +No type, constant, enum, schema, or contract may be defined in more than one package. Every shared concept lives in `packages/shared`. + +If a duplicated declaration is discovered, the next change that touches it must consolidate to `shared`. + +### 2.2 Framework-agnostic logic lives in `core` + +Any capture, parsing, normalization, sourcemap, UID, reporter, or WS-framing logic is framework-agnostic and lives in `packages/core`. Adapter packages call into `core`; they do not reimplement. + +If a feature requires the same logical change in two or more adapters, the logic does not belong in the adapters — it belongs in `core`. Stop and extract. + +### 2.3 Adapters are thin and isolated + +Adapter packages (`service`, `nightwatch-devtools`, `selenium-devtools`) own only: +- Framework-specific hook registration and lifecycle binding +- Framework-specific driver/browser patching +- Framework-specific config + +They **may not** import from each other. They **may** import from `shared` and `core`. They **may not** be imported by `backend` or `app`. + +### 2.4 `backend` and `app` are framework-agnostic + +`backend` and `app` import from `shared` (for contracts) and from each other only via the WS/HTTP boundary. They do not import any adapter package. + +If `backend` needs to behave differently per framework (e.g. building rerun CLI args in `runner.ts`), it branches on a typed `FrameworkId` from `shared`. **No string comparisons like `if (framework === 'nightwatch')`** anywhere outside an adapter. + +### 2.5 Boundaries have typed contracts + +Every `fetch(...)` and `ws.send(...)` has a typed request/response shape defined in `shared`. No untyped `any` payloads cross a package boundary. No "the caller knows what shape comes back" agreements. + +### 2.6 Workspace-internal packages must stay inlined at build time + +`packages/shared` and (when it exists) `packages/core` are marked `"private": true` and are **never published to npm**. Each consuming package's bundler must inline their code into its own `dist/` at build time. **Packages that consume `@wdio/devtools-shared` or `@wdio/devtools-core` must use a bundler — `tsc`-only builds emit literal `import` statements that npm cannot resolve at install time.** + +Bundlers in use today: **vite** for `app`, `service`, `script`; **tsup** for `backend`, `nightwatch-devtools`, `selenium-devtools`. + +- List `@wdio/devtools-shared` / `@wdio/devtools-core` in `devDependencies` with `workspace:^`, **never** in `dependencies`. Both tsup and vite externalize anything in `dependencies` by default — `devDependencies` is what gets inlined. If the dep leaks into `dependencies`, pnpm publish rewrites the version to something that doesn't exist on npm and end-user installs fail. +- Do **not** add `@wdio/devtools-shared` or `@wdio/devtools-core` to `rollupOptions.external` (vite) or to tsup's `external` option, or any equivalent. **Vite `external` callback footgun (bit us twice already):** vite resolves workspace imports BEFORE invoking the callback, so the `id` parameter is often an absolute path like `/Users/.../packages/core/src/index.ts`, *not* the package name `@wdio/devtools-core`. A check like `id !== '@wdio/devtools-core'` will silently miss the absolute-path form, and the dist ends up with literal absolute paths that work nowhere but the build machine. Always check for BOTH forms: package name (`id === '@wdio/devtools-core'`, `id.startsWith('@wdio/devtools-core/')`) AND resolved path (`id.includes('/packages/core/')`). See [`packages/service/vite.config.ts`](packages/service/vite.config.ts) for the canonical pattern. +- **Vite `external` relative-import footgun:** the same callback also receives bare relative imports for in-tree source files (e.g. `./utils.js` from index.ts, `../constants.js` from utils/source-mapping.ts). A check that only allows `./` will silently externalize `../`-style imports from subfolder modules — the dist ends up referencing a non-emitted file (`./constants.js` import with no `constants.js` on disk) and crashes at install time with `ERR_MODULE_NOT_FOUND`. Allow both `./` AND `../` prefixes (or just check `path.resolve(__dirname, 'src')`). When adding subfolders under `src/`, run a Node-resolve smoke test on the dist after build. +- Do **not** switch a consuming package's build to `tsc`-only. If the package needs a build, it gets a bundler. +- After any change to a bundler config or build script, run `pnpm build` on the affected package and verify its `dist/*.js` contain no references to private workspace packages — **check both forms**: + - `grep -E "@wdio/devtools-(core|shared)|/packages/(core|shared)/" packages//dist/*.js` should return nothing. Checking only `@wdio/devtools-core` misses the absolute-path form vite leaves behind when its `external` callback is misconfigured. + +### 2.7 Separation of concerns within a file + +A file owns one concern. Specifically: +- **UI components render.** They do not call `fetch`, manage WebSocket state, or run business logic. +- **Controllers/services own I/O and state.** They do not render. +- **Backend route handlers wire requests to services.** They do not contain business logic inline. +- **Reporters report.** They do not also do sourcemap resolution, file I/O, and step UID generation in the same file. + +A file that mixes these concerns is debt and must be split when next touched. + +--- + +## 3. Coding standards + +### TypeScript + +- `strict: true` is on (configured in root `tsconfig.json`). Do not weaken it. +- **No `any`.** If a framework or library forces it, isolate the `any` to one line at the boundary and cast to a typed shape immediately. Add a one-line comment explaining why. +- **No `as unknown as X`** double-casts unless the reason is documented inline. +- Prefer `type` for unions and `interface` for object shapes that may be extended. +- Exported names from `shared` and `core` are public API of those packages — treat renames as breaking changes. + +### Naming + +- **One name per concept across the whole repo.** The canonical name for test status is `TestStatus` in `@wdio/devtools-shared`. The sidebar `TestState` object is a value-only enum-style accessor; its values come from `TestStatus`. +- Constants: `SCREAMING_SNAKE_CASE`. Types: `PascalCase`. Functions and variables: `camelCase`. Files: `kebab-case.ts` unless matching a class name. + +### File and function size + +- **File**: ~400 lines. A larger file is a smell; do not add to it without splitting. +- **Function**: ~50 lines. +- Known god-files that must be split as they're touched: `packages/app/src/controller/DataManager.ts` (~986 lines), `packages/app/src/components/workbench/compare.ts` (~888 lines), `packages/app/src/components/sidebar/explorer.ts` (~670 lines), `packages/backend/src/index.ts` (~387 lines). + +### Comments + +- Default to no comments. Names should explain *what*. +- Write a comment only when the *why* is non-obvious: a hidden constraint, a workaround for a specific bug, a subtle invariant. +- Do not write `// TODO`, `// added for X feature`, `// removed old logic`, or `// keep in sync` comments. Git history holds the first three; the fourth means you should have used a single source of truth. +- One line max. No multi-paragraph docstrings. + +### Error handling + +- Validate at boundaries (HTTP input, WS messages, framework callbacks). Trust internal code. +- Never swallow errors silently. Catch only to add context, then rethrow or log with enough detail to debug. +- No `catch (e) {}` blocks. No empty catches. + +### Dead code + +- Delete unused exports, unused imports, commented-out blocks, and `_unused` parameters when you find them. +- Do not keep "in case we need it later" code. Git history is the safety net. + +--- + +## 4. Testing + +The repo uses **vitest** at the root. + +### Required + +- **`shared` and `core`**: unit tests for every new exported function or type guard. These are the foundation; bugs here cascade. +- **Bug fixes (any package)**: a regression test that fails before the fix and passes after. If you genuinely can't write one (e.g. it requires a real browser and the infra doesn't exist), say so explicitly in the PR. +- **New HTTP/WS contracts**: a test that exercises the contract end-to-end at least once. + +### Recommended + +- Adapter packages: unit tests for non-trivial parsing or transformation logic. Hook-wiring may be verified manually via `examples//`. +- `backend` and `app`: tests for non-UI logic (parsers, transforms, state reducers). + +### Manual verification + +For UI or runtime changes, you **must** run the change in `examples//` before claiming the work is done. Type-checks and unit tests verify code correctness, not feature correctness. If you cannot run the example, say so explicitly — do not claim success on the basis of `tsc --noEmit` alone. + +--- + +## 5. Workflow + +### Before you start + +1. Read this file. +2. Read the README of any package you're touching. +3. Ask: does this change belong in the package I'm about to edit, or does it belong in `shared` / `core`? If `shared` or `core` — go there first. + +### While you work + +- Make the minimum change that solves the problem. No drive-by refactors of unrelated code, no speculative abstractions for hypothetical future requirements. +- **The boy-scout rule applies always.** When you touch a file or a section, leave it more compliant with this document than you found it. If you touch a duplicated type, consolidate it into `shared`. If you edit a section of a god-file, split that section out. If you change a magic-string framework check, replace it with a typed `FrameworkId`. The scope of cleanup matches the scope of your change — don't rewrite the whole file, but don't leave a clear violation in the lines you touched either. +- Do not introduce new violations to "match the existing style." The existing style is debt. + +### Before you finish + +- Run `pnpm build`, `pnpm test`, and `pnpm lint`. Don't push red. +- Re-read your diff. Delete anything you wouldn't be able to justify to a reviewer. +- For UI/runtime changes, verify in `examples//`. +- Check: does the diff reduce or increase the count of known debt items in §7? If it increases, reconsider. + +### Commits + +- Small, focused commits. Don't bundle unrelated changes. +- Imperative mood. Explain *why*, not *what* — the diff shows the what. +- Never amend commits that have been pushed or shared. +- Never use `--no-verify` to skip hooks. If a hook fails, fix the underlying problem. + +### PRs + +- One concern per PR. A refactor and a feature are two PRs. +- If the PR touches more than one adapter package, the description must answer: **why isn't this in `core`?** +- Note in the PR description which debt items from §7 (if any) the change paid down. + +--- + +## 6. What an AI agent (Claude) should do + +You are expected to treat this file as a hard contract. + +### Refuse + +- Adding a type, constant, enum, or contract that duplicates one that exists in another package. Propose extracting to `shared` instead. +- Adding an `any` type at a package boundary. +- Adding `if (framework === '...')` or any string-based framework check outside an adapter package. +- Making the same logical change in two or more adapter packages. Propose extracting to `core` instead. +- Adding a `// TODO`, `// keep in sync`, or similar comment as a substitute for fixing the underlying issue. +- Skipping pre-commit hooks with `--no-verify`. +- Claiming a UI/runtime change works without running it in `examples//`. +- Importing one adapter package from another, or importing any adapter from `backend` or `app`. + +### Warn, then proceed if the user confirms + +- A file or function exceeds the soft size limits in §3. +- A change that grows a god-file rather than splitting the section being edited. +- Adding a feature behind a flag without an explicit request. + +### Do without asking + +- Run formatters, type checks, and tests. +- Move a duplicated type or constant to `shared` (creating the package if needed) as part of a change that touches it. That's the boy-scout rule, not scope creep. +- Split the *section being edited* out of a god-file. Do not rewrite the whole file uninvited. +- Replace a string-based framework check with a typed `FrameworkId` when you're editing the file containing it. + +### Always + +- State the planned approach in one or two sentences before making non-trivial changes, especially anything touching package boundaries. +- When the right place for new code is ambiguous (`shared` vs `core` vs adapter), ask the user before writing it. +- After completing a change, in one or two sentences: what changed, what's next, and which §7 debt item the change moved (if any). + +--- + +## 7. Known debt + +These are documented violations of this file's rules. They exist today; they are debt, not exceptions. Every change must reduce this list, never extend it. As items are resolved, delete them from this section. + +### Architecture debt + +- `packages/shared` contains `BASELINE_API`, `BASELINE_WS_SCOPE`, `TestRunnerId`, and the core test-event types (`CommandLog`, `ConsoleLog`, `NetworkRequest`, `Metadata`, `TraceLog`, `TraceType`, `PreservedAttempt`, `PreservedStep`, `TestStatus`, `TestError`, `TestStats`, `SuiteStats`, `ReporterError`, `PerformanceData`, `DocumentInfo`, `Viewport`, `ScreencastInfo`, `LogLevel`). `SuiteStats.featureFile` is the cucumber-only `.feature` path, distinct from `file` (which owns the suite's stable UID and stays at cwd). Adapter type files re-export shared types for backwards compatibility. +- `packages/core` contains console-capture constants and helpers (`CONSOLE_METHODS`, `ANSI_REGEX`, `LOG_LEVEL_PATTERNS`, `LOG_SOURCES`, `ERROR_INDICATORS`, `stripAnsi`, `detectLogLevel`, `createConsoleLogEntry`, `isInternalStreamLine`, `SPINNER_RE`), stable-UID helpers (`generateStableUid`, `deterministicUid`, `resetSignatureCounters`), stack-frame helpers (`isUserCodeFrame`, `normalizeFilePath`, `getCallSourceFromStack`), `serializeError` (returns `SerializedError`), net helpers (`isPortInUse`, `findFreePort`, `getRequestType`), `chromeLogLevelToLogLevel`, the `SessionCapturerBase` abstract class, and the `TestReporterBase` abstract class. Adapter `SessionCapturer` and `TestReporter` subclasses contain only framework-specific logic. +- Remaining adapter-side duplication: partially-shared `TIMING`/`DEFAULTS` constants (each adapter has framework-specific values, so partial sharing only saves a handful of lines). Service's WDIO-specific Cucumber UID branching stays in `service/reporter.ts` and delegates the actual hashing to core. The `sendUpstream` guard/try-catch is now in base; subclasses override `onUpstreamDrop` only when they want diagnostics on drop. +- `TraceMutation` is defined in `packages/script/types.d.ts` as a global (browser-only, depends on DOM types). Adapters and backend currently sidestep this with loose `unknown[]` / `MutationLike` types. A clean home for browser/page-side types is open: extract from script into a small package consumable by both browser and Node consumers, or accept that mutation arrays cross the boundary as `unknown[]`. + +### File-size debt (god-files to split as touched) + +- `packages/app/src/controller/DataManager.ts` (~751 lines, was 986 — suite-merge logic extracted as pure functions; remainder is the per-scope socket-message handlers tightly coupled to ContextProvider state) +- `packages/app/src/components/workbench/compare.ts` (~687 lines, was 888 — static styles extracted; remainder is Lit render methods tightly coupled to component state) +- `packages/app/src/components/sidebar/explorer.ts` (~506 lines, was 670 — entry-state logic extracted, remainder is Lit render + runner-options getters coupled to component state) + +### Type-safety debt + +_(All known type-safety debt resolved. New violations should still be tracked here as they're discovered.)_ + +--- + +## 8. Living document + +This file is expected to evolve. When you discover a recurring decision point it doesn't cover, propose adding it. When a rule turns out to be wrong in practice, propose changing it. + +Do not silently ignore rules. If a rule is getting in the way of real work, that's a signal to fix the rule, not to break it. diff --git a/README.md b/README.md index 8e13fdc1..a773ff28 100644 --- a/README.md +++ b/README.md @@ -39,14 +39,14 @@ Works with **WebdriverIO**, **[Nightwatch.js](./packages/nightwatch-devtools/REA ### 🎬 Session Screencast - **Automatic Video Recording**: Captures a continuous `.webm` video of the browser session alongside the existing snapshot and DOM mutation views -- **Cross-Browser**: Uses Chrome DevTools Protocol (CDP) push mode for Chrome/Chromium; automatically falls back to screenshot polling for Firefox, Safari, and other browsers (no configuration change needed) +- **Per-framework modes**: + - **WebdriverIO**: CDP push mode for Chrome/Chromium (efficient, no per-command overhead); polling fallback for other browsers + - **Selenium WebDriver**: CDP push mode via `selenium-webdriver/bidi`; polling fallback otherwise + - **Nightwatch.js**: Polling mode (Nightwatch doesn't expose a stable CDP escape hatch); works on every browser Nightwatch supports - **Per-Session Videos**: Each browser session (including sessions created by `browser.reloadSession()`) produces its own recording, selectable from a dropdown in the UI - **Smart Trimming**: Leading blank frames before the first URL navigation are automatically removed so videos start at the first meaningful page action -> **Note:** Screencast recording is currently supported for **WebdriverIO only**. Nightwatch.js support is planned for a future release. -> - -> For setup, configuration options, and prerequisites see the **[service README](./packages/service/README.md#screencast-recording)**. +> For setup, configuration options, and prerequisites see each adapter's README: **[WebdriverIO](./packages/service/README.md#screencast-recording)** · **[Selenium](./packages/selenium-devtools/README.md)** · **[Nightwatch](./packages/nightwatch-devtools/README.md#screencast)**. ### 🐞 Preserve & Rerun (Compare) - **When the bug icon appears**: Only on test/suite rows in a `failed` state and the icon sits next to ▶ on hover, available wherever a plain rerun is supported (e.g. Cucumber scenarios at the scenario row, Mocha tests at the test or suite row) @@ -54,7 +54,7 @@ Works with **WebdriverIO**, **[Nightwatch.js](./packages/nightwatch-devtools/REA - **Diagnose flaky tests**: See exactly which command differed between a pass and a fail without re-reading logs - **Pop out**: Open the comparison in a separate, themed window for a roomier view -> **Note:** Preserve & Rerun is currently supported for **WebdriverIO only**. Nightwatch.js and Selenium support is planned for a future release. +> Available across **WebdriverIO, Selenium WebDriver, and Nightwatch.js**. The rerun mechanism differs per framework (WDIO uses `--spec` + grep, Selenium substitutes a runner-specific filter flag like `--grep`/`--testNamePattern`, Nightwatch reads `DEVTOOLS_RERUN_LABEL`); the dashboard contract is identical. ### 🔍︎ TestLens - **Code Intelligence**: View test definitions directly in your editor @@ -143,7 +143,7 @@ pnpm install pnpm build # Run demo -pnpm demo +pnpm demo:wdio ``` ## Nightwatch Integration diff --git a/eslint.config.cjs b/eslint.config.cjs index f037455e..f26af245 100644 --- a/eslint.config.cjs +++ b/eslint.config.cjs @@ -90,7 +90,236 @@ module.exports = [ { files: ['**/*.test.ts'], rules: { - 'dot-notation': 'off' + 'dot-notation': 'off', + 'max-lines': 'off', + 'max-lines-per-function': 'off' + } + }, + + // Code-quality warnings (CLAUDE.md §3). + // Kept as `warn` so existing legacy violations surface in IDE/CI without + // blocking the build. Promote to `error` once known debt (CLAUDE.md §7) + // is cleared. + { + files: ['**/*.ts'], + rules: { + '@typescript-eslint/no-explicit-any': 'warn', + 'max-lines': [ + 'warn', + { max: 400, skipBlankLines: true, skipComments: true } + ], + 'max-lines-per-function': [ + 'warn', + { max: 50, skipBlankLines: true, skipComments: true, IIFEs: true } + ] + } + }, + + // CLAUDE.md §2.3 — no cross-adapter imports. + // Adapters (service, nightwatch-devtools, selenium-devtools) own + // framework-specific glue only. Anything shared between them belongs in + // packages/core (and is currently duplicated — see CLAUDE.md §7). + { + files: ['packages/service/**/*.{ts,tsx,js,mjs,cjs}'], + rules: { + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: [ + '@wdio/nightwatch-devtools', + '@wdio/nightwatch-devtools/*' + ], + message: + 'Adapters must not import from each other (CLAUDE.md §2.3). Extract shared logic to packages/core.' + }, + { + group: ['@wdio/selenium-devtools', '@wdio/selenium-devtools/*'], + message: + 'Adapters must not import from each other (CLAUDE.md §2.3). Extract shared logic to packages/core.' + } + ] + } + ] + } + }, + { + files: ['packages/nightwatch-devtools/**/*.{ts,tsx,js,mjs,cjs}'], + rules: { + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: ['@wdio/devtools-service', '@wdio/devtools-service/*'], + message: + 'Adapters must not import from each other (CLAUDE.md §2.3). Extract shared logic to packages/core.' + }, + { + group: ['@wdio/selenium-devtools', '@wdio/selenium-devtools/*'], + message: + 'Adapters must not import from each other (CLAUDE.md §2.3). Extract shared logic to packages/core.' + } + ] + } + ] + } + }, + { + files: ['packages/selenium-devtools/**/*.{ts,tsx,js,mjs,cjs}'], + rules: { + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: ['@wdio/devtools-service', '@wdio/devtools-service/*'], + message: + 'Adapters must not import from each other (CLAUDE.md §2.3). Extract shared logic to packages/core.' + }, + { + group: [ + '@wdio/nightwatch-devtools', + '@wdio/nightwatch-devtools/*' + ], + message: + 'Adapters must not import from each other (CLAUDE.md §2.3). Extract shared logic to packages/core.' + } + ] + } + ] + } + }, + + // CLAUDE.md §2.4 — backend does not import from adapters or app. + // Backend is framework-agnostic; framework branching uses a typed + // FrameworkId from packages/shared, never adapter internals. + { + files: ['packages/backend/**/*.{ts,tsx,js,mjs,cjs}'], + rules: { + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: ['@wdio/devtools-service', '@wdio/devtools-service/*'], + message: + 'Backend must not depend on any adapter (CLAUDE.md §2.4). Move shared types/constants to packages/shared.' + }, + { + group: [ + '@wdio/nightwatch-devtools', + '@wdio/nightwatch-devtools/*' + ], + message: + 'Backend must not depend on any adapter (CLAUDE.md §2.4). Move shared types/constants to packages/shared.' + }, + { + group: ['@wdio/selenium-devtools', '@wdio/selenium-devtools/*'], + message: + 'Backend must not depend on any adapter (CLAUDE.md §2.4). Move shared types/constants to packages/shared.' + }, + { + group: ['@/*', '@components/*'], + message: + 'Backend must not import from app (CLAUDE.md §2.4). App talks to backend over WS/HTTP using shared contracts.' + }, + { + group: ['@wdio/devtools-core', '@wdio/devtools-core/*'], + message: + 'Backend must not depend on core (CLAUDE.md §2.2). core is framework-agnostic adapter logic; backend only needs shared contracts.' + } + ] + } + ] + } + }, + + // CLAUDE.md §2.4 — app does not import from adapters or backend. + // App communicates with backend only over WS/HTTP, with contracts + // defined in packages/shared. + { + files: ['packages/app/**/*.{ts,tsx,js,mjs,cjs}'], + rules: { + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: ['@wdio/devtools-service', '@wdio/devtools-service/*'], + message: + 'App must not import from adapters (CLAUDE.md §2.4). Move shared types/constants to packages/shared.' + }, + { + group: [ + '@wdio/nightwatch-devtools', + '@wdio/nightwatch-devtools/*' + ], + message: + 'App must not import from adapters (CLAUDE.md §2.4). Move shared types/constants to packages/shared.' + }, + { + group: ['@wdio/selenium-devtools', '@wdio/selenium-devtools/*'], + message: + 'App must not import from adapters (CLAUDE.md §2.4). Move shared types/constants to packages/shared.' + }, + { + group: ['@wdio/devtools-backend', '@wdio/devtools-backend/*'], + message: + 'App must not import from backend directly (CLAUDE.md §2.4). Communicate via WS/HTTP using shared contracts.' + }, + { + group: ['@wdio/devtools-core', '@wdio/devtools-core/*'], + message: + 'App must not import from core (CLAUDE.md §2.2). core is framework-agnostic adapter logic; the app receives normalized events over WS.' + } + ] + } + ] + } + }, + + // CLAUDE.md §2.2 — core is for adapters only. Backend, app, and script + // must not depend on core. Core itself may only import from shared. + { + files: ['packages/core/**/*.{ts,tsx,js,mjs,cjs}'], + rules: { + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: ['@wdio/devtools-service', '@wdio/devtools-service/*'], + message: + 'core must not depend on any adapter (CLAUDE.md §2.2). Adapters import core, not the other way around.' + }, + { + group: [ + '@wdio/nightwatch-devtools', + '@wdio/nightwatch-devtools/*' + ], + message: + 'core must not depend on any adapter (CLAUDE.md §2.2). Adapters import core, not the other way around.' + }, + { + group: ['@wdio/selenium-devtools', '@wdio/selenium-devtools/*'], + message: + 'core must not depend on any adapter (CLAUDE.md §2.2). Adapters import core, not the other way around.' + }, + { + group: ['@wdio/devtools-backend', '@wdio/devtools-backend/*'], + message: + 'core must not depend on backend (CLAUDE.md §2.2). core is the lower layer.' + }, + { + group: ['@/*', '@components/*'], + message: + 'core must not depend on app (CLAUDE.md §2.2). core is Node-side adapter logic.' + } + ] + } + ] } } ] diff --git a/packages/nightwatch-devtools/example/README.md b/examples/nightwatch/README.md similarity index 100% rename from packages/nightwatch-devtools/example/README.md rename to examples/nightwatch/README.md diff --git a/packages/nightwatch-devtools/example/nightwatch.conf.cjs b/examples/nightwatch/nightwatch.conf.cjs similarity index 68% rename from packages/nightwatch-devtools/example/nightwatch.conf.cjs rename to examples/nightwatch/nightwatch.conf.cjs index 5e2467d7..0512597d 100644 --- a/packages/nightwatch-devtools/example/nightwatch.conf.cjs +++ b/examples/nightwatch/nightwatch.conf.cjs @@ -1,8 +1,10 @@ // Simple import - just require the package +const path = require('node:path') const nightwatchDevtools = require('@wdio/nightwatch-devtools').default module.exports = { - src_folders: ['example/tests'], + // Resolve relative to this config file so the path holds regardless of CWD. + src_folders: [path.resolve(__dirname, 'tests')], output_folder: false, // Skip generating nightwatch reports for this example // Add custom reporter to capture commands custom_commands_path: [], @@ -31,8 +33,13 @@ module.exports = { }, 'goog:loggingPrefs': { performance: 'ALL' } }, - // Simple configuration - just call the function to get globals - globals: nightwatchDevtools({ port: 3000 }) + // Simple configuration - just call the function to get globals. + // Screencast records a polling-mode .webm via fluent-ffmpeg; the file + // is written to cwd as nightwatch-video-.webm. + globals: nightwatchDevtools({ + port: 3000, + screencast: { enabled: true, pollIntervalMs: 200 } + }) } } } diff --git a/examples/nightwatch/package.json b/examples/nightwatch/package.json new file mode 100644 index 00000000..e36a50d4 --- /dev/null +++ b/examples/nightwatch/package.json @@ -0,0 +1,13 @@ +{ + "name": "@wdio/devtools-example-nightwatch", + "version": "0.0.0", + "private": true, + "description": "Nightwatch demo project used by pnpm demo:nightwatch. Needs its own node_modules so the backend's rerun spawner can resolve the nightwatch binary from this directory.", + "scripts": { + "lint": "eslint ." + }, + "dependencies": { + "@wdio/nightwatch-devtools": "workspace:^", + "nightwatch": "^3.0.0" + } +} diff --git a/packages/nightwatch-devtools/example/tests/login.js b/examples/nightwatch/tests/login.js similarity index 100% rename from packages/nightwatch-devtools/example/tests/login.js rename to examples/nightwatch/tests/login.js diff --git a/packages/nightwatch-devtools/example/tests/sample.js b/examples/nightwatch/tests/sample.js similarity index 100% rename from packages/nightwatch-devtools/example/tests/sample.js rename to examples/nightwatch/tests/sample.js diff --git a/examples/selenium/cucumber-test/cucumber.json b/examples/selenium/cucumber-test/cucumber.json new file mode 100644 index 00000000..d479ab56 --- /dev/null +++ b/examples/selenium/cucumber-test/cucumber.json @@ -0,0 +1,12 @@ +{ + "default": { + "import": [ + "../../examples/selenium/cucumber-test/features/support/setup.js", + "../../examples/selenium/cucumber-test/features/support/world.js", + "../../examples/selenium/cucumber-test/features/support/steps.js" + ], + "paths": ["../../examples/selenium/cucumber-test/features/*.feature"], + "publishQuiet": true, + "format": ["progress"] + } +} diff --git a/packages/selenium-devtools/example/cucumber-test/features/login.feature b/examples/selenium/cucumber-test/features/login.feature similarity index 100% rename from packages/selenium-devtools/example/cucumber-test/features/login.feature rename to examples/selenium/cucumber-test/features/login.feature diff --git a/packages/selenium-devtools/example/cucumber-test/features/support/setup.js b/examples/selenium/cucumber-test/features/support/setup.js similarity index 100% rename from packages/selenium-devtools/example/cucumber-test/features/support/setup.js rename to examples/selenium/cucumber-test/features/support/setup.js diff --git a/packages/selenium-devtools/example/cucumber-test/features/support/steps.js b/examples/selenium/cucumber-test/features/support/steps.js similarity index 100% rename from packages/selenium-devtools/example/cucumber-test/features/support/steps.js rename to examples/selenium/cucumber-test/features/support/steps.js diff --git a/packages/selenium-devtools/example/cucumber-test/features/support/world.js b/examples/selenium/cucumber-test/features/support/world.js similarity index 100% rename from packages/selenium-devtools/example/cucumber-test/features/support/world.js rename to examples/selenium/cucumber-test/features/support/world.js diff --git a/packages/selenium-devtools/example/jest-test/jest.config.json b/examples/selenium/jest-test/jest.config.json similarity index 100% rename from packages/selenium-devtools/example/jest-test/jest.config.json rename to examples/selenium/jest-test/jest.config.json diff --git a/packages/selenium-devtools/example/jest-test/test/example.js b/examples/selenium/jest-test/test/example.js similarity index 100% rename from packages/selenium-devtools/example/jest-test/test/example.js rename to examples/selenium/jest-test/test/example.js diff --git a/packages/selenium-devtools/example/mocha-test/test/example.js b/examples/selenium/mocha-test/test/example.js similarity index 100% rename from packages/selenium-devtools/example/mocha-test/test/example.js rename to examples/selenium/mocha-test/test/example.js diff --git a/examples/selenium/package.json b/examples/selenium/package.json new file mode 100644 index 00000000..de44e354 --- /dev/null +++ b/examples/selenium/package.json @@ -0,0 +1,17 @@ +{ + "name": "@wdio/devtools-example-selenium", + "version": "0.0.0", + "private": true, + "description": "Selenium WebDriver demo project used by pnpm demo:selenium. Imports selenium-webdriver directly; needs its own node_modules.", + "type": "module", + "scripts": { + "lint": "eslint ." + }, + "dependencies": { + "@wdio/selenium-devtools": "workspace:^", + "selenium-webdriver": "^4.27.0" + }, + "devDependencies": { + "@cucumber/cucumber": "^11.1.0" + } +} diff --git a/example/features/login.feature b/examples/wdio/features/login.feature similarity index 100% rename from example/features/login.feature rename to examples/wdio/features/login.feature diff --git a/example/features/pageobjects/login.page.ts b/examples/wdio/features/pageobjects/login.page.ts similarity index 100% rename from example/features/pageobjects/login.page.ts rename to examples/wdio/features/pageobjects/login.page.ts diff --git a/example/features/pageobjects/page.ts b/examples/wdio/features/pageobjects/page.ts similarity index 100% rename from example/features/pageobjects/page.ts rename to examples/wdio/features/pageobjects/page.ts diff --git a/example/features/pageobjects/secure.page.ts b/examples/wdio/features/pageobjects/secure.page.ts similarity index 100% rename from example/features/pageobjects/secure.page.ts rename to examples/wdio/features/pageobjects/secure.page.ts diff --git a/example/features/step-definitions/steps.ts b/examples/wdio/features/step-definitions/steps.ts similarity index 100% rename from example/features/step-definitions/steps.ts rename to examples/wdio/features/step-definitions/steps.ts diff --git a/example/package.json b/examples/wdio/package.json similarity index 100% rename from example/package.json rename to examples/wdio/package.json diff --git a/example/tsconfig.json b/examples/wdio/tsconfig.json similarity index 100% rename from example/tsconfig.json rename to examples/wdio/tsconfig.json diff --git a/example/wdio.conf.ts b/examples/wdio/wdio.conf.ts similarity index 97% rename from example/wdio.conf.ts rename to examples/wdio/wdio.conf.ts index 24ddf975..73e88052 100644 --- a/example/wdio.conf.ts +++ b/examples/wdio/wdio.conf.ts @@ -128,18 +128,19 @@ export const config: Options.Testrunner = { // your test setup with almost no effort. Unlike plugins, they don't add new // commands. Instead, they hook themselves up into the test process. services: [ - [ - 'devtools', - { - screencast: { - enabled: true, - captureFormat: 'jpeg', // 'jpeg' or 'png' — frame format sent by Chrome over CDP - quality: 70, // JPEG quality 0–100 - maxWidth: 1280, // max frame width in px - maxHeight: 720 // max frame height in px - } - } - ] + 'devtools' + // [ + // 'devtools', + // { + // screencast: { + // enabled: true, + // captureFormat: 'jpeg', // 'jpeg' or 'png' — frame format sent by Chrome over CDP + // quality: 70, // JPEG quality 0–100 + // maxWidth: 1280, // max frame width in px + // maxHeight: 720 // max frame height in px + // } + // } + // ] ], // // Framework you want to run your specs with. diff --git a/package.json b/package.json index 783835e6..bec2bb80 100644 --- a/package.json +++ b/package.json @@ -3,8 +3,9 @@ "type": "module", "scripts": { "build": "pnpm -r build", - "demo": "wdio run ./example/wdio.conf.ts", + "demo:wdio": "wdio run ./examples/wdio/wdio.conf.ts", "demo:nightwatch": "pnpm --filter @wdio/nightwatch-devtools example", + "demo:selenium": "pnpm --filter @wdio/selenium-devtools example", "dev": "pnpm --parallel dev", "preview": "pnpm --parallel preview", "test": "vitest run", diff --git a/packages/app/package.json b/packages/app/package.json index 5ab423dc..7e1feaaa 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -34,6 +34,7 @@ "license": "MIT", "devDependencies": { "@tailwindcss/postcss": "^4.1.18", + "@wdio/devtools-shared": "workspace:^", "@wdio/reporter": "9.27.0", "autoprefixer": "^10.4.21", "postcss": "^8.5.6", diff --git a/packages/app/src/app.ts b/packages/app/src/app.ts index 1852322f..c98fa1cf 100644 --- a/packages/app/src/app.ts +++ b/packages/app/src/app.ts @@ -1,7 +1,7 @@ import './tailwind.css' import { css, html, nothing } from 'lit' import { customElement, query } from 'lit/decorators.js' -import { TraceType, type TraceLog } from '@wdio/devtools-service/types' +import { TraceType, type TraceLog } from '@wdio/devtools-shared' import { Element } from '@core/element' import { DataManagerController } from './controller/DataManager.js' diff --git a/packages/app/src/components/browser/snapshot-styles.ts b/packages/app/src/components/browser/snapshot-styles.ts new file mode 100644 index 00000000..d996ca18 --- /dev/null +++ b/packages/app/src/components/browser/snapshot-styles.ts @@ -0,0 +1,135 @@ +import { css } from 'lit' + +/** Component styles for ``. Pulled out of snapshot.ts + * so the main component file stays focused on the iframe/screencast logic. */ +export const snapshotStyles = css` + :host { + width: 100%; + height: 100%; + display: flex; + padding: 2rem !important; + align-items: center; + justify-content: center; + box-sizing: border-box !important; + } + + section { + box-sizing: border-box; + width: calc(100% - 0px); /* host padding already applied */ + height: calc(100% - 0px); + display: flex; + flex-direction: column; + overflow: hidden; + background: var(--vscode-sideBar-background); + padding: 0.5rem; + gap: 0; + } + + .frame-dot { + border-radius: 50%; + height: 12px; + width: 12px; + margin: 1em 0.25em; + flex-shrink: 0; + } + + .frame-dot:nth-child(1) { + background-color: var(--vscode-notificationsErrorIcon-foreground, #e51400); + } + + .frame-dot:nth-child(2) { + background-color: var( + --vscode-notificationsWarningIcon-foreground, + #bf8803 + ); + } + + .frame-dot:nth-child(3) { + background-color: var(--vscode-ports-iconRunningProcessForeground, #369432); + } + + iframe { + background-color: white; + position: absolute; + top: 0; + left: 0; + border: none; + border-radius: 0 0 0.5rem 0.5rem; + } + + .screenshot-overlay { + position: absolute; + inset: 0; + background: #111; + display: flex; + align-items: flex-start; + justify-content: center; + border-radius: 0 0 0.5rem 0.5rem; + overflow: hidden; + } + + .screenshot-overlay img { + max-width: 100%; + height: auto; + display: block; + } + + .screencast-player { + width: 100%; + height: 100%; + object-fit: contain; + background: #111; + border-radius: 0 0 0.5rem 0.5rem; + display: block; + } + + .iframe-wrapper { + position: relative; + flex: 1; + min-height: 0; + overflow: hidden; + display: flex; + flex-direction: column; + } + + .view-toggle { + display: flex; + gap: 2px; + margin-left: 0.5rem; + flex-shrink: 0; + } + + .view-toggle button { + padding: 2px 10px; + font-size: 11px; + font-family: inherit; + border: 1px solid var(--vscode-editorSuggestWidget-border, #454545); + background: transparent; + color: var(--vscode-input-foreground, #ccc); + cursor: pointer; + border-radius: 3px; + line-height: 20px; + transition: + background 0.1s, + color 0.1s; + } + + .view-toggle button.active { + background: var(--vscode-button-background, #0e639c); + color: var(--vscode-button-foreground, #fff); + border-color: transparent; + } + + .video-select { + font-size: 11px; + font-family: inherit; + padding: 2px 4px; + border: 1px solid var(--vscode-dropdown-border, #454545); + border-radius: 3px; + background: var(--vscode-dropdown-background, #3c3c3c); + color: var(--vscode-dropdown-foreground, #ccc); + cursor: pointer; + line-height: 20px; + margin-left: 4px; + } +` diff --git a/packages/app/src/components/browser/snapshot.ts b/packages/app/src/components/browser/snapshot.ts index 6bce7775..b75dd5f1 100644 --- a/packages/app/src/components/browser/snapshot.ts +++ b/packages/app/src/components/browser/snapshot.ts @@ -1,18 +1,19 @@ import { Element } from '@core/element' -import { html, css, nothing } from 'lit' +import { html, nothing } from 'lit' import { consume } from '@lit/context' +import { snapshotStyles } from './snapshot-styles.js' import { type ComponentChildren, h, render, type VNode } from 'preact' import { customElement, query } from 'lit/decorators.js' import type { SimplifiedVNode } from '../../../../script/types' -import type { CommandLog } from '@wdio/devtools-service/types' +import type { CommandLog } from '@wdio/devtools-shared' import { mutationContext, metadataContext, commandContext } from '../../controller/context.js' -import type { Metadata } from '@wdio/devtools-service/types' +import type { Metadata } from '@wdio/devtools-shared' import '~icons/mdi/world.js' import '../placeholder.js' @@ -77,146 +78,7 @@ export class DevtoolsBrowser extends Element { @consume({ context: commandContext, subscribe: true }) commands: CommandLog[] = [] - static styles = [ - ...Element.styles, - css` - :host { - width: 100%; - height: 100%; - display: flex; - padding: 2rem !important; - align-items: center; - justify-content: center; - box-sizing: border-box !important; - } - - section { - box-sizing: border-box; - width: calc(100% - 0px); /* host padding already applied */ - height: calc(100% - 0px); - display: flex; - flex-direction: column; - overflow: hidden; - background: var(--vscode-sideBar-background); - padding: 0.5rem; - gap: 0; - } - - .frame-dot { - border-radius: 50%; - height: 12px; - width: 12px; - margin: 1em 0.25em; - flex-shrink: 0; - } - - .frame-dot:nth-child(1) { - background-color: var( - --vscode-notificationsErrorIcon-foreground, - #e51400 - ); - } - - .frame-dot:nth-child(2) { - background-color: var( - --vscode-notificationsWarningIcon-foreground, - #bf8803 - ); - } - - .frame-dot:nth-child(3) { - background-color: var( - --vscode-ports-iconRunningProcessForeground, - #369432 - ); - } - - iframe { - background-color: white; - position: absolute; - top: 0; - left: 0; - border: none; - border-radius: 0 0 0.5rem 0.5rem; - } - - .screenshot-overlay { - position: absolute; - inset: 0; - background: #111; - display: flex; - align-items: flex-start; - justify-content: center; - border-radius: 0 0 0.5rem 0.5rem; - overflow: hidden; - } - - .screenshot-overlay img { - max-width: 100%; - height: auto; - display: block; - } - - .screencast-player { - width: 100%; - height: 100%; - object-fit: contain; - background: #111; - border-radius: 0 0 0.5rem 0.5rem; - display: block; - } - - .iframe-wrapper { - position: relative; - flex: 1; - min-height: 0; - overflow: hidden; - display: flex; - flex-direction: column; - } - - .view-toggle { - display: flex; - gap: 2px; - margin-left: 0.5rem; - flex-shrink: 0; - } - - .view-toggle button { - padding: 2px 10px; - font-size: 11px; - font-family: inherit; - border: 1px solid var(--vscode-editorSuggestWidget-border, #454545); - background: transparent; - color: var(--vscode-input-foreground, #ccc); - cursor: pointer; - border-radius: 3px; - line-height: 20px; - transition: - background 0.1s, - color 0.1s; - } - - .view-toggle button.active { - background: var(--vscode-button-background, #0e639c); - color: var(--vscode-button-foreground, #fff); - border-color: transparent; - } - - .video-select { - font-size: 11px; - font-family: inherit; - padding: 2px 4px; - border: 1px solid var(--vscode-dropdown-border, #454545); - border-radius: 3px; - background: var(--vscode-dropdown-background, #3c3c3c); - color: var(--vscode-dropdown-foreground, #ccc); - cursor: pointer; - line-height: 20px; - margin-left: 4px; - } - ` - ] + static styles = [...Element.styles, snapshotStyles] @query('iframe') iframe?: HTMLIFrameElement @@ -258,8 +120,11 @@ export class DevtoolsBrowser extends Element { // viewport may not be serialized yet (race between metadata message and // first resize event), or may arrive without dimensions — fall back to // sensible defaults so we never throw. - const viewportWidth = (metadata.viewport as any)?.width || 1280 - const viewportHeight = (metadata.viewport as any)?.height || 800 + const viewport = metadata.viewport as + | { width?: number; height?: number } + | undefined + const viewportWidth = viewport?.width || 1280 + const viewportHeight = viewport?.height || 800 if (!viewportWidth || !viewportHeight) { return } diff --git a/packages/app/src/components/inputs/traceLoader.ts b/packages/app/src/components/inputs/traceLoader.ts index bdefc5b3..ada25ccf 100644 --- a/packages/app/src/components/inputs/traceLoader.ts +++ b/packages/app/src/components/inputs/traceLoader.ts @@ -1,7 +1,7 @@ import { Element } from '@core/element' import { html } from 'lit' import { customElement, property } from 'lit/decorators.js' -import type { TraceLog } from '@wdio/devtools-service/types' +import type { TraceLog } from '@wdio/devtools-shared' @customElement('wdio-devtools-trace-loader') export class DevtoolsTraceLoader extends Element { diff --git a/packages/app/src/components/sidebar/constants.ts b/packages/app/src/components/sidebar/constants.ts index 97c7d2c0..46e6f67a 100644 --- a/packages/app/src/components/sidebar/constants.ts +++ b/packages/app/src/components/sidebar/constants.ts @@ -1,6 +1,7 @@ import { TestState } from './types.js' +import type { TestStatus } from './types.js' -export const STATE_MAP: Record = { +export const STATE_MAP: Record = { running: TestState.RUNNING, failed: TestState.FAILED, passed: TestState.PASSED, diff --git a/packages/app/src/components/sidebar/explorer.ts b/packages/app/src/components/sidebar/explorer.ts index 08b639be..2c2c02e9 100644 --- a/packages/app/src/components/sidebar/explorer.ts +++ b/packages/app/src/components/sidebar/explorer.ts @@ -2,7 +2,7 @@ import { Element } from '@core/element' import { html, css, nothing, type TemplateResult } from 'lit' import { customElement, property } from 'lit/decorators.js' import { consume } from '@lit/context' -import type { Metadata } from '@wdio/devtools-service/types' +import type { Metadata } from '@wdio/devtools-shared' import { repeat } from 'lit/directives/repeat.js' import { suiteContext, metadataContext } from '../../controller/context.js' import type { @@ -16,12 +16,14 @@ import type { TestRunDetail } from './types.js' import { TestState } from './types.js' +import { DEFAULT_CAPABILITIES, FRAMEWORK_CAPABILITIES } from './constants.js' +import { getTestEntry } from './test-entry-state.js' import { - DEFAULT_CAPABILITIES, - FRAMEWORK_CAPABILITIES, - STATE_MAP -} from './constants.js' -import { BASELINE_API } from '../workbench/compare/constants.js' + BASELINE_API, + TESTS_API, + type BaselinePreserveRequest, + type RunnerRequestBody +} from '@wdio/devtools-shared' import '~icons/mdi/play.js' import '~icons/mdi/stop.js' @@ -127,7 +129,7 @@ export class DevtoolsSidebarExplorer extends CollapseableEntry { ) // Forward preserveBaseline so the backend knows whether to drop baselines. - const payload = { + const payload: RunnerRequestBody = { ...detail, runAll: detail.uid === '*', framework: this.#getFramework(), @@ -137,12 +139,12 @@ export class DevtoolsSidebarExplorer extends CollapseableEntry { launchCommand: this.#getLaunchCommand(), preserveBaseline: detail.preserveBaseline === true } - await this.#postToBackend('/api/tests/run', payload) + await this.#postToBackend(TESTS_API.run, payload) } async #handleTestStop(event: Event) { event.stopPropagation() - await this.#postToBackend('/api/tests/stop', {}) + await this.#postToBackend(TESTS_API.stop, {}) } async #handlePreserveAndRerun(event: Event) { @@ -155,13 +157,14 @@ export class DevtoolsSidebarExplorer extends CollapseableEntry { // Snapshot the current run BEFORE the rerun clears live data. try { + const body: BaselinePreserveRequest = { + testUid: detail.uid, + scope: detail.entryType + } const response = await fetch(BASELINE_API.preserve, { method: 'POST', headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ - testUid: detail.uid, - scope: detail.entryType - }) + body: JSON.stringify(body) }) if (!response.ok) { const errorText = await response.text() @@ -191,7 +194,10 @@ export class DevtoolsSidebarExplorer extends CollapseableEntry { ) } - async #postToBackend(path: string, body: Record) { + async #postToBackend( + path: typeof TESTS_API.run | typeof TESTS_API.stop, + body: RunnerRequestBody | Record + ) { try { const response = await fetch(path, { method: 'POST', @@ -255,7 +261,7 @@ export class DevtoolsSidebarExplorer extends CollapseableEntry { }) ) - void this.#postToBackend('/api/tests/run', { + const payload: RunnerRequestBody = { uid: '*', entryType: 'suite', runAll: true, @@ -263,13 +269,14 @@ export class DevtoolsSidebarExplorer extends CollapseableEntry { configFile: this.#getConfigPath(), rerunCommand: this.#getRerunCommand(), launchCommand: this.#getLaunchCommand() - }) + } + void this.#postToBackend(TESTS_API.run, payload) } #stopActiveRun() { - void this.#postToBackend('/api/tests/stop', { - uid: '*' - }) + // Backend ignores the body for /api/tests/stop — sending {} keeps the + // typed helper happy without changing behavior. + void this.#postToBackend(TESTS_API.stop, {}) } #getFramework(): string | undefined { @@ -403,179 +410,8 @@ export class DevtoolsSidebarExplorer extends CollapseableEntry { ) } - #isRunning(entry: TestStatsFragment | SuiteStatsFragment): boolean { - if ('tests' in entry) { - // Fastest path: any explicitly running descendant - if ( - (entry.tests ?? []).some((t) => t.state === 'running') || - (entry.suites ?? []).some((s) => this.#isRunning(s)) - ) { - return true - } - - const hasPendingTests = (entry.tests ?? []).some( - (t) => t.state === 'pending' - ) - const hasPendingSuites = (entry.suites ?? []).some((s) => - this.#hasPending(s) - ) - const suiteState = entry.state - - // If the suite was explicitly marked 'running' (e.g. by markTestAsRunning) - // and still has pending children, it's actively executing. - if (suiteState === 'running' && (hasPendingTests || hasPendingSuites)) { - return true - } - - // Mixed terminal + pending children = run is in progress regardless of - // explicit suite state (handles Nightwatch Cucumber where the feature - // suite state may be undefined in the JSON payload). - const allDescendants = [...(entry.tests ?? []), ...(entry.suites ?? [])] - const hasSomeTerminal = allDescendants.some( - (t) => - t.state === 'passed' || t.state === 'failed' || t.state === 'skipped' - ) - if ((hasPendingTests || hasPendingSuites) && hasSomeTerminal) { - return true - } - - return false - } - // For individual tests rely on explicit state only. - return entry.state === 'running' - } - - #hasPending(entry: TestStatsFragment | SuiteStatsFragment): boolean { - if ('tests' in entry) { - if (entry.state === 'pending') { - return true - } - if ((entry.tests ?? []).some((t) => t.state === 'pending')) { - return true - } - if ((entry.suites ?? []).some((s) => this.#hasPending(s))) { - return true - } - return false - } - return entry.state === 'pending' - } - - #hasFailed(entry: TestStatsFragment | SuiteStatsFragment): boolean { - if ('tests' in entry) { - // Check if any immediate test failed - if ((entry.tests ?? []).find((t) => t.state === 'failed')) { - return true - } - // Check if any nested suite has failures - if ((entry.suites ?? []).some((s) => this.#hasFailed(s))) { - return true - } - return false - } - // For individual tests - return entry.state === 'failed' - } - - #computeEntryState( - entry: TestStatsFragment | SuiteStatsFragment - ): TestState | 'pending' { - // For suites, check running state from children FIRST — this ensures that - // a rerun (which clears end times) shows the spinner immediately, even if - // the suite still has a cached 'passed'/'failed' state from the previous run. - if ('tests' in entry && this.#isRunning(entry)) { - return TestState.RUNNING - } - - const state = entry.state - - // A suite with an explicit 'pending' state is always in-progress from the - // UI's perspective — the backend uses 'pending' to signal a new run is - // starting. Skip the children check: stale terminal children from the - // previous run must not cause the suite to appear as passed. - if ('tests' in entry && state === 'pending') { - return TestState.RUNNING - } - - // For suites with no explicit terminal state, derive from children. - // A suite with state=undefined or state=running that has no terminal - // children yet is still in-progress — don't show PASSED prematurely. - if ('tests' in entry && (state === null || state === 'running')) { - const allDescendants = [...(entry.tests ?? []), ...(entry.suites ?? [])] - if (allDescendants.length > 0) { - const allTerminal = allDescendants.every( - (t) => - t.state === 'passed' || - t.state === 'failed' || - t.state === 'skipped' - ) - if (!allTerminal) { - // Still has non-terminal children — treat as running/loading - return TestState.RUNNING - } - } - } - - // Check explicit terminal state - const mappedState = state ? STATE_MAP[state] : undefined - if (mappedState) { - return mappedState - } - - // For suites, compute state from children - if ('tests' in entry) { - if (this.#hasFailed(entry)) { - return TestState.FAILED - } - return TestState.PASSED - } - - // For individual leaf tests: pending = spinner (run is in progress), - // not circle (which implies "never run"). - if (state === 'pending') { - return TestState.RUNNING - } - - return entry.end ? TestState.PASSED : 'pending' - } - #getTestEntry(entry: TestStatsFragment | SuiteStatsFragment): TestEntry { - if ('tests' in entry) { - const entries = [...(entry.tests ?? []), ...(entry.suites ?? [])] - // A suite whose children are themselves suites is a feature/file-level - // container (Cucumber feature or test file). Tag it as 'feature' so the - // backend runner can distinguish it from a scenario/spec-level suite and - // avoid applying a --name filter that would match no scenarios. - const hasChildSuites = entry.suites && entry.suites.length > 0 - const derivedType = hasChildSuites ? 'feature' : entry.type || 'suite' - return { - uid: entry.uid, - label: entry.title ?? '', - type: 'suite', - state: this.#computeEntryState(entry), - callSource: entry.callSource, - specFile: entry.file, - fullTitle: entry.title ?? '', - featureFile: entry.featureFile, - featureLine: entry.featureLine, - suiteType: derivedType, - children: Object.values(entries) - .map(this.#getTestEntry.bind(this)) - .filter(this.#filterEntry.bind(this)) - } - } - return { - uid: entry.uid, - label: entry.title ?? '', - type: 'test', - state: this.#computeEntryState(entry), - callSource: entry.callSource, - specFile: entry.file, - fullTitle: entry.fullTitle || entry.title, - featureFile: entry.featureFile, - featureLine: entry.featureLine, - children: [] - } + return getTestEntry(entry, this.#filterEntry.bind(this)) } render() { @@ -666,5 +502,5 @@ function getSearchableLabel(entry: TestEntry): string[] { if (entry.children.length === 0) { return [entry.label] } - return entry.children.map(getSearchableLabel) as any as string[] + return entry.children.flatMap(getSearchableLabel) } diff --git a/packages/app/src/components/sidebar/test-entry-state.ts b/packages/app/src/components/sidebar/test-entry-state.ts new file mode 100644 index 00000000..af7b6112 --- /dev/null +++ b/packages/app/src/components/sidebar/test-entry-state.ts @@ -0,0 +1,174 @@ +import type { + SuiteStatsFragment, + TestStatsFragment +} from '../../controller/types.js' +import { STATE_MAP } from './constants.js' +import { TestState } from './types.js' +import type { TestEntry, TestStatus } from './types.js' + +type Fragment = TestStatsFragment | SuiteStatsFragment + +/** A suite is "running" when there are pending children + at least one + * terminal child, or when the suite itself is marked running with pending + * children. Tests fall through to their explicit state. */ +export function isRunning(entry: Fragment): boolean { + if ('tests' in entry) { + if ( + (entry.tests ?? []).some((t) => t.state === 'running') || + (entry.suites ?? []).some((s) => isRunning(s)) + ) { + return true + } + + const hasPendingTests = (entry.tests ?? []).some( + (t) => t.state === 'pending' + ) + const hasPendingSuites = (entry.suites ?? []).some((s) => hasPending(s)) + const suiteState = entry.state + + if (suiteState === 'running' && (hasPendingTests || hasPendingSuites)) { + return true + } + + // Mixed terminal + pending = run in progress regardless of explicit suite + // state (Nightwatch-Cucumber leaves feature.state undefined in the JSON). + const allDescendants = [...(entry.tests ?? []), ...(entry.suites ?? [])] + const hasSomeTerminal = allDescendants.some( + (t) => + t.state === 'passed' || t.state === 'failed' || t.state === 'skipped' + ) + if ((hasPendingTests || hasPendingSuites) && hasSomeTerminal) { + return true + } + return false + } + return entry.state === 'running' +} + +export function hasPending(entry: Fragment): boolean { + if ('tests' in entry) { + if (entry.state === 'pending') { + return true + } + if ((entry.tests ?? []).some((t) => t.state === 'pending')) { + return true + } + if ((entry.suites ?? []).some((s) => hasPending(s))) { + return true + } + return false + } + return entry.state === 'pending' +} + +export function hasFailed(entry: Fragment): boolean { + if ('tests' in entry) { + if ((entry.tests ?? []).find((t) => t.state === 'failed')) { + return true + } + if ((entry.suites ?? []).some((s) => hasFailed(s))) { + return true + } + return false + } + return entry.state === 'failed' +} + +export function computeEntryState(entry: Fragment): TestStatus { + // Suites: check running from children FIRST. A rerun clears end times but + // not stale 'passed'/'failed' state — show the spinner before falling + // through to the cached terminal value. + if ('tests' in entry && isRunning(entry)) { + return TestState.RUNNING + } + + const state = entry.state + + // 'pending' on a suite = backend signaling a new run starting. Skip + // children check; stale terminal children must not flip suite to passed. + if ('tests' in entry && state === 'pending') { + return TestState.RUNNING + } + + // Suite with no explicit terminal state — derive from children. If any + // child is non-terminal, the run is still in progress. + if ('tests' in entry && (state === null || state === 'running')) { + const allDescendants = [...(entry.tests ?? []), ...(entry.suites ?? [])] + if (allDescendants.length > 0) { + const allTerminal = allDescendants.every( + (t) => + t.state === 'passed' || t.state === 'failed' || t.state === 'skipped' + ) + if (!allTerminal) { + return TestState.RUNNING + } + } + } + + const mappedState = state ? STATE_MAP[state] : undefined + if (mappedState) { + return mappedState + } + + if ('tests' in entry) { + if (hasFailed(entry)) { + return TestState.FAILED + } + return TestState.PASSED + } + + // Leaf test: pending → spinner (run is in progress), NOT circle (which + // would imply "never run"). + if (state === 'pending') { + return TestState.RUNNING + } + return entry.end ? TestState.PASSED : 'pending' +} + +/** + * Map a raw suite/test fragment to the sidebar's `TestEntry` shape. + * `filterEntry` is passed in because it depends on component-level filter + * state — the sidebar holds the active filter and decides which children + * stay visible. + */ +export function getTestEntry( + entry: Fragment, + filterEntry: (entry: TestEntry) => boolean +): TestEntry { + if ('tests' in entry) { + const entries = [...(entry.tests ?? []), ...(entry.suites ?? [])] + // A suite whose children are themselves suites is a feature/file-level + // container (Cucumber feature or test file). Tag it as 'feature' so the + // backend runner can distinguish it from a scenario/spec-level suite and + // avoid applying a --name filter that would match no scenarios. + const hasChildSuites = entry.suites && entry.suites.length > 0 + const derivedType = hasChildSuites ? 'feature' : entry.type || 'suite' + return { + uid: entry.uid, + label: entry.title ?? '', + type: 'suite', + state: computeEntryState(entry), + callSource: entry.callSource, + specFile: entry.file, + fullTitle: entry.title ?? '', + featureFile: entry.featureFile, + featureLine: entry.featureLine, + suiteType: derivedType, + children: Object.values(entries) + .map((e) => getTestEntry(e, filterEntry)) + .filter(filterEntry) + } + } + return { + uid: entry.uid, + label: entry.title ?? '', + type: 'test', + state: computeEntryState(entry), + callSource: entry.callSource, + specFile: entry.file, + fullTitle: entry.fullTitle || entry.title, + featureFile: entry.featureFile, + featureLine: entry.featureLine, + children: [] + } +} diff --git a/packages/app/src/components/sidebar/test-suite.ts b/packages/app/src/components/sidebar/test-suite.ts index ed237955..67424b53 100644 --- a/packages/app/src/components/sidebar/test-suite.ts +++ b/packages/app/src/components/sidebar/test-suite.ts @@ -3,7 +3,7 @@ import { html, css, nothing } from 'lit' import { customElement, property } from 'lit/decorators.js' import { CollapseableEntry } from './collapseableEntry.js' -import type { TestRunDetail } from './types.js' +import type { TestRunDetail, TestStatus } from './types.js' import { TestState } from './types.js' import '~icons/mdi/chevron-right.js' @@ -49,7 +49,7 @@ export class ExplorerTestEntry extends CollapseableEntry { uid?: string @property({ type: String }) - state?: TestState + state?: TestStatus @property({ type: String, attribute: 'call-source' }) callSource?: string diff --git a/packages/app/src/components/sidebar/types.ts b/packages/app/src/components/sidebar/types.ts index b1168590..cf72e77d 100644 --- a/packages/app/src/components/sidebar/types.ts +++ b/packages/app/src/components/sidebar/types.ts @@ -41,9 +41,19 @@ export interface TestRunDetail { preserveBaseline?: boolean } -export enum TestState { - PASSED = 'passed', - FAILED = 'failed', - RUNNING = 'running', - SKIPPED = 'skipped' -} +import type { TestStatus } from '@wdio/devtools-shared' + +/** + * Enum-style accessor for the canonical TestStatus values. Use the + * shared TestStatus type for type annotations; this object is for + * readable value comparisons (`state === TestState.PASSED`). + */ +export const TestState = { + PASSED: 'passed', + FAILED: 'failed', + RUNNING: 'running', + SKIPPED: 'skipped', + PENDING: 'pending' +} as const satisfies Record + +export type { TestStatus } from '@wdio/devtools-shared' diff --git a/packages/app/src/components/tabs.ts b/packages/app/src/components/tabs.ts index 90d94204..2762a327 100644 --- a/packages/app/src/components/tabs.ts +++ b/packages/app/src/components/tabs.ts @@ -31,7 +31,7 @@ export class DevtoolsTabs extends Element { const tabElement = this.tabs.find( (el) => el.getAttribute('label') === tabId ) - const badge = (tabElement as any)?.badge + const badge = (tabElement as { badge?: number } | undefined)?.badge const showBadge = badge && badge > 0 return html` diff --git a/packages/app/src/components/workbench.ts b/packages/app/src/components/workbench.ts index 7740083b..ac2f72d8 100644 --- a/packages/app/src/components/workbench.ts +++ b/packages/app/src/components/workbench.ts @@ -9,7 +9,7 @@ import { networkRequestContext, baselineContext } from '../controller/context.js' -import type { PreservedAttempt } from '@wdio/devtools-service/types' +import type { PreservedAttempt } from '@wdio/devtools-shared' import '~icons/mdi/arrow-collapse-down.js' import '~icons/mdi/arrow-collapse-up.js' diff --git a/packages/app/src/components/workbench/actionItems/command.ts b/packages/app/src/components/workbench/actionItems/command.ts index 3b1de55e..9723c606 100644 --- a/packages/app/src/components/workbench/actionItems/command.ts +++ b/packages/app/src/components/workbench/actionItems/command.ts @@ -1,7 +1,7 @@ import { html } from 'lit' import { customElement, property } from 'lit/decorators.js' -import type { CommandLog } from '@wdio/devtools-service/types' +import type { CommandLog } from '@wdio/devtools-shared' import { ActionItem, ICON_CLASS } from './item.js' import '~icons/mdi/arrow-right.js' diff --git a/packages/app/src/components/workbench/actionItems/item.ts b/packages/app/src/components/workbench/actionItems/item.ts index f778cb13..fb8628cd 100644 --- a/packages/app/src/components/workbench/actionItems/item.ts +++ b/packages/app/src/components/workbench/actionItems/item.ts @@ -1,7 +1,7 @@ import { Element } from '@core/element' import { html, css } from 'lit' import { property } from 'lit/decorators.js' -import type { CommandLog } from '@wdio/devtools-service/types' +import type { CommandLog } from '@wdio/devtools-shared' export type ActionEntry = TraceMutation | CommandLog diff --git a/packages/app/src/components/workbench/actions.ts b/packages/app/src/components/workbench/actions.ts index 7af6ca89..dc55c016 100644 --- a/packages/app/src/components/workbench/actions.ts +++ b/packages/app/src/components/workbench/actions.ts @@ -3,7 +3,7 @@ import { html, css } from 'lit' import { customElement } from 'lit/decorators.js' import { consume } from '@lit/context' -import type { CommandLog } from '@wdio/devtools-service/types' +import type { CommandLog } from '@wdio/devtools-shared' import { mutationContext, commandContext } from '../../controller/context.js' import '../placeholder.js' diff --git a/packages/app/src/components/workbench/compare.ts b/packages/app/src/components/workbench/compare.ts index ea5c877e..47b2b0fc 100644 --- a/packages/app/src/components/workbench/compare.ts +++ b/packages/app/src/components/workbench/compare.ts @@ -1,5 +1,5 @@ import { Element } from '@core/element' -import { html, css, nothing } from 'lit' +import { html, nothing } from 'lit' import { customElement, state } from 'lit/decorators.js' import { consume } from '@lit/context' @@ -9,7 +9,7 @@ import type { CommandLog, PreservedAttempt, PreservedStep -} from '@wdio/devtools-service/types' +} from '@wdio/devtools-shared' import { baselineContext, selectedTestUidContext, @@ -23,225 +23,25 @@ import { pairSteps, classifyDivergence, cleanErrorMessage, - extractExpectedFromStepText, safeJson, type ComparePairedStep, type DivergenceKind } from './compare/compareUtils.js' +import { BASELINE_API, type BaselineClearRequest } from '@wdio/devtools-shared' +import { POPOUT_QUERY, buildPopoutFeatures } from './compare/constants.js' +import { compareStyles } from './compare/styles.js' import { - BASELINE_API, - POPOUT_QUERY, - buildPopoutFeatures -} from './compare/constants.js' + liveStepsForUid, + findStepFor, + isFailureSite, + computeDetailBlockData +} from './compare/stepResolution.js' const COMPONENT = 'wdio-devtools-compare' @customElement(COMPONENT) export class DevtoolsCompare extends Element { - static styles = [ - ...Element.styles, - css` - :host { - display: flex; - flex-direction: column; - width: 100%; - height: 100%; - min-height: 0; - overflow: hidden; - /* Needed so popout mode (where Compare sits directly under body) is themed. */ - background-color: var(--vscode-editor-background, #1e1e1e); - color: var(--vscode-foreground, #cccccc); - } - .compare-grid { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 0; - flex: 1 1 auto; - min-height: 0; - overflow: auto; - /* Stack rows from the top so they don't stretch to fill the grid. */ - align-content: start; - grid-auto-rows: min-content; - } - .step-row { - display: contents; - } - .step-cell { - padding: 0.25rem 0.5rem; - border-bottom: 1px solid var(--vscode-panel-border, #2a2a2a); - font-family: var(--vscode-editor-font-family, monospace); - font-size: 0.85em; - cursor: pointer; - } - .step-cell.divergent { - background: rgba(255, 90, 90, 0.08); - } - .step-cell.divergent.first { - background: rgba(255, 90, 90, 0.18); - border-left: 3px solid var(--vscode-charts-red, #f48771); - } - .marker { - margin-left: 0.35rem; - font-size: 0.85em; - } - .marker.result { - color: var(--vscode-charts-orange, #d19a66); - } - .marker.error { - color: var(--vscode-charts-red, #f48771); - } - .marker.command { - color: var(--vscode-charts-red, #f48771); - } - .marker.ok { - color: var(--vscode-charts-green, #73c373); - } - .marker.info { - color: var(--vscode-descriptionForeground, #999); - opacity: 0.7; - } - .error-banner { - margin: 0.5rem 0.75rem; - padding: 0.5rem 0.75rem; - background: rgba(244, 135, 113, 0.12); - border-left: 3px solid var(--vscode-charts-red, #f48771); - border-radius: 3px; - font-size: 0.85em; - } - .error-banner-title { - font-weight: 600; - margin-bottom: 0.25rem; - opacity: 0.85; - font-family: inherit; - } - /* Pre-wrap only on the message body so template indentation doesn't render. */ - .error-banner-message { - font-family: var(--vscode-editor-font-family, monospace); - white-space: pre-wrap; - word-break: break-word; - margin: 0; - } - .step-cell.missing { - opacity: 0.35; - font-style: italic; - } - .step-cell:hover { - background: var( - --vscode-toolbar-hoverBackground, - rgba(255, 255, 255, 0.06) - ); - } - .step-cell.expanded { - background: rgba(80, 160, 255, 0.06); - } - .pill { - display: inline-flex; - align-items: center; - gap: 0.25rem; - padding: 0.1rem 0.5rem; - border-radius: 4px; - font-size: 0.85em; - background: var(--vscode-badge-background, #2a2a2a); - } - .pill.failed { - background: rgba(244, 135, 113, 0.2); - color: var(--vscode-charts-red, #f48771); - } - .pill.passed { - background: rgba(115, 195, 115, 0.2); - color: var(--vscode-charts-green, #73c373); - } - .topbar { - display: flex; - align-items: center; - gap: 0.5rem; - padding: 0.5rem 0.75rem; - border-bottom: 1px solid var(--vscode-panel-border, #2a2a2a); - flex: 0 0 auto; - } - .col-header { - position: sticky; - top: 0; - background: var(--vscode-editor-background, #1e1e1e); - z-index: 1; - padding: 0.5rem; - font-weight: 600; - font-size: 0.85em; - border-bottom: 1px solid var(--vscode-panel-border, #2a2a2a); - } - .detail-panel { - grid-column: span 2; - background: var(--vscode-editor-background, #1e1e1e); - border-bottom: 1px solid var(--vscode-panel-border, #2a2a2a); - padding: 0.5rem; - } - .detail-grid { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 0.75rem; - } - .detail-block { - font-size: 0.85em; - } - .detail-block h4 { - font-size: 0.85em; - margin: 0 0 0.25rem; - opacity: 0.7; - font-weight: 600; - } - .detail-block pre { - margin: 0; - white-space: pre-wrap; - word-break: break-word; - font-size: 0.85em; - background: rgba(255, 255, 255, 0.03); - padding: 0.25rem 0.4rem; - border-radius: 3px; - } - .empty-state { - flex: 1; - display: flex; - align-items: center; - justify-content: center; - color: var(--vscode-descriptionForeground, #888); - font-size: 0.9em; - text-align: center; - padding: 1rem; - } - .toggle-label { - display: inline-flex; - align-items: center; - gap: 0.35rem; - cursor: pointer; - font-size: 0.85em; - } - button.action { - background: transparent; - border: 1px solid var(--vscode-panel-border, #2a2a2a); - color: inherit; - padding: 0.2rem 0.5rem; - border-radius: 3px; - cursor: pointer; - font-size: 0.85em; - } - button.action:hover { - background: var( - --vscode-toolbar-hoverBackground, - rgba(255, 255, 255, 0.06) - ); - } - button.action.icon-only { - display: inline-flex; - align-items: center; - justify-content: center; - padding: 0.25rem 0.4rem; - } - button.action.icon-only svg { - width: 1em; - height: 1em; - } - ` - ] + static styles = [...Element.styles, compareStyles] @consume({ context: baselineContext, subscribe: true }) @state() @@ -309,125 +109,21 @@ export class DevtoolsCompare extends Element { /** Walk live suiteContext under selectedTestUid and collect leaf tests * so live commands can be attributed to their parent step. */ #liveStepsForSelectedUid(): PreservedStep[] { - const target = this.selectedTestUid - if (!target || !this.liveSuites) { - return [] - } - const out: PreservedStep[] = [] - let foundRoot: SuiteStatsFragment | undefined - const findRoot = ( - s: SuiteStatsFragment | undefined - ): SuiteStatsFragment | undefined => { - if (!s) { - return undefined - } - if (s.uid === target) { - return s - } - for (const child of s.suites ?? []) { - const hit = findRoot(child) - if (hit) { - return hit - } - } - return undefined - } - for (const chunk of this.liveSuites) { - for (const root of Object.values(chunk)) { - foundRoot = findRoot(root) - if (foundRoot) { - break - } - } - if (foundRoot) { - break - } - } - if (!foundRoot) { - return [] - } - const visit = (s: SuiteStatsFragment) => { - for (const t of s.tests ?? []) { - out.push({ - uid: t.uid, - title: t.title, - fullTitle: t.fullTitle, - start: t.start ? new Date(t.start).getTime() : undefined, - end: t.end ? new Date(t.end).getTime() : undefined, - state: - t.state === 'pending' || t.state === 'running' ? t.state : t.state, - error: t.error - ? { - message: t.error.message, - name: t.error.name, - stack: t.error.stack - } - : undefined - }) - } - for (const child of s.suites ?? []) { - visit(child) - } - } - visit(foundRoot) - return out + return liveStepsForUid(this.selectedTestUid, this.liveSuites) } #findStepFor( cmd: CommandLog | undefined, side: 'baseline' | 'latest' ): PreservedStep | undefined { - if (!cmd?.timestamp) { - return undefined - } - const steps = - side === 'baseline' - ? (this.#getBaseline()?.steps ?? []) - : this.#liveStepsForSelectedUid() - const ts = cmd.timestamp - return steps.find( - (s) => - s.start !== null && - s.start !== undefined && - s.end !== null && - s.end !== undefined && - ts >= s.start && - ts <= s.end + return findStepFor( + cmd, + side, + this.#getBaseline(), + this.#liveStepsForSelectedUid() ) } - /** The failure site is either the command that errored at the WebDriver - * level OR the last command in a failed step (assertion site). */ - #isFailureSite( - cmd: CommandLog, - step: PreservedStep | undefined, - allCommandsOnSide: CommandLog[] - ): boolean { - if (!step || step.state !== 'failed') { - return false - } - if (cmd.error?.message) { - return true - } - if (step.start === null || step.end === null) { - return false - } - let lastTs = 0 - for (const c of allCommandsOnSide) { - if ( - c.timestamp !== null && - step.start !== undefined && - step.end !== undefined && - c.timestamp >= step.start && - c.timestamp <= step.end && - c.timestamp > lastTs - ) { - lastTs = c.timestamp - } - } - return cmd.timestamp === lastTs - } - /** Scope the global live command stream to commands within the selected * test's step time windows (mirrors the backend's snapshot filter). */ #liveCommandsForSelectedUid(): CommandLog[] { @@ -460,10 +156,11 @@ export class DevtoolsCompare extends Element { return } try { + const body: BaselineClearRequest = { testUid: this.selectedTestUid } await fetch(BASELINE_API.clear, { method: 'POST', headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ testUid: this.selectedTestUid }) + body: JSON.stringify(body) }) } catch { // best-effort; the server broadcast updates the context. @@ -641,8 +338,7 @@ export class DevtoolsCompare extends Element { ? ((this.#getBaseline()?.commands ?? []) as CommandLog[]) : this.#liveCommandsForSelectedUid() const statusMarker = - step?.state === 'failed' && - this.#isFailureSite(cmd, step, allCmdsThisSide) + step?.state === 'failed' && isFailureSite(cmd, step, allCmdsThisSide) ? html`No command at this step ` } - const argsStr = safeJson(cmd.args) - const resultStr = safeJson(cmd.result) - const step = this.#findStepFor(cmd, side) // Only the failure-site command shows step-level expected/actual/assertion; // other commands in the failed step succeeded individually. const allCmdsThisSide = side === 'baseline' ? ((this.#getBaseline()?.commands ?? []) as CommandLog[]) : this.#liveCommandsForSelectedUid() - const isFailureSite = this.#isFailureSite(cmd, step, allCmdsThisSide) - const expected = - isFailureSite && step?.error?.expected !== undefined - ? step.error.expected - : isFailureSite - ? step?.error?.matcherResult?.expected - : undefined - const actual = - isFailureSite && step?.error?.actual !== undefined - ? step.error.actual - : isFailureSite - ? step?.error?.matcherResult?.actual - : undefined - const rawAssertion = isFailureSite - ? step?.error?.matcherResult?.message || step?.error?.message - : undefined - const assertionMessage = rawAssertion - ? cleanErrorMessage(rawAssertion) - : undefined - // Fallback: extract the expected from the Cucumber step text. - const stepText = step?.fullTitle || step?.title || '' - const fallbackExpected = - isFailureSite && expected === undefined && step?.state === 'failed' - ? extractExpectedFromStepText(stepText) - : undefined + const { + argsStr, + resultStr, + step, + expected, + actual, + assertionMessage, + fallbackExpected, + stepText + } = computeDetailBlockData( + cmd, + this.#findStepFor(cmd, side), + allCmdsThisSide + ) return html`

${label} · ${cmd.command}

diff --git a/packages/app/src/components/workbench/compare/compareUtils.ts b/packages/app/src/components/workbench/compare/compareUtils.ts index a4d3d1b2..a1dc34fd 100644 --- a/packages/app/src/components/workbench/compare/compareUtils.ts +++ b/packages/app/src/components/workbench/compare/compareUtils.ts @@ -1,4 +1,4 @@ -import type { CommandLog } from '@wdio/devtools-service/types' +import type { CommandLog } from '@wdio/devtools-shared' export interface ComparePairedStep { index: number diff --git a/packages/app/src/components/workbench/compare/constants.ts b/packages/app/src/components/workbench/compare/constants.ts index 7594824b..b6dbb6b2 100644 --- a/packages/app/src/components/workbench/compare/constants.ts +++ b/packages/app/src/components/workbench/compare/constants.ts @@ -1,13 +1,3 @@ -export const BASELINE_API = { - preserve: '/api/baseline/preserve', - clear: '/api/baseline/clear' -} as const - -export const BASELINE_WS_SCOPE = { - saved: 'baseline:saved', - cleared: 'baseline:cleared' -} as const - export const POPOUT_QUERY = { viewKey: 'view', viewValue: 'compare', diff --git a/packages/app/src/components/workbench/compare/stepResolution.ts b/packages/app/src/components/workbench/compare/stepResolution.ts new file mode 100644 index 00000000..c7bf7ff1 --- /dev/null +++ b/packages/app/src/components/workbench/compare/stepResolution.ts @@ -0,0 +1,210 @@ +import type { + CommandLog, + PreservedAttempt, + PreservedStep +} from '@wdio/devtools-shared' +import type { SuiteStatsFragment } from '../../../controller/types.js' +import { + cleanErrorMessage, + extractExpectedFromStepText, + safeJson +} from './compareUtils.js' + +/** + * Walk the live suite tree to find the subtree rooted at `selectedTestUid` + * and flatten its test entries into `PreservedStep[]` so the compare panel + * can treat live and baseline data uniformly. + * + * Returns `[]` when the selected UID isn't found in any chunk (e.g. when the + * user navigated to a stale UID that's no longer in the dashboard tree). + */ +export function liveStepsForUid( + selectedTestUid: string | undefined, + liveSuites: Array> | undefined +): PreservedStep[] { + if (!selectedTestUid || !liveSuites) { + return [] + } + let foundRoot: SuiteStatsFragment | undefined + const findRoot = ( + s: SuiteStatsFragment | undefined + ): SuiteStatsFragment | undefined => { + if (!s) { + return undefined + } + if (s.uid === selectedTestUid) { + return s + } + for (const child of s.suites ?? []) { + const hit = findRoot(child) + if (hit) { + return hit + } + } + return undefined + } + for (const chunk of liveSuites) { + for (const root of Object.values(chunk)) { + foundRoot = findRoot(root) + if (foundRoot) { + break + } + } + if (foundRoot) { + break + } + } + if (!foundRoot) { + return [] + } + const out: PreservedStep[] = [] + const visit = (s: SuiteStatsFragment) => { + for (const t of s.tests ?? []) { + out.push({ + uid: t.uid, + title: t.title, + fullTitle: t.fullTitle, + start: t.start ? new Date(t.start).getTime() : undefined, + end: t.end ? new Date(t.end).getTime() : undefined, + state: + t.state === 'pending' || t.state === 'running' ? t.state : t.state, + error: t.error + ? { + message: t.error.message, + name: t.error.name, + stack: t.error.stack + } + : undefined + }) + } + for (const child of s.suites ?? []) { + visit(child) + } + } + visit(foundRoot) + return out +} + +/** + * Find which preserved step a command belongs to, by timestamp containment. + * The `side` selects whether to search the baseline's preserved steps or the + * live (selected-uid) steps. + */ +export function findStepFor( + cmd: CommandLog | undefined, + side: 'baseline' | 'latest', + baseline: PreservedAttempt | undefined, + liveSteps: PreservedStep[] +): PreservedStep | undefined { + if (!cmd?.timestamp) { + return undefined + } + const steps = side === 'baseline' ? (baseline?.steps ?? []) : liveSteps + const ts = cmd.timestamp + return steps.find( + (s) => + s.start !== null && + s.start !== undefined && + s.end !== null && + s.end !== undefined && + ts >= s.start && + ts <= s.end + ) +} + +/** + * Pre-computed data for one side of a detail-block render. Pulling this out + * of compare.ts's `#renderDetailBlock` lets the template stay focused on + * markup and lets the computation be tested in isolation. + */ +export interface DetailBlockData { + argsStr: string + resultStr: string + step: PreservedStep | undefined + atFailureSite: boolean + expected: unknown + actual: unknown + assertionMessage: string | undefined + fallbackExpected: string | undefined + stepText: string +} + +export function computeDetailBlockData( + cmd: CommandLog, + step: PreservedStep | undefined, + allCommandsOnSide: CommandLog[] +): DetailBlockData { + const atFailureSite = isFailureSite(cmd, step, allCommandsOnSide) + const expected = + atFailureSite && step?.error?.expected !== undefined + ? step.error.expected + : atFailureSite + ? step?.error?.matcherResult?.expected + : undefined + const actual = + atFailureSite && step?.error?.actual !== undefined + ? step.error.actual + : atFailureSite + ? step?.error?.matcherResult?.actual + : undefined + const rawAssertion = atFailureSite + ? step?.error?.matcherResult?.message || step?.error?.message + : undefined + const assertionMessage = rawAssertion + ? cleanErrorMessage(rawAssertion) + : undefined + const stepText = step?.fullTitle || step?.title || '' + // Fallback: extract the expected from the Cucumber step text when the + // assertion library didn't surface a structured expected value. + const fallbackExpected = + atFailureSite && expected === undefined && step?.state === 'failed' + ? extractExpectedFromStepText(stepText) + : undefined + + return { + argsStr: safeJson(cmd.args), + resultStr: safeJson(cmd.result), + step, + atFailureSite, + expected, + actual, + assertionMessage, + fallbackExpected, + stepText + } +} + +/** + * Identify the "failure site" of a failed step — either the command whose own + * `error` is set (the WebDriver-level failure) OR the last command before the + * step's end time (the assertion site, where the matcher threw). + */ +export function isFailureSite( + cmd: CommandLog, + step: PreservedStep | undefined, + allCommandsOnSide: CommandLog[] +): boolean { + if (!step || step.state !== 'failed') { + return false + } + if (cmd.error?.message) { + return true + } + if (step.start === null || step.end === null) { + return false + } + let lastTs = 0 + for (const c of allCommandsOnSide) { + if ( + c.timestamp !== null && + step.start !== undefined && + step.end !== undefined && + c.timestamp >= step.start && + c.timestamp <= step.end && + c.timestamp > lastTs + ) { + lastTs = c.timestamp + } + } + return cmd.timestamp === lastTs +} diff --git a/packages/app/src/components/workbench/compare/styles.ts b/packages/app/src/components/workbench/compare/styles.ts new file mode 100644 index 00000000..3b8cadc8 --- /dev/null +++ b/packages/app/src/components/workbench/compare/styles.ts @@ -0,0 +1,205 @@ +import { css } from 'lit' + +/** Component styles for ``. Pulled out of compare.ts + * so the main component file stays focused on data and render logic. */ +export const compareStyles = css` + :host { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + min-height: 0; + overflow: hidden; + /* Needed so popout mode (where Compare sits directly under body) is themed. */ + background-color: var(--vscode-editor-background, #1e1e1e); + color: var(--vscode-foreground, #cccccc); + } + .compare-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0; + flex: 1 1 auto; + min-height: 0; + overflow: auto; + /* Stack rows from the top so they don't stretch to fill the grid. */ + align-content: start; + grid-auto-rows: min-content; + } + .step-row { + display: contents; + } + .step-cell { + padding: 0.25rem 0.5rem; + border-bottom: 1px solid var(--vscode-panel-border, #2a2a2a); + font-family: var(--vscode-editor-font-family, monospace); + font-size: 0.85em; + cursor: pointer; + } + .step-cell.divergent { + background: rgba(255, 90, 90, 0.08); + } + .step-cell.divergent.first { + background: rgba(255, 90, 90, 0.18); + border-left: 3px solid var(--vscode-charts-red, #f48771); + } + .marker { + margin-left: 0.35rem; + font-size: 0.85em; + } + .marker.result { + color: var(--vscode-charts-orange, #d19a66); + } + .marker.error { + color: var(--vscode-charts-red, #f48771); + } + .marker.command { + color: var(--vscode-charts-red, #f48771); + } + .marker.ok { + color: var(--vscode-charts-green, #73c373); + } + .marker.info { + color: var(--vscode-descriptionForeground, #999); + opacity: 0.7; + } + .error-banner { + margin: 0.5rem 0.75rem; + padding: 0.5rem 0.75rem; + background: rgba(244, 135, 113, 0.12); + border-left: 3px solid var(--vscode-charts-red, #f48771); + border-radius: 3px; + font-size: 0.85em; + } + .error-banner-title { + font-weight: 600; + margin-bottom: 0.25rem; + opacity: 0.85; + font-family: inherit; + } + /* Pre-wrap only on the message body so template indentation doesn't render. */ + .error-banner-message { + font-family: var(--vscode-editor-font-family, monospace); + white-space: pre-wrap; + word-break: break-word; + margin: 0; + } + .step-cell.missing { + opacity: 0.35; + font-style: italic; + } + .step-cell:hover { + background: var( + --vscode-toolbar-hoverBackground, + rgba(255, 255, 255, 0.06) + ); + } + .step-cell.expanded { + background: rgba(80, 160, 255, 0.06); + } + .pill { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.1rem 0.5rem; + border-radius: 4px; + font-size: 0.85em; + background: var(--vscode-badge-background, #2a2a2a); + } + .pill.failed { + background: rgba(244, 135, 113, 0.2); + color: var(--vscode-charts-red, #f48771); + } + .pill.passed { + background: rgba(115, 195, 115, 0.2); + color: var(--vscode-charts-green, #73c373); + } + .topbar { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + border-bottom: 1px solid var(--vscode-panel-border, #2a2a2a); + flex: 0 0 auto; + } + .col-header { + position: sticky; + top: 0; + background: var(--vscode-editor-background, #1e1e1e); + z-index: 1; + padding: 0.5rem; + font-weight: 600; + font-size: 0.85em; + border-bottom: 1px solid var(--vscode-panel-border, #2a2a2a); + } + .detail-panel { + grid-column: span 2; + background: var(--vscode-editor-background, #1e1e1e); + border-bottom: 1px solid var(--vscode-panel-border, #2a2a2a); + padding: 0.5rem; + } + .detail-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.75rem; + } + .detail-block { + font-size: 0.85em; + } + .detail-block h4 { + font-size: 0.85em; + margin: 0 0 0.25rem; + opacity: 0.7; + font-weight: 600; + } + .detail-block pre { + margin: 0; + white-space: pre-wrap; + word-break: break-word; + font-size: 0.85em; + background: rgba(255, 255, 255, 0.03); + padding: 0.25rem 0.4rem; + border-radius: 3px; + } + .empty-state { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + color: var(--vscode-descriptionForeground, #888); + font-size: 0.9em; + text-align: center; + padding: 1rem; + } + .toggle-label { + display: inline-flex; + align-items: center; + gap: 0.35rem; + cursor: pointer; + font-size: 0.85em; + } + button.action { + background: transparent; + border: 1px solid var(--vscode-panel-border, #2a2a2a); + color: inherit; + padding: 0.2rem 0.5rem; + border-radius: 3px; + cursor: pointer; + font-size: 0.85em; + } + button.action:hover { + background: var( + --vscode-toolbar-hoverBackground, + rgba(255, 255, 255, 0.06) + ); + } + button.action.icon-only { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.25rem 0.4rem; + } + button.action.icon-only svg { + width: 1em; + height: 1em; + } +` diff --git a/packages/app/src/components/workbench/list.ts b/packages/app/src/components/workbench/list.ts index 54825f95..f5c45c70 100644 --- a/packages/app/src/components/workbench/list.ts +++ b/packages/app/src/components/workbench/list.ts @@ -122,7 +122,7 @@ export class DevtoolsList extends Element {
${this.#renderSectionHeader(this.label)}
- ${(entries as any[]).map((entry) => { + ${(entries as unknown[]).map((entry) => { let key: string | undefined let val: unknown diff --git a/packages/app/src/components/workbench/logs.ts b/packages/app/src/components/workbench/logs.ts index 0000450a..652df813 100644 --- a/packages/app/src/components/workbench/logs.ts +++ b/packages/app/src/components/workbench/logs.ts @@ -2,7 +2,7 @@ import { Element } from '@core/element' import { html, css } from 'lit' import { customElement, property } from 'lit/decorators.js' -import type { CommandLog } from '@wdio/devtools-service/types' +import type { CommandLog } from '@wdio/devtools-shared' import type { CommandEndpoint } from '@wdio/protocols' import './list.js' diff --git a/packages/app/src/components/workbench/metadata.ts b/packages/app/src/components/workbench/metadata.ts index bdf2c01a..70faa320 100644 --- a/packages/app/src/components/workbench/metadata.ts +++ b/packages/app/src/components/workbench/metadata.ts @@ -3,7 +3,7 @@ import { html, css } from 'lit' import { customElement } from 'lit/decorators.js' import { consume } from '@lit/context' -import type { Metadata } from '@wdio/devtools-service/types' +import type { Metadata } from '@wdio/devtools-shared' import { metadataContext } from '../../controller/context.js' import './list.js' @@ -34,7 +34,16 @@ export class DevtoolsMetadata extends Element { return html`` } - const m = this.metadata as any + const m = this.metadata as { + sessionId?: string + testEnv?: string + host?: string + modulePath?: string + url?: string + capabilities?: Record + desiredCapabilities?: Record + options?: Record + } const sessionInfo: Record = {} if (m.sessionId) { sessionInfo['Session ID'] = m.sessionId diff --git a/packages/app/src/components/workbench/network.ts b/packages/app/src/components/workbench/network.ts index e58154cd..51b541bd 100644 --- a/packages/app/src/components/workbench/network.ts +++ b/packages/app/src/components/workbench/network.ts @@ -1,5 +1,6 @@ import { Element } from '@core/element' -import { html, css, nothing } from 'lit' +import { html, nothing } from 'lit' +import { networkStyles } from './network/styles.js' import { customElement, state } from 'lit/decorators.js' import { consume } from '@lit/context' import { networkRequestContext } from '../../controller/context.js' @@ -64,205 +65,7 @@ export class DevtoolsNetwork extends Element { } } - static styles = [ - ...Element.styles, - css` - :host { - display: flex; - flex-direction: column; - height: 100%; - width: 100%; - overflow: hidden; - color: var(--vscode-foreground); - background-color: var(--vscode-editor-background); - } - - .network-header { - padding: 0.5rem 1rem; - border-bottom: 1px solid var(--vscode-panel-border); - display: flex; - gap: 0.5rem; - align-items: center; - flex-shrink: 0; - } - - .search-input { - padding: 0.375rem 0.75rem; - border: 1px solid var(--vscode-panel-border); - background: var(--vscode-input-background); - color: var(--vscode-input-foreground); - border-radius: 4px; - font-size: 0.875rem; - min-width: 200px; - } - - .search-input:focus { - outline: none; - border-color: var(--vscode-focusBorder); - } - - .filter-tabs { - display: flex; - gap: 0.25rem; - margin-left: 1rem; - } - - .filter-tab { - padding: 0.375rem 0.75rem; - border: none; - background: transparent; - color: var(--vscode-foreground); - cursor: pointer; - font-size: 0.875rem; - transition: all 0.15s; - border-bottom: 2px solid transparent; - } - - .filter-tab:hover { - background: var(--vscode-toolbar-hoverBackground); - } - - .filter-tab.active { - color: var(--vscode-textLink-activeForeground); - border-bottom-color: var(--vscode-textLink-activeForeground); - } - - .network-content { - display: flex; - flex: 1; - overflow: hidden; - } - - .requests-list { - flex: 1; - overflow-y: auto; - overflow-x: auto; - border-right: 1px solid var(--vscode-panel-border); - min-width: 0; - } - - .requests-header { - display: grid; - grid-template-columns: 200px 80px 70px 180px 90px 80px 90px; - min-width: 790px; - border-bottom: 1px solid var(--vscode-panel-border); - font-size: 0.75rem; - font-weight: 600; - color: var(--vscode-descriptionForeground); - position: sticky; - top: 0; - background: var(--vscode-editor-background); - z-index: 1; - } - - .requests-header > div { - padding: 0.5rem; - border-right: 1px solid var(--vscode-panel-border); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - - .requests-header > div:last-child { - border-right: none; - } - - .request-row { - display: grid; - grid-template-columns: 200px 80px 70px 180px 90px 80px 90px; - min-width: 790px; - border-bottom: 1px solid var(--vscode-panel-border); - cursor: pointer; - font-size: 0.875rem; - transition: background 0.15s; - align-items: center; - } - - .request-row > span { - padding: 0.5rem; - border-right: 1px solid var(--vscode-panel-border); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - - .request-row > span:last-child { - border-right: none; - } - - .request-row:hover { - background: var(--vscode-list-hoverBackground); - } - - .request-row.selected { - background: var(--vscode-list-activeSelectionBackground); - color: var(--vscode-list-activeSelectionForeground); - } - - .request-row.error { - color: var(--vscode-errorForeground); - } - - .request-detail { - flex: 1; - overflow-y: auto; - padding: 1rem; - min-width: 400px; - } - - .detail-section { - margin-bottom: 1.5rem; - } - - .detail-title { - font-size: 0.875rem; - font-weight: 600; - margin-bottom: 0.5rem; - color: var(--vscode-foreground); - } - - .detail-content { - background: var(--vscode-editor-background); - padding: 0.75rem; - border-radius: 4px; - border: 1px solid var(--vscode-panel-border); - font-family: monospace; - font-size: 0.75rem; - overflow-x: auto; - } - - .header-row { - display: flex; - gap: 1rem; - padding: 0.25rem 0; - border-bottom: 1px solid var(--vscode-panel-border); - } - - .header-key { - font-weight: 600; - color: var(--vscode-symbolIcon-keyForeground); - flex-shrink: 0; - min-width: 80px; - } - - .header-value { - color: var(--vscode-symbolIcon-stringForeground); - word-break: break-word; - flex: 1; - text-align: right; - } - - .truncate { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - - .text-muted { - color: var(--vscode-descriptionForeground); - } - ` - ] + static styles = [...Element.styles, networkStyles] #filterRequests(): NetworkRequest[] { let filtered = this.networkRequests diff --git a/packages/app/src/components/workbench/network/styles.ts b/packages/app/src/components/workbench/network/styles.ts new file mode 100644 index 00000000..17d039b5 --- /dev/null +++ b/packages/app/src/components/workbench/network/styles.ts @@ -0,0 +1,200 @@ +import { css } from 'lit' + +/** Component styles for ``. Pulled out so the main + * network component file stays focused on request filtering and rendering. */ +export const networkStyles = css` + :host { + display: flex; + flex-direction: column; + height: 100%; + width: 100%; + overflow: hidden; + color: var(--vscode-foreground); + background-color: var(--vscode-editor-background); + } + + .network-header { + padding: 0.5rem 1rem; + border-bottom: 1px solid var(--vscode-panel-border); + display: flex; + gap: 0.5rem; + align-items: center; + flex-shrink: 0; + } + + .search-input { + padding: 0.375rem 0.75rem; + border: 1px solid var(--vscode-panel-border); + background: var(--vscode-input-background); + color: var(--vscode-input-foreground); + border-radius: 4px; + font-size: 0.875rem; + min-width: 200px; + } + + .search-input:focus { + outline: none; + border-color: var(--vscode-focusBorder); + } + + .filter-tabs { + display: flex; + gap: 0.25rem; + margin-left: 1rem; + } + + .filter-tab { + padding: 0.375rem 0.75rem; + border: none; + background: transparent; + color: var(--vscode-foreground); + cursor: pointer; + font-size: 0.875rem; + transition: all 0.15s; + border-bottom: 2px solid transparent; + } + + .filter-tab:hover { + background: var(--vscode-toolbar-hoverBackground); + } + + .filter-tab.active { + color: var(--vscode-textLink-activeForeground); + border-bottom-color: var(--vscode-textLink-activeForeground); + } + + .network-content { + display: flex; + flex: 1; + overflow: hidden; + } + + .requests-list { + flex: 1; + overflow-y: auto; + overflow-x: auto; + border-right: 1px solid var(--vscode-panel-border); + min-width: 0; + } + + .requests-header { + display: grid; + grid-template-columns: 200px 80px 70px 180px 90px 80px 90px; + min-width: 790px; + border-bottom: 1px solid var(--vscode-panel-border); + font-size: 0.75rem; + font-weight: 600; + color: var(--vscode-descriptionForeground); + position: sticky; + top: 0; + background: var(--vscode-editor-background); + z-index: 1; + } + + .requests-header > div { + padding: 0.5rem; + border-right: 1px solid var(--vscode-panel-border); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .requests-header > div:last-child { + border-right: none; + } + + .request-row { + display: grid; + grid-template-columns: 200px 80px 70px 180px 90px 80px 90px; + min-width: 790px; + border-bottom: 1px solid var(--vscode-panel-border); + cursor: pointer; + font-size: 0.875rem; + transition: background 0.15s; + align-items: center; + } + + .request-row > span { + padding: 0.5rem; + border-right: 1px solid var(--vscode-panel-border); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .request-row > span:last-child { + border-right: none; + } + + .request-row:hover { + background: var(--vscode-list-hoverBackground); + } + + .request-row.selected { + background: var(--vscode-list-activeSelectionBackground); + color: var(--vscode-list-activeSelectionForeground); + } + + .request-row.error { + color: var(--vscode-errorForeground); + } + + .request-detail { + flex: 1; + overflow-y: auto; + padding: 1rem; + min-width: 400px; + } + + .detail-section { + margin-bottom: 1.5rem; + } + + .detail-title { + font-size: 0.875rem; + font-weight: 600; + margin-bottom: 0.5rem; + color: var(--vscode-foreground); + } + + .detail-content { + background: var(--vscode-editor-background); + padding: 0.75rem; + border-radius: 4px; + border: 1px solid var(--vscode-panel-border); + font-family: monospace; + font-size: 0.75rem; + overflow-x: auto; + } + + .header-row { + display: flex; + gap: 1rem; + padding: 0.25rem 0; + border-bottom: 1px solid var(--vscode-panel-border); + } + + .header-key { + font-weight: 600; + color: var(--vscode-symbolIcon-keyForeground); + flex-shrink: 0; + min-width: 80px; + } + + .header-value { + color: var(--vscode-symbolIcon-stringForeground); + word-break: break-word; + flex: 1; + text-align: right; + } + + .truncate { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .text-muted { + color: var(--vscode-descriptionForeground); + } +` diff --git a/packages/app/src/controller/DataManager.ts b/packages/app/src/controller/DataManager.ts index a147e9b4..41f1aef2 100644 --- a/packages/app/src/controller/DataManager.ts +++ b/packages/app/src/controller/DataManager.ts @@ -5,7 +5,7 @@ import type { CommandLog, TraceLog, PreservedAttempt -} from '@wdio/devtools-service/types' +} from '@wdio/devtools-shared' import { mutationContext, @@ -20,15 +20,17 @@ import { baselineContext, selectedTestUidContext } from './context.js' -import { BASELINE_WS_SCOPE } from '../components/workbench/compare/constants.js' +import { BASELINE_WS_SCOPE, WS_SCOPE } from '@wdio/devtools-shared' import { CACHE_ID } from './constants.js' -import { getTimestamp } from '../utils/helpers.js' import { rerunState } from './rerunState.js' -import type { - TestStatsFragment, - SuiteStatsFragment, - SocketMessage -} from './types.js' +import type { SuiteStatsFragment, SocketMessage } from './types.js' +import { canonicalizeUids, mergeSuite } from './suite-merge.js' +import { + markAllRunning, + markSpecificRunning, + markRunningAsStopped +} from './mark-running.js' +import { shouldResetForNewRun } from './run-detection.js' export class DataManagerController implements ReactiveController { #ws?: WebSocket @@ -163,133 +165,11 @@ export class DataManagerController implements ReactiveController { #markTestAsRunning(uid: string, entryType?: 'suite' | 'test') { const suites = this.suitesContextProvider.value || [] - - // If uid is '*', mark ALL tests/suites as running - if (uid === '*') { - const updatedSuites = suites.map((chunk) => { - const updatedChunk: Record = {} - Object.entries(chunk as Record).forEach( - ([suiteUid, suite]) => { - if (!suite) { - updatedChunk[suiteUid] = suite - return - } - - const markAllAsRunning = ( - s: SuiteStatsFragment - ): SuiteStatsFragment => { - return { - ...s, - state: 'running', - start: new Date(), - end: undefined, - // Clear leaf-level tests so stale step entries from a previous - // run don't linger when the feature file or test code changed - // between runs (e.g. Cucumber step text edited). The new run - // repopulates them. Child suites are preserved so the tree - // structure remains visible during the rerun. - tests: [] as TestStatsFragment[], - suites: s.suites?.map(markAllAsRunning) || [] - } - } - - updatedChunk[suiteUid] = markAllAsRunning(suite) - } - ) - return updatedChunk - }) - this.suitesContextProvider.setValue(updatedSuites) - this.#host.requestUpdate() - return - } - - // Otherwise, mark specific test/suite as running - const updatedSuites = suites.map((chunk) => { - const updatedChunk: Record = {} - Object.entries(chunk as Record).forEach( - ([suiteUid, suite]) => { - if (!suite) { - updatedChunk[suiteUid] = suite - return - } - - // Recursive helper to mark only the targeted branch as running - const markAsRunning = ( - s: SuiteStatsFragment - ): { suite: SuiteStatsFragment; matched: boolean } => { - const runStart = new Date() - - if (entryType !== 'test' && s.uid === uid) { - const markSuiteTreeAsRunning = ( - suiteNode: SuiteStatsFragment - ): SuiteStatsFragment => ({ - ...suiteNode, - state: 'running', - start: runStart, - end: undefined, - // Clear leaf-level tests on rerun so stale step entries from - // a previous run can't linger. See sibling markAllAsRunning. - tests: [] as TestStatsFragment[], - suites: suiteNode.suites?.map(markSuiteTreeAsRunning) || [] - }) - - return { - matched: true, - suite: markSuiteTreeAsRunning(s) - } - } - - let matched = false - const updatedTests = (s.tests?.map((test) => { - if (test.uid === uid) { - matched = true - return { - ...test, - state: 'pending', - start: new Date(), - end: undefined - } - } - return test - }) ?? []) as TestStatsFragment[] - - const updatedNestedSuites = - s.suites?.map((nestedSuite) => { - const nestedResult = markAsRunning(nestedSuite) - if (nestedResult.matched) { - matched = true - } - return nestedResult.suite - }) || [] - - return { - matched, - suite: { - ...s, - ...(matched - ? { - state: 'running' as const, - // Don't reset the parent's start/end when it is already - // running — subsequent child-scenario marks would otherwise - // reset the feature's original run timestamp. - ...(s.state !== 'running' - ? { start: runStart, end: undefined } - : {}) - } - : {}), - tests: updatedTests || [], - suites: updatedNestedSuites - } - } - } - - updatedChunk[suiteUid] = markAsRunning(suite).suite - } - ) - return updatedChunk - }) - - this.suitesContextProvider.setValue(updatedSuites) + const updated = + uid === '*' + ? markAllRunning(suites) + : markSpecificRunning(suites, uid, entryType) + this.suitesContextProvider.setValue(updated) this.#host.requestUpdate() } @@ -331,7 +211,7 @@ export class DataManagerController implements ReactiveController { return } - if (scope === 'testStopped') { + if (scope === WS_SCOPE.testStopped) { this.#handleTestStopped() this.#host.requestUpdate() return @@ -345,7 +225,7 @@ export class DataManagerController implements ReactiveController { return } - if (scope === 'clearExecutionData') { + if (scope === WS_SCOPE.clearExecutionData) { const { uid, entryType, clearSuiteTree } = data as SocketMessage<'clearExecutionData'>['data'] this.clearExecutionData(uid, entryType) @@ -359,7 +239,7 @@ export class DataManagerController implements ReactiveController { return } - if (scope === 'replaceCommand') { + if (scope === WS_SCOPE.replaceCommand) { const { oldTimestamp, command } = data as SocketMessage<'replaceCommand'>['data'] this.#handleReplaceCommand(oldTimestamp, command) @@ -413,84 +293,16 @@ export class DataManagerController implements ReactiveController { } #shouldResetForNewRun(data: unknown): boolean { - // During a UI-triggered rerun, suppress auto-detection so sibling-scenario - // updates don't wipe accumulated execution data. - // Still update #lastSeenRunTimestamp so that once activeRerunSuiteUid is - // cleared the final suite update isn't mistakenly treated as a new run. - if (rerunState.activeRerunSuiteUid) { - const payloads = Array.isArray(data) - ? (data as Record[]) - : ([data] as Record[]) - for (const chunk of payloads) { - if (!chunk) { - continue - } - for (const suite of Object.values(chunk)) { - if (!suite?.start) { - continue - } - const t = getTimestamp( - suite.start as Date | number | string | undefined - ) - if (t > this.#lastSeenRunTimestamp) { - this.#lastSeenRunTimestamp = t - } - } - } - return false - } - - const payloads = Array.isArray(data) - ? (data as Record[]) - : ([data] as Record[]) - - for (const chunk of payloads) { - if (!chunk) { - continue - } - - for (const suite of Object.values(chunk)) { - if (!suite?.start) { - continue - } - - const suiteStartTime = getTimestamp( - suite.start as Date | number | string | undefined - ) - - if (suiteStartTime <= 0) { - continue - } - - // New run detected if we see a newer start timestamp. - // Exception: if the existing suite for this uid has no end time, it is - // still an ongoing run (e.g. a Cucumber feature spanning multiple - // scenarios) — treat it as a continuation, not a new run. - if (suiteStartTime > this.#lastSeenRunTimestamp) { - const existingChunks = this.suitesContextProvider.value || [] - let existingEnd: unknown = undefined - outer: for (const ec of existingChunks) { - for (const [uid, existing] of Object.entries(ec)) { - if (uid === Object.keys(chunk)[0]) { - existingEnd = existing?.end - break outer - } - } - } - // Only reset if the previous run was already finished (had an end time). - // An ongoing run (end == null / undefined) is just a continuation. - const previousRunFinished = - existingEnd !== null && existingEnd !== undefined - if (previousRunFinished) { - this.#lastSeenRunTimestamp = suiteStartTime - return true - } - // Continuation — update tracking timestamp but do NOT reset - this.#lastSeenRunTimestamp = suiteStartTime - } - } - } - return false + const { shouldReset, newLastSeenTimestamp } = shouldResetForNewRun( + data, + { + lastSeenRunTimestamp: this.#lastSeenRunTimestamp, + activeRerunSuiteUid: rerunState.activeRerunSuiteUid + }, + this.suitesContextProvider.value || [] + ) + this.#lastSeenRunTimestamp = newLastSeenTimestamp + return shouldReset } #resetExecutionData() { @@ -511,72 +323,8 @@ export class DataManagerController implements ReactiveController { #handleTestStopped() { this.#activeRerunTestUid = undefined rerunState.activeRerunSuiteUid = undefined - - // Mark all running tests as failed when test execution is stopped const suites = this.suitesContextProvider.value || [] - const updatedSuites = suites.map((chunk) => { - const updatedChunk: Record = {} - Object.entries(chunk as Record).forEach( - ([uid, suite]) => { - if (!suite) { - updatedChunk[uid] = suite - return - } - - // Recursive helper to update tests and nested suites - const updateSuite = (s: SuiteStatsFragment): SuiteStatsFragment => { - const updatedTests = s.tests?.map((test): TestStatsFragment => { - // If test is running (no end time), mark it as failed - if (test && !test.end) { - return { - ...test, - end: new Date(), - state: 'failed', - error: { - message: 'Test execution stopped', - name: 'TestStoppedError' - } - } - } - return test - }) - - // Recursively update nested suites (for Cucumber scenarios) - const updatedNestedSuites = s.suites?.map(updateSuite) - - // Derive the suite's own state from its updated children so that - // STATE_MAP['running'] no longer produces a spinner after stop. - const allTests = [ - ...(updatedTests || []), - ...(updatedNestedSuites || []) - ] - const hasFailed = allTests.some((t) => t?.state === 'failed') - const hasRunning = allTests.some((t) => !t?.end) - const derivedState: SuiteStatsFragment['state'] = hasRunning - ? s.state - : hasFailed - ? 'failed' - : s.state === 'running' - ? 'failed' - : s.state - - return { - ...s, - state: derivedState, - ...(!hasRunning && !s.end ? { end: new Date() } : {}), - - tests: updatedTests || [], - suites: updatedNestedSuites || [] - } - } - - updatedChunk[uid] = updateSuite(suite) - } - ) - return updatedChunk - }) - - this.suitesContextProvider.setValue(updatedSuites) + this.suitesContextProvider.setValue(markRunningAsStopped(suites)) } #handleMutationsUpdate(data: TraceMutation[]) { @@ -694,7 +442,11 @@ export class DataManagerController implements ReactiveController { } } }) - const canonicalizedRoots = this.#canonicalizeUids( + const mergeCtx = { + activeRerunTestUid: this.#activeRerunTestUid, + activeRerunSuiteUid: rerunState.activeRerunSuiteUid + } + const canonicalizedRoots = canonicalizeUids( existingRootSuites, incomingRootSuites ) @@ -704,7 +456,7 @@ export class DataManagerController implements ReactiveController { return } const existing = suiteMap.get(suite.uid) - const merged = existing ? this.#mergeSuite(existing, suite) : suite + const merged = existing ? mergeSuite(existing, suite, mergeCtx) : suite suiteMap.set(suite.uid, merged) }) @@ -726,251 +478,11 @@ export class DataManagerController implements ReactiveController { this.logsContextProvider.setValue(data) } - #mergeSuite(existing: SuiteStatsFragment, incoming: SuiteStatsFragment) { - // First merge tests and suites properly - const mergedTests = this.#mergeTests(existing.tests, incoming.tests) - const mergedSuites = this.#mergeChildSuites( - existing.suites, - incoming.suites - ) - - // Then merge suite properties, ensuring merged tests/suites are preserved - const { tests, suites, ...incomingProps } = incoming - - // Strip undefined state from incoming so it doesn't overwrite a valid existing state. - // The Nightwatch reporter may send suites without a state field when the JSON - // serialization omits properties that are undefined on the object. - if (incomingProps.state === undefined || incomingProps.state === null) { - delete (incomingProps as any).state - } - - // Treat incoming state=undefined/null the same as pending — WDIO's SuiteStats - // doesn't set 'state' on suite end (unlike TestStats), so undefined means the - // backend hasn't assigned a terminal state. Null is the Nightwatch equivalent. - const incomingStateIsPendingOrUnset = - incoming.state === 'pending' || - incoming.state === null || - incoming.state === undefined - - const allChildren = [...(mergedTests || []), ...(mergedSuites || [])] - // Treat children with undefined/null state as in-progress (not yet terminal). - // This prevents prematurely deriving 'passed' when children haven't reported yet. - const hasInProgressChildren = allChildren.some( - (child) => - child?.state === 'running' || - child?.state === 'pending' || - child?.state === null - ) - const hasFailedChildren = allChildren.some( - (child) => child?.state === 'failed' - ) - const hasChildren = allChildren.length > 0 - - // Only derive 'passed' when ALL children have reached a terminal state. - const allChildrenTerminal = - hasChildren && - allChildren.every( - (child) => - child?.state === 'passed' || - child?.state === 'failed' || - child?.state === 'skipped' - ) - - // On rerun start we optimistically mark the suite as running in the UI. - // Keep (or set) running state whenever the incoming state is unset/pending - // AND children are still in-progress. This handles both: - // • Nightwatch: suite was already 'running' → keep it running - // • WDIO: suite was 'passed' from previous run but now has running children - // (WDIO SuiteStats never carries an explicit state, so the previous - // derivedCompletedState='passed' would otherwise be silently preserved) - const keepRunningState = - incomingStateIsPendingOrUnset && hasInProgressChildren - - // Only derive 'passed'/'failed' from children when the backend hasn't - // assigned an explicit state (WDIO case: SuiteStats.state is never set on - // suite end). When state is explicitly 'pending' the backend is signalling - // a new run is starting — stale children from the previous run must not - // be used to derive a completed state. - const incomingStateIsUnset = - incoming.state === null || incoming.state === undefined - - const derivedCompletedState: SuiteStatsFragment['state'] | undefined = - allChildrenTerminal && incomingStateIsUnset - ? hasFailedChildren - ? 'failed' - : 'passed' - : undefined - - // When a new run starts the backend sends the feature suite with - // state: 'pending' before it has pushed any scenario children. - // #mergeChildSuites preserves stale child suites from the previous run, - // but they must not keep their terminal states — mark them 'pending' so - // they render as a spinner instead of a stale checkmark/cross. - // Exception: when only a specific child scenario is being rerun - // (activeRerunSuiteUid differs from the incoming feature suite's uid), - // sibling scenarios must keep their existing terminal states. - const isChildRerun = - !!rerunState.activeRerunSuiteUid && - rerunState.activeRerunSuiteUid !== incoming.uid - const finalSuites = - incoming.state === 'pending' && mergedSuites && !isChildRerun - ? mergedSuites.map((s) => - s.state === 'passed' || s.state === 'failed' - ? { ...s, state: 'pending' as const, end: undefined } - : s - ) - : mergedSuites - - return { - ...existing, - ...incomingProps, - ...(keepRunningState && hasInProgressChildren - ? { state: 'running' as const } - : incomingStateIsPendingOrUnset && - !hasInProgressChildren && - derivedCompletedState - ? { state: derivedCompletedState } - : {}), - tests: mergedTests, - suites: finalSuites - } - } - - /** - * Build a stable identity key for a test/suite that survives reporter UID drift - * across reruns. The reporter's signature counter can reassign UIDs when a - * single scenario is rerun (e.g. Cucumber outline example 2 reruns alone and - * gets the UID example 1 originally had). Matching by (file + featureLine + - * fullTitle) lets the merge dedupe by stable identity instead of the unstable - * uid. - */ - #canonicalKey( - item: TestStatsFragment | SuiteStatsFragment - ): string | undefined { - const file = item.file ?? '' - const featureFile = item.featureFile ?? '' - const featureLine = item.featureLine ?? '' - const fullTitle = item.fullTitle ?? item.title ?? '' - if (!file && !featureFile && !fullTitle) { - return undefined - } - return `${file}::${featureFile}:${featureLine}::${fullTitle}` - } - - /** - * Map an incoming item's uid to an existing entry's uid when their canonical - * keys match. Lets rerun payloads merge into the original rows even if the - * reporter assigned a different uid this time around. - */ - #canonicalizeUids( - prev: T[], - next: T[] - ): T[] { - if (!next.length || !prev.length) { - return next - } - const canonicalToUid = new Map() - for (const item of prev) { - if (!item) { - continue - } - const key = this.#canonicalKey(item) - if (key && !canonicalToUid.has(key)) { - canonicalToUid.set(key, item.uid) - } - } - return next.map((item) => { - if (!item) { - return item - } - const key = this.#canonicalKey(item) - if (!key) { - return item - } - const stableUid = canonicalToUid.get(key) - if (stableUid && stableUid !== item.uid) { - return { ...item, uid: stableUid } - } - return item - }) - } - - #mergeChildSuites( - prev: SuiteStatsFragment[] = [], - next: SuiteStatsFragment[] = [] - ) { - const map = new Map() - prev?.forEach((suite) => suite && map.set(suite.uid, suite)) - - const canonicalizedNext = this.#canonicalizeUids(prev || [], next || []) - - canonicalizedNext.forEach((suite) => { - if (!suite) { - return - } - const existing = map.get(suite.uid) - map.set(suite.uid, existing ? this.#mergeSuite(existing, suite) : suite) - }) - - return Array.from(map.values()) - } - - #mergeTests(prev: TestStatsFragment[] = [], next: TestStatsFragment[] = []) { - const map = new Map() - prev?.forEach((test) => test && map.set(test.uid, test)) - - const canonicalizedNext = this.#canonicalizeUids(prev || [], next || []) - - canonicalizedNext.forEach((test) => { - if (!test) { - return - } - const existing = map.get(test.uid) - const activeTargetUid = this.#activeRerunTestUid - - // During a single-test rerun, keep all sibling tests frozen exactly as - // they were before the rerun started. The backend can still emit suite- - // wide updates for those siblings, but the UI should only change the - // targeted test and its parent suite state. - if (activeTargetUid && test.uid !== activeTargetUid && existing) { - map.set(test.uid, { ...existing }) - return - } - - // Check if this test is a rerun (different start time) - const isRerun = - existing && - test.start && - existing.start && - getTimestamp(test.start) !== getTimestamp(existing.start) - - if (activeTargetUid && isRerun && test.state === 'pending' && existing) { - // The incoming suite structure marks all tests as "pending" at start. - // Preserve the ENTIRE existing record (including its old start time) so - // that tests not part of the current rerun keep their previous results. - // Crucially, keeping `existing.start` (the old run's timestamp) means - // every subsequent update for this test during the new run still has a - // different start time and therefore continues to be detected as a - // rerun — preventing a later normal-merge from overwriting state/end. - // When the test actually starts executing its state changes to "running" - // (non-pending), which falls through to the replace branch below. - map.set(test.uid, { ...existing }) - return - } - - // Replace on rerun (non-pending incoming), merge on normal update - map.set( - test.uid, - isRerun ? test : existing ? { ...existing, ...test } : test - ) - }) - - return Array.from(map.values()) - } - loadTraceFile(traceFile: TraceLog) { localStorage.setItem(CACHE_ID, JSON.stringify(traceFile)) - this.mutationsContextProvider.setValue(traceFile.mutations) + this.mutationsContextProvider.setValue( + traceFile.mutations as TraceMutation[] + ) this.logsContextProvider.setValue(traceFile.logs) this.consoleLogsContextProvider.setValue(traceFile.consoleLogs) this.networkRequestsContextProvider.setValue( diff --git a/packages/app/src/controller/context.ts b/packages/app/src/controller/context.ts index 27fe9373..58979892 100644 --- a/packages/app/src/controller/context.ts +++ b/packages/app/src/controller/context.ts @@ -3,7 +3,7 @@ import type { Metadata, CommandLog, PreservedAttempt -} from '@wdio/devtools-service/types' +} from '@wdio/devtools-shared' import type { SuiteStatsFragment } from './types.js' export const mutationContext = createContext( diff --git a/packages/app/src/controller/mark-running.ts b/packages/app/src/controller/mark-running.ts new file mode 100644 index 00000000..34b6db63 --- /dev/null +++ b/packages/app/src/controller/mark-running.ts @@ -0,0 +1,198 @@ +import type { SuiteStatsFragment, TestStatsFragment } from './types.js' + +/** + * Pure tree transforms that mark a suite/test as "running" on rerun start. + * Lifted out of DataManagerController so they're testable and the controller + * method stays a thin wrapper around the context-provider read/write. + */ + +type SuiteChunks = Array> + +/** + * Mark every suite (and its descendants) as running. Used when the user + * clicks the global "TESTS" rerun (uid='*'). Leaf-level tests are cleared so + * stale step entries from a previous run don't linger; the new run will + * repopulate them. Child suites are preserved so the tree structure stays + * visible during the rerun. + */ +export function markAllRunning(suites: SuiteChunks): SuiteChunks { + const markAllAsRunning = (s: SuiteStatsFragment): SuiteStatsFragment => ({ + ...s, + state: 'running', + start: new Date(), + end: undefined, + tests: [] as TestStatsFragment[], + suites: s.suites?.map(markAllAsRunning) || [] + }) + + return suites.map((chunk) => { + const updatedChunk: Record = {} + Object.entries(chunk as Record).forEach( + ([suiteUid, suite]) => { + if (!suite) { + updatedChunk[suiteUid] = suite + return + } + updatedChunk[suiteUid] = markAllAsRunning(suite) + } + ) + return updatedChunk + }) +} + +/** + * Mark a specific suite OR test as running by walking the tree: + * - When `entryType !== 'test'` and a suite matches by uid, mark that suite + * AND ALL its descendants as running (full feature/scenario rerun). + * - When `entryType === 'test'` and a test matches by uid, mark just that + * test pending (start=now, end=undefined). Parent suites get state: + * 'running' marked on the matched path but their start/end are preserved + * if already running so re-clicking a child doesn't reset the feature's + * run timestamp. + */ +export function markSpecificRunning( + suites: SuiteChunks, + uid: string, + entryType: 'suite' | 'test' | undefined +): SuiteChunks { + return suites.map((chunk) => { + const updatedChunk: Record = {} + Object.entries(chunk as Record).forEach( + ([suiteUid, suite]) => { + if (!suite) { + updatedChunk[suiteUid] = suite + return + } + + const markAsRunning = ( + s: SuiteStatsFragment + ): { suite: SuiteStatsFragment; matched: boolean } => { + const runStart = new Date() + + if (entryType !== 'test' && s.uid === uid) { + const markSuiteTreeAsRunning = ( + suiteNode: SuiteStatsFragment + ): SuiteStatsFragment => ({ + ...suiteNode, + state: 'running', + start: runStart, + end: undefined, + tests: [] as TestStatsFragment[], + suites: suiteNode.suites?.map(markSuiteTreeAsRunning) || [] + }) + return { matched: true, suite: markSuiteTreeAsRunning(s) } + } + + let matched = false + const updatedTests = (s.tests?.map((test) => { + if (test.uid === uid) { + matched = true + return { + ...test, + state: 'pending', + start: new Date(), + end: undefined + } + } + return test + }) ?? []) as TestStatsFragment[] + + const updatedNestedSuites = + s.suites?.map((nestedSuite) => { + const nestedResult = markAsRunning(nestedSuite) + if (nestedResult.matched) { + matched = true + } + return nestedResult.suite + }) || [] + + return { + matched, + suite: { + ...s, + ...(matched + ? { + state: 'running' as const, + // Preserve parent's start/end if already running — + // subsequent child-scenario marks would otherwise reset + // the feature's original run timestamp. + ...(s.state !== 'running' + ? { start: runStart, end: undefined } + : {}) + } + : {}), + tests: updatedTests || [], + suites: updatedNestedSuites + } + } + } + + updatedChunk[suiteUid] = markAsRunning(suite).suite + } + ) + return updatedChunk + }) +} + +/** + * Mark every still-running test (no `end`) as failed. Used when the user + * manually stops the run from the dashboard — without this, suites with + * `state: 'running'` would keep showing their spinner indefinitely. + * + * The suite's state is derived from its updated children: if any child is + * failed (or the suite itself was 'running' with no live children left), + * the suite ends up failed. Otherwise the existing state is preserved. + */ +export function markRunningAsStopped(suites: SuiteChunks): SuiteChunks { + const updateSuite = (s: SuiteStatsFragment): SuiteStatsFragment => { + const updatedTests = s.tests?.map((test): TestStatsFragment => { + if (test && !test.end) { + return { + ...test, + end: new Date(), + state: 'failed', + error: { + message: 'Test execution stopped', + name: 'TestStoppedError' + } + } + } + return test + }) + + const updatedNestedSuites = s.suites?.map(updateSuite) + + const allTests = [...(updatedTests || []), ...(updatedNestedSuites || [])] + const hasFailed = allTests.some((t) => t?.state === 'failed') + const hasRunning = allTests.some((t) => !t?.end) + const derivedState: SuiteStatsFragment['state'] = hasRunning + ? s.state + : hasFailed + ? 'failed' + : s.state === 'running' + ? 'failed' + : s.state + + return { + ...s, + state: derivedState, + ...(!hasRunning && !s.end ? { end: new Date() } : {}), + tests: updatedTests || [], + suites: updatedNestedSuites || [] + } + } + + return suites.map((chunk) => { + const updatedChunk: Record = {} + Object.entries(chunk as Record).forEach( + ([uid, suite]) => { + if (!suite) { + updatedChunk[uid] = suite + return + } + updatedChunk[uid] = updateSuite(suite) + } + ) + return updatedChunk + }) +} diff --git a/packages/app/src/controller/run-detection.ts b/packages/app/src/controller/run-detection.ts new file mode 100644 index 00000000..e8a5d92f --- /dev/null +++ b/packages/app/src/controller/run-detection.ts @@ -0,0 +1,108 @@ +import { getTimestamp } from '../utils/helpers.js' +import type { SuiteStatsFragment } from './types.js' + +type SuiteChunks = Array> + +export interface RunDetectionState { + /** Highest start-timestamp seen so far across any incoming suite. */ + lastSeenRunTimestamp: number + /** Active feature/scenario rerun (set by clearExecutionData). Presence + * suppresses new-run auto-detection so sibling updates don't wipe data. */ + activeRerunSuiteUid: string | undefined +} + +export interface RunDetectionResult { + /** True if the incoming payload signals a fresh test run — caller should + * reset the execution-data context providers. */ + shouldReset: boolean + /** Updated `lastSeenRunTimestamp` value the caller should write back. */ + newLastSeenTimestamp: number +} + +/** + * Decide whether an incoming `suites` payload represents a new run that + * should wipe accumulated execution data. + * + * Rules (in order): + * 1. If a UI-triggered rerun is active (`activeRerunSuiteUid` set), never + * auto-reset — siblings under the same feature would lose state. The + * timestamp tracker still advances so the post-rerun final update isn't + * mistakenly treated as a new run. + * 2. If we see a suite whose start-timestamp is newer than anything + * previously seen AND the existing suite for that uid is finished + * (has an `end`), it's a brand-new run → reset. + * 3. If the existing suite has no `end`, it's an ongoing run (e.g. a + * cucumber feature spanning multiple scenarios) — continuation, no reset. + * + * Pure: no `this`. Pass state in, write the returned timestamp back. + */ +export function shouldResetForNewRun( + data: unknown, + state: RunDetectionState, + existingChunks: SuiteChunks +): RunDetectionResult { + let lastSeen = state.lastSeenRunTimestamp + + const payloads = Array.isArray(data) + ? (data as Record[]) + : ([data] as Record[]) + + if (state.activeRerunSuiteUid) { + for (const chunk of payloads) { + if (!chunk) { + continue + } + for (const suite of Object.values(chunk)) { + if (!suite?.start) { + continue + } + const t = getTimestamp( + suite.start as Date | number | string | undefined + ) + if (t > lastSeen) { + lastSeen = t + } + } + } + return { shouldReset: false, newLastSeenTimestamp: lastSeen } + } + + for (const chunk of payloads) { + if (!chunk) { + continue + } + for (const suite of Object.values(chunk)) { + if (!suite?.start) { + continue + } + const suiteStartTime = getTimestamp( + suite.start as Date | number | string | undefined + ) + if (suiteStartTime <= 0) { + continue + } + if (suiteStartTime > lastSeen) { + let existingEnd: unknown = undefined + outer: for (const ec of existingChunks) { + for (const [uid, existing] of Object.entries(ec)) { + if (uid === Object.keys(chunk)[0]) { + existingEnd = existing?.end + break outer + } + } + } + const previousRunFinished = + existingEnd !== null && existingEnd !== undefined + if (previousRunFinished) { + return { + shouldReset: true, + newLastSeenTimestamp: suiteStartTime + } + } + // Continuation — update tracking timestamp but do NOT reset + lastSeen = suiteStartTime + } + } + } + return { shouldReset: false, newLastSeenTimestamp: lastSeen } +} diff --git a/packages/app/src/controller/suite-merge.ts b/packages/app/src/controller/suite-merge.ts new file mode 100644 index 00000000..cf4174b0 --- /dev/null +++ b/packages/app/src/controller/suite-merge.ts @@ -0,0 +1,266 @@ +import { getTimestamp } from '../utils/helpers.js' +import type { SuiteStatsFragment, TestStatsFragment } from './types.js' + +/** + * Pure suite-tree merge logic, lifted out of DataManagerController to keep it + * testable and to drop ~280 lines from the controller class. The functions + * take rerun-state explicitly via {@link MergeContext} so they don't depend on + * module-level mutable state. + */ +export interface MergeContext { + /** Set during a single-test rerun — siblings should stay frozen at their + * pre-rerun state. */ + activeRerunTestUid?: string + /** Set during a feature/scenario rerun — used to detect "child rerun" so + * sibling scenarios under the same feature aren't optimistically flipped + * back to 'pending' when the feature suite re-emits with state='pending'. */ + activeRerunSuiteUid?: string +} + +/** + * Stable identity key for a test/suite that survives reporter UID drift + * across reruns. The reporter's signature counter can reassign UIDs when a + * single scenario is rerun (e.g. Cucumber outline example 2 reruns alone and + * gets the UID example 1 originally had). Matching by (file + featureLine + + * fullTitle) lets the merge dedupe by stable identity instead of the unstable + * uid. + */ +export function canonicalKey( + item: TestStatsFragment | SuiteStatsFragment +): string | undefined { + const file = item.file ?? '' + const featureFile = item.featureFile ?? '' + const featureLine = item.featureLine ?? '' + const fullTitle = item.fullTitle ?? item.title ?? '' + if (!file && !featureFile && !fullTitle) { + return undefined + } + return `${file}::${featureFile}:${featureLine}::${fullTitle}` +} + +/** + * Rewrite each incoming item's uid to the matching existing entry's uid when + * their canonical keys match. Lets rerun payloads merge into the original + * rows even if the reporter assigned a different uid this time around. + */ +export function canonicalizeUids< + T extends TestStatsFragment | SuiteStatsFragment +>(prev: T[], next: T[]): T[] { + if (!next.length || !prev.length) { + return next + } + const canonicalToUid = new Map() + for (const item of prev) { + if (!item) { + continue + } + const key = canonicalKey(item) + if (key && !canonicalToUid.has(key)) { + canonicalToUid.set(key, item.uid) + } + } + return next.map((item) => { + if (!item) { + return item + } + const key = canonicalKey(item) + if (!key) { + return item + } + const stableUid = canonicalToUid.get(key) + if (stableUid && stableUid !== item.uid) { + return { ...item, uid: stableUid } + } + return item + }) +} + +export function mergeTests( + prev: TestStatsFragment[] = [], + next: TestStatsFragment[] = [], + ctx: MergeContext +): TestStatsFragment[] { + const map = new Map() + prev?.forEach((test) => test && map.set(test.uid, test)) + + const canonicalizedNext = canonicalizeUids(prev || [], next || []) + + canonicalizedNext.forEach((test) => { + if (!test) { + return + } + const existing = map.get(test.uid) + const activeTargetUid = ctx.activeRerunTestUid + + // During a single-test rerun, keep all sibling tests frozen exactly as + // they were before the rerun started. The backend can still emit suite- + // wide updates for those siblings, but the UI should only change the + // targeted test and its parent suite state. + if (activeTargetUid && test.uid !== activeTargetUid && existing) { + map.set(test.uid, { ...existing }) + return + } + + // Check if this test is a rerun (different start time) + const isRerun = + existing && + test.start && + existing.start && + getTimestamp(test.start) !== getTimestamp(existing.start) + + if (activeTargetUid && isRerun && test.state === 'pending' && existing) { + // The incoming suite structure marks all tests as "pending" at start. + // Preserve the ENTIRE existing record (including its old start time) so + // that tests not part of the current rerun keep their previous results. + // Crucially, keeping `existing.start` (the old run's timestamp) means + // every subsequent update for this test during the new run still has a + // different start time and therefore continues to be detected as a + // rerun — preventing a later normal-merge from overwriting state/end. + // When the test actually starts executing its state changes to "running" + // (non-pending), which falls through to the replace branch below. + map.set(test.uid, { ...existing }) + return + } + + // Replace on rerun (non-pending incoming), merge on normal update + map.set( + test.uid, + isRerun ? test : existing ? { ...existing, ...test } : test + ) + }) + + return Array.from(map.values()) +} + +export function mergeChildSuites( + prev: SuiteStatsFragment[] = [], + next: SuiteStatsFragment[] = [], + ctx: MergeContext +): SuiteStatsFragment[] { + const map = new Map() + prev?.forEach((suite) => suite && map.set(suite.uid, suite)) + + const canonicalizedNext = canonicalizeUids(prev || [], next || []) + + canonicalizedNext.forEach((suite) => { + if (!suite) { + return + } + const existing = map.get(suite.uid) + map.set(suite.uid, existing ? mergeSuite(existing, suite, ctx) : suite) + }) + + return Array.from(map.values()) +} + +export function mergeSuite( + existing: SuiteStatsFragment, + incoming: SuiteStatsFragment, + ctx: MergeContext +): SuiteStatsFragment { + // First merge tests and suites properly + const mergedTests = mergeTests(existing.tests, incoming.tests, ctx) + const mergedSuites = mergeChildSuites(existing.suites, incoming.suites, ctx) + + // Then merge suite properties, ensuring merged tests/suites are preserved + const { tests, suites, ...incomingProps } = incoming + void tests + void suites + + // Strip undefined state from incoming so it doesn't overwrite a valid existing state. + // The Nightwatch reporter may send suites without a state field when the JSON + // serialization omits properties that are undefined on the object. + if (incomingProps.state === undefined || incomingProps.state === null) { + delete (incomingProps as Partial).state + } + + // Treat incoming state=undefined/null the same as pending — WDIO's SuiteStats + // doesn't set 'state' on suite end (unlike TestStats), so undefined means the + // backend hasn't assigned a terminal state. Null is the Nightwatch equivalent. + const incomingStateIsPendingOrUnset = + incoming.state === 'pending' || + incoming.state === null || + incoming.state === undefined + + const allChildren = [...(mergedTests || []), ...(mergedSuites || [])] + // Treat children with undefined/null state as in-progress (not yet terminal). + // This prevents prematurely deriving 'passed' when children haven't reported yet. + const hasInProgressChildren = allChildren.some( + (child) => + child?.state === 'running' || + child?.state === 'pending' || + child?.state === null + ) + const hasFailedChildren = allChildren.some( + (child) => child?.state === 'failed' + ) + const hasChildren = allChildren.length > 0 + + // Only derive 'passed' when ALL children have reached a terminal state. + const allChildrenTerminal = + hasChildren && + allChildren.every( + (child) => + child?.state === 'passed' || + child?.state === 'failed' || + child?.state === 'skipped' + ) + + // On rerun start we optimistically mark the suite as running in the UI. + // Keep (or set) running state whenever the incoming state is unset/pending + // AND children are still in-progress. This handles both: + // • Nightwatch: suite was already 'running' → keep it running + // • WDIO: suite was 'passed' from previous run but now has running children + // (WDIO SuiteStats never carries an explicit state, so the previous + // derivedCompletedState='passed' would otherwise be silently preserved) + const keepRunningState = + incomingStateIsPendingOrUnset && hasInProgressChildren + + // Only derive 'passed'/'failed' from children when the backend hasn't + // assigned an explicit state (WDIO case: SuiteStats.state is never set on + // suite end). When state is explicitly 'pending' the backend is signalling + // a new run is starting — stale children from the previous run must not + // be used to derive a completed state. + const incomingStateIsUnset = + incoming.state === null || incoming.state === undefined + + const derivedCompletedState: SuiteStatsFragment['state'] | undefined = + allChildrenTerminal && incomingStateIsUnset + ? hasFailedChildren + ? 'failed' + : 'passed' + : undefined + + // When a new run starts the backend sends the feature suite with + // state: 'pending' before it has pushed any scenario children. + // mergeChildSuites preserves stale child suites from the previous run, + // but they must not keep their terminal states — mark them 'pending' so + // they render as a spinner instead of a stale checkmark/cross. + // Exception: when only a specific child scenario is being rerun + // (activeRerunSuiteUid differs from the incoming feature suite's uid), + // sibling scenarios must keep their existing terminal states. + const isChildRerun = + !!ctx.activeRerunSuiteUid && ctx.activeRerunSuiteUid !== incoming.uid + const finalSuites = + incoming.state === 'pending' && mergedSuites && !isChildRerun + ? mergedSuites.map((s) => + s.state === 'passed' || s.state === 'failed' + ? { ...s, state: 'pending' as const, end: undefined } + : s + ) + : mergedSuites + + return { + ...existing, + ...incomingProps, + ...(keepRunningState && hasInProgressChildren + ? { state: 'running' as const } + : incomingStateIsPendingOrUnset && + !hasInProgressChildren && + derivedCompletedState + ? { state: derivedCompletedState } + : {}), + tests: mergedTests, + suites: finalSuites + } +} diff --git a/packages/app/src/controller/types.ts b/packages/app/src/controller/types.ts index 02d6e085..d789b957 100644 --- a/packages/app/src/controller/types.ts +++ b/packages/app/src/controller/types.ts @@ -1,13 +1,15 @@ import type { SuiteStats, TestStats } from '@wdio/reporter' import type { TraceLog, - CommandLog, - PreservedAttempt -} from '@wdio/devtools-service/types' + TestStatus, + BaselineSavedWsPayload, + BaselineClearedWsPayload, + ReplaceCommandWsPayload +} from '@wdio/devtools-shared' export type TestStatsFragment = Omit, 'uid' | 'state'> & { uid: string - state?: 'running' | 'passed' | 'failed' | 'pending' | 'skipped' + state?: TestStatus callSource?: string featureFile?: string featureLine?: number @@ -18,7 +20,7 @@ export type SuiteStatsFragment = Omit< 'uid' | 'tests' | 'suites' > & { uid: string - state?: 'running' | 'passed' | 'failed' | 'pending' + state?: TestStatus tests?: TestStatsFragment[] suites?: SuiteStatsFragment[] callSource?: string @@ -53,10 +55,10 @@ export interface SocketMessage< clearSuiteTree?: boolean } : T extends 'replaceCommand' - ? { oldTimestamp: number; command: CommandLog } + ? ReplaceCommandWsPayload : T extends 'baseline:saved' - ? { testUid: string; attempt: PreservedAttempt } + ? BaselineSavedWsPayload : T extends 'baseline:cleared' - ? { testUid: string } + ? BaselineClearedWsPayload : unknown } diff --git a/packages/app/tests/mark-running.test.ts b/packages/app/tests/mark-running.test.ts new file mode 100644 index 00000000..badfb80f --- /dev/null +++ b/packages/app/tests/mark-running.test.ts @@ -0,0 +1,195 @@ +import { describe, it, expect } from 'vitest' + +import { + markAllRunning, + markSpecificRunning, + markRunningAsStopped +} from '../src/controller/mark-running.js' +import type { + SuiteStatsFragment, + TestStatsFragment +} from '../src/controller/types.js' + +type SuiteChunks = Array> + +const test = ( + uid: string, + overrides: Record = {} +): TestStatsFragment => + ({ + uid, + title: uid, + fullTitle: uid, + state: 'passed', + start: new Date(2026, 0, 1), + end: new Date(2026, 0, 2), + ...overrides + }) as never as TestStatsFragment + +const suite = ( + uid: string, + overrides: Record = {} +): SuiteStatsFragment => + ({ + uid, + title: uid, + fullTitle: uid, + state: 'passed', + start: new Date(2026, 0, 1), + end: new Date(2026, 0, 2), + tests: [], + suites: [], + ...overrides + }) as never as SuiteStatsFragment + +const chunks = (...suites: SuiteStatsFragment[]): SuiteChunks => + suites.map((s) => ({ [s.uid]: s })) + +describe('markAllRunning', () => { + it('marks the root suite and all descendants as running, clearing leaf tests', () => { + const input = chunks( + suite('root', { + tests: [test('t1'), test('t2')], + suites: [ + suite('child', { + tests: [test('c1', { state: 'failed' })] + }) + ] + }) + ) + const out = markAllRunning(input) + const root = out[0].root + expect(root.state).toBe('running') + expect(root.end).toBeUndefined() + expect(root.tests).toEqual([]) + expect(root.suites?.[0]?.state).toBe('running') + expect(root.suites?.[0]?.tests).toEqual([]) + }) + + it('skips null/undefined suite entries without throwing', () => { + const input = chunks(suite('a')) + // Inject an undefined entry — markAllRunning must preserve it. + ;(input[0] as Record)['ghost'] = undefined + const out = markAllRunning(input) + expect(out[0].ghost).toBeUndefined() + expect(out[0].a.state).toBe('running') + }) +}) + +describe('markSpecificRunning', () => { + it('marks a matched suite subtree as running when entryType is suite', () => { + const input = chunks( + suite('root', { + suites: [suite('target'), suite('sibling', { state: 'failed' })] + }) + ) + const out = markSpecificRunning(input, 'target', 'suite') + const root = out[0].root + const target = root.suites?.find((s) => s.uid === 'target') + const sibling = root.suites?.find((s) => s.uid === 'sibling') + expect(target?.state).toBe('running') + expect(target?.end).toBeUndefined() + expect(sibling?.state).toBe('failed') // untouched + }) + + it('marks a matched test as pending and only flips parent suite state', () => { + const input = chunks( + suite('root', { + state: 'passed', + tests: [test('t1'), test('t2', { state: 'failed' })] + }) + ) + const out = markSpecificRunning(input, 't1', 'test') + const root = out[0].root + const t1 = root.tests?.find((t) => t.uid === 't1') + const t2 = root.tests?.find((t) => t.uid === 't2') + expect(t1?.state).toBe('pending') + expect(t1?.end).toBeUndefined() + expect(t2?.state).toBe('failed') // untouched + expect(root.state).toBe('running') + }) + + it("preserves a parent suite's running start/end on a second child match", () => { + const originalStart = new Date(2026, 0, 1) + const input = chunks( + suite('root', { + state: 'running', + start: originalStart, + end: undefined, + tests: [test('t1', { state: 'pending' })] + }) + ) + const out = markSpecificRunning(input, 't1', 'test') + expect(out[0].root.start).toEqual(originalStart) // not reset + }) + + it('returns the suite unchanged when no descendant matches', () => { + const input = chunks( + suite('root', { + state: 'passed', + tests: [test('t1')] + }) + ) + const out = markSpecificRunning(input, 'no-such-uid', 'test') + expect(out[0].root.state).toBe('passed') + expect(out[0].root.tests?.[0]?.state).toBe('passed') + }) +}) + +describe('markRunningAsStopped', () => { + it('marks running tests (no end) as failed with a TestStoppedError', () => { + const input = chunks( + suite('root', { + tests: [test('t1', { state: 'running', end: null })] + }) + ) + const out = markRunningAsStopped(input) + const t1 = out[0].root.tests?.[0] + expect(t1?.state).toBe('failed') + expect(t1?.error?.name).toBe('TestStoppedError') + expect(t1?.end).toBeInstanceOf(Date) + }) + + it('leaves already-terminal tests untouched', () => { + const input = chunks( + suite('root', { + tests: [test('t1', { state: 'passed' })] + }) + ) + const out = markRunningAsStopped(input) + expect(out[0].root.tests?.[0]?.state).toBe('passed') + expect(out[0].root.tests?.[0]?.error).toBeUndefined() + }) + + it('derives suite state="failed" when no terminal children remain after stop', () => { + const input = chunks( + suite('root', { + state: 'running', + end: null, + tests: [test('t1', { state: 'running', end: null })] + }) + ) + const out = markRunningAsStopped(input) + expect(out[0].root.state).toBe('failed') + expect(out[0].root.end).toBeInstanceOf(Date) + }) + + it('recurses into nested suites', () => { + const input = chunks( + suite('root', { + state: 'running', + end: null, + suites: [ + suite('child', { + state: 'running', + end: null, + tests: [test('c1', { state: 'running', end: null })] + }) + ] + }) + ) + const out = markRunningAsStopped(input) + expect(out[0].root.suites?.[0]?.state).toBe('failed') + expect(out[0].root.suites?.[0]?.tests?.[0]?.state).toBe('failed') + }) +}) diff --git a/packages/app/tests/run-detection.test.ts b/packages/app/tests/run-detection.test.ts new file mode 100644 index 00000000..10037e22 --- /dev/null +++ b/packages/app/tests/run-detection.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect } from 'vitest' + +import { + shouldResetForNewRun, + type RunDetectionState +} from '../src/controller/run-detection.js' +import type { SuiteStatsFragment } from '../src/controller/types.js' + +type SuiteChunks = Array> + +const state = ( + overrides: Partial = {} +): RunDetectionState => ({ + lastSeenRunTimestamp: 0, + activeRerunSuiteUid: undefined, + ...overrides +}) + +const suite = ( + uid: string, + overrides: Record = {} +): SuiteStatsFragment => + ({ + uid, + title: uid, + fullTitle: uid, + state: 'passed', + start: new Date(2026, 0, 1, 10, 0, 0), + end: new Date(2026, 0, 1, 10, 5, 0), + tests: [], + suites: [], + ...overrides + }) as never as SuiteStatsFragment + +const chunks = (...suites: SuiteStatsFragment[]): SuiteChunks => + suites.map((s) => ({ [s.uid]: s })) + +describe('shouldResetForNewRun', () => { + it('returns false when an active rerun is in progress', () => { + const incoming = chunks(suite('root', { start: new Date(2026, 0, 2) })) + const existing = chunks(suite('root')) + const result = shouldResetForNewRun( + incoming, + state({ activeRerunSuiteUid: 'root' }), + existing + ) + expect(result.shouldReset).toBe(false) + // Tracker still advances so the post-rerun final update isn't mis-detected. + expect(result.newLastSeenTimestamp).toBeGreaterThan(0) + }) + + it('returns true when a newer start arrives AND the previous run was finished', () => { + const oldStart = new Date(2026, 0, 1, 10, 0, 0).getTime() + const incoming = chunks( + suite('root', { start: new Date(2026, 0, 1, 11, 0, 0) }) + ) + const existing = chunks( + suite('root', { end: new Date(2026, 0, 1, 10, 30, 0) }) + ) + const result = shouldResetForNewRun( + incoming, + state({ lastSeenRunTimestamp: oldStart }), + existing + ) + expect(result.shouldReset).toBe(true) + }) + + it('treats an ongoing previous run as a continuation (no reset)', () => { + const oldStart = new Date(2026, 0, 1, 10, 0, 0).getTime() + const incoming = chunks( + suite('root', { start: new Date(2026, 0, 1, 11, 0, 0) }) + ) + // Existing root has no `end` → still running (e.g. cucumber feature + // spanning multiple scenarios). + const existing = chunks(suite('root', { end: undefined })) + const result = shouldResetForNewRun( + incoming, + state({ lastSeenRunTimestamp: oldStart }), + existing + ) + expect(result.shouldReset).toBe(false) + // Timestamp still advances. + expect(result.newLastSeenTimestamp).toBeGreaterThan(oldStart) + }) + + it('returns false when no start timestamp is present', () => { + const incoming = chunks(suite('root', { start: undefined })) + const result = shouldResetForNewRun(incoming, state(), []) + expect(result.shouldReset).toBe(false) + }) + + it('handles array-wrapped and single-chunk payloads identically', () => { + const existing: SuiteChunks = [] + const oneChunk = { root: suite('root', { start: new Date(2026, 0, 2) }) } + const asSingle = shouldResetForNewRun(oneChunk, state(), existing) + const asArray = shouldResetForNewRun([oneChunk], state(), existing) + expect(asSingle).toEqual(asArray) + }) + + it('skips null chunks in the payload', () => { + const incoming = [ + null as unknown as Record, + { root: suite('root') } + ] + expect(() => shouldResetForNewRun(incoming, state(), [])).not.toThrow() + }) +}) diff --git a/packages/app/tests/suite-merge.test.ts b/packages/app/tests/suite-merge.test.ts new file mode 100644 index 00000000..3fe23b9b --- /dev/null +++ b/packages/app/tests/suite-merge.test.ts @@ -0,0 +1,260 @@ +import { describe, it, expect } from 'vitest' + +import { + canonicalKey, + canonicalizeUids, + mergeTests, + mergeChildSuites, + mergeSuite, + type MergeContext +} from '../src/controller/suite-merge.js' +import type { + SuiteStatsFragment, + TestStatsFragment +} from '../src/controller/types.js' + +const ctx = (override: Partial = {}): MergeContext => ({ + activeRerunTestUid: undefined, + activeRerunSuiteUid: undefined, + ...override +}) + +// Tests use `number` start/end values for terseness — the fragment types +// declare `Date` (from @wdio/reporter), but the merge logic only compares +// via `getTimestamp` which accepts both shapes. Cast through `as never` to +// bypass the structural mismatch. +const test = ( + uid: string, + overrides: Record = {} +): TestStatsFragment => + ({ + uid, + title: uid, + fullTitle: uid, + state: 'passed', + start: 1000, + end: 2000, + ...overrides + }) as never as TestStatsFragment + +const suite = ( + uid: string, + overrides: Record = {} +): SuiteStatsFragment => + ({ + uid, + title: uid, + fullTitle: uid, + state: 'passed', + start: 1000, + end: 2000, + tests: [], + suites: [], + ...overrides + }) as never as SuiteStatsFragment + +describe('canonicalKey', () => { + it('builds a stable key from file + featureLine + fullTitle', () => { + expect( + canonicalKey({ + uid: 'a', + file: '/path/login.feature', + featureFile: '/path/login.feature', + featureLine: 5, + fullTitle: 'logs in' + } as TestStatsFragment) + ).toBe('/path/login.feature::/path/login.feature:5::logs in') + }) + + it('returns undefined when there is nothing to key on', () => { + expect(canonicalKey({ uid: 'a' } as TestStatsFragment)).toBeUndefined() + }) + + it('falls back from fullTitle to title', () => { + expect( + canonicalKey({ + uid: 'a', + file: '/x.ts', + title: 'fallback' + } as TestStatsFragment) + ).toBe('/x.ts:::::fallback') + }) +}) + +describe('canonicalizeUids', () => { + it('rewrites incoming uid to existing uid when canonical keys match', () => { + const prev = [test('old-uid', { file: '/a.ts', fullTitle: 'login' })] + const next = [test('new-uid', { file: '/a.ts', fullTitle: 'login' })] + const result = canonicalizeUids(prev, next) + expect(result[0]?.uid).toBe('old-uid') + }) + + it('leaves uid alone when canonical key does not match', () => { + const prev = [test('old', { file: '/a.ts', fullTitle: 'login' })] + const next = [test('new', { file: '/b.ts', fullTitle: 'logout' })] + expect(canonicalizeUids(prev, next)[0]?.uid).toBe('new') + }) + + it('short-circuits when either side is empty', () => { + expect(canonicalizeUids([], [test('x')])).toEqual([test('x')]) + expect(canonicalizeUids([test('x')], [])).toEqual([]) + }) +}) + +describe('mergeTests', () => { + it('replaces a test on rerun (different start time)', () => { + const prev = [test('t1', { state: 'failed', start: 1000, end: 2000 })] + const next = [test('t1', { state: 'passed', start: 5000, end: 6000 })] + const merged = mergeTests(prev, next, ctx()) + expect(merged[0]?.state).toBe('passed') + expect(merged[0]?.start).toBe(5000) + }) + + it('shallow-merges when start times match (normal update)', () => { + const prev = [test('t1', { state: 'running', start: 1000, end: undefined })] + const next = [test('t1', { state: 'passed', start: 1000, end: 2000 })] + const merged = mergeTests(prev, next, ctx()) + expect(merged[0]?.state).toBe('passed') + expect(merged[0]?.end).toBe(2000) + }) + + it('freezes sibling tests during a single-test rerun', () => { + const prev = [ + test('target', { state: 'failed', start: 1000 }), + test('sibling', { state: 'passed', start: 1000 }) + ] + const next = [ + test('target', { state: 'running', start: 5000 }), + test('sibling', { state: 'pending', start: 5000 }) + ] + const merged = mergeTests(prev, next, ctx({ activeRerunTestUid: 'target' })) + const sibling = merged.find((t) => t.uid === 'sibling')! + expect(sibling.state).toBe('passed') + expect(sibling.start).toBe(1000) + }) + + it('preserves existing record when incoming test is pending on a rerun', () => { + // Mid-rerun: backend sends all tests as 'pending' first. Untouched tests + // must keep their previous results (state, end, start) so future updates + // for this run still get detected as a rerun via start-time mismatch. + const prev = [test('target', { state: 'failed', start: 1000, end: 2000 })] + const next = [test('target', { state: 'pending', start: 5000 })] + const merged = mergeTests(prev, next, ctx({ activeRerunTestUid: 'target' })) + expect(merged[0]?.state).toBe('failed') + expect(merged[0]?.start).toBe(1000) + expect(merged[0]?.end).toBe(2000) + }) + + it('inserts a brand-new test', () => { + expect(mergeTests([], [test('new')], ctx())[0]?.uid).toBe('new') + }) +}) + +describe('mergeSuite', () => { + it('derives state="passed" only when all children are terminal', () => { + const existing = suite('s', { state: undefined, tests: [], suites: [] }) + const incoming = suite('s', { + state: undefined, + tests: [test('t1', { state: 'passed' }), test('t2', { state: 'passed' })], + suites: [] + }) + expect(mergeSuite(existing, incoming, ctx()).state).toBe('passed') + }) + + it('derives state="failed" when any child failed', () => { + const existing = suite('s', { state: undefined, tests: [], suites: [] }) + const incoming = suite('s', { + state: undefined, + tests: [test('t1', { state: 'failed' }), test('t2', { state: 'passed' })], + suites: [] + }) + expect(mergeSuite(existing, incoming, ctx()).state).toBe('failed') + }) + + it('keeps state="running" when children are still in-progress and incoming is pending', () => { + const existing = suite('s', { state: 'passed', tests: [], suites: [] }) + const incoming = suite('s', { + state: 'pending', + tests: [test('t1', { state: 'running' })], + suites: [] + }) + expect(mergeSuite(existing, incoming, ctx()).state).toBe('running') + }) + + it('marks stale child suites as pending on full-feature rerun', () => { + // Feature suite re-emits with state='pending', no children yet. The stale + // scenario suites from the previous run must show a spinner, not their + // old passed/failed icons. + const oldChild = suite('scenario-1', { state: 'passed' }) + const existing = suite('feature', { suites: [oldChild] }) + const incoming = suite('feature', { + state: 'pending', + tests: [], + suites: [suite('scenario-1', { state: 'passed' })] + }) + const merged = mergeSuite(existing, incoming, ctx()) + expect(merged.suites?.[0]?.state).toBe('pending') + expect(merged.suites?.[0]?.end).toBeUndefined() + }) + + it('keeps sibling scenarios with their terminal state during a child-scenario rerun', () => { + // Scenario 2 is being rerun; the feature suite is re-emitted with + // state='pending' but scenario 1's state must stay 'passed'. + const existing = suite('feature', { + suites: [ + suite('scenario-1', { state: 'passed' }), + suite('scenario-2', { state: 'failed' }) + ] + }) + const incoming = suite('feature', { + state: 'pending', + suites: [ + suite('scenario-1', { state: 'passed' }), + suite('scenario-2', { state: 'failed' }) + ] + }) + const merged = mergeSuite( + existing, + incoming, + ctx({ activeRerunSuiteUid: 'scenario-2' }) + ) + expect(merged.suites?.find((s) => s.uid === 'scenario-1')?.state).toBe( + 'passed' + ) + }) + + it('strips undefined/null state from incoming to preserve existing state', () => { + const existing = suite('s', { state: 'passed' }) + const incoming = suite('s', { + state: undefined as never, + tests: [test('t', { state: 'passed' })] + }) + // Existing state preserved because the merge derives 'passed' from + // children (all terminal), but the key behavior is that incoming + // state=undefined doesn't clobber existing 'passed'. + expect(mergeSuite(existing, incoming, ctx()).state).toBe('passed') + }) +}) + +describe('mergeChildSuites', () => { + it('combines existing + incoming suites by uid', () => { + const existing = [suite('a'), suite('b')] + const incoming = [suite('b', { state: 'failed' }), suite('c')] + const merged = mergeChildSuites(existing, incoming, ctx()) + const uids = merged.map((s) => s.uid).sort() + expect(uids).toEqual(['a', 'b', 'c']) + expect(merged.find((s) => s.uid === 'b')?.state).toBe('failed') + }) + + it('canonicalizes uids before merging so rerun-renamed scenarios match', () => { + const existing = [ + suite('original', { file: '/f.feature', fullTitle: 'A scenario' }) + ] + const incoming = [ + suite('renamed', { file: '/f.feature', fullTitle: 'A scenario' }) + ] + const merged = mergeChildSuites(existing, incoming, ctx()) + expect(merged).toHaveLength(1) + expect(merged[0]?.uid).toBe('original') + }) +}) diff --git a/packages/backend/package.json b/packages/backend/package.json index 235d8c95..e68d94af 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -17,9 +17,9 @@ "typeScriptVersion": "^5.0.0", "scripts": { "dev": "run-p dev:*", - "dev:ts": "tsc --watch", + "dev:ts": "tsup src/index.ts --format esm --dts --watch", "dev:app": "nodemon --watch ./dist ./dist/index.js", - "build": "tsc -p ./tsconfig.json", + "build": "tsup src/index.ts --format esm --dts --clean", "lint": "eslint .", "prepublishOnly": "pnpm build" }, @@ -34,12 +34,14 @@ "get-port": "^7.1.0", "import-meta-resolve": "^4.1.0", "shell-quote": "^1.8.3", - "tree-kill": "^1.2.2" + "tree-kill": "^1.2.2", + "ws": "^8.18.3" }, "devDependencies": { "@types/shell-quote": "^1.7.5", "@types/ws": "^8.18.1", + "@wdio/devtools-shared": "workspace:^", "nodemon": "^3.1.14", - "ws": "^8.18.3" + "tsup": "^8.0.0" } } diff --git a/packages/backend/src/baseline/constants.ts b/packages/backend/src/baseline/constants.ts deleted file mode 100644 index d958b741..00000000 --- a/packages/backend/src/baseline/constants.ts +++ /dev/null @@ -1,13 +0,0 @@ -export const BASELINE_API = { - preserve: '/api/baseline/preserve', - clear: '/api/baseline/clear', - get: '/api/baseline/:testUid' -} as const - -export const BASELINE_WS_SCOPE = { - saved: 'baseline:saved', - cleared: 'baseline:cleared' -} as const - -export type BaselineWsScope = - (typeof BASELINE_WS_SCOPE)[keyof typeof BASELINE_WS_SCOPE] diff --git a/packages/backend/src/baseline/types.ts b/packages/backend/src/baseline/types.ts index c0729245..a7211761 100644 --- a/packages/backend/src/baseline/types.ts +++ b/packages/backend/src/baseline/types.ts @@ -1,40 +1,28 @@ -export interface CommandLogLike { - timestamp: number - [key: string]: unknown -} - -export interface ConsoleLogLike { - timestamp: number - [key: string]: unknown -} - -export interface NetworkRequestLike { - id?: string - timestamp: number - startTime?: number - endTime?: number - [key: string]: unknown -} - +import type { + CommandLog, + ConsoleLog, + NetworkRequest, + TestError, + TestStatus +} from '@wdio/devtools-shared' + +// Backend storage uses the canonical shared types. The `*Like` aliases below +// are kept so existing backend code that referenced them continues to compile; +// new code should use the shared types directly. +export type CommandLogLike = CommandLog +export type ConsoleLogLike = ConsoleLog +export type NetworkRequestLike = NetworkRequest + +// Mutations stay loose: the concrete shape (TraceMutation) lives in +// packages/script (browser-side, depends on DOM types) and isn't safe to +// import here. export interface MutationLike { timestamp: number [key: string]: unknown } -export type NodeState = 'passed' | 'failed' | 'skipped' | 'pending' | 'running' - -export interface NodeError { - message?: string - name?: string - stack?: string - expected?: unknown - actual?: unknown - matcherResult?: { - expected?: unknown - actual?: unknown - message?: string - } -} +export type NodeState = TestStatus +export type NodeError = TestError export interface TimeWindowNode { uid: string @@ -50,44 +38,12 @@ export interface TimeWindowNode { childUids: string[] } -export interface PreservedStep { - uid: string - title?: string - fullTitle?: string - start?: number - end?: number - state?: NodeState - error?: NodeError -} - -export interface PreservedAttempt { - testUid: string - scope: 'test' | 'suite' - capturedAt: number - window: { start: number; end: number } - test: { - title?: string - fullTitle?: string - file?: string - callSource?: string - start?: number - end?: number - duration?: number - state?: NodeState - error?: NodeError - } - steps?: PreservedStep[] - commands: CommandLogLike[] - consoleLogs: ConsoleLogLike[] - networkRequests: NetworkRequestLike[] - mutations: MutationLike[] - sources: Record -} +export type { PreservedAttempt, PreservedStep } from '@wdio/devtools-shared' export interface ActiveRun { - commands: CommandLogLike[] - consoleLogs: ConsoleLogLike[] - networkRequests: NetworkRequestLike[] + commands: CommandLog[] + consoleLogs: ConsoleLog[] + networkRequests: NetworkRequest[] mutations: MutationLike[] sources: Record nodes: Map diff --git a/packages/backend/src/bin-resolver.ts b/packages/backend/src/bin-resolver.ts new file mode 100644 index 00000000..f5ac6414 --- /dev/null +++ b/packages/backend/src/bin-resolver.ts @@ -0,0 +1,95 @@ +import fs from 'node:fs' +import path from 'node:path' +import { createRequire } from 'node:module' +import { RUNNER_ENV } from '@wdio/devtools-shared' + +const require = createRequire(import.meta.url) + +/** + * Resolve the nightwatch CLI entry point. Honors `DEVTOOLS_NIGHTWATCH_BIN` + * for testing/override; otherwise walks up from `baseDir` looking for + * `node_modules/nightwatch/package.json` and resolves its `bin` to the + * actual JS entry (avoids running the shell-script wrapper at + * `node_modules/.bin/nightwatch` via node). + */ +export function resolveNightwatchBin(baseDir: string): string { + const envOverride = process.env[RUNNER_ENV.NIGHTWATCH_BIN] + if (envOverride) { + const resolved = path.isAbsolute(envOverride) + ? envOverride + : path.resolve(process.cwd(), envOverride) + if (fs.existsSync(resolved)) { + return resolved + } + } + + let dir = baseDir + const root = path.parse(dir).root + while (dir !== root) { + const nightwatchPkgPath = path.join( + dir, + 'node_modules', + 'nightwatch', + 'package.json' + ) + if (fs.existsSync(nightwatchPkgPath)) { + try { + const pkg = JSON.parse(fs.readFileSync(nightwatchPkgPath, 'utf8')) + const nightwatchDir = path.join(dir, 'node_modules', 'nightwatch') + const binEntry = + typeof pkg.bin === 'string' + ? pkg.bin + : (pkg.bin?.nightwatch ?? pkg.bin?.nw) + if (binEntry) { + const jsPath = path.resolve(nightwatchDir, binEntry) + if (fs.existsSync(jsPath)) { + return jsPath + } + } + } catch { + // malformed package.json — continue walking + } + } + const parent = path.dirname(dir) + if (parent === dir) { + break + } + dir = parent + } + + throw new Error( + 'Cannot find nightwatch binary. Install nightwatch locally or set DEVTOOLS_NIGHTWATCH_BIN env var.' + ) +} + +/** + * Resolve the wdio CLI entry. Honors `DEVTOOLS_WDIO_BIN`; otherwise derives + * from the `@wdio/cli` package's location (the published `bin/wdio.js`). + */ +export function resolveWdioBin(): string { + const envOverride = process.env[RUNNER_ENV.WDIO_BIN] + if (envOverride) { + const overriddenPath = path.isAbsolute(envOverride) + ? envOverride + : path.resolve(process.cwd(), envOverride) + if (!fs.existsSync(overriddenPath)) { + throw new Error( + `DEVTOOLS_WDIO_BIN "${overriddenPath}" does not exist or is not accessible` + ) + } + return overriddenPath + } + + try { + const cliEntry = require.resolve('@wdio/cli') + const candidate = path.resolve(path.dirname(cliEntry), '../bin/wdio.js') + if (!fs.existsSync(candidate)) { + throw new Error(`Derived WDIO bin "${candidate}" does not exist`) + } + return candidate + } catch (error) { + throw new Error( + `Failed to resolve WDIO binary. Provide DEVTOOLS_WDIO_BIN env var. ${(error as Error).message}` + ) + } +} diff --git a/packages/backend/src/framework-filters.ts b/packages/backend/src/framework-filters.ts new file mode 100644 index 00000000..09e097d7 --- /dev/null +++ b/packages/backend/src/framework-filters.ts @@ -0,0 +1,137 @@ +import type { RunnerRequestBody, TestRunnerId } from '@wdio/devtools-shared' + +function escapeRegex(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +export type FilterBuilder = (ctx: { + specArg?: string + payload: RunnerRequestBody +}) => string[] + +// Map (not object) keeps payload-supplied `framework` from reaching +// prototype methods at dispatch time — CodeQL: unvalidated-dynamic-method-call. +// Keyed by TestRunnerId so adding a new runner forces compile-time updates here. +const FRAMEWORK_FILTERS = new Map() + +FRAMEWORK_FILTERS.set('cucumber', ({ specArg, payload }) => { + const filters: string[] = [] + + // For feature-level suites, run the entire feature file + if (payload.suiteType === 'feature' && specArg) { + // Remove any line number from specArg for feature-level execution + const featureFile = specArg.split(':')[0] + filters.push('--spec', featureFile) + return filters + } + + // Priority 1: Use feature file with line number for exact scenario targeting (works for examples) + // Note: Cucumber scenarios are type 'suite', not 'test' + if (payload.featureFile && payload.featureLine) { + filters.push('--spec', `${payload.featureFile}:${payload.featureLine}`) + return filters + } + + // Priority 2: For specific test reruns with example row number, use exact regex match + if (payload.entryType === 'test' && payload.fullTitle) { + // Cucumber fullTitle format: "1: Scenario name" or "2: Scenario name" + // Extract the row number and scenario name + // Avoid ReDoS by removing ambiguous \s* before .* - use string operations instead + const colonIndex = payload.fullTitle.indexOf(':') + if (colonIndex > 0) { + const rowNumber = payload.fullTitle.substring(0, colonIndex) + const scenarioName = payload.fullTitle.substring(colonIndex + 1).trim() + // Validate row number is digits only + if (/^\d+$/.test(rowNumber)) { + // Use spec file filter + if (specArg) { + filters.push('--spec', specArg) + } + // Use regex to match the exact "rowNumber: scenarioName" pattern + // This ensures we only run that specific example row + filters.push( + '--cucumberOpts.name', + `^${rowNumber}:\\s*${escapeRegex(scenarioName)}$` + ) + return filters + } + } + // No row number - use plain name filter + if (specArg) { + filters.push('--spec', specArg) + } + filters.push('--cucumberOpts.name', payload.fullTitle.trim()) + return filters + } + + // Suite-level rerun + if (specArg) { + filters.push('--spec', specArg) + } + return filters +}) + +FRAMEWORK_FILTERS.set('mocha', ({ specArg, payload }) => { + const filters: string[] = [] + if (specArg) { + filters.push('--spec', specArg) + } + // For both tests and suites, use grep to filter + if (payload.fullTitle) { + filters.push('--mochaOpts.grep', payload.fullTitle) + } + return filters +}) + +FRAMEWORK_FILTERS.set('jasmine', ({ specArg, payload }) => { + const filters: string[] = [] + if (specArg) { + filters.push('--spec', specArg) + } + // For both tests and suites, use grep to filter + if (payload.fullTitle) { + filters.push('--jasmineOpts.grep', payload.fullTitle) + } + return filters +}) + +// Nightwatch CLI: positional spec file + optional --testcase filter +FRAMEWORK_FILTERS.set('nightwatch', ({ specArg, payload }) => { + const filters: string[] = [] + if (specArg) { + // Nightwatch doesn't support file:line — strip any trailing line number + filters.push(specArg.split(':')[0]) + } + if (payload.entryType === 'test' && payload.label) { + filters.push('--testcase', payload.label) + } + return filters +}) + +// Nightwatch + Cucumber: feature files are resolved via the config's feature_path. +// Never pass .feature files as positional args — Nightwatch rejects them. +// Nightwatch forwards --name and --tags to the underlying Cucumber runner. +FRAMEWORK_FILTERS.set('nightwatch-cucumber', ({ payload }) => { + const filters: string[] = [] + + // Only pass --name for scenario-level reruns. Feature/file-level suites + // (suiteType === 'feature') run all their scenarios, so no --name filter. + const isFeatureLevel = payload.suiteType === 'feature' || payload.runAll + if (!isFeatureLevel && payload.fullTitle) { + // Wrap as an anchored exact regex so "Scenario A" never also matches + // "Scenario A-1" (Cucumber treats --name as a regex). + const escaped = escapeRegex(payload.fullTitle) + filters.push('--name', `^${escaped}$`) + } + return filters +}) + +const DEFAULT_FILTERS: FilterBuilder = ({ specArg }) => + specArg ? ['--spec', specArg] : [] + +/** Resolve the filter builder for a given runner, falling back to spec-only. */ +export function getFilterBuilder( + runnerId: TestRunnerId | undefined +): FilterBuilder { + return (runnerId && FRAMEWORK_FILTERS.get(runnerId)) || DEFAULT_FILTERS +} diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index a6cda5a1..91142623 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -1,7 +1,11 @@ import fs from 'node:fs' import url from 'node:url' -import Fastify, { type FastifyInstance, type FastifyRequest } from 'fastify' +import Fastify, { + type FastifyInstance, + type FastifyReply, + type FastifyRequest +} from 'fastify' import staticServer from '@fastify/static' import rateLimit from '@fastify/rate-limit' import websocket from '@fastify/websocket' @@ -13,7 +17,19 @@ import { getDevtoolsApp } from './utils.js' import { DEFAULT_PORT } from './constants.js' import { testRunner } from './runner.js' import { baselineStore } from './baselineStore.js' -import { BASELINE_API, BASELINE_WS_SCOPE } from './baseline/constants.js' +import { createWorkerMessageHandler } from './worker-message-handler.js' +import { + BASELINE_API, + BASELINE_WS_SCOPE, + WS_PATHS, + WS_SCOPE, + type BaselinePreserveRequest, + type BaselineClearRequest, + type BaselineGetParams, + type BaselineGetQuery, + type BaselineSavedWsPayload, + type BaselineClearedWsPayload +} from '@wdio/devtools-shared' import type { RunnerRequestBody } from './types.js' let server: FastifyInstance | undefined @@ -28,7 +44,14 @@ const clients = new Set() // Notify the worker when a UI client connects so the plugin can unblock // Builder.build() instead of finishing the run before the dashboard appears. +// +// `parentWorkerSocket` is the long-lived worker (the original test runner +// holding the keep-alive on shutdown). `workerSocket` tracks whichever worker +// most recently connected — typically a rerun child while it runs. Outbound +// signals like `clientDisconnected` go to the PARENT, otherwise a closed +// rerun-child leaves the parent unreachable and `clientDisconnected` is lost. let workerSocket: WebSocket | undefined +let parentWorkerSocket: WebSocket | undefined // sessionId → absolute path of the encoded .webm; queried by /api/video/:sessionId. const videoRegistry = new Map() @@ -59,7 +82,7 @@ function replayBufferedMessages(socket: WebSocket) { } } -function serveVideo(sessionId: string, reply: any) { +function serveVideo(sessionId: string, reply: FastifyReply) { const videoPath = videoRegistry.get(sessionId) if (!videoPath) { return reply.code(404).send({ error: 'Video not found' }) @@ -107,7 +130,7 @@ export async function start( // Broadcast a clear so popouts (which only see WS events) wipe too. broadcastToClients( JSON.stringify({ - scope: 'clearExecutionData', + scope: WS_SCOPE.clearExecutionData, data: { uid: body.uid, entryType: body.entryType } }) ) @@ -141,7 +164,7 @@ export async function start( testRunner.stop() broadcastToClients( JSON.stringify({ - scope: 'testStopped', + scope: WS_SCOPE.testStopped, data: { stopped: true, timestamp: Date.now() } }) ) @@ -151,9 +174,7 @@ export async function start( server.post( BASELINE_API.preserve, async ( - request: FastifyRequest<{ - Body: { testUid?: string; scope?: 'test' | 'suite' } - }>, + request: FastifyRequest<{ Body: Partial }>, reply ) => { const { testUid, scope } = request.body || {} @@ -168,11 +189,9 @@ export async function start( .code(409) .send({ error: 'No captured data for the requested uid' }) } + const payload: BaselineSavedWsPayload = { testUid, attempt } broadcastToClients( - JSON.stringify({ - scope: BASELINE_WS_SCOPE.saved, - data: { testUid, attempt } - }) + JSON.stringify({ scope: BASELINE_WS_SCOPE.saved, data: payload }) ) return reply.send({ ok: true, attempt }) } @@ -180,18 +199,19 @@ export async function start( server.post( BASELINE_API.clear, - async (request: FastifyRequest<{ Body: { testUid?: string } }>, reply) => { + async ( + request: FastifyRequest<{ Body: Partial }>, + reply + ) => { const { testUid } = request.body || {} if (!testUid) { return reply.code(400).send({ error: 'testUid required' }) } const removed = baselineStore.clear(testUid) if (removed) { + const payload: BaselineClearedWsPayload = { testUid } broadcastToClients( - JSON.stringify({ - scope: BASELINE_WS_SCOPE.cleared, - data: { testUid } - }) + JSON.stringify({ scope: BASELINE_WS_SCOPE.cleared, data: payload }) ) } return reply.send({ ok: true, removed }) @@ -202,8 +222,8 @@ export async function start( BASELINE_API.get, async ( request: FastifyRequest<{ - Params: { testUid: string } - Querystring: { scope?: 'test' | 'suite' } + Params: BaselineGetParams + Querystring: BaselineGetQuery }>, reply ) => { @@ -214,7 +234,7 @@ export async function start( ) server.get( - '/client', + WS_PATHS.client, { websocket: true }, (socket: WebSocket, _req: FastifyRequest) => { log.info( @@ -227,23 +247,33 @@ export async function start( // Last dashboard window closed — tell the worker so it can wind // down. Lets the user close Chrome to end an interactive review // session under any runner. - if (clients.size === 0 && workerSocket?.readyState === WebSocket.OPEN) { - workerSocket.send( - JSON.stringify({ scope: 'clientDisconnected', data: {} }) + // Route to the PARENT worker — it owns the keep-alive + shutdown + // handler. The `workerSocket` ref may point at a rerun child that's + // about to exit; falling back to `parentWorkerSocket` handles that + // (and a fresh post-rerun click before the child fully closes). + const target = + parentWorkerSocket?.readyState === WebSocket.OPEN + ? parentWorkerSocket + : workerSocket?.readyState === WebSocket.OPEN + ? workerSocket + : undefined + if (clients.size === 0 && target) { + target.send( + JSON.stringify({ scope: WS_SCOPE.clientDisconnected, data: {} }) ) } }) if (workerSocket?.readyState === WebSocket.OPEN) { workerSocket.send( - JSON.stringify({ scope: 'clientConnected', data: {} }) + JSON.stringify({ scope: WS_SCOPE.clientConnected, data: {} }) ) } } ) server.get( - '/worker', + WS_PATHS.worker, { websocket: true }, (socket: WebSocket, _req: FastifyRequest) => { // Don't drop the message buffer for rerun-child connects (the dashboard @@ -257,79 +287,32 @@ export async function start( baselineStore.resetActiveRun() } workerSocket = socket + if (!isRerunChild) { + parentWorkerSocket = socket + } socket.on('close', () => { if (workerSocket === socket) { workerSocket = undefined } + if (parentWorkerSocket === socket) { + parentWorkerSocket = undefined + } }) if (clients.size > 0) { - socket.send(JSON.stringify({ scope: 'clientConnected', data: {} })) - } - socket.on('message', (message: Buffer) => { - // Use `debug` — at `info` level this feeds the worker's stream - // capture and creates a backend↔capture loop. - log.debug( - `received ${message.length} byte message from worker to ${clients.size} client${clients.size > 1 ? 's' : ''}` + socket.send( + JSON.stringify({ scope: WS_SCOPE.clientConnected, data: {} }) ) - - try { - const parsed = JSON.parse(message.toString()) - - if (parsed.scope === 'clearCommands') { - const testUid = parsed.data?.testUid - log.info(`Clearing commands for test: ${testUid || 'all'}`) - // Mirror the dashboard's reset behavior: clearing without a uid - // is a full reset, so wipe the baseline accumulator too. - if (!testUid) { - baselineStore.resetActiveRun() - } - broadcastToClients( - JSON.stringify({ - scope: 'clearExecutionData', - data: { uid: testUid } - }) - ) - return - } - - if (parsed.scope === 'config' && parsed.data?.configFile) { - testRunner.registerConfigFile(parsed.data.configFile) - log.info( - `Registered config file for reruns: ${parsed.data.configFile}` - ) - return - } - - // Intercept screencast messages: store the absolute videoPath in the - // registry (backend-only), then forward only the sessionId to the UI - // so the UI can request the video via GET /api/video/:sessionId. - if (parsed.scope === 'screencast' && parsed.data?.sessionId) { - const { sessionId, videoPath } = parsed.data - if (videoPath) { - videoRegistry.set(sessionId, videoPath) - log.info( - `Screencast registered for session ${sessionId}: ${videoPath}` - ) - } - broadcastToClients( - JSON.stringify({ - scope: 'screencast', - data: { sessionId } - }) - ) - return - } - // Tee the event into the baseline accumulator for time-window - // partitioning at preserve time. Done after special-case handling - // so we don't accumulate control frames (clearCommands, screencast). - baselineStore.recordEvent(parsed.scope, parsed.data) - } catch { - // Not JSON or parsing failed, forward as-is - } - - // Forward all other messages as-is - broadcastToClients(message.toString()) - }) + } + socket.on( + 'message', + createWorkerMessageHandler({ + baselineStore, + testRunner, + videoRegistry, + broadcastToClients, + clientCount: () => clients.size + }) + ) } ) diff --git a/packages/backend/src/runner.ts b/packages/backend/src/runner.ts index 4fc20f07..8b2dddb0 100644 --- a/packages/backend/src/runner.ts +++ b/packages/backend/src/runner.ts @@ -2,146 +2,20 @@ import { spawn, type ChildProcess } from 'node:child_process' import fs from 'node:fs' import path from 'node:path' import url from 'node:url' -import { createRequire } from 'node:module' import kill from 'tree-kill' -import { parse as shellParse } from 'shell-quote' -import type { RunnerRequestBody } from './types.js' +import { parse as shellParse, quote as shellQuote } from 'shell-quote' +import { + REUSE_ENV, + RUNNER_ENV, + type RunnerRequestBody, + type TestRunnerId +} from '@wdio/devtools-shared' import { WDIO_CONFIG_FILENAMES, NIGHTWATCH_CONFIG_FILENAMES } from './types.js' +import { getFilterBuilder } from './framework-filters.js' +import { resolveNightwatchBin, resolveWdioBin } from './bin-resolver.js' -const require = createRequire(import.meta.url) const wdioBin = resolveWdioBin() -/** - * Escape special regex characters in a string - */ -function escapeRegex(str: string): string { - return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') -} - -type FilterBuilder = (ctx: { - specArg?: string - payload: RunnerRequestBody -}) => string[] - -// Map (not object) keeps payload-supplied `framework` from reaching -// prototype methods at dispatch time — CodeQL: unvalidated-dynamic-method-call. -const FRAMEWORK_FILTERS = new Map() - -FRAMEWORK_FILTERS.set('cucumber', ({ specArg, payload }) => { - const filters: string[] = [] - - // For feature-level suites, run the entire feature file - if (payload.suiteType === 'feature' && specArg) { - // Remove any line number from specArg for feature-level execution - const featureFile = specArg.split(':')[0] - filters.push('--spec', featureFile) - return filters - } - - // Priority 1: Use feature file with line number for exact scenario targeting (works for examples) - // Note: Cucumber scenarios are type 'suite', not 'test' - if (payload.featureFile && payload.featureLine) { - filters.push('--spec', `${payload.featureFile}:${payload.featureLine}`) - return filters - } - - // Priority 2: For specific test reruns with example row number, use exact regex match - if (payload.entryType === 'test' && payload.fullTitle) { - // Cucumber fullTitle format: "1: Scenario name" or "2: Scenario name" - // Extract the row number and scenario name - // Avoid ReDoS by removing ambiguous \s* before .* - use string operations instead - const colonIndex = payload.fullTitle.indexOf(':') - if (colonIndex > 0) { - const rowNumber = payload.fullTitle.substring(0, colonIndex) - const scenarioName = payload.fullTitle.substring(colonIndex + 1).trim() - // Validate row number is digits only - if (/^\d+$/.test(rowNumber)) { - // Use spec file filter - if (specArg) { - filters.push('--spec', specArg) - } - // Use regex to match the exact "rowNumber: scenarioName" pattern - // This ensures we only run that specific example row - filters.push( - '--cucumberOpts.name', - `^${rowNumber}:\\s*${escapeRegex(scenarioName)}$` - ) - return filters - } - } - // No row number - use plain name filter - if (specArg) { - filters.push('--spec', specArg) - } - filters.push('--cucumberOpts.name', payload.fullTitle.trim()) - return filters - } - - // Suite-level rerun - if (specArg) { - filters.push('--spec', specArg) - } - return filters -}) - -FRAMEWORK_FILTERS.set('mocha', ({ specArg, payload }) => { - const filters: string[] = [] - if (specArg) { - filters.push('--spec', specArg) - } - // For both tests and suites, use grep to filter - if (payload.fullTitle) { - filters.push('--mochaOpts.grep', payload.fullTitle) - } - return filters -}) - -FRAMEWORK_FILTERS.set('jasmine', ({ specArg, payload }) => { - const filters: string[] = [] - if (specArg) { - filters.push('--spec', specArg) - } - // For both tests and suites, use grep to filter - if (payload.fullTitle) { - filters.push('--jasmineOpts.grep', payload.fullTitle) - } - return filters -}) - -const DEFAULT_FILTERS: FilterBuilder = ({ specArg }) => - specArg ? ['--spec', specArg] : [] - -// Nightwatch CLI: positional spec file + optional --testcase filter -FRAMEWORK_FILTERS.set('nightwatch', ({ specArg, payload }) => { - const filters: string[] = [] - if (specArg) { - // Nightwatch doesn't support file:line — strip any trailing line number - filters.push(specArg.split(':')[0]) - } - if (payload.entryType === 'test' && payload.label) { - filters.push('--testcase', payload.label) - } - return filters -}) - -// Nightwatch + Cucumber: feature files are resolved via the config's feature_path. -// Never pass .feature files as positional args — Nightwatch rejects them. -// Nightwatch forwards --name and --tags to the underlying Cucumber runner. -FRAMEWORK_FILTERS.set('nightwatch-cucumber', ({ payload }) => { - const filters: string[] = [] - - // Only pass --name for scenario-level reruns. Feature/file-level suites - // (suiteType === 'feature') run all their scenarios, so no --name filter. - const isFeatureLevel = payload.suiteType === 'feature' || payload.runAll - if (!isFeatureLevel && payload.fullTitle) { - // Wrap as an anchored exact regex so "Scenario A" never also matches - // "Scenario A-1" (Cucumber treats --name as a regex). - const escaped = payload.fullTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') - filters.push('--name', `^${escaped}$`) - } - return filters -}) - class TestRunner { #child?: ChildProcess #lastPayload?: RunnerRequestBody @@ -182,15 +56,15 @@ class TestRunner { const childEnv = { ...process.env } if (payload.devtoolsHost && payload.devtoolsPort) { - childEnv.DEVTOOLS_APP_HOST = payload.devtoolsHost - childEnv.DEVTOOLS_APP_PORT = String(payload.devtoolsPort) - childEnv.DEVTOOLS_APP_REUSE = '1' + childEnv[REUSE_ENV.HOST] = payload.devtoolsHost + childEnv[REUSE_ENV.PORT] = String(payload.devtoolsPort) + childEnv[REUSE_ENV.REUSE] = '1' } let child: ChildProcess if (isGenericShell) { const command = this.#resolveGenericCommand(payload) - this.#baseDir = process.env.DEVTOOLS_RUNNER_CWD || process.cwd() + this.#baseDir = process.env[RUNNER_ENV.RUNNER_CWD] || process.cwd() const { file, args } = this.#parseGenericCommand(command) child = spawn(file, args, { cwd: this.#baseDir, @@ -201,7 +75,7 @@ class TestRunner { } else { const configPath = this.#resolveConfigPath(payload) this.#baseDir = - process.env.DEVTOOLS_RUNNER_CWD || path.dirname(configPath) + process.env[RUNNER_ENV.RUNNER_CWD] || path.dirname(configPath) let args: string[] if (isNightwatch) { const nightwatchBin = resolveNightwatchBin(this.#baseDir) @@ -221,11 +95,11 @@ class TestRunner { } if (isNightwatch) { if (payload.entryType === 'test' && payload.label) { - childEnv.DEVTOOLS_RERUN_ENTRY_TYPE = 'test' - childEnv.DEVTOOLS_RERUN_LABEL = payload.label + childEnv[REUSE_ENV.RERUN_ENTRY_TYPE] = 'test' + childEnv[REUSE_ENV.RERUN_LABEL] = payload.label } else { - delete childEnv.DEVTOOLS_RERUN_ENTRY_TYPE - delete childEnv.DEVTOOLS_RERUN_LABEL + delete childEnv[REUSE_ENV.RERUN_ENTRY_TYPE] + delete childEnv[REUSE_ENV.RERUN_LABEL] } } child = spawn(process.execPath, args, { @@ -259,6 +133,13 @@ class TestRunner { // Targeted reruns substitute {{testName}} into rerunCommand; suite filtering // works because mocha/jest/cucumber filter flags match by name (describe/it/scenario alike). + // + // Exception: cucumber's `--name` matches scenario titles only, never feature + // titles — a suite-level rerun on a feature would substitute the feature name + // and match zero scenarios. When the payload looks like a cucumber feature + // rerun (entryType='suite', spec file ends in `.feature`, template carries + // `--name "{{testName}}"`), strip `--name` and pass the feature file as a + // positional arg so cucumber-js runs every scenario in that file. #resolveGenericCommand(payload: RunnerRequestBody): string { const template = payload.rerunCommand const fallback = payload.launchCommand || '' @@ -266,11 +147,29 @@ class TestRunner { !payload.runAll && (payload.entryType === 'test' || payload.entryType === 'suite') && Boolean(payload.label || payload.fullTitle) - if (template && isTargetedRerun) { - const name = payload.label || payload.fullTitle || '' - return template.replace(/\{\{testName\}\}/g, name) + if (!template || !isTargetedRerun) { + return fallback || template || '' } - return fallback || template || '' + // Cucumber's `--name` matches scenario titles, never feature titles. + // Feature-level reruns must drop `--name` and pass the .feature path as a + // positional arg. The dashboard tags the root suite with + // `suiteType: 'feature'`, which is what distinguishes a true feature-level + // rerun from a scenario rerun (scenarios are also `entryType: 'suite'` but + // `suiteType: 'suite'`). + const featureSpec = + payload.featureFile || + (payload.specFile?.endsWith('.feature') ? payload.specFile : undefined) + const isCucumberFeatureRerun = + payload.entryType === 'suite' && + payload.suiteType === 'feature' && + Boolean(featureSpec) && + /--name\s+"\{\{testName\}\}"/.test(template) + if (isCucumberFeatureRerun && featureSpec) { + const stripped = template.replace(/\s*--name\s+"\{\{testName\}\}"/, '') + return `${stripped} ${shellQuote([featureSpec])}` + } + const name = payload.label || payload.fullTitle || '' + return template.replace(/\{\{testName\}\}/g, name) } #parseGenericCommand(command: string): { file: string; args: string[] } { @@ -325,16 +224,15 @@ class TestRunner { : specFile : undefined - const candidateBuilder = FRAMEWORK_FILTERS.get(framework) - const builder = - typeof candidateBuilder === 'function' - ? candidateBuilder - : DEFAULT_FILTERS + // Cast: framework comes from an HTTP payload, so it's `string` at the + // boundary. getFilterBuilder() falls back to the default spec-only + // builder for unknown runners. + const builder = getFilterBuilder(framework as TestRunnerId) const baseFilters = builder({ specArg, payload }) // Scope "Run All" to the user's original --spec args. Nightwatch resolves specs via its own filter. if (payload.runAll && !framework.startsWith('nightwatch')) { - const initialSpecs = process.env.DEVTOOLS_WDIO_INITIAL_SPECS + const initialSpecs = process.env[RUNNER_ENV.WDIO_INITIAL_SPECS] if (initialSpecs) { const specs = initialSpecs.split(path.delimiter).filter(Boolean) for (const spec of specs) { @@ -401,8 +299,8 @@ class TestRunner { payload?.configFile, this.#lastPayload?.configFile, this.#registeredConfigFile, - process.env.DEVTOOLS_WDIO_CONFIG, - process.env.DEVTOOLS_NIGHTWATCH_CONFIG, + process.env[RUNNER_ENV.WDIO_CONFIG], + process.env[RUNNER_ENV.NIGHTWATCH_CONFIG], this.#findConfigFromSpec(specCandidate, isNightwatch), ...this.#expandDefaultConfigsFor(this.#baseDir, isNightwatch), ...this.#expandDefaultConfigsFor( @@ -478,85 +376,4 @@ class TestRunner { } } -function resolveNightwatchBin(baseDir: string): string { - const envOverride = process.env.DEVTOOLS_NIGHTWATCH_BIN - if (envOverride) { - const resolved = path.isAbsolute(envOverride) - ? envOverride - : path.resolve(process.cwd(), envOverride) - if (fs.existsSync(resolved)) { - return resolved - } - } - - // Walk up from baseDir looking for node_modules/nightwatch/package.json - // and resolve the actual JS entry (avoids running the shell-script wrapper - // at node_modules/.bin/nightwatch directly via node). - let dir = baseDir - const root = path.parse(dir).root - while (dir !== root) { - const nightwatchPkgPath = path.join( - dir, - 'node_modules', - 'nightwatch', - 'package.json' - ) - if (fs.existsSync(nightwatchPkgPath)) { - try { - const pkg = JSON.parse(fs.readFileSync(nightwatchPkgPath, 'utf8')) - const nightwatchDir = path.join(dir, 'node_modules', 'nightwatch') - const binEntry = - typeof pkg.bin === 'string' - ? pkg.bin - : (pkg.bin?.nightwatch ?? pkg.bin?.nw) - if (binEntry) { - const jsPath = path.resolve(nightwatchDir, binEntry) - if (fs.existsSync(jsPath)) { - return jsPath - } - } - } catch { - // malformed package.json — continue walking - } - } - const parent = path.dirname(dir) - if (parent === dir) { - break - } - dir = parent - } - - throw new Error( - 'Cannot find nightwatch binary. Install nightwatch locally or set DEVTOOLS_NIGHTWATCH_BIN env var.' - ) -} - -function resolveWdioBin() { - const envOverride = process.env.DEVTOOLS_WDIO_BIN - if (envOverride) { - const overriddenPath = path.isAbsolute(envOverride) - ? envOverride - : path.resolve(process.cwd(), envOverride) - if (!fs.existsSync(overriddenPath)) { - throw new Error( - `DEVTOOLS_WDIO_BIN "${overriddenPath}" does not exist or is not accessible` - ) - } - return overriddenPath - } - - try { - const cliEntry = require.resolve('@wdio/cli') - const candidate = path.resolve(path.dirname(cliEntry), '../bin/wdio.js') - if (!fs.existsSync(candidate)) { - throw new Error(`Derived WDIO bin "${candidate}" does not exist`) - } - return candidate - } catch (error) { - throw new Error( - `Failed to resolve WDIO binary. Provide DEVTOOLS_WDIO_BIN env var. ${(error as Error).message}` - ) - } -} - export const testRunner = new TestRunner() diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts index faa055f3..61a3ded6 100644 --- a/packages/backend/src/types.ts +++ b/packages/backend/src/types.ts @@ -13,23 +13,4 @@ export const NIGHTWATCH_CONFIG_FILENAMES = [ 'nightwatch.json' ] as const -export interface RunnerRequestBody { - uid: string - entryType: 'suite' | 'test' - specFile?: string - fullTitle?: string - label?: string - callSource?: string - runAll?: boolean - framework?: string - configFile?: string - lineNumber?: number - devtoolsHost?: string - devtoolsPort?: number - featureFile?: string - featureLine?: number - suiteType?: string - rerunCommand?: string - launchCommand?: string - preserveBaseline?: boolean -} +export type { RunnerRequestBody } from '@wdio/devtools-shared' diff --git a/packages/backend/src/worker-message-handler.ts b/packages/backend/src/worker-message-handler.ts new file mode 100644 index 00000000..4bbc981b --- /dev/null +++ b/packages/backend/src/worker-message-handler.ts @@ -0,0 +1,88 @@ +import logger from '@wdio/logger' +import { WS_SCOPE } from '@wdio/devtools-shared' +import type { baselineStore as BaselineStore } from './baselineStore.js' +import type { testRunner as TestRunner } from './runner.js' + +const log = logger('@wdio/devtools-backend') + +export interface WorkerMessageContext { + baselineStore: typeof BaselineStore + testRunner: typeof TestRunner + videoRegistry: Map + broadcastToClients: (message: string) => void + clientCount: () => number +} + +/** + * Build the worker WS `message` listener for {@link WS_PATHS.worker}. Handles + * three control scopes inline (`clearCommands`, `config`, `screencast`) and + * forwards everything else verbatim to the dashboard clients. + */ +export function createWorkerMessageHandler( + ctx: WorkerMessageContext +): (message: Buffer) => void { + return (message: Buffer) => { + // Use `debug` — at `info` level this feeds the worker's stream + // capture and creates a backend↔capture loop. + const count = ctx.clientCount() + log.debug( + `received ${message.length} byte message from worker to ${count} client${count > 1 ? 's' : ''}` + ) + + try { + const parsed = JSON.parse(message.toString()) + + if (parsed.scope === WS_SCOPE.clearCommands) { + const testUid = parsed.data?.testUid + log.info(`Clearing commands for test: ${testUid || 'all'}`) + // Mirror the dashboard's reset behavior: clearing without a uid + // is a full reset, so wipe the baseline accumulator too. + if (!testUid) { + ctx.baselineStore.resetActiveRun() + } + ctx.broadcastToClients( + JSON.stringify({ + scope: WS_SCOPE.clearExecutionData, + data: { uid: testUid } + }) + ) + return + } + + if (parsed.scope === 'config' && parsed.data?.configFile) { + ctx.testRunner.registerConfigFile(parsed.data.configFile) + log.info(`Registered config file for reruns: ${parsed.data.configFile}`) + return + } + + // Intercept screencast messages: store the absolute videoPath in the + // registry (backend-only), then forward only the sessionId to the UI + // so the UI can request the video via GET /api/video/:sessionId. + if (parsed.scope === 'screencast' && parsed.data?.sessionId) { + const { sessionId, videoPath } = parsed.data + if (videoPath) { + ctx.videoRegistry.set(sessionId, videoPath) + log.info( + `Screencast registered for session ${sessionId}: ${videoPath}` + ) + } + ctx.broadcastToClients( + JSON.stringify({ + scope: 'screencast', + data: { sessionId } + }) + ) + return + } + // Tee the event into the baseline accumulator for time-window + // partitioning at preserve time. Done after special-case handling + // so we don't accumulate control frames (clearCommands, screencast). + ctx.baselineStore.recordEvent(parsed.scope, parsed.data) + } catch { + // Not JSON or parsing failed, forward as-is + } + + // Forward all other messages as-is + ctx.broadcastToClients(message.toString()) + } +} diff --git a/packages/backend/tsconfig.json b/packages/backend/tsconfig.json index 7cfb7e99..9ccab6be 100644 --- a/packages/backend/tsconfig.json +++ b/packages/backend/tsconfig.json @@ -5,7 +5,7 @@ "module": "NodeNext", "moduleResolution": "NodeNext", "outDir": "dist", - "rootDir": "src", + "rootDir": "..", "noEmit": false, "allowImportingTsExtensions": false, "declaration": true diff --git a/packages/core/package.json b/packages/core/package.json new file mode 100644 index 00000000..a23022a1 --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,34 @@ +{ + "name": "@wdio/devtools-core", + "version": "0.0.0", + "private": true, + "description": "Framework-agnostic capture/reporter logic shared by @wdio/devtools-* adapters. Workspace-internal, never published — code is inlined into each consuming adapter at build time.", + "repository": { + "type": "git", + "url": "git+https://github.com/webdriverio/devtools.git", + "directory": "packages/core" + }, + "type": "module", + "sideEffects": false, + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./*": { + "types": "./src/*.ts", + "default": "./src/*.ts" + } + }, + "types": "./src/index.ts", + "scripts": { + "lint": "eslint ." + }, + "license": "MIT", + "devDependencies": { + "@wdio/devtools-shared": "workspace:^", + "@types/ws": "^8.18.1", + "stacktrace-parser": "^0.1.11", + "ws": "^8.18.3" + } +} diff --git a/packages/core/src/console.ts b/packages/core/src/console.ts new file mode 100644 index 00000000..2034eb05 --- /dev/null +++ b/packages/core/src/console.ts @@ -0,0 +1,129 @@ +import type { ConsoleLog, LogLevel, LogSource } from '@wdio/devtools-shared' + +/** + * Console methods we intercept to forward test/runner-process output into the + * UI Console tab. + */ +export const CONSOLE_METHODS = ['log', 'info', 'warn', 'error'] as const + +/** + * Strips ANSI escape sequences (colour codes, cursor moves, etc.) from + * terminal output so the UI Console renders plain text. The pattern accepts + * any trailing letter, not just `m`, so cursor/style sequences are handled + * too. + */ +export const ANSI_REGEX = /\x1b\[[?]?[0-9;]*[A-Za-z]/g + +export function stripAnsi(text: string): string { + return text.replace(ANSI_REGEX, '') +} + +/** + * Log-level detection patterns, applied in priority order (highest to + * lowest). The first matching pattern wins. + */ +export const LOG_LEVEL_PATTERNS: ReadonlyArray<{ + level: 'trace' | 'debug' | 'info' | 'warn' | 'error' + pattern: RegExp +}> = [ + { level: 'trace', pattern: /\btrace\b/i }, + { level: 'debug', pattern: /\bdebug\b/i }, + { level: 'info', pattern: /\binfo\b/i }, + { level: 'warn', pattern: /\bwarn(ing)?\b/i }, + { level: 'error', pattern: /\berror\b/i } +] as const + +/** Visual indicators that suggest error-level logs in unstructured output. */ +export const ERROR_INDICATORS = ['✗', 'failed', 'failure'] as const + +/** + * Matches the leading Braille spinner glyphs that runners (Nightwatch CLI, + * Selenium tooling) emit for in-place progress updates. Adapters skip lines + * that match this so the dashboard's Console tab isn't flooded with frames. + */ +export const SPINNER_RE = /^[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]/u + +/** + * Filter out terminal/stream lines that would feed back into the WS bridge + * and cause an infinite forwarding loop: pino JSON output, [SESSION] markers, + * backend logger lines, Jest console framing, and bare stack-frame lines. + * + * Adapters call this from their stream-patch before forwarding lines to the + * UI Console tab. Combine with SPINNER_RE for full noise filtering. + */ +export function isInternalStreamLine(line: string): boolean { + const t = line.trim() + if (t.startsWith('{"') || t.startsWith('[SESSION]')) { + return true + } + if (t.includes('@wdio/devtools-backend')) { + return true + } + if (/^console\.(log|info|warn|error|debug|trace)$/.test(t)) { + return true + } + if (/^at\s.+:\d+:\d+\)?$/.test(t)) { + return true + } + return false +} + +/** Enum-style accessor for the canonical LogSource values from shared. */ +export const LOG_SOURCES = { + BROWSER: 'browser', + TEST: 'test', + TERMINAL: 'terminal' +} as const satisfies Record + +export type { LogSource } from '@wdio/devtools-shared' + +/** + * Classify a line of unstructured terminal output by scanning for log-level + * keywords. Falls back to `'log'` when no pattern matches. + */ +export function detectLogLevel(text: string): LogLevel { + const normalised = stripAnsi(text).toLowerCase() + for (const { level, pattern } of LOG_LEVEL_PATTERNS) { + if (pattern.test(normalised)) { + return level + } + } + if (ERROR_INDICATORS.some((i) => normalised.includes(i.toLowerCase()))) { + return 'error' + } + return 'log' +} + +/** Build a ConsoleLog entry tagged with the supplied source. */ +export function createConsoleLogEntry( + type: LogLevel, + args: any[], + source: LogSource = LOG_SOURCES.TEST +): ConsoleLog { + return { timestamp: Date.now(), type, args, source } +} + +/** + * Map a Chrome DevTools log-level string (or `{name, value}` object) to our + * `LogLevel` union. Used by CDP/BiDi consumers that surface browser-side + * console output through SEVERE/WARNING/INFO/DEBUG severity names. + */ +export function chromeLogLevelToLogLevel( + level: string | { value?: number; name?: string } +): LogLevel { + const levelName = ( + typeof level === 'object' ? (level?.name ?? '') : (level ?? '') + ).toUpperCase() + switch (levelName) { + case 'SEVERE': + return 'error' + case 'WARNING': + return 'warn' + case 'INFO': + return 'info' + case 'DEBUG': + return 'debug' + default: + return 'log' + } +} diff --git a/packages/core/src/error.ts b/packages/core/src/error.ts new file mode 100644 index 00000000..7422fb84 --- /dev/null +++ b/packages/core/src/error.ts @@ -0,0 +1,79 @@ +/** Plain-object shape of an Error after `serializeError`. */ +export interface SerializedError { + name: string + message: string + stack?: string +} + +/** + * Coerce an unknown value (caught exception, framework-supplied error + * object, string, etc.) into an Error instance. Used at adapter command + * boundaries where caught values can be anything — Error subclasses, + * thrown strings, framework objects with a `.message` — and downstream + * code wants a stable `Error` to inspect and serialize. + */ +export function toError(value: unknown): Error { + if (value instanceof Error) { + return value + } + if ( + value !== null && + typeof value === 'object' && + typeof (value as { message?: unknown }).message === 'string' + ) { + const e = new Error((value as { message: string }).message) + const name = (value as { name?: unknown }).name + if (typeof name === 'string') { + e.name = name + } + return e + } + return new Error(String(value)) +} + +/** + * Extract a printable message from a caught value. Equivalent to reading + * `.message` on an Error, but degrades cleanly when the thrown value is a + * string, a plain object, undefined, or anything else — `(err as Error).message` + * silently returns `undefined` in those cases and yields useless log output. + */ +export function errorMessage(value: unknown): string { + if (value instanceof Error) { + return value.message + } + if (typeof value === 'string') { + return value + } + if ( + value !== null && + typeof value === 'object' && + typeof (value as { message?: unknown }).message === 'string' + ) { + return (value as { message: string }).message + } + if (value === undefined || value === null) { + return 'unknown error' + } + try { + return String(value) + } catch { + return 'unknown error' + } +} + +/** + * Normalize an Error to a plain object so its fields survive `JSON.stringify` + * over the WS bridge. Error instances have `message`/`name`/`stack` as + * non-enumerable, which `JSON.stringify` would drop. + * + * Returns `undefined` when the input is undefined so callers can pass through + * possibly-undefined values without an extra branch. + */ +export function serializeError( + error: Error | undefined +): SerializedError | undefined { + if (!error) { + return undefined + } + return { name: error.name, message: error.message, stack: error.stack } +} diff --git a/packages/core/src/finalize-screencast.ts b/packages/core/src/finalize-screencast.ts new file mode 100644 index 00000000..ed0a2474 --- /dev/null +++ b/packages/core/src/finalize-screencast.ts @@ -0,0 +1,83 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import type { ScreencastInfo } from '@wdio/devtools-shared' + +import type { ScreencastRecorderBase } from './screencast.js' +import { errorMessage } from './error.js' +import { encodeToVideo } from './video-encoder.js' + +export interface FinalizeScreencastInput { + recorder: ScreencastRecorderBase + sessionId: string + /** Filename without the .webm suffix (e.g. 'wdio-video', 'selenium-video'). */ + filenamePrefix: string + /** Preferred output dir; falls back to cwd, then os.tmpdir() if unwritable. */ + outputDir?: string + /** Skip encoding when the recorder collected fewer frames than this. */ + minFrames?: number + captureFormat?: 'jpeg' | 'png' + /** Forward the encoded-video metadata to the dashboard. */ + sendUpstream: (scope: string, data: ScreencastInfo) => void + /** Optional hook for adapter-side logging on each lifecycle step. */ + onLog?: (level: 'info' | 'warn', message: string) => void +} + +/** + * Stop the recorder, encode its frames to a `.webm` (preferred dir → cwd → + * tmpdir), and forward the metadata to the dashboard. All errors are caught + * and reported via `onLog` — screencast is best-effort and must not abort the + * run on stop/encode failure. + * + * Shared across all three adapters: each one provides only the recorder + * subclass, the filename prefix, and a sendUpstream binding to its + * SessionCapturer. + */ +export async function finalizeScreencast({ + recorder, + sessionId, + filenamePrefix, + outputDir, + minFrames = 1, + captureFormat, + sendUpstream, + onLog +}: FinalizeScreencastInput): Promise { + const log = (level: 'info' | 'warn', message: string) => + onLog?.(level, message) + + try { + await recorder.stop() + } catch (err) { + log('warn', `Screencast stop failed: ${errorMessage(err)}`) + return + } + + const frames = recorder.frames + if (frames.length < minFrames) { + return + } + + const fileName = `${filenamePrefix}-${sessionId}.webm` + const candidate = outputDir || process.cwd() + let videoPath = path.join(candidate, fileName) + try { + fs.accessSync(candidate, fs.constants.W_OK) + } catch { + videoPath = path.join(os.tmpdir(), fileName) + } + + try { + await encodeToVideo(frames, videoPath, { captureFormat }) + log('info', `📹 Screencast video: ${videoPath}`) + sendUpstream('screencast', { + sessionId, + videoPath, + videoFile: fileName, + frameCount: frames.length, + duration: recorder.duration + }) + } catch (err) { + log('warn', `Screencast encode failed: ${errorMessage(err)}`) + } +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts new file mode 100644 index 00000000..5e9c418e --- /dev/null +++ b/packages/core/src/index.ts @@ -0,0 +1,15 @@ +// Framework-agnostic capture/reporter logic shared by @wdio/devtools-* +// adapters. See ARCHITECTURE.md §2 and CLAUDE.md §2.2. + +export * from './console.js' +export * from './uid.js' +export * from './net.js' +export * from './stack.js' +export * from './error.js' +export * from './finalize-screencast.js' +export * from './retry-tracker.js' +export * from './screencast.js' +export * from './script-loader.js' +export * from './session-capturer.js' +export * from './test-reporter.js' +export * from './video-encoder.js' diff --git a/packages/core/src/net.ts b/packages/core/src/net.ts new file mode 100644 index 00000000..eaf385da --- /dev/null +++ b/packages/core/src/net.ts @@ -0,0 +1,76 @@ +import * as net from 'node:net' + +/** + * Return true if the given TCP port on `hostname` cannot be bound for + * listening (already in use, or otherwise unavailable). + */ +export function isPortInUse(port: number, hostname: string): Promise { + return new Promise((resolve) => { + const server = net.createServer() + server.once('error', () => resolve(true)) + server.once('listening', () => server.close(() => resolve(false))) + server.listen(port, hostname) + }) +} + +/** + * Walk upward from `startPort` until a free port is found and return it. + * Silent: callers that want to log retries should wrap this themselves. + */ +export async function findFreePort( + startPort: number, + hostname: string +): Promise { + let port = startPort + while (await isPortInUse(port, hostname)) { + port++ + } + return port +} + +/** + * Classify an HTTP request into the categories the dashboard's Network tab + * uses, preferring the response `mimeType` and falling back to URL extension + * heuristics. Unknown shapes return `'xhr'`. + */ +export function getRequestType(url: string, mimeType?: string): string { + const contentType = mimeType?.toLowerCase() ?? '' + const urlLower = url.toLowerCase() + if (contentType.includes('text/html')) { + return 'document' + } + if (contentType.includes('text/css')) { + return 'stylesheet' + } + if ( + contentType.includes('javascript') || + contentType.includes('ecmascript') + ) { + return 'script' + } + if (contentType.includes('image/')) { + return 'image' + } + if (contentType.includes('font/') || contentType.includes('woff')) { + return 'font' + } + if (contentType.includes('application/json')) { + return 'fetch' + } + if (urlLower.endsWith('.html') || urlLower.endsWith('.htm')) { + return 'document' + } + if (urlLower.endsWith('.css')) { + return 'stylesheet' + } + if (urlLower.endsWith('.js') || urlLower.endsWith('.mjs')) { + return 'script' + } + if (/\.(png|jpg|jpeg|gif|svg|webp|ico)$/.test(urlLower)) { + return 'image' + } + if (/\.(woff|woff2|ttf|eot|otf)$/.test(urlLower)) { + return 'font' + } + return 'xhr' +} diff --git a/packages/core/src/retry-tracker.ts b/packages/core/src/retry-tracker.ts new file mode 100644 index 00000000..332e3cdb --- /dev/null +++ b/packages/core/src/retry-tracker.ts @@ -0,0 +1,62 @@ +/** + * Tiny state holder for command-retry detection. Both the selenium and + * nightwatch adapters need exactly this same pattern: compute a stable + * signature for the incoming command, compare it to the last one we + * captured, and treat a match as "the framework is retrying — replace the + * previous entry instead of pushing a new one". + * + * The signature is JSON-stringified `{command, args, src: callSource}`. Test + * boundaries (new test, new scenario) call `reset()` to drop the last + * signature so a deliberate re-run of the same call counts as a fresh + * command, not a retry. + */ +export class RetryTracker { + #lastSig: string | null = null + #lastId: number | null = null + + /** Build the canonical signature used for retry-equality checks. */ + static signature( + command: string, + args: unknown, + callSource?: string + ): string { + return JSON.stringify({ command, args, src: callSource ?? null }) + } + + /** True when the incoming signature matches the last captured one AND we + * have an id to replace (otherwise there's nothing to replace yet). */ + isRetry(sig: string): boolean { + return sig === this.#lastSig && this.#lastId !== null + } + + /** The id of the last captured command, if any (for the replace-in-place + * flow). */ + get lastId(): number | null { + return this.#lastId + } + + /** Record a fresh capture — sets both sig and id together. */ + recordCapture(sig: string, id: number | null): void { + this.#lastSig = sig + this.#lastId = id + } + + /** Record only the id (used by adapters that compute the sig but defer the + * id assignment to after an async capture call). */ + setLastId(id: number | null): void { + this.#lastId = id + } + + /** Stage the sig before an async capture so the next call already sees the + * signature change (prevents stale-sig matches on rapid back-to-back + * commands). Pair with {@link setLastId} once the capture resolves. */ + setLastSig(sig: string): void { + this.#lastSig = sig + } + + /** Reset at test/scenario boundaries so the next capture is "fresh". */ + reset(): void { + this.#lastSig = null + this.#lastId = null + } +} diff --git a/packages/core/src/screencast.ts b/packages/core/src/screencast.ts new file mode 100644 index 00000000..892d1bcd --- /dev/null +++ b/packages/core/src/screencast.ts @@ -0,0 +1,212 @@ +import type { ScreencastFrame, ScreencastOptions } from '@wdio/devtools-shared' +import { SCREENCAST_DEFAULTS } from '@wdio/devtools-shared' + +/** + * Shared screencast scaffolding consumed by every adapter (service, selenium, + * nightwatch). Owns the frame buffer, public API (start/stop/setStartMarker, + * frames/duration/isRecording getters) and the polling fallback. Subclasses + * provide framework-specific driver access: + * + * - `takeScreenshot()` — required. Used by the polling path. + * - `tryStartCdp() / tryStopCdp()` — optional CDP push-mode override. + * Default returns false → falls through to polling. + * + * Adapters that have a stable CDP escape hatch (WDIO via getPuppeteer, + * Selenium via createCDPConnection) override the CDP hooks. Nightwatch + * inherits the polling-only default — works on every browser Nightwatch + * supports without extra plumbing. + */ +export abstract class ScreencastRecorderBase { + protected buffer: ScreencastFrame[] = [] + protected options: Required + protected driver?: TDriver + #pollTimer: ReturnType | undefined + #isRecording = false + #cdpActive = false + #startIndex = 0 + #startMarkerSet = false + + constructor(options: ScreencastOptions = {}) { + this.options = { ...SCREENCAST_DEFAULTS, ...options } + } + + /** + * Start recording. Tries the CDP fast-path first (if the subclass overrode + * `tryStartCdp`); falls back to screenshot polling otherwise. Safe to call + * even if the browser doesn't support screenshots — failures are logged and + * recording is simply skipped. + */ + async start(driver: TDriver): Promise { + if (this.#isRecording) { + return + } + this.driver = driver + const cdpOk = await this.tryStartCdp() + if (cdpOk) { + this.#cdpActive = true + this.#isRecording = true + return + } + await this.#startPolling() + } + + /** + * Stop recording and release resources. Safe to call even if start() was + * never called or failed. + */ + async stop(): Promise { + if (!this.#isRecording) { + return + } + if (this.#cdpActive) { + await this.tryStopCdp() + this.#cdpActive = false + } else if (this.#pollTimer !== undefined) { + this.#stopPolling() + } + this.#isRecording = false + } + + /** + * Mark the current frame position as the start of meaningful recording. + * Frames captured before this call (blank browser, pre-navigation pauses) + * are excluded from `frames`. Idempotent — only the first call takes effect. + */ + setStartMarker(): void { + if (!this.#startMarkerSet) { + this.#startMarkerSet = true + this.#startIndex = this.buffer.length + } + } + + /** Frames to encode — everything from the first meaningful action onwards. */ + get frames(): ScreencastFrame[] { + return this.buffer.slice(this.#startIndex) + } + + /** Duration in ms between first and last captured frame. Zero if <2 frames. */ + get duration(): number { + const f = this.frames + if (f.length < 2) { + return 0 + } + return f[f.length - 1].timestamp - f[0].timestamp + } + + get isRecording(): boolean { + return this.#isRecording + } + + // ─── Subclass hooks ────────────────────────────────────────────────────── + + /** + * Capture a single screenshot via the framework's driver API. Used by the + * polling fallback. Return `null` to indicate a transient failure (loop + * continues); throw to abort polling entirely. + */ + protected abstract takeScreenshot(): Promise + + /** + * Try to start CDP push-mode recording. Return `true` on success. Default + * returns `false` → caller falls back to polling. Subclasses that wire CDP + * push themselves (WDIO via Puppeteer, Selenium via createCDPConnection) + * override and push frames into `this.frames` directly when CDP fires. + */ + protected async tryStartCdp(): Promise { + return false + } + + /** Stop the CDP push-mode session started by `tryStartCdp`. */ + protected async tryStopCdp(): Promise { + // no-op + } + + /** + * Helper for CDP subclasses: push a frame onto the buffer with the right + * timestamp normalization (CDP gives seconds-as-float; we store ms). + */ + protected pushCdpFrame(data: string, timestampSeconds?: number): void { + const timestamp = + typeof timestampSeconds === 'number' + ? Math.round(timestampSeconds * 1000) + : Date.now() + this.buffer.push({ data, timestamp }) + } + + /** Whether `setStartMarker` (or `markStartAtLatest`) has fired yet. */ + protected get hasStartMarker(): boolean { + return this.#startMarkerSet + } + + /** + * Anchor the start marker to the most recently pushed frame. Used by + * subclasses that detect the first content-bearing frame heuristically + * (e.g. selenium's blank-frame-byte-size threshold) and want to skip the + * preceding about:blank dead-air without waiting for an explicit caller. + */ + protected markStartAtLatest(): void { + if (!this.#startMarkerSet) { + this.#startMarkerSet = true + this.#startIndex = Math.max(0, this.buffer.length - 1) + } + } + + // ─── Polling implementation ───────────────────────────────────────────── + + /** + * Hook fired when the polling loop starts. Default: no-op. Subclasses + * (adapters with their own logger) override to surface visibility. + */ + protected onPollingStarted(_intervalMs: number): void { + // no-op + } + + /** Hook fired when polling stops cleanly (driver still alive at the time). */ + protected onPollingStopped(_frameCount: number): void { + // no-op + } + + /** Hook fired when the polling fallback couldn't even take the first shot. */ + protected onUnavailable(_err: unknown): void { + // no-op + } + + // ─── Polling implementation ───────────────────────────────────────────── + + async #startPolling(): Promise { + try { + const first = await this.takeScreenshot() + if (first === null) { + this.onUnavailable(new Error('first screenshot returned null')) + return + } + this.buffer.push({ data: first, timestamp: Date.now() }) + + const intervalMs = this.options.pollIntervalMs + this.#pollTimer = setInterval(async () => { + try { + const data = await this.takeScreenshot() + if (data !== null) { + this.buffer.push({ data, timestamp: Date.now() }) + } + } catch { + // Session ended mid-interval — stop polling gracefully. + this.#stopPolling() + } + }, intervalMs) + + this.#isRecording = true + this.onPollingStarted(intervalMs) + } catch (err) { + this.onUnavailable(err) + } + } + + #stopPolling(): void { + if (this.#pollTimer !== undefined) { + clearInterval(this.#pollTimer) + this.#pollTimer = undefined + this.onPollingStopped(this.buffer.length) + } + } +} diff --git a/packages/core/src/script-loader.ts b/packages/core/src/script-loader.ts new file mode 100644 index 00000000..a17a472a --- /dev/null +++ b/packages/core/src/script-loader.ts @@ -0,0 +1,41 @@ +import fs from 'node:fs/promises' +import path from 'node:path' +import { createRequire } from 'node:module' + +const require = createRequire(import.meta.url) + +/** + * Load the `@wdio/devtools-script` browser preload, wrapped in an async IIFE + * so its top-level `await` works inside a regular `