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..6d4fecc0 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,235 @@ +# Architecture + +A descriptive map of how the pieces fit together. For conventions and coding standards, see [CLAUDE.md](./CLAUDE.md). + +--- + +## At a glance + +A devtools dashboard for end-to-end browser tests. Three test frameworks (WebdriverIO, Nightwatch, Selenium) push the same normalized event stream through a single backend into a single browser UI. + +``` +[user's test framework] + │ + ▼ + [adapter] thin: framework-specific hooks + driver patching + │ + ▼ + [core] framework-agnostic capture/reporting library + │ + ▼ (WS frames typed by shared) + [backend] Fastify + WS gateway + baseline store + rerun spawner + │ + ▼ (WS + HTTP, both typed by shared) + [app] Lit browser UI, framework-agnostic +``` + +A separate piece, **`packages/script`**, is injected into the browser under test (not Node) to capture DOM mutations from the page's own JS context. It communicates back through the adapter, not directly to the backend. + +--- + +## Packages + +The workspace is a pnpm monorepo. Two of the packages (`shared`, `core`) are workspace-internal — they're marked `"private": true` and never published; consumers bundle their code into their own `dist/`. + +### `packages/shared` + +Types, constants, enums, HTTP/WS contract definitions. Pure TypeScript, no runtime dependencies on any other package in the monorepo. Workspace-internal; inlined into every consumer at build time. + +Contains the canonical definitions for: + +- Domain types: `CommandLog`, `ConsoleLog`, `NetworkRequest`, `TraceMutation`, `Metadata`, `TraceLog`, `TraceType`, `TestStats`, `SuiteStats`, `TestStatus`, `TestError`, `ReporterError`, `PreservedAttempt`, `PreservedStep`, `PerformanceData`, `DocumentInfo`, `Viewport`, `ScreencastInfo`, `ScreencastFrame`, `ScreencastOptions`, `LogLevel`, `LogSource`. +- WS wire format: `SocketMessage`, `WsMessageScope`, `WsPayloadFor`, `ClearExecutionDataWsPayload`, `ReplaceCommandWsPayload`. +- Routing/scope constants: `WS_PATHS`, `WS_SCOPE`, `BASELINE_WS_SCOPE`, `TESTS_API`, `BASELINE_API`. +- Process-control env vars: `REUSE_ENV`, `RUNNER_ENV`. +- Defaults: `TIMING_BASE`, `DEFAULTS_BASE`, `SCREENCAST_DEFAULTS`. +- File patterns: `SPEC_FILE_RE`, `FEATURE_FILE_RE` (the latter Cucumber-only). +- Test-runner identification: `TestRunnerId = 'mocha' | 'jasmine' | 'cucumber' | 'nightwatch' | 'nightwatch-cucumber' | 'selenium-webdriver'`. + +Imports from: nothing. Imported by: every other package. + +### `packages/core` + +Framework-agnostic capture and reporting library. Workspace-internal; inlined into each adapter at build time. + +Contains: + +- `SessionCapturerBase` — orchestrates per-session capture (console/stream patching, WS connection, command-id bookkeeping, upstream-send guard with `onUpstreamDrop` hook). +- `TestReporterBase` — common reporter behavior, extended by Nightwatch + Selenium reporters (Service uses `@wdio/reporter` from WDIO directly). +- `ScreencastRecorderBase` — frame buffer + polling fallback shared by all three adapters. +- `resolveAdapterOutputDir` — the dir-resolution helper that picks where screencast/trace files land (test-file dir → config dir → cwd, with a `node_modules/` skip). +- Pure helpers: `assert-patcher`, `bidi` (`attachBidiHandlers`, `loadSeleniumSubmodule`, `arrayHeadersToObject`), `console` (`stripAnsi`, `detectLogLevel`, `createConsoleLogEntry`, `mapChromeBrowserLogs`, `chromeLogLevelToLogLevel`), `error` (`serializeError`, `errorMessage`), `finalize-screencast`, `net` (`isPortInUse`, `findFreePort`, `getRequestType`), `performance-capture` (`CAPTURE_PERFORMANCE_SCRIPT`, `applyPerformanceData`), `retry-tracker`, `script-loader` (`loadInjectableScript`, `pollUntilReady`), `stack` (`isUserCodeFrame`, `normalizeFilePath`, `getCallSourceFromStack`), `suite-helpers`, `test-discovery` (`findTestDefinitions`, `extractTestMetadata`), `uid` (`generateStableUid`, `deterministicUid`, `resetSignatureCounters`), `video-encoder` (`encodeToVideo`). + +Imports from: `shared`. Imported by: all three adapter packages. + +### `packages/service` — WebdriverIO adapter + +WebdriverIO-specific glue. + +Contains: WDIO service hooks (`beforeCommand`, `afterCommand`, `beforeTest`, `afterTest`, `beforeSession`, `afterSession`, `onPrepare`, `onComplete`), a reporter that extends WDIO's `Reporters.ReporterEntry`, the BiDi listener wiring (`bidi-listeners.ts`), launcher entry point, cucumber step-definition AST scanning, and the standalone runner (`standalone.ts`). + +Imports from: `@wdio/types`, `@wdio/reporter`, `@wdio/logger`, `@wdio/protocols`, `webdriverio`, `core`, `shared`. + +### `packages/nightwatch-devtools` — Nightwatch adapter + +Nightwatch-specific glue. + +Contains: + +- The `NightwatchDevToolsPlugin` class + factory in `index.ts`. +- Lifecycle modules: `run-lifecycle.ts`, `test-lifecycle.ts`, `cucumber-lifecycle.ts`, `session-init.ts`, `event-hub.ts`. +- `BrowserProxy` (in `helpers/`) that wraps Nightwatch's browser API and forwards each command into the session capturer. +- A `SessionCapturer` subclass + a Nightwatch-flavored `SuiteManager` / `TestManager`. +- BiDi opt-in support (gated on `bidi: true` in plugin options + the `webSocketUrl: true` capability). +- Cucumber wiring: `cucumberHooks.cjs` (registered via the Cucumber `require` option), feature-file scanning, step-definition resolution. +- A perf-log → NetworkRequest parser (`helpers/perfLogs.ts`) for the CDP perf-log path when BiDi isn't attached. + +Imports from: `@wdio/logger`, `core`, `shared`. Does not import: other adapter packages, `backend`, `app`. + +### `packages/selenium-devtools` — Selenium adapter + +Selenium-webdriver-specific glue. + +Contains: + +- `driverPatcher.ts` — wraps `selenium-webdriver`'s `WebDriver` / `WebElement` / `Builder` prototypes with command capture. +- Per-runner hooks for Mocha, Jest, Jasmine, Vitest, and Cucumber (`runnerHooks/*.ts`). +- Native BiDi via `selenium-webdriver/bidi`. +- Driver-launch + dashboard-launch helpers, detached-backend mode, process-hook shutdown. +- `SessionCapturer` subclass + Selenium-flavored `SuiteManager` / `TestManager`. + +Imports from: `core`, `shared`, `selenium-webdriver` (peer). Does not import: other adapter packages, `backend`, `app`. + +### `packages/backend` + +The server adapters connect to and the app talks to. + +Contains: + +- Fastify HTTP server. +- WebSocket gateway: one connection per adapter worker, one per app client. +- Baseline store (in-memory) for preserve-and-rerun; reuses `shared` types directly via thin `*Like` aliases (`baseline/types.ts`). +- Test runner spawner (`runner.ts`) — spawns the user's `wdio` / `nightwatch` / `selenium` binary with rerun filters. +- Framework-specific CLI args live in `framework-filters.ts` — a `switch` over `TestRunnerId` returning the right `FilterBuilder`. (The switch shape is deliberate: CodeQL trusts compile-time-known callable selection, table dispatch trips its `unvalidated-dynamic-method-call` query.) +- Bin resolver (`bin-resolver.ts`) — finds the WDIO/Nightwatch CLI in the user's `node_modules/` or `npx` cache. +- Worker-message handler (`worker-message-handler.ts`) — dispatches messages from spawned workers (config/sessionId/videoPath/...). + +Framework awareness lives only in `runner.ts` and `framework-filters.ts`, always through `TestRunnerId`, never magic strings. + +Imports from: `shared`. Does not import: any adapter package, `app`, or `core` (the backend doesn't capture; core is for capturers). + +### `packages/app` + +The browser UI. + +Contains: + +- Lit web components (sidebar/explorer, workbench/compare, workbench/console, workbench/network, workbench/snapshot, etc.). +- WebSocket client for the live event stream. +- Context providers (`@lit/context`) for each data stream. +- `DataManagerController` — orchestrates the WS connection and the 11 context providers (one per scope). +- Pure helpers: suite-merge logic, mark-running logic, run-detection logic, context-update transforms (`contextUpdates.ts`), runner-capability derivations (`runnerCapabilities.ts`). + +Imports from: `shared`. Does not import: any adapter package, `backend` directly (only via WS/HTTP), `core`. + +### `packages/script` + +Browser-injected runtime — runs **inside the page under test**, not in Node. + +Contains: DOM mutation observers, page-side trace collection, a small logger. It's loaded into the page via `loadInjectableScript()` (which reads the built `dist/script.js`) and communicates back through the WebDriver bridge (`executeScript` / `getLog`), not directly to the backend. + +The execution environment is the browser, not Node, so this package cannot import from `core` (Node-only) or from non-browser-safe parts of `shared`. + +### `examples/` + +Per-framework demo projects used for manual verification. + +- `examples/wdio/` — WebdriverIO with Mocha (default). Run via `pnpm demo:wdio`. +- `examples/nightwatch/` — Nightwatch (both vanilla and Cucumber). Run via `pnpm demo:nightwatch`. +- `examples/selenium/` — Selenium with subdirs for `mocha-test/`, `jest-test/`, `cucumber-test/`, `jasmine-test/`, `vitest-test/`. `pnpm demo:selenium` runs mocha; `pnpm --filter @wdio/selenium-devtools example:` runs the others. + +--- + +## Data flow + +### A test run, end to end + +1. The user runs their normal command (`wdio run …`, `nightwatch test`, `mocha + selenium`, ...). +2. The framework loads its adapter via service/plugin config. +3. The adapter constructs a `SessionCapturer` (subclass of `core`'s `SessionCapturerBase`). The base class opens a WS connection to the backend, patches `console.*`, intercepts stdout/stderr, and installs the upstream-send guard. +4. The framework fires lifecycle hooks (suite/test start, command, etc.). The adapter translates each into a `core` call. +5. `core` builds the typed event per `shared` schema and pushes it through the WS. +6. `backend` receives the event, optionally persists it (baseline store, video registry), and broadcasts to every connected app client. +7. `app` updates its Lit components reactively via the context providers. + +### Preserve-and-rerun + +1. User clicks "📌 Preserve & Rerun" on a failed test in the dashboard. +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 + +`backend/src/runner.ts` is the only place outside an adapter that knows about specific frameworks. It uses `TestRunnerId` from shared and dispatches via `framework-filters.ts`'s `switch`: + +- `cucumber`: `--spec ` and/or `--cucumberOpts.name `. +- `mocha`/`jasmine`: `--spec ` + `--mochaOpts.grep`/`--jasmineOpts.grep`. +- `nightwatch`: positional spec file + optional `--testcase `. +- `nightwatch-cucumber`: `--name ` (feature files via `feature_path` config). +- Unknown/missing: spec-only fallback. + +Everywhere else in the system, events are framework-agnostic. + +--- + +## Boundaries + +Every data crossing between packages goes through a typed contract in `shared`: + +| Boundary | Direction | Transport | Lives in | +|---|---|---|---| +| Adapter → backend | One-way events (command, console, network, mutation, …) | WebSocket frames | `shared/ws.ts` (`SocketMessage`) | +| App → backend | Preserve, clear, run, stop, get-baseline | HTTP (Fastify) | `shared/baseline.ts`, `shared/runner.ts` | +| Backend → app | Live event broadcast + API responses | WebSocket + HTTP | `shared/ws.ts`, `shared/baseline.ts` | +| Backend → spawned worker | Run config, rerun env, video paths | Env vars + IPC | `shared/runner.ts` (`REUSE_ENV`, `RUNNER_ENV`) | +| Script → adapter | Mutation events, trace data | `executeScript` return values + `getLog` channel | Implicit in adapter — script's payload shape is consumed by core's `processTracePayload` | + +New events or HTTP routes start with a `shared` change. The other packages then import the contract. + +--- + +## Where things live + +The repo has converged on a clear ownership story. When in doubt, the top-down decision tree is: + +- A type, constant, enum, schema, or contract used by more than one package → **`shared`**. +- Capture, parsing, normalization, sourcemap, UID, reporter, screencast, or WS-framing logic that doesn't depend on a specific framework's API → **`core`**. +- A specific framework's hook, driver patch, or runner integration → the matching **adapter** package. Adapter code calls `core` for the actual work and only owns the hook registration. +- A backend HTTP route, WS handler, or rerun behavior → **`backend`**, with the contract added to `shared` first. +- UI → **`app`**, consuming `shared` contracts only. +- Code that runs inside the browser under test → **`script`**. + +A few cross-cutting conventions follow from this layout: + +- Adapter packages don't import each other. Anything two adapters would both want lives in `core`. +- Backend doesn't import adapter packages, and adapter packages don't import backend or app. +- The script package is a leaf — adapters load its built bundle as a string and inject it; they don't import from it at runtime. +- `shared` and `core` are private workspace packages. Consumers bundle them. The bundler config has to inline them (not externalize) or the published artifact won't resolve — see the build-config notes in `CLAUDE.md`. + +--- + +## Current state + +The architecture above is the actual state of the repo. Where it diverges from the ideal, the divergences are tracked in [CLAUDE.md §7](./CLAUDE.md#known-debt). + +Notable in-place pieces worth knowing about: + +- `replaceCommand` has two semantics across adapters — Selenium mutates the existing entry in place (preserves `_id`/`id` continuity for chained calls); Nightwatch splices and reissues with a new `_id`. Both call the same `core/suite-helpers` factories; the storage strategy stays adapter-specific because the runner integrations differ. +- `patchNodeAssert` is wired only in `selenium-devtools` (Selenium's primary assertion style is `node:assert`). The shared helper lives in `core/assert-patcher`; Service and Nightwatch can opt in via a one-line call when they need to, but it's not auto-enabled because both communities lean on chai/expect. +- BiDi is auto-attached in Service and Selenium. Nightwatch is opt-in via `bidi: true` and requires `webSocketUrl: true` in capabilities — historically Nightwatch users haven't all enabled BiDi by default. +- Performance API capture (`CAPTURE_PERFORMANCE_SCRIPT`) is identical across all three adapters; each wires it into its own afterCommand-equivalent path. +- Output directory for screencast videos and trace files is resolved through `core/resolveAdapterOutputDir` — adapters feed `userConfiguredDir` (WDIO honors `wdio.conf.ts`'s `outputDir`/`rootDir`), `testFilePath` (Selenium/Nightwatch), and `configPath` (Nightwatch), and the helper picks the first writable, non-`node_modules/` candidate. + +For per-package implementation details, see each package's `README.md` diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..c3736982 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,262 @@ +# Repo conventions + +This file describes the conventions in place across the devtools monorepo — how code is organized, how packages relate to each other, how tests are structured, and what the coding style looks like. It's the companion to [ARCHITECTURE.md](./ARCHITECTURE.md): that file says where the pieces are; this one says why they're shaped the way they are and what to look for when adding or changing code. + +Anyone working in the repo, human or AI agent, can use this as the source of truth for "how do we do things here." + +--- + +## What this repo is + +A devtools dashboard for end-to-end browser tests. Three test frameworks (WebdriverIO, Nightwatch, Selenium) push the same normalized event stream through a single backend into a single Lit-based browser UI. The adapters are deliberately thin — they translate framework hooks into calls on a shared core capture/reporting library and own only the framework-specific glue. + +Package map and data flow are in [ARCHITECTURE.md](./ARCHITECTURE.md). The summary: `shared` for types and contracts, `core` for framework-agnostic capture, three adapters (`service`, `nightwatch-devtools`, `selenium-devtools`) for framework glue, `backend` for the server, `app` for the UI, `script` for the page-injected runtime. + +--- + +## 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 test:coverage` | Run vitest with v8 coverage. The thresholds in `vitest.config.ts` are the floor — drops fail CI. | +| `pnpm lint` | Lint all packages in parallel. Includes `eslint-plugin-security` for a subset of CodeQL findings; deeper taint-flow checks surface on the PR's CodeQL scan. | +| `pnpm demo:wdio` / `pnpm demo:nightwatch` / `pnpm demo:selenium` | Run the per-framework example projects. Useful for manual verification of UI or runtime changes. | +| `pnpm dev` | Run all packages in parallel dev mode. | + +`selenium-devtools` exposes per-runner variants of its example via `pnpm --filter @wdio/selenium-devtools example:mocha` / `:jest` / `:cucumber` / `:jasmine` / `:vitest`. + +--- + +## Path aliases + +Defined in root `tsconfig.json`: + +| Alias | Resolves to | +|---|---| +| `@/*` | `packages/app/src/*` | +| `@components/*` | `packages/app/src/components/*` | +| `@core/*` | `packages/app/src/core/*` (app-internal — not the framework-agnostic `packages/core`) | +| `@wdio/devtools-backend` / `*` | `packages/backend/src/...` | +| `@wdio/devtools-script` / `*` | `packages/script/src/...` | +| `@wdio/devtools-service` / `*` | `packages/service/src/...` | +| `@wdio/selenium-devtools` / `*` | `packages/selenium-devtools/src/...` | +| `@wdio/devtools-shared` / `*` | `packages/shared/src/...` | +| `@wdio/devtools-core` / `*` | `packages/core/src/...` | + +These exist so imports stay short and grep-able. Long relative paths (`../../../components/…`) aren't used. + +The `@core/*` name is a historical alias for app-internal helpers and predates `packages/core`. They don't collide because they resolve to different roots, but the names are confusable. + +--- + +## Conventions + +### One source of truth per concept + +Every shared type, constant, enum, schema, and HTTP/WS contract lives in `packages/shared`. Adapter packages and the app never re-declare a concept that already exists upstream — they re-export shared definitions when a local consumer name needs to stay stable (e.g. nightwatch's `TEST_FILE_PATTERN` is `export { SPEC_FILE_RE as TEST_FILE_PATTERN } from '@wdio/devtools-shared'`). + +When a duplicate is discovered, the next change that touches either copy consolidates them into shared. + +### Framework-agnostic logic lives in `core` + +Anything that captures, parses, normalizes, formats, or transports test-event data and doesn't depend on a specific framework's API lives in `packages/core`. Adapters call into core; they don't reimplement. + +If the same logical change would land in two or more adapters, the logic belongs in core. This rule produced the current `SessionCapturerBase`, `TestReporterBase`, `ScreencastRecorderBase`, `resolveAdapterOutputDir`, and the pure helpers around console capture, error serialization, UID generation, stack-trace parsing, BiDi attachment, and screencast finalization. + +Some helpers are framework-agnostic by nature but used in only one adapter today (e.g. nightwatch's `parseNetworkFromPerfLogs` for CDP perf-log parsing, selenium's `detectRunner`/`captureLaunchCommand`). They stay in their adapter until a second consumer appears; at that point they move to core. + +### Adapters are thin and isolated + +Adapter packages own only: + +- Framework-specific hook registration and lifecycle binding. +- Framework-specific driver/browser patching. +- Framework-specific config and capabilities. + +They import from `shared` and `core`, never from each other. They aren't imported by `backend` or `app`. + +### Backend and app are framework-agnostic + +`backend` and `app` import from `shared` only (for contracts) and from each other via the WS/HTTP boundary. Neither imports an adapter package. + +Framework-specific behavior in the backend is contained in two files: `runner.ts` and `framework-filters.ts`. Both branch on a typed `TestRunnerId` from shared, never on a magic string. The `framework-filters` dispatch is a `switch` over `TestRunnerId` (not a table lookup) so CodeQL's `unvalidated-dynamic-method-call` query trusts the call site. + +### Boundaries have typed contracts + +Every `fetch(...)` and `ws.send(...)` has a typed request/response shape in shared. `SocketMessage` is the canonical WS wire format — receivers narrow on `scope` to get the exact payload type per branch. + +No `any` crosses a package boundary. When a framework API forces a loosely-typed value (Nightwatch's `currentTest`, Selenium's BiDi events, raw HTTP payloads), the `any` is cast to a typed shape immediately at the boundary, with the cast site documenting why. + +### Workspace-internal packages stay bundled + +`packages/shared` and `packages/core` are `"private": true` and never published. Each consumer inlines their code into its own `dist/` at build time. + +- Both deps are listed in `devDependencies` with `workspace:^`, never in `dependencies`. Vite and tsup both externalize anything in `dependencies` by default; `devDependencies` is what gets inlined. +- Neither is added to a bundler's `external` config. Vite's `external` callback receives both the bare package name *and* the resolved absolute path (e.g. `/Users/.../packages/core/src/index.ts`); a check for only one form silently externalizes the other. +- The same callback receives bare relative imports (`./utils.js`, `../constants.js`). A check that allows only `./` will externalize `../`-style imports from subfolders and the dist crashes with `ERR_MODULE_NOT_FOUND` at install time. +- `packages/service/vite.config.ts` is the canonical pattern for getting both right. +- After any change to a bundler config or build script, `grep -E "@wdio/devtools-(core|shared)|/packages/(core|shared)/" packages//dist/*.js` should return nothing. That's how you catch the absolute-path leak. + +Bundlers in use: **vite** for `app`, `service`, `script`; **tsup** for `backend`, `nightwatch-devtools`, `selenium-devtools`. + +### Separation of concerns within a file + +Files own one concern: + +- UI components render. They don't `fetch`, manage WebSocket state, or run business logic. +- Controllers and services own I/O and state. They don't render. +- Backend route handlers wire requests to services. They don't contain business logic inline. +- Reporters report. They don't also resolve sourcemaps, read files, and generate step UIDs in the same module. + +Mixed-concern files are split as they're touched. The app-side helpers like `contextUpdates.ts`, `runnerCapabilities.ts`, `renderDetailBlock.ts`, `compareUtils.ts`, `suite-merge.ts`, `mark-running.ts`, `run-detection.ts`, and `stepResolution.ts` are all extractions from larger god-files. + +### TypeScript + +- `strict: true` is on (root `tsconfig.json`). +- No `any`. If a framework or library forces it, the `any` is isolated at the boundary and cast to a typed shape with a one-line comment explaining why. As of writing, there are no `no-explicit-any` warnings repo-wide. +- No `as unknown as X` double-casts unless the reason is documented inline. +- `type` for unions, `interface` for object shapes that may be extended. +- Names exported from `shared` and `core` are public API of those packages — renames are breaking changes for downstream consumers. + +### Naming + +- One name per concept across the whole repo. The canonical test-status name is `TestStatus` in shared; the sidebar `TestState` is a value-only enum-style accessor over the same string union. +- Constants are `SCREAMING_SNAKE_CASE`. Types are `PascalCase`. Functions and variables are `camelCase`. Files are `kebab-case.ts` unless they match a class name (`SessionCapturer.ts`). + +### File and function size + +Soft caps (warnings in `pnpm lint`, not errors): + +- **File**: 500 logic lines (blank lines and comments excluded). Files growing toward this cap are split as their sections are edited. +- **Function**: 50 logic lines. + +A few declarative blocks (`#getInternals` accessor bags in the adapter plugins) exceed the function cap intentionally — splitting them artificially hurts readability. Those are marked with an inline `eslint-disable-next-line max-lines-per-function` plus a one-line justification. + +### Comments + +- Default to no comments. Names should explain *what*. +- A comment is written only when the *why* is non-obvious: a hidden constraint, a workaround for a specific bug, a subtle invariant, behavior that would surprise a reader. +- `// TODO`, `// added for X`, `// removed Y`, `// keep in sync` aren't used — the first three belong in git history; the fourth means a single source of truth is missing. +- One line max. Multi-paragraph docstrings aren't used. + +### Error handling + +- Validation happens at boundaries (HTTP input, WS messages, framework callbacks). Internal code is trusted. +- Errors aren't swallowed silently. `catch` only adds context, then rethrows or logs with enough detail to debug. Empty catches don't appear in production code. + +### Dead code + +Unused exports, unused imports, commented-out blocks, and `_unused` parameters get deleted when discovered. Git history is the safety net for "in case we need it later" code. + +--- + +## Testing + +The repo uses **vitest** at the root. The current state: 566 tests across 47 files; thresholds at `vitest.config.ts` enforce a floor of 85/77/86/85 (statements/branches/functions/lines). Coverage is ratcheted upward as gaps close, never downward. + +### What gets tested + +- **`shared` and `core`**: unit tests for every exported function and type guard. These are the foundation; regressions cascade. +- **Bug fixes (any package)**: a regression test that fails before the fix and passes after. When a real test is genuinely impossible (e.g. requires a live browser the infra doesn't have), the PR description says so. +- **New HTTP/WS contracts**: a test that exercises the contract end-to-end at least once. + +### Adapter and backend logic + +Non-trivial parsing or transformation logic in adapters has unit tests. Hook wiring is verified manually via `examples//`. `backend` and `app` test their non-UI logic (parsers, transforms, state reducers); UI verification is manual. + +### Manual verification + +For UI or runtime changes, `examples//` is the verification harness. Type-checks and unit tests verify code correctness, not feature correctness — claiming a UI change works on the basis of `tsc --noEmit` alone misses the point. + +When CI can't run an example (no real browser), the PR description says so explicitly. + +### Skipping tests that depend on workspace-internal build artifacts + +A handful of tests need `@wdio/devtools-script` to be built first (the browser-injected bundle). CI test jobs sometimes run before that build step; those tests gate on `it.skipIf` after probing `createRequire(import.meta.url).resolve('@wdio/devtools-script')`. Locally they run normally. + +--- + +## Workflow + +### When adding code + +The decision tree from [ARCHITECTURE.md "Where things live"](./ARCHITECTURE.md#where-things-live) is the starting point. The general shape: + +- Shared concept → `shared`. +- Framework-agnostic capture/reporting logic → `core`. +- Framework-specific glue → the matching adapter. +- Server route/WS handler → `backend` (contract in `shared` first). +- UI → `app`. +- Code that runs in the browser under test → `script`. + +When the right place is ambiguous (something between `shared` and `core`, or between `core` and an adapter), the question that resolves it is: *who else would want this?* If the answer is "any future adapter would," it's `core`. If "only the framework with X-specific API does," it's the adapter. + +### While editing + +- Boy-scout rule applies: when touching a file or section, leave it more aligned with these conventions than it was found. Touch a duplicated type, consolidate it into shared. Touch a section of a god-file, split that section out. Touch a magic-string framework check, replace it with `TestRunnerId`. The cleanup scope matches the change scope — don't rewrite the whole file, but don't leave a clear convention violation in lines just touched. +- New code doesn't introduce violations to match existing style. Where existing style violates these conventions, that's documented debt (§ Known debt), not a template. + +### Before pushing + +- `pnpm build`, `pnpm test`, `pnpm lint`. Don't push red. +- For UI or runtime changes: verify in `examples//`. +- Deeper security findings (taint flow, polynomial-redos with adjacent quantifiers) surface on the PR's CodeQL scan; review and fix those before merge. + +### Commits + +- Small, focused. Don't bundle unrelated changes. +- Imperative mood. The commit message explains *why*; the diff shows *what*. +- New commits, not amends to pushed/shared commits. +- No `--no-verify` to skip hooks. If a hook fails, the underlying issue gets fixed. + +### PRs + +- One concern per PR. A refactor and a feature are two PRs. +- A PR touching more than one adapter package answers in its description: *why isn't this in `core`?* + +--- + +## Known debt + +Documented divergences from the conventions above. They exist today as debt to be paid down, not exceptions to the rules. Each change reduces this list; new violations don't get added. + +### Architecture + +- `replaceCommand` has two semantics — Selenium mutates in place (preserves `_id`/`id` for chained calls); Nightwatch splices and reissues. Both call the same `core/suite-helpers` factories; the storage strategy stays adapter-specific because runner integrations differ. Could be unified by parameterizing the policy if the divergence ever causes a real problem. +- `patchNodeAssert` is wired only in `selenium-devtools`. The shared helper lives in `core/assert-patcher`; Service and Nightwatch can opt in via a one-line call when ready. Not auto-enabled — both communities lean on chai/expect. +- BiDi is auto-attached in Service and Selenium; Nightwatch is opt-in via `bidi: true` and requires `webSocketUrl: true` in capabilities. + +### File-size (raw line counts; soft cap is 500 logic lines) + +None of the entries below trigger the `max-lines` lint rule after `skipBlankLines`/`skipComments`. They're documented because their raw line count is over 500, and the next substantive change to any of them should still look for an extraction opportunity. + +- `packages/nightwatch-devtools/src/index.ts` (~536 raw). Cucumber/test/run-lifecycle, session-init, event-hub modules already extracted; remainder is the `PluginInternals` accessor bag plus per-method delegators plus the factory. Accept-as-is. +- `packages/selenium-devtools/src/index.ts` (~560 raw). Session/test-lifecycle extracted; remainder is the `PluginInternals` accessor bag plus onCommand/onDriverCreated wiring. Same situation as nightwatch. +- `packages/nightwatch-devtools/src/session.ts` (~468). `captureNetworkFromPerformanceLogs` + `captureBrowserLogs` + `captureTrace` are tightly coupled to NightwatchBrowser state. Coverage at 78% after recent backfill; further extraction would need rewriting the browser-coupling. + +### Test coverage gaps (worst-risk-first) + +Numbers reflect actual `pnpm test:coverage` output. + +- `packages/selenium-devtools/src/session.ts` — **83%**. Remaining branches are inside http-error / no-such-session paths that need a real driver to exercise. +- `packages/nightwatch-devtools/src/session.ts` — **78%**. `takeScreenshotViaHttp` error branches need real WebDriver. +- `packages/service/src/screencast.ts` — **76%**. CDP fast-path branches hard to exercise without a real Chrome. +- `packages/backend/src/baselineStore.ts` — **91%**. Remaining 9% is leaf-error paths. + +The threshold gate in `vitest.config.ts` enforces the current floor — it ratchets upward as gaps close, never downward. + +### Type-safety + +No known violations. New ones get tracked here as discovered. + +--- + +## Living document + +This file evolves with the repo. When a convention turns out to be wrong in practice, the right fix is to update the convention, not to silently break it. When a recurring decision point isn't covered here, it gets added. diff --git a/README.md b/README.md index 8e13fdc1..62a6448f 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,19 @@ 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. + +### 🌐 BiDi capture (browser console + JS exceptions + network) + +Real-time capture of browser-side events through the WebDriver BiDi protocol — entries arrive in the dashboard as they happen instead of being scraped after each command. + +| Adapter | BiDi source | Default | How to enable | +|---|---|---|---| +| **WebdriverIO** | WDIO's native `browser.on('log.entryAdded' \| 'network.*')` | On | Automatic when the driver advertises BiDi (Chrome ≥114) | +| **Selenium WebDriver** | `selenium-webdriver/bidi/{logInspector, networkInspector}` | On when available | Automatic; `ensureBidiCapability` sets `webSocketUrl=true` on the Builder | +| **Nightwatch.js** | Same `selenium-webdriver/bidi` inspectors (Nightwatch ships selenium-webdriver internally) | Opt-in | `globals: nightwatchDevtools({ bidi: true })` + `desiredCapabilities: { webSocketUrl: true }` | + +When BiDi is active in Selenium or Nightwatch, the per-command Chrome performance-log network-capture path is gated off so requests don't appear twice in the dashboard. The attach + sink logic lives in `@wdio/devtools-core`'s `bidi.ts` — same module both adapters consume. ### 🔍︎ TestLens - **Code Intelligence**: View test definitions directly in your editor @@ -63,11 +75,13 @@ Works with **WebdriverIO**, **[Nightwatch.js](./packages/nightwatch-devtools/REA - **Status Indicators**: Visual feedback for test pass/fail states in the editor ### 🏗️ Architecture -- **Frontend**: Lit web components with reactive state management (@lit/context) +- **Frontend**: Lit web components with reactive state management (`@lit/context`) - **Backend**: Fastify server with WebSocket streaming for real-time updates -- **Service**: WebdriverIO reporter integration with stable UID generation +- **Shared core**: All three adapters share the same capture/reporting library (`@wdio/devtools-core`) — `SessionCapturerBase`, `TestReporterBase`, `ScreencastRecorderBase`, plus pure helpers for console/network/error/sourcemap/BiDi - **Process Management**: Tree-kill for proper cleanup of spawned processes +See [ARCHITECTURE.md](./ARCHITECTURE.md) for the full package map and data flow, and [CLAUDE.md](./CLAUDE.md) for the conventions in place across the repo. + ## Demo ### 🛠️ Test Rerunner & Snapshot @@ -143,7 +157,7 @@ pnpm install pnpm build # Run demo -pnpm demo +pnpm demo:wdio ``` ## Nightwatch Integration @@ -162,14 +176,18 @@ Using `selenium-webdriver` directly — under Mocha, Jest, Cucumber, or a plain ``` packages/ +├── shared/ # Types, constants, HTTP/WS contracts — single source of truth +├── core/ # Framework-agnostic capture/reporting library (SessionCapturerBase, etc.) ├── app/ # Frontend Lit-based UI application -├── backend/ # Fastify server with test runner management -├── service/ # WebdriverIO service and reporter -├── script/ # Browser-injected trace collection script -├── nightwatch-devtools/ # Nightwatch adapter plugin -└── selenium-devtools/ # Selenium WebDriver adapter plugin +├── backend/ # Fastify server, WS gateway, baseline store, rerun spawner +├── script/ # Browser-injected trace collection script (runs in the page under test) +├── service/ # WebdriverIO adapter (@wdio/devtools-service) +├── nightwatch-devtools/ # Nightwatch adapter (@wdio/nightwatch-devtools) +└── selenium-devtools/ # Selenium WebDriver adapter (@wdio/selenium-devtools) ``` +`shared` and `core` are workspace-internal (`"private": true`) — every consumer bundles them into its own `dist/` at build time. The three adapter packages each translate framework-specific hooks into calls on `core`'s shared capture library; `backend` and `app` import only from `shared` and communicate via the WS/HTTP boundary. + ## Contributing Contributions are welcome! Please feel free to submit a Pull Request. diff --git a/eslint.config.cjs b/eslint.config.cjs index f037455e..ca64c748 100644 --- a/eslint.config.cjs +++ b/eslint.config.cjs @@ -5,6 +5,7 @@ const importPlugin = require('eslint-plugin-import') const unicorn = require('eslint-plugin-unicorn') const prettierConfig = require('eslint-config-prettier') const prettierPlugin = require('eslint-plugin-prettier') +const security = require('eslint-plugin-security') module.exports = [ { @@ -86,11 +87,286 @@ module.exports = [ } }, - // TypeScript test files + // 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. + // MUST come before the test-file override block — flat config rule blocks + // apply in order, so later blocks override earlier ones for matching files. + { + files: ['**/*.ts'], + rules: { + '@typescript-eslint/no-explicit-any': 'warn', + 'max-lines': [ + 'warn', + { max: 500, skipBlankLines: true, skipComments: true } + ], + 'max-lines-per-function': [ + 'warn', + { max: 50, skipBlankLines: true, skipComments: true, IIFEs: true } + ] + } + }, + + // Security rules — local mirror of the high-signal CodeQL findings that + // burned us in CI. Keeps the round-trip short: `pnpm lint` flags the same + // patterns the PR's CodeQL scan would. GitHub-managed default-setup CodeQL + // still runs as the authoritative gate (it has taint flow / + // interprocedural analysis these rules can't match). + // + // Rule selection is conservative — rules that produced >0 true positives + // here or that fire on unambiguously bad patterns (eval, new Buffer). + // Excluded: + // - detect-non-literal-fs-filename: 50+ false positives on legitimate + // internal file reads (config/test-file discovery, source loading). + // - detect-non-literal-require: createRequire patterns are by design. + // - detect-object-injection: fires on every `arr[i]`. + // - detect-possible-timing-attacks: not relevant for this dashboard. + { + files: ['**/*.{ts,tsx,js,mjs,cjs}'], + plugins: { security }, + rules: { + // Matches CodeQL `js/polynomial-redos` + `js/redos`. The detector is + // somewhat over-conservative (flags benign `(\d+)?` patterns) but the + // false-positive cost (one-off review) is lower than missing a real + // ReDoS — keep at `warn`. + 'security/detect-unsafe-regex': 'warn', + // Matches CodeQL `js/non-literal-regexp`. `new RegExp(userInput)` is a + // ReDoS vector; even when inputs are controlled it's worth eyes. + 'security/detect-non-literal-regexp': 'warn', + // Matches CodeQL `js/code-injection`. Should never appear. + 'security/detect-eval-with-expression': 'error', + // Node.js footguns that should never appear in production code. + 'security/detect-buffer-noassert': 'error', + 'security/detect-new-buffer': 'error', + 'security/detect-pseudoRandomBytes': 'error' + } + }, + + // TypeScript test files — turns off the size rules. MUST come AFTER the + // production rules block above so the off-rule wins for matching files. { files: ['**/*.test.ts'], rules: { - 'dot-notation': 'off' + 'dot-notation': 'off', + 'max-lines': 'off', + 'max-lines-per-function': 'off', + // Test fixtures intentionally use `any` to construct partial mocks + // without restating every field of the real type. The cost of forcing + // proper types here is high (lots of `as unknown as RealType` casts) + // and the benefit is low — tests don't ship. + '@typescript-eslint/no-explicit-any': 'off', + // Tests legitimately build dynamic regexes from fixture data. + 'security/detect-non-literal-regexp': 'off', + 'security/detect-unsafe-regex': 'off' + } + }, + + // 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/example/package.json b/example/package.json deleted file mode 100644 index 4d5f4e03..00000000 --- a/example/package.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "name": "examples", - "type": "module", - "devDependencies": { - "@wdio/cli": "9.27.0", - "@wdio/cucumber-framework": "9.27.0", - "@wdio/devtools-service": "workspace:*", - "@wdio/globals": "9.27.0", - "@wdio/local-runner": "9.27.0", - "@wdio/spec-reporter": "9.27.0", - "@wdio/types": "9.27.0", - "expect-webdriverio": "^5.4.0", - "ts-node": "^10.9.2", - "tsconfig-paths": "^4.2.0", - "tsx": "^4.20.3", - "typescript": "^6.0.2" - }, - "scripts": { - "wdio": "wdio run ./wdio.conf.ts" - } -} 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 53% rename from packages/nightwatch-devtools/example/nightwatch.conf.cjs rename to examples/nightwatch/nightwatch.conf.cjs index 5e2467d7..a817bf57 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: [], @@ -21,6 +23,10 @@ module.exports = { desiredCapabilities: { browserName: 'chrome', + // Required for chromedriver to expose the BiDi WebSocket channel. + // Without this, attachBidiHandlers silently fails and the perf-log + // fallback takes over. + webSocketUrl: true, 'goog:chromeOptions': { args: [ '--headless', @@ -31,8 +37,17 @@ 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: polling-mode .webm written to cwd as + // nightwatch-video-.webm. + // - bidi: opt-in WebDriver BiDi capture for console + network. When + // attached, the per-command Chrome perf-log network path is gated + // off to avoid duplicate entries. + globals: nightwatchDevtools({ + port: 3000, + screencast: { enabled: true, pollIntervalMs: 200 }, + bidi: true + }) } } } diff --git a/examples/nightwatch/package.json b/examples/nightwatch/package.json new file mode 100644 index 00000000..f3be394e --- /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.16.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..cb046553 --- /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.44.0" + }, + "devDependencies": { + "@cucumber/cucumber": "^13.0.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/examples/wdio/package.json b/examples/wdio/package.json new file mode 100644 index 00000000..d3cc2104 --- /dev/null +++ b/examples/wdio/package.json @@ -0,0 +1,21 @@ +{ + "name": "examples", + "type": "module", + "devDependencies": { + "@wdio/cli": "9.27.2", + "@wdio/cucumber-framework": "9.27.2", + "@wdio/devtools-service": "workspace:*", + "@wdio/globals": "9.27.2", + "@wdio/local-runner": "9.27.2", + "@wdio/spec-reporter": "9.27.2", + "@wdio/types": "9.27.2", + "expect-webdriverio": "^5.6.7", + "ts-node": "^10.9.2", + "tsconfig-paths": "^4.2.0", + "tsx": "^4.22.4", + "typescript": "^6.0.3" + }, + "scripts": { + "wdio": "wdio run ./wdio.conf.ts" + } +} 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..5e4c703e 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", @@ -18,7 +19,7 @@ "pnpm": { "overrides": { "vite": "^8.0.7", - "@types/node": "25.5.2", + "@types/node": "25.9.1", "@codemirror/state": "6.5.4" }, "onlyBuiltDependencies": [ @@ -27,32 +28,34 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", - "@types/node": "25.5.2", - "@typescript-eslint/eslint-plugin": "^8.40.0", - "@typescript-eslint/parser": "^8.40.0", - "@typescript-eslint/utils": "^8.40.0", - "@vitest/browser": "^4.0.16", - "autoprefixer": "^10.4.27", - "eslint": "^10.2.0", + "@types/node": "25.9.1", + "@typescript-eslint/eslint-plugin": "^8.60.1", + "@typescript-eslint/parser": "^8.60.1", + "@typescript-eslint/utils": "^8.60.1", + "@vitest/browser": "^4.1.8", + "@vitest/coverage-v8": "^4.1.8", + "autoprefixer": "^10.5.0", + "eslint": "^10.4.1", "eslint-config-prettier": "^10.1.8", "eslint-plugin-import": "^2.32.0", - "eslint-plugin-prettier": "^5.5.5", + "eslint-plugin-prettier": "^5.5.6", + "eslint-plugin-security": "4.0.0", "eslint-plugin-unicorn": "^64.0.0", - "happy-dom": "^20.0.11", + "happy-dom": "^20.9.0", "npm-run-all": "^4.1.5", - "postcss": "^8.5.9", - "postcss-lit": "^1.2.0", - "prettier": "^3.6.2", - "tailwindcss": "^4.1.12", + "postcss": "^8.5.15", + "postcss-lit": "^1.4.1", + "prettier": "^3.8.3", + "tailwindcss": "^4.3.0", "ts-node": "^10.9.2", - "tsx": "^4.20.4", - "typescript": "^6.0.2", + "tsx": "^4.22.4", + "typescript": "^6.0.3", "unplugin-icons": "^23.0.1", - "vite": "^8.0.7", - "vitest": "^4.0.16", - "webdriverio": "^9.19.1" + "vite": "^8.0.16", + "vitest": "^4.1.8", + "webdriverio": "^9.27.2" }, "dependencies": { - "@wdio/cli": "9.27.0" + "@wdio/cli": "9.27.2" } } diff --git a/packages/app/package.json b/packages/app/package.json index 5ab423dc..d9182e6a 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@wdio/devtools-app", - "version": "1.4.2", + "version": "1.5.0", "description": "Browser devtools extension for debugging WebdriverIO tests.", "type": "module", "repository": { @@ -19,31 +19,32 @@ "dependencies": { "@codemirror/lang-javascript": "^6.2.5", "@codemirror/theme-one-dark": "^6.1.3", - "@codemirror/view": "^6.38.1", + "@codemirror/view": "^6.43.0", "@iconify-json/mdi": "^1.2.3", "@lit/context": "^1.1.6", "@wdio/devtools-service": "workspace:*", - "@wdio/protocols": "9.27.0", + "@wdio/protocols": "9.27.2", "codemirror": "^6.0.2", - "lit": "^3.3.2", + "lit": "^3.3.3", "placeholder-loading": "^0.7.0", "pointer-tracker": "^2.5.3", - "preact": "^10.27.1" + "preact": "^10.29.2" }, "author": "Christian Bromann ", "license": "MIT", "devDependencies": { - "@tailwindcss/postcss": "^4.1.18", - "@wdio/reporter": "9.27.0", - "autoprefixer": "^10.4.21", - "postcss": "^8.5.6", + "@tailwindcss/postcss": "^4.3.0", + "@wdio/devtools-shared": "workspace:^", + "@wdio/reporter": "9.27.2", + "autoprefixer": "^10.5.0", + "postcss": "^8.5.15", "postcss-import": "^16.1.1", - "rollup": "^4.47.0", - "stylelint": "^17.6.0", + "rollup": "^4.61.0", + "stylelint": "^17.12.0", "stylelint-config-recommended": "^18.0.0", "stylelint-config-tailwindcss": "^1.0.1", - "tailwindcss": "~4.2.2", - "typescript": "6.0.2", - "vite": "8.0.7" + "tailwindcss": "~4.3.0", + "typescript": "6.0.3", + "vite": "^8.0.16" } } 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..d2a218cc 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' @@ -25,17 +26,31 @@ declare global { } } -function transform(node: any): VNode<{}> { +interface SerializedVNode { + type?: string + props?: { + children?: SerializedVNode | SerializedVNode[] | string | number + } & Record +} +type TransformInput = SerializedVNode | string | number | null + +function transform(node: TransformInput): VNode<{}> { if (typeof node !== 'object' || node === null) { // Plain string/number text node — return as-is for Preact to render as text. - return node as VNode<{}> + return node as unknown as VNode<{}> } const { children, ...props } = node.props ?? {} /** * ToDo(Christian): fix way we collect data on added nodes in script */ - if (!node.type && children?.type) { + if ( + !node.type && + children && + typeof children === 'object' && + !Array.isArray(children) && + children.type + ) { return transform(children) } @@ -77,146 +92,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 +134,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 } @@ -559,19 +438,97 @@ export class DevtoolsBrowser extends Element { return null } + #renderViewToggle() { + if (this.#videos.length === 0) { + return nothing + } + return html` +
+ + + ${this.#videos.length > 1 + ? html`` + : nothing} +
+ ` + } + + #renderViewport(hasMutations: number | null) { + if (this.#viewMode === 'video' && this.#activeVideoUrl) { + return html`
+ +
` + } + if (this.#screenshotData) { + return html`
+
+ +
+
` + } + if (hasMutations) { + return html`
+ +
` + } + const autoScreenshot = hasMutations ? null : this.#latestAutoScreenshot + if (autoScreenshot) { + return html`
+
+ +
+
` + } + return html`` + } + render() { - /** - * render a browser state if it hasn't before - */ + // Render the initial browser state lazily on first mutation arrival. if (this.mutations && this.mutations.length && !this.#activeUrl) { this.#setIframeSize() this.#renderBrowserState() } - const hasMutations = this.mutations && this.mutations.length - const autoScreenshot = hasMutations ? null : this.#latestAutoScreenshot - const displayScreenshot = this.#screenshotData ?? autoScreenshot - return html`
${this.#activeUrl} - ${this.#videos.length > 0 - ? html` -
- - - ${this.#videos.length > 1 - ? html`` - : nothing} -
- ` - : nothing} + ${this.#renderViewToggle()} - ${this.#viewMode === 'video' && this.#activeVideoUrl - ? html`
- -
` - : this.#screenshotData - ? html`
-
- -
-
` - : hasMutations - ? html`
- -
` - : displayScreenshot - ? html`
-
- -
-
` - : html``} + ${this.#renderViewport(hasMutations)}
` } 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..3ee8a88b 100644 --- a/packages/app/src/components/sidebar/explorer.ts +++ b/packages/app/src/components/sidebar/explorer.ts @@ -2,26 +2,33 @@ 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 { SuiteStatsFragment, TestStatsFragment } from '../../controller/types.js' -import type { - TestEntry, - RunCapabilities, - RunnerOptions, - TestRunDetail -} from './types.js' +import type { TestEntry, TestRunDetail } from './types.js' import { TestState } from './types.js' +import { getTestEntry } from './test-entry-state.js' +import { + getCapabilityWarning, + getConfigPath, + getFramework, + getLaunchCommand, + getRerunCommand, + getRunCapabilities, + getRunDisabledReason, + isRunDisabled, + isRunDisabledDetail +} from './runnerCapabilities.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 +134,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 +144,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 +162,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 +199,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 +266,7 @@ export class DevtoolsSidebarExplorer extends CollapseableEntry { }) ) - void this.#postToBackend('/api/tests/run', { + const payload: RunnerRequestBody = { uid: '*', entryType: 'suite', runAll: true, @@ -263,89 +274,44 @@ 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: '*' - }) - } - - #getFramework(): string | undefined { - return this.#getRunnerOptions()?.framework + // Backend ignores the body for /api/tests/stop — sending {} keeps the + // typed helper happy without changing behavior. + void this.#postToBackend(TESTS_API.stop, {}) } - #getRunnerOptions(): RunnerOptions | undefined { - return this.metadata?.options as RunnerOptions | undefined + #getFramework() { + return getFramework(this.metadata) } - - #getRunCapabilities(): RunCapabilities { - const options = this.#getRunnerOptions() - if (options?.runCapabilities) { - return { - ...DEFAULT_CAPABILITIES, - ...options.runCapabilities - } - } - const framework = options?.framework?.toLowerCase() ?? '' - return FRAMEWORK_CAPABILITIES[framework] || DEFAULT_CAPABILITIES + #getRunCapabilities() { + return getRunCapabilities(this.metadata) } - #isRunDisabled(entry: TestEntry) { - const caps = this.#getRunCapabilities() - if (entry.type === 'test' && !caps.canRunTests) { - return true - } - if (entry.type === 'suite' && !caps.canRunSuites) { - return true - } - return false + return isRunDisabled(this.metadata, entry) } - #isRunDisabledDetail(detail: TestRunDetail) { - const caps = this.#getRunCapabilities() - if (detail.entryType === 'test' && !caps.canRunTests) { - return true - } - if (detail.entryType === 'suite' && !caps.canRunSuites) { - return true - } - return false + return isRunDisabledDetail(this.metadata, detail) } - #surfaceCapabilityWarning(detail: TestRunDetail) { - const message = - detail.entryType === 'test' - ? 'Single-test execution is not supported by this framework.' - : 'Suite execution is disabled by this framework.' window.dispatchEvent( - new CustomEvent('app-logs', { - detail: message - }) + new CustomEvent('app-logs', { detail: getCapabilityWarning(detail) }) ) } - #getRunDisabledReason(entry: TestEntry) { - if (!this.#isRunDisabled(entry)) { - return undefined - } - return entry.type === 'test' - ? 'Single-test execution is not supported by this framework.' - : 'Suite execution is not supported by this framework.' + return getRunDisabledReason(this.metadata, entry) } - - #getConfigPath(): string | undefined { - const options = this.#getRunnerOptions() - return options?.configFilePath || options?.configFile + #getConfigPath() { + return getConfigPath(this.metadata) } - - #getRerunCommand(): string | undefined { - return this.#getRunnerOptions()?.rerunCommand + #getRerunCommand() { + return getRerunCommand(this.metadata) } - - #getLaunchCommand(): string | undefined { - return this.#getRunnerOptions()?.launchCommand + #getLaunchCommand() { + return getLaunchCommand(this.metadata) } #renderEntry(entry: TestEntry): TemplateResult { @@ -403,243 +369,65 @@ 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 { + return getTestEntry(entry, this.#filterEntry.bind(this)) } - #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: [] - } + #renderHeaderToolbar() { + const canRunAll = this.#getRunCapabilities().canRunAll + const runBtnCls = canRunAll + ? 'hover:bg-toolbarHoverBackground' + : 'opacity-30 cursor-not-allowed' + const iconCls = (color: string) => (canRunAll ? `group-hover:${color}` : '') + return html` + + ` } render() { if (!this.suites) { return } - const rootSuites = this.suites .flatMap((s) => Object.values(s)) .filter((suite) => !suite.parent) - const uniqueSuites = Array.from( new Map(rootSuites.map((suite) => [suite.uid, suite])).values() ) - const suites = uniqueSuites .map(this.#getTestEntry.bind(this)) .filter(this.#filterEntry.bind(this)) - return html`

Tests

- + ${this.#renderHeaderToolbar()}
${suites.length @@ -666,5 +454,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/filter.ts b/packages/app/src/components/sidebar/filter.ts index db607c23..7ee639ff 100644 --- a/packages/app/src/components/sidebar/filter.ts +++ b/packages/app/src/components/sidebar/filter.ts @@ -36,13 +36,14 @@ export class DevtoolsSidebarFilter extends Element { @query('input[name="filter"]') queryInput?: HTMLInputElement - #updateState(change: any) { - if (!change.target) { + #updateState(change: Event) { + const target = change.target as HTMLInputElement | null + if (!target) { return } - this.#filterState = change.target.checked - ? this.#filterState + Number(change.target.value) - : this.#filterState - Number(change.target.value) + this.#filterState = target.checked + ? this.#filterState + Number(target.value) + : this.#filterState - Number(target.value) this.requestUpdate() this.#emitState() } @@ -99,6 +100,15 @@ export class DevtoolsSidebarFilter extends Element { return this.#filterQuery } + #renderStateCheckbox(state: FilterState, label: string, id: string) { + return html` +
  • + + +
  • + ` + } + render() { return html` + ` + } + + #renderRunButton() { const runTooltip = this.runDisabled ? this.runDisabledReason || 'Single-step execution is controlled by its scenario.' : 'Run this entry' + return html` + + ` + } + #renderRunStopButtons() { + if (this.isRunning) { + return this.runDisabled ? nothing : this.#renderStopButton() + } + return html` + ${this.#renderRunButton()} + ${this.hasFailed && !this.runDisabled + ? html` + + ` + : nothing} + ` + } + + #renderToolbar(hasNoChildren: boolean) { + return html` + + ` + } + + render() { + const hasNoChildren = !this.hasChildren + const isCollapsed = this.isCollapsed === 'true' return html`
    - ${this.hasFailed && !this.runDisabled - ? html` - - ` - : nothing} - ` - : !this.runDisabled - ? html` - - ` - : nothing} - - ${!hasNoChildren - ? html` - - ` - : nothing} - + ${this.#renderToolbar(hasNoChildren)}
    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..9159f82c 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' @@ -120,81 +120,93 @@ export class DevtoolsWorkbench extends Element { @query('section[data-horizontal-resizer-window]') horizontalResizerWindow?: HTMLElement - render() { - // When collapsed keep previous full behavior; when expanded no fixed height class - const heightWorkbench = this.#toolbarCollapsed ? 'h-full flex-1' : '' - - const styleWorkbench = this.#toolbarCollapsed - ? '' - : (() => { - const m = this.#dragVertical.getPosition().match(/(\d+(?:\.\d+)?)px/) - const raw = m ? parseFloat(m[1]) : window.innerHeight * 0.7 - const capped = Math.min(raw, window.innerHeight * 0.7) - const paneHeight = Math.max(MIN_WORKBENCH_HEIGHT, capped) - return `flex:0 0 ${paneHeight}px; height:${paneHeight}px; max-height:70vh; min-height:0;` - })() + #computeWorkbenchPaneStyle(): string { + if (this.#toolbarCollapsed) { + return '' + } + const m = this.#dragVertical.getPosition().match(/(\d+(?:\.\d+)?)px/) + const raw = m ? parseFloat(m[1]) : window.innerHeight * 0.7 + const capped = Math.min(raw, window.innerHeight * 0.7) + const paneHeight = Math.max(MIN_WORKBENCH_HEIGHT, capped) + return `flex:0 0 ${paneHeight}px; height:${paneHeight}px; max-height:70vh; min-height:0;` + } - const sidebarStyle = this.#workbenchSidebarCollapsed - ? 'width:0; flex:0 0 0; overflow:hidden;' - : (() => { - const pos = this.#dragHorizontal.getPosition() - const m = pos.match(/flex-basis:\s*([\d.]+)px/) - const w = m ? m[1] : MIN_METATAB_WIDTH - return `${pos}; flex:0 0 auto; min-width:${w}px; max-width:${w}px;` - })() + #computeSidebarStyle(): string { + if (this.#workbenchSidebarCollapsed) { + return 'width:0; flex:0 0 0; overflow:hidden;' + } + const pos = this.#dragHorizontal.getPosition() + const m = pos.match(/flex-basis:\s*([\d.]+)px/) + const w = m ? m[1] : MIN_METATAB_WIDTH + return `${pos}; flex:0 0 auto; min-width:${w}px; max-width:${w}px;` + } + #renderActionsSidebar() { return html` -
    -
    - + + + + + + - - ${this.#workbenchSidebarCollapsed - ? html` - - ` - : nothing} -
    - ${!this.#workbenchSidebarCollapsed - ? this.#dragHorizontal.getSlider('z-30') - : nothing} -
    - -
    -
    - ${!this.#toolbarCollapsed - ? this.#dragVertical.getSlider('z-[999] -mt-[5px] pointer-events-auto') + + + + + ${this.#workbenchSidebarCollapsed + ? html` + + ` : nothing} + ` + } + + #renderCompareTabIfAvailable() { + if ((this.baselines?.size || 0) === 0) { + return nothing + } + return html` + + + + ` + } + + #renderToolbarCollapseButton() { + if (!this.#toolbarCollapsed) { + return nothing + } + return html` + + ` + } + + #renderWorkbenchTabs() { + return html` - - - ` - : nothing} + ${this.#renderCompareTabIfAvailable()} - ${this.#toolbarCollapsed - ? html` - - ` + ${this.#renderToolbarCollapseButton()} + ` + } + + render() { + const heightWorkbench = this.#toolbarCollapsed ? 'h-full flex-1' : '' + return html` +
    +
    + ${this.#renderActionsSidebar()} +
    + ${!this.#workbenchSidebarCollapsed + ? this.#dragHorizontal.getSlider('z-30') + : nothing} +
    + +
    +
    + ${!this.#toolbarCollapsed + ? this.#dragVertical.getSlider('z-[999] -mt-[5px] pointer-events-auto') : nothing} + ${this.#renderWorkbenchTabs()} ` } } 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..84e4c91e 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,34 @@ import { pairSteps, classifyDivergence, cleanErrorMessage, - extractExpectedFromStepText, - safeJson, type ComparePairedStep, type DivergenceKind } from './compare/compareUtils.js' + +interface RenderPairCtx { + pair: ComparePairedStep + kind: DivergenceKind + isTruncation: boolean + oneSideEntirelyEmpty: boolean + expanded: boolean + isFirstDivergent: boolean +} +import { BASELINE_API, type BaselineClearRequest } from '@wdio/devtools-shared' +import { POPOUT_QUERY, buildPopoutFeatures } from './compare/constants.js' +import { renderMarker } from './compare/markers.js' +import { compareStyles } from './compare/styles.js' import { - BASELINE_API, - POPOUT_QUERY, - buildPopoutFeatures -} from './compare/constants.js' + liveStepsForUid, + findStepFor, + isFailureSite +} from './compare/stepResolution.js' +import { renderDetailBlock } from './compare/renderDetailBlock.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 +118,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 +165,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. @@ -482,49 +188,47 @@ export class DevtoolsCompare extends Element { window.open(url, `compare-${this.selectedTestUid}`, buildPopoutFeatures()) } - render() { - const baseline = this.#getBaseline() - if (!baseline) { - return html` -
    -
    -

    No baseline preserved.

    -

    - Click the - 📌 Preserve & Rerun button on a failed test - to compare the failing run against the rerun. -

    -
    + #renderEmptyState() { + return html` +
    +
    +

    No baseline preserved.

    +

    + Click the + 📌 Preserve & Rerun button on a failed test to + compare the failing run against the rerun. +

    - ` - } - - const baselineCommands = baseline.commands as CommandLog[] - const latestCommands = this.#liveCommandsForSelectedUid() - - // Naming follows physical sides (left/right) after swap. - const leftAttempt = this.swapped ? null : baseline - const rightAttempt = this.swapped ? baseline : null - const leftCommands = this.swapped ? latestCommands : baselineCommands - const rightCommands = this.swapped ? baselineCommands : latestCommands +
    + ` + } - const pairs = pairSteps(baselineCommands, latestCommands) - const visiblePairs = this.differencesOnly - ? pairs.filter((p) => p.divergent || !p.baseline || !p.latest) - : pairs - const firstDivergent = pairs.findIndex((p) => p.divergent) + #renderPopoutButton() { + if (this.#isPopout) { + return nothing + } + return html` + + ` + } - const errorMessage = baseline.test.error?.message - ? cleanErrorMessage(baseline.test.error.message) - : undefined + #renderTopbar(baseline: PreservedAttempt, latestCount: number) { + const baselineCount = (baseline.commands as CommandLog[]).length return html`
    - Baseline · ${baseline.test.state || 'unknown'} · - ${baselineCommands.length} commands + Baseline · ${baseline.test.state || 'unknown'} · ${baselineCount} + commands - Latest · ${latestCommands.length} commands + Latest · ${latestCount} commands ${baseline.scope === 'suite' ? 'suite scope' : 'test scope'} @@ -551,19 +255,31 @@ export class DevtoolsCompare extends Element { > Clear - ${this.#isPopout - ? nothing - : html` - - `} + ${this.#renderPopoutButton()}
    + ` + } + + render() { + const baseline = this.#getBaseline() + if (!baseline) { + return this.#renderEmptyState() + } + const baselineCommands = baseline.commands as CommandLog[] + const latestCommands = this.#liveCommandsForSelectedUid() + // Naming follows physical sides (left/right) after swap. + const leftCommands = this.swapped ? latestCommands : baselineCommands + const rightCommands = this.swapped ? baselineCommands : latestCommands + const pairs = pairSteps(baselineCommands, latestCommands) + const visiblePairs = this.differencesOnly + ? pairs.filter((p) => p.divergent || !p.baseline || !p.latest) + : pairs + const firstDivergent = pairs.findIndex((p) => p.divergent) + const errorMessage = baseline.test.error?.message + ? cleanErrorMessage(baseline.test.error.message) + : undefined + return html` + ${this.#renderTopbar(baseline, latestCommands.length)} ${errorMessage ? html`
    Why the baseline failed
    @@ -577,7 +293,6 @@ export class DevtoolsCompare extends Element { this.#renderPair(pair, leftCommands, rightCommands, firstDivergent) )}
    - ${leftAttempt || rightAttempt ? nothing : nothing} ` } @@ -591,168 +306,28 @@ export class DevtoolsCompare extends Element { const expanded = this.expandedIndex === pair.index const left = leftCommands[pair.index] const right = rightCommands[pair.index] - // Classify divergence ONCE so left and right rows share the same label. const kind: DivergenceKind = classifyDivergence(left, right) - const stepFor = ( - cmd: CommandLog | undefined, - side: 'baseline' | 'latest' - ) => this.#findStepFor(cmd, side) // Skip "missing" markers when one side is entirely empty (e.g. the rerun // hasn't produced commands yet). The populated side should display its // own status, not be falsely flagged as "missing". - const leftEmpty = leftCommands.length === 0 - const rightEmpty = rightCommands.length === 0 - const oneSideEntirelyEmpty = leftEmpty || rightEmpty - const marker = ( - cmd: CommandLog | undefined, - side: 'baseline' | 'latest' - ) => { - if (!cmd) { - return nothing - } - // Row-level divergence wins over the per-command status marker. - switch (kind) { - case 'commandName': - return html`different command` - case 'args': - return html`args differ` - case 'error': - if (cmd.error?.message) { - return html`⚠ error` - } - break - } - const step = stepFor(cmd, side) - const allCmdsThisSide = - side === 'baseline' - ? ((this.#getBaseline()?.commands ?? []) as CommandLog[]) - : this.#liveCommandsForSelectedUid() - const statusMarker = - step?.state === 'failed' && - this.#isFailureSite(cmd, step, allCmdsThisSide) - ? html`✗ in failed step` - : step?.state === 'passed' - ? html`` - : html`` - // Truncation: status + a muted "only here" pill. - if (kind === 'missing' && !oneSideEntirelyEmpty) { - return html`${statusMarker}only here` - } - return statusMarker - } - - // Truncation = one side has the command, the other doesn't. - const isTruncation = !left || !right - /** Per-cell divergence so the passing side stays neutral when only the - * other side has the actual problem. */ - const cellIsDivergent = ( - cmd: CommandLog | undefined, - side: 'baseline' | 'latest' - ) => { - if (!pair.divergent || isTruncation || !cmd) { - return false - } - switch (kind) { - case 'commandName': - case 'args': - // Both sides genuinely differ — both cells are divergent. - return true - case 'error': - // Only the side with the actual error is divergent. - return !!cmd.error?.message - case 'missing': - return false - case 'none': - default: { - // Step-level failure site: only the failure site is divergent. - const step = this.#findStepFor(cmd, side) - if (step?.state !== 'failed') { - return false - } - const allCmds = - side === 'baseline' - ? ((this.#getBaseline()?.commands ?? []) as CommandLog[]) - : this.#liveCommandsForSelectedUid() - return this.#isFailureSite(cmd, step, allCmds) - } - } + const oneSideEntirelyEmpty = + leftCommands.length === 0 || rightCommands.length === 0 + const ctx: RenderPairCtx = { + pair, + kind, + isTruncation: !left || !right, + oneSideEntirelyEmpty, + expanded, + isFirstDivergent } - const cellClass = ( - cmd: CommandLog | undefined, - side: 'baseline' | 'latest' - ) => { - const cls = ['step-cell'] - if (!cmd) { - cls.push('missing') - } - const divergent = cellIsDivergent(cmd, side) - if (divergent) { - cls.push('divergent') - } - if (isFirstDivergent && divergent) { - cls.push('first') - } - if (expanded) { - cls.push('expanded') - } - return cls.join(' ') - } - type Side = 'baseline' | 'latest' const leftSide: Side = this.swapped ? 'latest' : 'baseline' const rightSide: Side = this.swapped ? 'baseline' : 'latest' return html`
    -
    - ${left - ? html`${pair.index + 1}. ${left.command}${marker( - left, - leftSide - )}` - : html`—`} -
    -
    - ${right - ? html`${pair.index + 1}. ${right.command}${marker( - right, - rightSide - )}` - : html`—`} -
    + ${this.#renderPairCell(left, leftSide, ctx)} + ${this.#renderPairCell(right, rightSide, ctx)} ${expanded ? html`
    @@ -775,110 +350,92 @@ export class DevtoolsCompare extends Element { ` } - #renderDetailBlock( - label: string, + #cellIsDivergent( cmd: CommandLog | undefined, - side: 'baseline' | 'latest' + side: 'baseline' | 'latest', + ctx: RenderPairCtx + ): boolean { + if (!ctx.pair.divergent || ctx.isTruncation || !cmd) { + return false + } + switch (ctx.kind) { + case 'commandName': + case 'args': + return true + case 'error': + return !!cmd.error?.message + case 'missing': + return false + case 'none': + default: { + const step = this.#findStepFor(cmd, side) + if (step?.state !== 'failed') { + return false + } + const allCmds = + side === 'baseline' + ? ((this.#getBaseline()?.commands ?? []) as CommandLog[]) + : this.#liveCommandsForSelectedUid() + return isFailureSite(cmd, step, allCmds) + } + } + } + + #renderPairCell( + cmd: CommandLog | undefined, + side: 'baseline' | 'latest', + ctx: RenderPairCtx ) { + const cls = ['step-cell'] if (!cmd) { - return html`
    -

    ${label}

    - No command at this step -
    ` + cls.push('missing') + } + const divergent = this.#cellIsDivergent(cmd, side, ctx) + if (divergent) { + cls.push('divergent') + } + if (ctx.isFirstDivergent && divergent) { + cls.push('first') + } + if (ctx.expanded) { + cls.push('expanded') } - 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 = + const allCmds = 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 marker = renderMarker({ + cmd, + kind: ctx.kind, + step: this.#findStepFor(cmd, side), + allCmdsThisSide: allCmds, + oneSideEntirelyEmpty: ctx.oneSideEntirelyEmpty + }) return html` -
    -

    ${label} · ${cmd.command}

    - ${step - ? html`
    -step: ${stepText || step.uid}
    ` - : nothing} -
    args: ${argsStr}
    - ${cmd.error - ? html`
    -error: ${cmd.error.message || String(cmd.error)}
    ` - : html`
    result: ${resultStr}
    `} - ${expected !== undefined - ? html`
    -expected: ${safeJson(expected)}
    ` - : fallbackExpected - ? html`
    -expected (from step): ${fallbackExpected}
    ` - : nothing} - ${actual !== undefined - ? html`
    -actual:   ${safeJson(actual)}
    ` - : nothing} - ${assertionMessage - ? html`
    -assertion: ${assertionMessage}
    ` - : nothing} - ${cmd.screenshot - ? html`` - : nothing} +
    + ${cmd + ? html`${ctx.pair.index + 1}. ${cmd.command}${marker}` + : html`—`}
    ` } + #renderDetailBlock( + label: string, + cmd: CommandLog | undefined, + side: 'baseline' | 'latest' + ) { + return renderDetailBlock(label, cmd, side, { + baseline: this.#getBaseline(), + liveCommandsForSelectedUid: () => this.#liveCommandsForSelectedUid(), + findStepFor: (c, s) => this.#findStepFor(c, s) + }) + } + #toggleExpand(index: number) { this.expandedIndex = this.expandedIndex === index ? null : index } 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/markers.ts b/packages/app/src/components/workbench/compare/markers.ts new file mode 100644 index 00000000..ed87c5a9 --- /dev/null +++ b/packages/app/src/components/workbench/compare/markers.ts @@ -0,0 +1,108 @@ +import { html, nothing, type TemplateResult } from 'lit' +import type { CommandLog, PreservedStep } from '@wdio/devtools-shared' +import { type DivergenceKind } from './compareUtils.js' +import { isFailureSite } from './stepResolution.js' + +export interface MarkerContext { + cmd: CommandLog | undefined + /** Pre-classified divergence kind for the row (shared across left/right cells). */ + kind: DivergenceKind + /** Already-resolved step for this command + side (resolved by the parent). */ + step: PreservedStep | undefined + /** + * All commands on this side, used by `isFailureSite` to decide whether this + * specific command is the failure-site (vs another command in the same + * failed step). The parent computes it once per side and passes it in to + * avoid redundant resolver calls. + */ + allCmdsThisSide: CommandLog[] + /** + * True when one of the two compared runs has zero commands. Suppresses the + * "only here" pill on truncated rows — the populated side should display + * its own status, not be falsely flagged. + */ + oneSideEntirelyEmpty: boolean +} + +/** + * Render the per-cell status marker for the Compare view. Extracted from + * `#renderPair` — pure function of `MarkerContext`, + * no component-state coupling. Returns a Lit template, an `html` fragment + * (for the truncation "only here" case), or `nothing` when there's no + * command to mark. + */ +function renderRowDivergenceMarker( + kind: MarkerContext['kind'], + cmd: NonNullable +): TemplateResult | undefined { + switch (kind) { + case 'commandName': + return html`different command` + case 'args': + return html`args differ` + case 'error': + if (cmd.error?.message) { + return html`⚠ error` + } + return undefined + } + return undefined +} + +function renderStatusMarker( + cmd: NonNullable, + step: MarkerContext['step'], + allCmdsThisSide: MarkerContext['allCmdsThisSide'] +): TemplateResult { + if (step?.state === 'failed' && isFailureSite(cmd, step, allCmdsThisSide)) { + const id = step.fullTitle || step.title || step.uid + const titleText = step.error?.message + ? `Failed step: ${id}\n${step.error.message}` + : `Failed step: ${id}` + return html`✗ in failed step` + } + if (step?.state === 'passed') { + return html`` + } + return html`` +} + +export function renderMarker( + opts: MarkerContext +): TemplateResult | typeof nothing { + const { cmd, kind, step, allCmdsThisSide, oneSideEntirelyEmpty } = opts + if (!cmd) { + return nothing + } + const divergence = renderRowDivergenceMarker(kind, cmd) + if (divergence) { + return divergence + } + const statusMarker = renderStatusMarker(cmd, step, allCmdsThisSide) + if (kind === 'missing' && !oneSideEntirelyEmpty) { + return html`${statusMarker}only here` + } + return statusMarker +} diff --git a/packages/app/src/components/workbench/compare/renderDetailBlock.ts b/packages/app/src/components/workbench/compare/renderDetailBlock.ts new file mode 100644 index 00000000..1c4c03ed --- /dev/null +++ b/packages/app/src/components/workbench/compare/renderDetailBlock.ts @@ -0,0 +1,131 @@ +/** + * Detail-block rendering for the compare view. Extracted from the host + * component so the Lit class stays under the file-size cap; everything + * the renderers need is passed in through the {@link DetailBlockCtx} bag. + */ + +import { html, nothing, type TemplateResult } from 'lit' +import type { + CommandLog, + PreservedAttempt, + PreservedStep +} from '@wdio/devtools-shared' +import { safeJson } from './compareUtils.js' +import { computeDetailBlockData } from './stepResolution.js' + +/** Hooks the detail-block renderers need to reach component state. */ +export interface DetailBlockCtx { + baseline: PreservedAttempt | undefined + liveCommandsForSelectedUid(): CommandLog[] + findStepFor( + cmd: CommandLog | undefined, + side: 'baseline' | 'latest' + ): PreservedStep | undefined +} + +export function renderDetailStepBanner( + step: PreservedStep | undefined, + stepText: string +): TemplateResult | typeof nothing { + if (!step) { + return nothing + } + const color = + step.state === 'failed' + ? 'var(--vscode-charts-red,#f48771)' + : 'var(--vscode-charts-green,#73c373)' + return html`
    +step: ${stepText || step.uid}
    ` +} + +export function renderExpectedActualAssertion( + expected: unknown, + actual: unknown, + assertionMessage: string | undefined, + fallbackExpected: string | undefined +): TemplateResult { + return html` + ${expected !== undefined + ? html`
    +expected: ${safeJson(expected)}
    ` + : fallbackExpected + ? html`
    +expected (from step): ${fallbackExpected}
    ` + : nothing} + ${actual !== undefined + ? html`
    +actual:   ${safeJson(actual)}
    ` + : nothing} + ${assertionMessage + ? html`
    +assertion: ${assertionMessage}
    ` + : nothing} + ` +} + +export function renderDetailBlock( + label: string, + cmd: CommandLog | undefined, + side: 'baseline' | 'latest', + ctx: DetailBlockCtx +): TemplateResult { + if (!cmd) { + return html`
    +

    ${label}

    + No command at this step +
    ` + } + // Only the failure-site command shows step-level expected/actual/assertion; + // other commands in the failed step succeeded individually. + const allCmdsThisSide = + side === 'baseline' + ? ((ctx.baseline?.commands ?? []) as CommandLog[]) + : ctx.liveCommandsForSelectedUid() + const data = computeDetailBlockData( + cmd, + ctx.findStepFor(cmd, side), + allCmdsThisSide + ) + return html` +
    +

    ${label} · ${cmd.command}

    + ${renderDetailStepBanner(data.step, data.stepText)} +
    args: ${data.argsStr}
    + ${cmd.error + ? html`
    +error: ${cmd.error.message || String(cmd.error)}
    ` + : html`
    result: ${data.resultStr}
    `} + ${renderExpectedActualAssertion( + data.expected, + data.actual, + data.assertionMessage, + data.fallbackExpected + )} + ${cmd.screenshot + ? html`` + : nothing} +
    + ` +} 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..54c36469 --- /dev/null +++ b/packages/app/src/components/workbench/compare/stepResolution.ts @@ -0,0 +1,212 @@ +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). + */ +function findSuiteByUid( + s: SuiteStatsFragment | undefined, + uid: string +): SuiteStatsFragment | undefined { + if (!s) { + return undefined + } + if (s.uid === uid) { + return s + } + for (const child of s.suites ?? []) { + const hit = findSuiteByUid(child, uid) + if (hit) { + return hit + } + } + return undefined +} + +function flattenSuiteTests(s: SuiteStatsFragment, out: PreservedStep[]): void { + 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 ?? []) { + flattenSuiteTests(child, out) + } +} + +export function liveStepsForUid( + selectedTestUid: string | undefined, + liveSuites: Array> | undefined +): PreservedStep[] { + if (!selectedTestUid || !liveSuites) { + return [] + } + let foundRoot: SuiteStatsFragment | undefined + for (const chunk of liveSuites) { + for (const root of Object.values(chunk)) { + foundRoot = findSuiteByUid(root, selectedTestUid) + if (foundRoot) { + break + } + } + if (foundRoot) { + break + } + } + if (!foundRoot) { + return [] + } + const out: PreservedStep[] = [] + flattenSuiteTests(foundRoot, out) + 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/console.ts b/packages/app/src/components/workbench/console.ts index 3494f387..09d451a6 100644 --- a/packages/app/src/components/workbench/console.ts +++ b/packages/app/src/components/workbench/console.ts @@ -164,7 +164,7 @@ export class DevtoolsConsoleLogs extends Element { return `${elapsed.toFixed(1)}s` } - #formatArgs(args: any[]): string { + #formatArgs(args: unknown): string { if (Array.isArray(args)) { return args .map((arg) => { @@ -182,58 +182,53 @@ export class DevtoolsConsoleLogs extends Element { return String(args) } - render() { - if (!this.logs || this.logs.length === 0) { - return html` -
    -
    📋
    -
    No console logs captured yet
    -
    - ` - } + #renderEmptyState() { + return html` +
    +
    📋
    +
    No console logs captured yet
    +
    + ` + } - if (this.logs.length === 0) { - return html` -
    -
    📋
    -
    No console logs captured yet
    + #renderLogEntry(log: ConsoleLogs) { + const icon = LOG_ICONS[log.type] || LOG_ICONS.log + const sourceLabel = + log.source === 'test' + ? '[TEST]' + : log.source === 'terminal' + ? '[WDIO]' + : log.source === 'browser' + ? '[BROWSER]' + : '' + const sourceClass = log.source ? `source-${log.source}` : '' + return html` +
    + ${log.timestamp + ? html`
    + ${this.#formatElapsedTime(log.timestamp)} +
    ` + : nothing} +
    ${icon}
    +
    + ${sourceLabel + ? html`${sourceLabel}` + : nothing} + ${this.#formatArgs(log.args)}
    - ` - } +
    + ` + } + render() { + if (!this.logs || this.logs.length === 0) { + return this.#renderEmptyState() + } return html`
    - ${this.logs.map((log: any) => { - const icon = LOG_ICONS[log.type] || LOG_ICONS.log - const sourceLabel = - log.source === 'test' - ? '[TEST]' - : log.source === 'terminal' - ? '[WDIO]' - : log.source === 'browser' - ? '[BROWSER]' - : '' - const sourceClass = log.source ? `source-${log.source}` : '' - - return html` -
    - ${log.timestamp - ? html`
    - ${this.#formatElapsedTime(log.timestamp)} -
    ` - : nothing} -
    ${icon}
    -
    - ${sourceLabel - ? html`${sourceLabel}` - : nothing} - ${this.#formatArgs(log.args)} -
    -
    - ` - })} + ${this.logs.map((log) => this.#renderLogEntry(log))}
    ` } diff --git a/packages/app/src/components/workbench/list.ts b/packages/app/src/components/workbench/list.ts index 54825f95..5fc0b995 100644 --- a/packages/app/src/components/workbench/list.ts +++ b/packages/app/src/components/workbench/list.ts @@ -64,7 +64,7 @@ export class DevtoolsList extends Element { ` ] - #renderMetadataProp(prop: any) { + #renderMetadataProp(prop: unknown) { if (typeof prop === 'object' && prop !== null) { return html`
    ${JSON.stringify(prop, null, 2)}
    ` } @@ -94,10 +94,53 @@ export class DevtoolsList extends Element { ` } + #unpackEntry( + entry: unknown, + isArrayList: boolean + ): { key: string | undefined; val: unknown } { + const isKeyValueTuple = (v: unknown): v is [string, unknown] => + Array.isArray(v) && v.length === 2 && typeof v[0] === 'string' + if (isArrayList) { + if (isKeyValueTuple(entry)) { + return { key: entry[0], val: entry[1] } + } + return { key: undefined, val: entry } + } + const tuple = entry as [string, unknown] + return { key: tuple[0], val: tuple[1] } + } + + #renderRow(entry: unknown, isArrayList: boolean) { + const { key, val } = this.#unpackEntry(entry, isArrayList) + const stringForMeasure = + val && typeof val === 'object' + ? JSON.stringify(val, null, 2) + : String(val) + const isMultiline = + /\n/.test(stringForMeasure) || + stringForMeasure.length > 40 || + (val && typeof val === 'object') + const baseCls = 'row px-2 py-1 border-b-[1px] border-b-panelBorder' + const colCls = isMultiline ? 'basis-full w-full' : 'basis-1/2' + const collapsedCls = this.isCollapsed ? 'collapse' : 'max-h-[500px]' + if (key === undefined) { + return html` +
    + ${this.#renderMetadataProp(val)} +
    + ` + } + return html` +
    ${key}
    +
    + ${this.#renderMetadataProp(val)} +
    + ` + } + render() { const list = this.list ?? {} const isArrayList = Array.isArray(list) - if (list === null) { return null } @@ -110,63 +153,14 @@ export class DevtoolsList extends Element { ) { return null } - - const entries: unknown[] | [string, unknown][] = isArrayList + const entries: unknown[] = isArrayList ? (this.list as unknown[]) : Object.entries(this.list as Record) - - const isKeyValueTuple = (val: unknown): val is [string, unknown] => - Array.isArray(val) && val.length === 2 && typeof val[0] === 'string' - return html`
    ${this.#renderSectionHeader(this.label)}
    - ${(entries as any[]).map((entry) => { - let key: string | undefined - let val: unknown - - if (isArrayList) { - if (isKeyValueTuple(entry)) { - key = entry[0] - val = entry[1] - } else { - val = entry - } - } else { - key = (entry as [string, unknown])[0] - val = (entry as [string, unknown])[1] - } - - const stringForMeasure = - val && typeof val === 'object' - ? JSON.stringify(val, null, 2) - : String(val) - - const isMultiline = - /\n/.test(stringForMeasure) || - stringForMeasure.length > 40 || - (val && typeof val === 'object') - - const baseCls = 'row px-2 py-1 border-b-[1px] border-b-panelBorder' - const colCls = isMultiline ? 'basis-full w-full' : 'basis-1/2' - const collapsedCls = this.isCollapsed ? 'collapse' : 'max-h-[500px]' - - if (key === undefined) { - return html` -
    - ${this.#renderMetadataProp(val)} -
    - ` - } - - return html` -
    ${key}
    -
    - ${this.#renderMetadataProp(val)} -
    - ` - })} + ${entries.map((entry) => this.#renderRow(entry, isArrayList))}
    ` diff --git a/packages/app/src/components/workbench/logs.ts b/packages/app/src/components/workbench/logs.ts index 0000450a..674082ce 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' @@ -76,6 +76,35 @@ export class DevtoolsCommandLogs extends Element { }) } + #renderParameters() { + const args = this.command!.args || [] + const params = args.reduce( + (acc: Record, val: unknown, i: number) => { + const paramName = this.#commandDefinition?.parameters?.[i]?.name ?? i + acc[paramName] = val + return acc + }, + {} as Record + ) + return html`` + } + + #renderResult() { + const result = this.command!.result + if (result === null || result === undefined) { + return '' + } + return html`` + } + render() { if (!this.command) { return html` @@ -84,7 +113,6 @@ export class DevtoolsCommandLogs extends Element {
    ` } - return html`
    `} - - ${this.command.result !== null && this.command.result !== undefined - ? html`` - : ''} + ${this.#renderParameters()} ${this.#renderResult()} ` } } diff --git a/packages/app/src/components/workbench/metadata.ts b/packages/app/src/components/workbench/metadata.ts index bdf2c01a..75a0553f 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' @@ -29,12 +29,7 @@ export class DevtoolsMetadata extends Element { ` ] - render() { - if (!this.metadata) { - return html`` - } - - const m = this.metadata as any + #buildSessionInfo(m: MetadataShape): Record { const sessionInfo: Record = {} if (m.sessionId) { sessionInfo['Session ID'] = m.sessionId @@ -51,37 +46,49 @@ export class DevtoolsMetadata extends Element { if (m.url) { sessionInfo.URL = m.url } + return sessionInfo + } - const caps = m.capabilities || {} - const desiredCaps = m.desiredCapabilities || {} + #renderListIfNonEmpty(label: string, list: Record) { + return Object.keys(list).length + ? html`` + : '' + } + render() { + if (!this.metadata) { + return html`` + } + const m = this.metadata as MetadataShape return html` - ${Object.keys(sessionInfo).length - ? html`` - : ''} + ${this.#renderListIfNonEmpty('Session', this.#buildSessionInfo(m))} - ${Object.keys(desiredCaps).length - ? html`` - : ''} - ${m.options && Object.keys(m.options).length - ? html`` - : ''} + ${this.#renderListIfNonEmpty( + 'Desired Capabilities', + m.desiredCapabilities || {} + )} + ${this.#renderListIfNonEmpty('Options', m.options || {})} ` } } +interface MetadataShape { + sessionId?: string + testEnv?: string + host?: string + modulePath?: string + url?: string + capabilities?: Record + desiredCapabilities?: Record + options?: Record +} + declare global { interface HTMLElementTagNameMap { [SOURCE_COMPONENT]: DevtoolsMetadata diff --git a/packages/app/src/components/workbench/network.ts b/packages/app/src/components/workbench/network.ts index e58154cd..9fe9aa6b 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 @@ -293,19 +96,7 @@ export class DevtoolsNetwork extends Element { this.selectedRequest = request } - render() { - const filteredRequests = this.#filterRequests() - - if (!this.networkRequests || this.networkRequests.length === 0) { - return html` - - ` - } - + #renderNetworkHeader() { return html`
    + ` + } + + #renderRequestRow(request: NetworkRequest) { + return html` +
    + ${getFileName(request.url)} + ${request.method} + ${request.status || (request.error ? 'ERR' : '-')} + ${request.responseHeaders?.['content-type']?.split(';')[0] || + '-'} + ${formatTime(request.time)} + ${formatBytes(request.size)} + ${request.startTime ? `${request.startTime.toFixed(1)}s` : '-'} +
    + ` + } + + render() { + if (!this.networkRequests || this.networkRequests.length === 0) { + return html` + + ` + } + const filteredRequests = this.#filterRequests() + return html` + ${this.#renderNetworkHeader()}
    @@ -341,154 +176,94 @@ export class DevtoolsNetwork extends Element {
    Start
    ${filteredRequests.length === 0 - ? html` -
    - No requests match your filter -
    - ` - : filteredRequests.map( - (request) => html` -
    - ${getFileName(request.url)} - ${request.method} - ${request.status || (request.error ? 'ERR' : '-')} - ${request.responseHeaders?.['content-type']?.split( - ';' - )[0] || '-'} - ${formatTime(request.time)} - ${formatBytes(request.size)} - ${request.startTime - ? `${request.startTime.toFixed(1)}s` - : '-'} -
    - ` - )} + ? html`
    + No requests match your filter +
    ` + : filteredRequests.map((r) => this.#renderRequestRow(r))}
    ${this.selectedRequest ? this.#renderRequestDetail() : nothing}
    ` } - #renderRequestDetail() { - const req = this.selectedRequest! + #renderHeaderRow(key: string, value: unknown, valueClass = '') { + return html` +
    + ${key}: + ${value} +
    + ` + } + #renderHeadersSection( + title: string, + headers: Record | undefined + ) { + if (!headers || Object.keys(headers).length === 0) { + return nothing + } return html` -
    -
    -
    General
    -
    -
    - URL: - ${req.url} -
    -
    - Method: - ${req.method} -
    -
    - Status: - ${req.status || '-'} ${req.statusText || ''} -
    -
    - Type: - ${req.type} -
    - ${req.time - ? html` -
    - Time: - ${formatTime(req.time)} -
    - ` - : nothing} - ${req.size - ? html` -
    - Size: - ${formatBytes(req.size)} -
    - ` - : nothing} - ${req.error - ? html` -
    - Error: - ${req.error} -
    - ` - : nothing} -
    +
    +
    ${title}
    +
    + ${Object.entries(headers).map(([k, v]) => + this.#renderHeaderRow(k, v) + )} +
    +
    + ` + } + + #renderBodySection(title: string, body: string | undefined) { + if (!body) { + return nothing + } + return html` +
    +
    ${title}
    +
    +
    ${this.#formatBody(body)}
    +
    + ` + } - ${req.requestHeaders && Object.keys(req.requestHeaders).length > 0 - ? html` -
    -
    Request Headers
    -
    - ${Object.entries(req.requestHeaders).map( - ([key, value]) => html` -
    - ${key}: - ${value} -
    - ` - )} -
    -
    - ` - : nothing} - ${req.requestBody - ? html` -
    -
    Request Body
    -
    -
    ${this.#formatBody(req.requestBody)}
    -
    -
    - ` - : nothing} - ${req.responseHeaders && Object.keys(req.responseHeaders).length > 0 - ? html` -
    -
    Response Headers
    -
    - ${Object.entries(req.responseHeaders).map( - ([key, value]) => html` -
    - ${key}: - ${value} -
    - ` - )} -
    -
    - ` - : nothing} - ${req.responseBody - ? html` -
    -
    Response Body
    -
    -
    ${this.#formatBody(req.responseBody)}
    -
    -
    - ` - : nothing} + #renderGeneralSection(req: NetworkRequest) { + return html` +
    +
    General
    +
    + ${this.#renderHeaderRow('URL', req.url)} + ${this.#renderHeaderRow('Method', req.method)} + ${this.#renderHeaderRow( + 'Status', + html`${req.status || '-'} ${req.statusText || ''}`, + getStatusClass(req.status) + )} + ${this.#renderHeaderRow('Type', req.type)} + ${req.time + ? this.#renderHeaderRow('Time', formatTime(req.time)) + : nothing} + ${req.size + ? this.#renderHeaderRow('Size', formatBytes(req.size)) + : nothing} + ${req.error + ? this.#renderHeaderRow('Error', req.error, 'text-red-500') + : nothing} +
    +
    + ` + } + + #renderRequestDetail() { + const req = this.selectedRequest! + return html` +
    + ${this.#renderGeneralSection(req)} + ${this.#renderHeadersSection('Request Headers', req.requestHeaders)} + ${this.#renderBodySection('Request Body', req.requestBody)} + ${this.#renderHeadersSection('Response Headers', req.responseHeaders)} + ${this.#renderBodySection('Response Body', req.responseBody)}
    ` } 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..4d59c0f6 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,18 @@ 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' +import { mergeNetworkRequests, replaceCommand } from './contextUpdates.js' export class DataManagerController implements ReactiveController { #ws?: WebSocket @@ -163,133 +166,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() } @@ -324,88 +205,93 @@ export class DataManagerController implements ReactiveController { } } - #handleSocketMessage(event: MessageEvent) { - try { - const { scope, data } = JSON.parse(event.data) as SocketMessage - if (!data) { - return - } - - if (scope === 'testStopped') { - this.#handleTestStopped() - this.#host.requestUpdate() - return - } - - if (scope === 'screencast') { - const { sessionId } = data as { sessionId: string } - window.dispatchEvent( - new CustomEvent('screencast-ready', { detail: { sessionId } }) - ) - return - } + #handleClearExecutionScope(data: unknown): void { + const { uid, entryType, clearSuiteTree } = + data as SocketMessage<'clearExecutionData'>['data'] + this.clearExecutionData(uid, entryType) + if (clearSuiteTree) { + this.suitesContextProvider.setValue([]) + this.#activeRerunTestUid = undefined + rerunState.activeRerunSuiteUid = undefined + this.#lastSeenRunTimestamp = 0 + } + } - if (scope === 'clearExecutionData') { - const { uid, entryType, clearSuiteTree } = - data as SocketMessage<'clearExecutionData'>['data'] - this.clearExecutionData(uid, entryType) - if (clearSuiteTree) { - this.suitesContextProvider.setValue([]) - this.#activeRerunTestUid = undefined - rerunState.activeRerunSuiteUid = undefined - this.#lastSeenRunTimestamp = 0 - } - this.#host.requestUpdate() - return - } + // Returns true if the control scope was fully handled and the regular + // dispatch should be skipped. Caller is responsible for requestUpdate(). + #handleControlScope(scope: string, data: unknown): boolean { + if (scope === WS_SCOPE.testStopped) { + this.#handleTestStopped() + return true + } + if (scope === 'screencast') { + const { sessionId } = data as { sessionId: string } + window.dispatchEvent( + new CustomEvent('screencast-ready', { detail: { sessionId } }) + ) + return true + } + if (scope === WS_SCOPE.clearExecutionData) { + this.#handleClearExecutionScope(data) + return true + } + if (scope === WS_SCOPE.replaceCommand) { + const { oldTimestamp, command } = + data as SocketMessage<'replaceCommand'>['data'] + this.#handleReplaceCommand(oldTimestamp, command) + return true + } + if (scope === BASELINE_WS_SCOPE.saved) { + const { testUid, attempt } = data as SocketMessage< + typeof BASELINE_WS_SCOPE.saved + >['data'] + this.#handleBaselineSaved(testUid, attempt) + return true + } + if (scope === BASELINE_WS_SCOPE.cleared) { + const { testUid } = data as SocketMessage< + typeof BASELINE_WS_SCOPE.cleared + >['data'] + this.#handleBaselineCleared(testUid) + return true + } + return false + } - if (scope === 'replaceCommand') { - const { oldTimestamp, command } = - data as SocketMessage<'replaceCommand'>['data'] - this.#handleReplaceCommand(oldTimestamp, command) - this.#host.requestUpdate() - return + #dispatchDataScope(scope: string, data: unknown): void { + if (scope === 'mutations') { + this.#handleMutationsUpdate(data as TraceMutation[]) + } else if (scope === 'logs') { + this.#handleLogsUpdate(data as string[]) + } else if (scope === 'commands') { + this.#handleCommandsUpdate(data as CommandLog[]) + } else if (scope === 'metadata') { + this.#handleMetadataUpdate(data as Metadata) + } else if (scope === 'consoleLogs') { + this.#handleConsoleLogsUpdate(data as string[]) + } else if (scope === 'networkRequests') { + this.#handleNetworkRequestsUpdate(data as NetworkRequest[]) + } else if (scope === 'sources') { + this.#handleSourcesUpdate(data as Record) + } else if (scope === 'suites') { + if (this.#shouldResetForNewRun(data)) { + this.#resetExecutionData() } + this.#handleSuitesUpdate(data) + } + } - if (scope === BASELINE_WS_SCOPE.saved) { - const { testUid, attempt } = data as SocketMessage< - typeof BASELINE_WS_SCOPE.saved - >['data'] - this.#handleBaselineSaved(testUid, attempt) - this.#host.requestUpdate() + #handleSocketMessage(event: MessageEvent) { + try { + const { scope, data } = JSON.parse(event.data) as SocketMessage + if (!data) { return } - - if (scope === BASELINE_WS_SCOPE.cleared) { - const { testUid } = data as SocketMessage< - typeof BASELINE_WS_SCOPE.cleared - >['data'] - this.#handleBaselineCleared(testUid) + if (this.#handleControlScope(scope, data)) { this.#host.requestUpdate() return } - - if (scope === 'mutations') { - this.#handleMutationsUpdate(data as TraceMutation[]) - } else if (scope === 'logs') { - this.#handleLogsUpdate(data as string[]) - } else if (scope === 'commands') { - this.#handleCommandsUpdate(data as CommandLog[]) - } else if (scope === 'metadata') { - this.#handleMetadataUpdate(data as Metadata) - } else if (scope === 'consoleLogs') { - this.#handleConsoleLogsUpdate(data as string[]) - } else if (scope === 'networkRequests') { - this.#handleNetworkRequestsUpdate(data as NetworkRequest[]) - } else if (scope === 'sources') { - this.#handleSourcesUpdate(data as Record) - } else if (scope === 'suites') { - if (this.#shouldResetForNewRun(data)) { - this.#resetExecutionData() - } - this.#handleSuitesUpdate(data) - } - + this.#dispatchDataScope(scope, data) this.#host.requestUpdate() } catch (e: unknown) { console.warn(`Failed to parse socket message: ${(e as Error).message}`) @@ -413,84 +299,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 +329,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[]) { @@ -594,25 +348,13 @@ export class DataManagerController implements ReactiveController { } #handleReplaceCommand(oldTimestamp: number, newCommand: CommandLog) { - const current = this.commandsContextProvider.value || [] - // Prefer stable `id` — chained selenium calls share a millisecond. - let idx = -1 - const newId = (newCommand as CommandLog & { id?: number }).id - if (typeof newId === 'number') { - idx = current.findIndex( - (c) => (c as CommandLog & { id?: number }).id === newId + this.commandsContextProvider.setValue( + replaceCommand( + this.commandsContextProvider.value || [], + oldTimestamp, + newCommand ) - } - if (idx === -1) { - idx = current.map((c) => c.timestamp).lastIndexOf(oldTimestamp) - } - if (idx !== -1) { - const updated = [...current] - updated[idx] = newCommand - this.commandsContextProvider.setValue(updated) - } else { - this.commandsContextProvider.setValue([...current, newCommand]) - } + ) } #handleConsoleLogsUpdate(data: string[]) { @@ -623,28 +365,12 @@ export class DataManagerController implements ReactiveController { } #handleNetworkRequestsUpdate(data: NetworkRequest[]) { - const current = this.networkRequestsContextProvider.value || [] - const byId = new Map() - current.forEach((r, i) => { - if (r?.id) { - byId.set(r.id, i) - } - }) - const next = [...current] - for (const incoming of data) { - if (!incoming?.id) { - next.push(incoming) - continue - } - const existingIdx = byId.get(incoming.id) - if (existingIdx !== undefined) { - next[existingIdx] = incoming - } else { - byId.set(incoming.id, next.length) - next.push(incoming) - } - } - this.networkRequestsContextProvider.setValue(next) + this.networkRequestsContextProvider.setValue( + mergeNetworkRequests( + this.networkRequestsContextProvider.value || [], + data + ) + ) } #handleMetadataUpdate(data: Metadata) { @@ -662,14 +388,8 @@ export class DataManagerController implements ReactiveController { this.sourcesContextProvider.setValue(merged) } - #handleSuitesUpdate(data: unknown) { - const payloads = Array.isArray(data) - ? (data as Record[]) - : ([data] as Record[]) - + #seedSuiteMapFromContext(): Map { const suiteMap = new Map() - - // Populate with existing suites (keeps test list visible) ;(this.suitesContextProvider.value || []).forEach((chunk) => { Object.entries(chunk as Record).forEach( ([uid, suite]) => { @@ -679,35 +399,51 @@ export class DataManagerController implements ReactiveController { } ) }) + return suiteMap + } - // Canonicalize uids for root suites so a rerun whose reporter assigned a - // different uid still merges into the original row. - const existingRootSuites = Array.from(suiteMap.values()) - const incomingRootSuites: SuiteStatsFragment[] = [] + #collectIncomingRootSuites( + payloads: Record[] + ): SuiteStatsFragment[] { + const out: SuiteStatsFragment[] = [] payloads.forEach((chunk) => { if (!chunk) { return } for (const suite of Object.values(chunk)) { if (suite?.uid) { - incomingRootSuites.push(suite) + out.push(suite) } } }) - const canonicalizedRoots = this.#canonicalizeUids( + return out + } + + #handleSuitesUpdate(data: unknown) { + const payloads = Array.isArray(data) + ? (data as Record[]) + : ([data] as Record[]) + const suiteMap = this.#seedSuiteMapFromContext() + // Canonicalize uids for root suites so a rerun whose reporter assigned a + // different uid still merges into the original row. + const existingRootSuites = Array.from(suiteMap.values()) + const incomingRootSuites = this.#collectIncomingRootSuites(payloads) + const mergeCtx = { + activeRerunTestUid: this.#activeRerunTestUid, + activeRerunSuiteUid: rerunState.activeRerunSuiteUid + } + const canonicalizedRoots = canonicalizeUids( existingRootSuites, incomingRootSuites ) - canonicalizedRoots.forEach((suite) => { if (!suite?.uid) { 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) }) - this.suitesContextProvider.setValue( Array.from(suiteMap.entries()).map(([uid, suite]) => ({ [uid]: suite })) ) @@ -726,251 +462,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/contextUpdates.ts b/packages/app/src/controller/contextUpdates.ts new file mode 100644 index 00000000..a9b338b7 --- /dev/null +++ b/packages/app/src/controller/contextUpdates.ts @@ -0,0 +1,69 @@ +/** + * Pure transforms for the live-context arrays managed by DataManager. + * + * Extracted from DataManager so the controller stays under the file-size + * cap and these merges can be unit-tested in isolation. Each function + * takes the current context value + an incoming payload and returns the + * new value the ContextProvider should publish. + */ + +import type { CommandLog, NetworkRequest } from '@wdio/devtools-shared' + +/** + * Replace an existing command entry (matched first by stable `id`, then by + * `timestamp` as a fallback for runners that don't surface ids). When no + * match is found, the new entry is appended. + */ +export function replaceCommand( + current: CommandLog[], + oldTimestamp: number, + newCommand: CommandLog +): CommandLog[] { + let idx = -1 + const newId = (newCommand as CommandLog & { id?: number }).id + if (typeof newId === 'number') { + idx = current.findIndex( + (c) => (c as CommandLog & { id?: number }).id === newId + ) + } + if (idx === -1) { + idx = current.map((c) => c.timestamp).lastIndexOf(oldTimestamp) + } + if (idx !== -1) { + const next = [...current] + next[idx] = newCommand + return next + } + return [...current, newCommand] +} + +/** + * Merge incoming network requests into the current list, deduping by `id`. + * Requests without an id are always appended. + */ +export function mergeNetworkRequests( + current: NetworkRequest[], + incoming: NetworkRequest[] +): NetworkRequest[] { + const byId = new Map() + current.forEach((r, i) => { + if (r?.id) { + byId.set(r.id, i) + } + }) + const next = [...current] + for (const req of incoming) { + if (!req?.id) { + next.push(req) + continue + } + const existing = byId.get(req.id) + if (existing !== undefined) { + next[existing] = req + } else { + byId.set(req.id, next.length) + next.push(req) + } + } + return next +} diff --git a/packages/app/src/controller/mark-running.ts b/packages/app/src/controller/mark-running.ts new file mode 100644 index 00000000..346c365e --- /dev/null +++ b/packages/app/src/controller/mark-running.ts @@ -0,0 +1,206 @@ +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. + */ +function markSuiteTreeAsRunning( + suiteNode: SuiteStatsFragment, + runStart: Date +): SuiteStatsFragment { + return { + ...suiteNode, + state: 'running', + start: runStart, + end: undefined, + tests: [] as TestStatsFragment[], + suites: + suiteNode.suites?.map((s) => markSuiteTreeAsRunning(s, runStart)) || [] + } +} + +function markSuiteWithUid( + s: SuiteStatsFragment, + uid: string, + entryType: 'suite' | 'test' | undefined, + runStart: Date +): { suite: SuiteStatsFragment; matched: boolean } { + if (entryType !== 'test' && s.uid === uid) { + return { matched: true, suite: markSuiteTreeAsRunning(s, runStart) } + } + 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 = markSuiteWithUid( + nestedSuite, + uid, + entryType, + runStart + ) + 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 + } + } +} + +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 runStart = new Date() + updatedChunk[suiteUid] = markSuiteWithUid( + suite, + uid, + entryType, + runStart + ).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..51e3cc5b --- /dev/null +++ b/packages/app/src/controller/run-detection.ts @@ -0,0 +1,117 @@ +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. + */ +// During a known rerun: just advance the lastSeen high-water mark and don't +// signal a reset — we'd otherwise wipe the rerun's own freshly-written tree. +function advanceLastSeenAcrossPayloads( + payloads: Record[], + lastSeen: number +): number { + 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 lastSeen +} + +function lookupExistingSuiteEnd( + chunk: Record, + existingChunks: SuiteChunks +): unknown { + const firstUid = Object.keys(chunk)[0] + for (const ec of existingChunks) { + for (const [uid, existing] of Object.entries(ec)) { + if (uid === firstUid) { + return existing?.end + } + } + } + return undefined +} + +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) { + lastSeen = advanceLastSeenAcrossPayloads(payloads, lastSeen) + 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 || suiteStartTime <= lastSeen) { + continue + } + const existingEnd = lookupExistingSuiteEnd(chunk, existingChunks) + const previousRunFinished = + existingEnd !== null && existingEnd !== undefined + if (previousRunFinished) { + return { shouldReset: true, newLastSeenTimestamp: suiteStartTime } + } + // Continuation — advance high-water mark, don't 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..3cacaecd --- /dev/null +++ b/packages/app/src/controller/suite-merge.ts @@ -0,0 +1,272 @@ +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()) +} + +interface ChildStateSummary { + hasInProgressChildren: boolean + hasFailedChildren: boolean + allChildrenTerminal: boolean +} + +function summarizeChildStates( + mergedTests: SuiteStatsFragment['tests'] | undefined, + mergedSuites: SuiteStatsFragment['suites'] | undefined +): ChildStateSummary { + const allChildren = [...(mergedTests || []), ...(mergedSuites || [])] + // undefined/null state counts as in-progress so we don't derive 'passed' + // before children have reported. + 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 + const allChildrenTerminal = + hasChildren && + allChildren.every( + (child) => + child?.state === 'passed' || + child?.state === 'failed' || + child?.state === 'skipped' + ) + return { hasInProgressChildren, hasFailedChildren, allChildrenTerminal } +} + +// When a new run starts the backend sends the feature suite with +// state: 'pending' before it has pushed any scenario children. Stale child +// suites preserved by mergeChildSuites must not keep their terminal states — +// mark them 'pending' so they render as a spinner instead of a stale check. +// Exception: child-scope rerun (activeRerunSuiteUid differs from the +// incoming feature suite's uid) — sibling scenarios keep terminal states. +function resetStaleChildrenOnRerun( + mergedSuites: SuiteStatsFragment['suites'] | undefined, + incoming: SuiteStatsFragment, + ctx: MergeContext +): SuiteStatsFragment['suites'] | undefined { + const isChildRerun = + !!ctx.activeRerunSuiteUid && ctx.activeRerunSuiteUid !== incoming.uid + if (incoming.state !== 'pending' || !mergedSuites || isChildRerun) { + return mergedSuites + } + return mergedSuites.map((s) => + s.state === 'passed' || s.state === 'failed' + ? { ...s, state: 'pending' as const, end: undefined } + : s + ) +} + +export function mergeSuite( + existing: SuiteStatsFragment, + incoming: SuiteStatsFragment, + ctx: MergeContext +): SuiteStatsFragment { + const mergedTests = mergeTests(existing.tests, incoming.tests, ctx) + const mergedSuites = mergeChildSuites(existing.suites, incoming.suites, ctx) + + // Strip nullish state from incoming so it doesn't overwrite a valid existing + // state. Nightwatch reporter may omit state fields entirely. + const { tests, suites, ...incomingProps } = incoming + void tests + void suites + if (incomingProps.state === undefined || incomingProps.state === null) { + delete (incomingProps as Partial).state + } + + // WDIO SuiteStats never carries 'state' on suite end → treat + // undefined/null/pending the same. + const incomingStateIsPendingOrUnset = + incoming.state === 'pending' || + incoming.state === null || + incoming.state === undefined + const incomingStateIsUnset = + incoming.state === null || incoming.state === undefined + + const { hasInProgressChildren, hasFailedChildren, allChildrenTerminal } = + summarizeChildStates(mergedTests, mergedSuites) + + // Keep 'running' when the backend hasn't reported a terminal state and any + // child is still in flight — covers both Nightwatch (was 'running') and + // WDIO (was 'passed' from previous run, now has new running children). + const keepRunningState = + incomingStateIsPendingOrUnset && hasInProgressChildren + + // Only derive a terminal state when the backend left it unset AND every + // child has settled. Avoids deriving 'passed' from stale previous-run kids. + const derivedCompletedState: SuiteStatsFragment['state'] | undefined = + allChildrenTerminal && incomingStateIsUnset + ? hasFailedChildren + ? 'failed' + : 'passed' + : undefined + + const finalSuites = resetStaleChildrenOnRerun(mergedSuites, incoming, ctx) + + 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..2945fbc4 100644 --- a/packages/app/src/controller/types.ts +++ b/packages/app/src/controller/types.ts @@ -1,13 +1,21 @@ import type { SuiteStats, TestStats } from '@wdio/reporter' -import type { - TraceLog, - CommandLog, - PreservedAttempt -} from '@wdio/devtools-service/types' +import type { TestStatus } from '@wdio/devtools-shared' + +// SocketMessage / WsScope / WsPayloadFor are the WS wire format and live in +// @wdio/devtools-shared (§2.1 + §2.5). Re-exported here for back-compat with +// existing import sites; new code should import from shared directly. +export type { + ControlScope, + ClearExecutionDataWsPayload, + SocketMessage, + TraceScope, + WsMessageScope, + WsPayloadFor +} 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 +26,7 @@ export type SuiteStatsFragment = Omit< 'uid' | 'tests' | 'suites' > & { uid: string - state?: 'running' | 'passed' | 'failed' | 'pending' + state?: TestStatus tests?: TestStatsFragment[] suites?: SuiteStatsFragment[] callSource?: string @@ -27,36 +35,3 @@ export type SuiteStatsFragment = Omit< type?: string file?: string } - -export interface SocketMessage< - T extends - | keyof TraceLog - | 'testStopped' - | 'clearExecutionData' - | 'replaceCommand' - | 'baseline:saved' - | 'baseline:cleared' = - | keyof TraceLog - | 'testStopped' - | 'clearExecutionData' - | 'replaceCommand' - | 'baseline:saved' - | 'baseline:cleared' -> { - scope: T - data: T extends keyof TraceLog - ? TraceLog[T] - : T extends 'clearExecutionData' - ? { - uid?: string - entryType?: 'suite' | 'test' - clearSuiteTree?: boolean - } - : T extends 'replaceCommand' - ? { oldTimestamp: number; command: CommandLog } - : T extends 'baseline:saved' - ? { testUid: string; attempt: PreservedAttempt } - : T extends 'baseline:cleared' - ? { testUid: string } - : unknown -} diff --git a/packages/app/src/utils/DragController.ts b/packages/app/src/utils/DragController.ts index 2d1d0a5e..6d831cb9 100644 --- a/packages/app/src/utils/DragController.ts +++ b/packages/app/src/utils/DragController.ts @@ -145,17 +145,17 @@ export class DragController implements ReactiveController { const host = this.#host this.#pointerTracker = new PointerTracker(this.#draggableEl, { - start(pointer: any) { + start(pointer: Pointer) { onDragStart(pointer) updateState('dragging') host.requestUpdate() return true }, - move(previousPointers: any, changedPointers: any) { + move(previousPointers: Pointer[], changedPointers: Pointer[]) { onDrag(previousPointers, changedPointers) }, - end(pointer: any, ev: Event) { - onDragEnd(pointer, ev) + end(pointer: Pointer, ev: Event) { + onDragEnd(pointer, ev as InputEvent) updateState('idle') host.requestUpdate() adjustPosition() diff --git a/packages/app/tests/contextUpdates.test.ts b/packages/app/tests/contextUpdates.test.ts new file mode 100644 index 00000000..6389fb8a --- /dev/null +++ b/packages/app/tests/contextUpdates.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'vitest' +import type { CommandLog, NetworkRequest } from '@wdio/devtools-shared' +import { + mergeNetworkRequests, + replaceCommand +} from '../src/controller/contextUpdates.js' + +function cmd( + id: number | undefined, + timestamp: number, + command = 'click', + extra: Partial = {} +): CommandLog & { id?: number } { + return { command, args: [], timestamp, id, ...extra } +} + +describe('replaceCommand', () => { + it('replaces by stable `id` when both ids match', () => { + const current = [cmd(1, 100), cmd(2, 100), cmd(3, 200)] + const incoming = cmd(2, 100, 'click-updated') + const next = replaceCommand(current, 100, incoming) + expect(next).toHaveLength(3) + expect(next[1].command).toBe('click-updated') + expect(next[0]).toBe(current[0]) + expect(next[2]).toBe(current[2]) + }) + + it('falls back to timestamp lastIndexOf when id is missing', () => { + const current = [cmd(undefined, 100), cmd(undefined, 100)] + const incoming = cmd(undefined, 100, 'click-final') + const next = replaceCommand(current, 100, incoming) + expect(next[0]).toBe(current[0]) + expect(next[1].command).toBe('click-final') + }) + + it('appends when no match found', () => { + const current = [cmd(1, 100)] + const incoming = cmd(99, 999, 'new') + const next = replaceCommand(current, 999, incoming) + expect(next).toHaveLength(2) + expect(next[1].command).toBe('new') + }) + + it('returns a NEW array (does not mutate input)', () => { + const current = [cmd(1, 100)] + const incoming = cmd(1, 100, 'replaced') + const next = replaceCommand(current, 100, incoming) + expect(next).not.toBe(current) + expect(current[0].command).toBe('click') + }) +}) + +function req(id: string | undefined, url: string): NetworkRequest { + return { + id: id as string, + url, + method: 'GET', + timestamp: Date.now(), + startTime: 0, + type: 'fetch' + } +} + +describe('mergeNetworkRequests', () => { + it('appends new entries by id', () => { + const current = [req('1', '/a')] + const next = mergeNetworkRequests(current, [req('2', '/b')]) + expect(next).toHaveLength(2) + expect(next.map((r) => r.id)).toEqual(['1', '2']) + }) + + it('updates an existing entry when ids match', () => { + const current = [req('1', '/a'), req('2', '/b')] + const next = mergeNetworkRequests(current, [req('1', '/a-updated')]) + expect(next).toHaveLength(2) + expect(next[0].url).toBe('/a-updated') + }) + + it('appends id-less entries (no dedup)', () => { + const current = [req('1', '/a')] + const noId = req(undefined, '/c') + const next = mergeNetworkRequests(current, [noId, noId]) + expect(next).toHaveLength(3) + }) + + it('returns a new array (does not mutate input)', () => { + const current = [req('1', '/a')] + const next = mergeNetworkRequests(current, [req('2', '/b')]) + expect(next).not.toBe(current) + expect(current).toHaveLength(1) + }) +}) 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/runnerCapabilities.test.ts b/packages/app/tests/runnerCapabilities.test.ts new file mode 100644 index 00000000..8fd525d0 --- /dev/null +++ b/packages/app/tests/runnerCapabilities.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from 'vitest' +import type { Metadata } from '@wdio/devtools-shared' +import { + getCapabilityWarning, + getConfigPath, + getFramework, + getLaunchCommand, + getRerunCommand, + getRunCapabilities, + getRunDisabledReason, + isRunDisabled, + isRunDisabledDetail +} from '../src/components/sidebar/runnerCapabilities.js' +import type { + TestEntry, + TestRunDetail +} from '../src/components/sidebar/types.js' + +function md(options: Record = {}): Metadata { + return { options } as unknown as Metadata +} + +function entry(type: 'test' | 'suite'): TestEntry { + return { type, uid: 'u', label: 'u', children: [] } +} +function detail(entryType: 'test' | 'suite'): TestRunDetail { + return { entryType, uid: 'u' } +} + +describe('getFramework', () => { + it('reads options.framework', () => { + expect(getFramework(md({ framework: 'wdio' }))).toBe('wdio') + }) + it('undefined when metadata missing', () => { + expect(getFramework(undefined)).toBeUndefined() + }) +}) + +describe('getRunCapabilities', () => { + it('returns explicit runCapabilities merged over defaults', () => { + const caps = getRunCapabilities( + md({ runCapabilities: { canRunTests: false } }) + ) + expect(caps).toEqual({ + canRunSuites: true, + canRunTests: false, + canRunAll: true + }) + }) + + it('falls back to FRAMEWORK_CAPABILITIES by name', () => { + expect(getRunCapabilities(md({ framework: 'cucumber' })).canRunTests).toBe( + false + ) + }) + + it('returns DEFAULT_CAPABILITIES when framework unknown', () => { + expect(getRunCapabilities(md({ framework: 'unknown-x' }))).toEqual({ + canRunSuites: true, + canRunTests: true, + canRunAll: true + }) + }) +}) + +describe('isRunDisabled / isRunDisabledDetail', () => { + it('disables test runs when canRunTests is false', () => { + const m = md({ runCapabilities: { canRunTests: false } }) + expect(isRunDisabled(m, entry('test'))).toBe(true) + expect(isRunDisabledDetail(m, detail('test'))).toBe(true) + expect(isRunDisabled(m, entry('suite'))).toBe(false) + }) + + it('disables suite runs when canRunSuites is false', () => { + const m = md({ runCapabilities: { canRunSuites: false } }) + expect(isRunDisabled(m, entry('suite'))).toBe(true) + expect(isRunDisabledDetail(m, detail('suite'))).toBe(true) + expect(isRunDisabled(m, entry('test'))).toBe(false) + }) +}) + +describe('getRunDisabledReason', () => { + it('returns undefined when run is allowed', () => { + expect(getRunDisabledReason(md({}), entry('test'))).toBeUndefined() + }) + it('phrases reason per type', () => { + const m = md({ runCapabilities: { canRunTests: false } }) + expect(getRunDisabledReason(m, entry('test'))).toContain('Single-test') + const m2 = md({ runCapabilities: { canRunSuites: false } }) + expect(getRunDisabledReason(m2, entry('suite'))).toContain('Suite') + }) +}) + +describe('getCapabilityWarning', () => { + it('phrases warning per detail entryType', () => { + expect(getCapabilityWarning(detail('test'))).toContain('Single-test') + expect(getCapabilityWarning(detail('suite'))).toContain('Suite') + }) +}) + +describe('config + command getters', () => { + it('getConfigPath prefers configFilePath over configFile', () => { + expect(getConfigPath(md({ configFilePath: '/a', configFile: '/b' }))).toBe( + '/a' + ) + expect(getConfigPath(md({ configFile: '/b' }))).toBe('/b') + expect(getConfigPath(md({}))).toBeUndefined() + }) + + it('getRerunCommand / getLaunchCommand pluck from options', () => { + expect(getRerunCommand(md({ rerunCommand: 'a' }))).toBe('a') + expect(getLaunchCommand(md({ launchCommand: 'b' }))).toBe('b') + expect(getRerunCommand(undefined)).toBeUndefined() + expect(getLaunchCommand(undefined)).toBeUndefined() + }) +}) 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/README.md b/packages/backend/README.md index 9531a576..6686cc78 100644 --- a/packages/backend/README.md +++ b/packages/backend/README.md @@ -1,3 +1,21 @@ -# WebdriverIO DevTools Backend +# @wdio/devtools-backend +The server that the three adapter packages connect to and the dashboard UI talks to. Internal to the monorepo — not published. +## Responsibilities + +- **Fastify HTTP server** — REST endpoints for preserve/clear/run/stop and the dashboard's baseline pair lookups. +- **WebSocket gateway** — one connection per adapter worker, one per dashboard client. Adapter events fan out to every connected dashboard. +- **Baseline store** (in-memory) — captures a snapshot of a failing test attempt, plus per-uid metadata, so the "Preserve & Rerun" flow can show a side-by-side diff. +- **Rerun spawner** (`runner.ts`) — spawns the user's `wdio` / `nightwatch` / `selenium` binary with rerun filters built from the dashboard's payload. +- **Worker-message handler** — dispatches messages from spawned workers (config path, session id, video path, ...). + +## Framework awareness + +Lives only in `runner.ts` and `framework-filters.ts`. Both branch on a typed `TestRunnerId` from `@wdio/devtools-shared` (never a magic string). `framework-filters.ts` uses an explicit `switch` over the runner id rather than a Map/object lookup so CodeQL's `unvalidated-dynamic-method-call` query trusts the dispatch. + +## Public API + +The backend is consumed only by other workspace packages. Adapter launchers call `start({ port, hostname })` and receive the bound port. The dashboard accesses it via the documented HTTP routes (`packages/shared/src/baseline.ts`, `packages/shared/src/runner.ts`) and WS scopes (`packages/shared/src/ws.ts`, `packages/shared/src/routes.ts`). + +For the full picture of how events flow adapter → backend → dashboard, see [ARCHITECTURE.md](../../ARCHITECTURE.md). diff --git a/packages/backend/package.json b/packages/backend/package.json index 235d8c95..5c7c9e75 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -1,6 +1,6 @@ { "name": "@wdio/devtools-backend", - "version": "1.4.2", + "version": "1.5.0", "description": "Backend service to spin up WebdriverIO Devtools", "author": "Christian Bromann ", "license": "MIT", @@ -17,29 +17,31 @@ "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" }, "dependencies": { "@fastify/rate-limit": "^10.3.0", - "@fastify/static": "^9.0.0", + "@fastify/static": "^9.1.3", "@fastify/websocket": "^11.2.0", - "@wdio/cli": "9.27.0", + "@wdio/cli": "9.27.2", "@wdio/devtools-app": "workspace:^", "@wdio/logger": "9.18.0", - "fastify": "^5.8.4", - "get-port": "^7.1.0", - "import-meta-resolve": "^4.1.0", - "shell-quote": "^1.8.3", - "tree-kill": "^1.2.2" + "fastify": "^5.8.5", + "get-port": "^7.2.0", + "import-meta-resolve": "^4.2.0", + "shell-quote": "^1.8.4", + "tree-kill": "^1.2.2", + "ws": "^8.21.0" }, "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.5.1" } } 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/baselineStore.ts b/packages/backend/src/baselineStore.ts index 4b5f2efd..d504f9c1 100644 --- a/packages/backend/src/baselineStore.ts +++ b/packages/backend/src/baselineStore.ts @@ -266,6 +266,43 @@ class BaselineStore { return node.error } + #collectStepsRecursive(node: TimeWindowNode, steps: PreservedStep[]): void { + if (node.kind === 'test') { + steps.push({ + uid: node.uid, + title: node.title, + fullTitle: node.fullTitle, + start: node.start, + end: node.end, + state: node.state, + error: node.error + }) + } + for (const childUid of node.childUids) { + const child = this.#activeRun.nodes.get(childUid) + if (child) { + this.#collectStepsRecursive(child, steps) + } + } + } + + #buildTestSnapshot(node: TimeWindowNode): PreservedAttempt['test'] { + return { + title: node.title, + fullTitle: node.fullTitle, + file: node.file, + callSource: node.callSource, + start: node.start, + end: node.end, + duration: + node.start !== undefined && node.end !== undefined + ? node.end - node.start + : undefined, + state: this.#deriveState(node), + error: this.#deriveError(node) + } + } + snapshot(uid: string, scope: 'test' | 'suite'): PreservedAttempt | undefined { const node = this.#activeRun.nodes.get(uid) if (!node) { @@ -275,7 +312,6 @@ class BaselineStore { if (!window) { return undefined } - const inWindow = (t: number | undefined) => t !== undefined && t >= window.start && t <= window.end const inWindowSpan = (start?: number, end?: number) => { @@ -283,48 +319,14 @@ class BaselineStore { const e = end ?? start ?? Date.now() return e >= window.start && s <= window.end } - const steps: PreservedStep[] = [] - const collectSteps = (n: TimeWindowNode) => { - if (n.kind === 'test') { - steps.push({ - uid: n.uid, - title: n.title, - fullTitle: n.fullTitle, - start: n.start, - end: n.end, - state: n.state, - error: n.error - }) - } - for (const childUid of n.childUids) { - const child = this.#activeRun.nodes.get(childUid) - if (child) { - collectSteps(child) - } - } - } - collectSteps(node) - + this.#collectStepsRecursive(node, steps) return { testUid: uid, scope, capturedAt: Date.now(), window, - test: { - title: node.title, - fullTitle: node.fullTitle, - file: node.file, - callSource: node.callSource, - start: node.start, - end: node.end, - duration: - node.start !== undefined && node.end !== undefined - ? node.end - node.start - : undefined, - state: this.#deriveState(node), - error: this.#deriveError(node) - }, + test: this.#buildTestSnapshot(node), steps: steps.length > 0 ? steps : undefined, commands: this.#activeRun.commands.filter((c) => inWindow(c.timestamp)), consoleLogs: this.#activeRun.consoleLogs.filter((c) => 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..92ff8290 --- /dev/null +++ b/packages/backend/src/framework-filters.ts @@ -0,0 +1,147 @@ +import type { RunnerRequestBody } from '@wdio/devtools-shared' + +function escapeRegex(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +export type FilterBuilder = (ctx: { + specArg?: string + payload: RunnerRequestBody +}) => string[] + +// Each runner's filter builder is a named const — `getFilterBuilder` dispatches +// via an explicit `switch` over the (untrusted) runner-id string instead of a +// table lookup. This closes CodeQL's `unvalidated-dynamic-method-call` +// finding: the call site sees a closed set of statically-known callables. + +const buildCucumberFilters: FilterBuilder = ({ specArg, payload }) => { + const filters: string[] = [] + + // Feature-level suites run the entire feature file + if (payload.suiteType === 'feature' && specArg) { + const featureFile = specArg.split(':')[0] + filters.push('--spec', featureFile) + return filters + } + + // Priority 1: 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: specific test reruns with an example row number use an + // exact regex match. + if (payload.entryType === 'test' && payload.fullTitle) { + // Cucumber fullTitle format: "1: Scenario name" or "2: Scenario name". + // Avoid ReDoS by removing ambiguous \s* before .* — use string ops instead. + const colonIndex = payload.fullTitle.indexOf(':') + if (colonIndex > 0) { + const rowNumber = payload.fullTitle.substring(0, colonIndex) + const scenarioName = payload.fullTitle.substring(colonIndex + 1).trim() + if (/^\d+$/.test(rowNumber)) { + if (specArg) { + filters.push('--spec', specArg) + } + filters.push( + '--cucumberOpts.name', + `^${rowNumber}:\\s*${escapeRegex(scenarioName)}$` + ) + return filters + } + } + // No row number — 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 +} + +const buildMochaFilters: FilterBuilder = ({ specArg, payload }) => { + const filters: string[] = [] + if (specArg) { + filters.push('--spec', specArg) + } + if (payload.fullTitle) { + filters.push('--mochaOpts.grep', payload.fullTitle) + } + return filters +} + +const buildJasmineFilters: FilterBuilder = ({ specArg, payload }) => { + const filters: string[] = [] + if (specArg) { + filters.push('--spec', specArg) + } + if (payload.fullTitle) { + filters.push('--jasmineOpts.grep', payload.fullTitle) + } + return filters +} + +// Nightwatch CLI: positional spec file + optional --testcase filter +const buildNightwatchFilters: FilterBuilder = ({ 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 underlying Cucumber. +const buildNightwatchCucumberFilters: FilterBuilder = ({ 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. + * + * Takes `string | undefined` (not `TestRunnerId`) so callers can pass the + * raw HTTP-payload value without a cast. The switch enumerates every + * supported runner explicitly — closes CodeQL's + * `js/unvalidated-dynamic-method-call` finding at the call site. + */ +export function getFilterBuilder(runnerId: string | undefined): FilterBuilder { + switch (runnerId) { + case 'cucumber': + return buildCucumberFilters + case 'mocha': + return buildMochaFilters + case 'jasmine': + return buildJasmineFilters + case 'nightwatch': + return buildNightwatchFilters + case 'nightwatch-cucumber': + return buildNightwatchCucumberFilters + default: + return DEFAULT_FILTERS + } +} diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index a6cda5a1..300ffcf2 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' }) @@ -72,138 +95,126 @@ function serveVideo(sessionId: string, reply: any) { .send(fs.createReadStream(videoPath)) } -export async function start( - opts: DevtoolsBackendOptions = {} -): Promise<{ server: FastifyInstance; port: number }> { - const host = opts.hostname || 'localhost' - // Use getPort to find an available port, starting with the preferred port - const preferredPort = opts.port || DEFAULT_PORT - const port = await getPort({ port: preferredPort }) - - // Log if we had to use a different port - if (opts.port && port !== opts.port) { - log.warn(`Port ${opts.port} is already in use, using port ${port} instead`) +async function handleTestRun( + body: RunnerRequestBody, + host: string, + port: number, + reply: FastifyReply +): Promise { + if (!body?.uid || !body.entryType) { + return reply.code(400).send({ error: 'Invalid run payload' }) } - - const appPath = await getDevtoolsApp() - - server = Fastify({ logger: true }) - await server.register(rateLimit, { - max: 100, - timeWindow: '1 minute' - }) - await server.register(websocket) - await server.register(staticServer, { - root: appPath - }) - - server.post( - '/api/tests/run', - async (request: FastifyRequest<{ Body: RunnerRequestBody }>, reply) => { - const body = request.body - if (!body?.uid || !body.entryType) { - return reply.code(400).send({ error: 'Invalid run payload' }) - } - // Broadcast a clear so popouts (which only see WS events) wipe too. + // Broadcast a clear so popouts (which only see WS events) wipe too. + broadcastToClients( + JSON.stringify({ + scope: WS_SCOPE.clearExecutionData, + data: { uid: body.uid, entryType: body.entryType } + }) + ) + // Plain Rerun hides the Compare tab by dropping all baselines. + if (!body.preserveBaseline) { + const clearedUids = baselineStore.clearAll() + for (const testUid of clearedUids) { broadcastToClients( JSON.stringify({ - scope: 'clearExecutionData', - data: { uid: body.uid, entryType: body.entryType } + scope: BASELINE_WS_SCOPE.cleared, + data: { testUid } }) ) - // Plain Rerun hides the Compare tab by dropping all baselines. - if (!body.preserveBaseline) { - const clearedUids = baselineStore.clearAll() - for (const testUid of clearedUids) { - broadcastToClients( - JSON.stringify({ - scope: BASELINE_WS_SCOPE.cleared, - data: { testUid } - }) - ) - } - } - try { - await testRunner.run({ - ...body, - devtoolsHost: host, - devtoolsPort: port - }) - return reply.send({ ok: true }) - } catch (error) { - log.error(`Failed to start test run: ${(error as Error).message}`) - return reply.code(500).send({ error: (error as Error).message }) - } } + } + try { + await testRunner.run({ ...body, devtoolsHost: host, devtoolsPort: port }) + return reply.send({ ok: true }) + } catch (error) { + log.error(`Failed to start test run: ${(error as Error).message}`) + return reply.code(500).send({ error: (error as Error).message }) + } +} + +function registerTestRoutes( + s: FastifyInstance, + host: string, + port: number +): void { + s.post( + '/api/tests/run', + (request: FastifyRequest<{ Body: RunnerRequestBody }>, reply) => + handleTestRun(request.body, host, port, reply) ) - server.post('/api/tests/stop', async (_request, reply) => { + s.post('/api/tests/stop', async (_request, reply) => { testRunner.stop() broadcastToClients( JSON.stringify({ - scope: 'testStopped', + scope: WS_SCOPE.testStopped, data: { stopped: true, timestamp: Date.now() } }) ) reply.send({ ok: true }) }) +} - server.post( +async function handleBaselinePreserve( + body: Partial, + reply: FastifyReply +): Promise { + const { testUid, scope } = body || {} + if (!testUid || (scope !== 'test' && scope !== 'suite')) { + return reply.code(400).send({ + error: 'Invalid preserve payload: testUid and scope required' + }) + } + const attempt = baselineStore.preserve(testUid, scope) + if (!attempt) { + return reply + .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: payload }) + ) + return reply.send({ ok: true, attempt }) +} + +async function handleBaselineClear( + body: Partial, + reply: FastifyReply +): Promise { + const { testUid } = 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: payload }) + ) + } + return reply.send({ ok: true, removed }) +} + +function registerBaselineRoutes(s: FastifyInstance): void { + s.post( BASELINE_API.preserve, - async ( - request: FastifyRequest<{ - Body: { testUid?: string; scope?: 'test' | 'suite' } - }>, + ( + request: FastifyRequest<{ Body: Partial }>, reply - ) => { - const { testUid, scope } = request.body || {} - if (!testUid || (scope !== 'test' && scope !== 'suite')) { - return reply.code(400).send({ - error: 'Invalid preserve payload: testUid and scope required' - }) - } - const attempt = baselineStore.preserve(testUid, scope) - if (!attempt) { - return reply - .code(409) - .send({ error: 'No captured data for the requested uid' }) - } - broadcastToClients( - JSON.stringify({ - scope: BASELINE_WS_SCOPE.saved, - data: { testUid, attempt } - }) - ) - return reply.send({ ok: true, attempt }) - } + ) => handleBaselinePreserve(request.body, reply) ) - - server.post( + s.post( BASELINE_API.clear, - async (request: FastifyRequest<{ Body: { testUid?: string } }>, reply) => { - const { testUid } = request.body || {} - if (!testUid) { - return reply.code(400).send({ error: 'testUid required' }) - } - const removed = baselineStore.clear(testUid) - if (removed) { - broadcastToClients( - JSON.stringify({ - scope: BASELINE_WS_SCOPE.cleared, - data: { testUid } - }) - ) - } - return reply.send({ ok: true, removed }) - } + (request: FastifyRequest<{ Body: Partial }>, reply) => + handleBaselineClear(request.body, reply) ) - - server.get( + s.get( BASELINE_API.get, async ( request: FastifyRequest<{ - Params: { testUid: string } - Querystring: { scope?: 'test' | 'suite' } + Params: BaselineGetParams + Querystring: BaselineGetQuery }>, reply ) => { @@ -212,9 +223,32 @@ export async function start( return reply.send(baselineStore.getPair(testUid, scope)) } ) +} - server.get( - '/client', +function handleClientWsClose(socket: WebSocket): void { + clients.delete(socket) + // 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. 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: {} }) + ) + } +} + +function registerClientWebSocket(s: FastifyInstance): void { + s.get( + WS_PATHS.client, { websocket: true }, (socket: WebSocket, _req: FastifyRequest) => { log.info( @@ -222,28 +256,19 @@ export async function start( ) replayBufferedMessages(socket) clients.add(socket) - socket.on('close', () => { - clients.delete(socket) - // 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: {} }) - ) - } - }) - + socket.on('close', () => handleClientWsClose(socket)) if (workerSocket?.readyState === WebSocket.OPEN) { workerSocket.send( - JSON.stringify({ scope: 'clientConnected', data: {} }) + JSON.stringify({ scope: WS_SCOPE.clientConnected, data: {} }) ) } } ) +} - server.get( - '/worker', +function registerWorkerWebSocket(s: FastifyInstance): void { + s.get( + WS_PATHS.worker, { websocket: true }, (socket: WebSocket, _req: FastifyRequest) => { // Don't drop the message buffer for rerun-child connects (the dashboard @@ -257,86 +282,41 @@ 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 + }) + ) } ) +} - server.get( +function registerVideoRoute(s: FastifyInstance): void { + s.get( '/api/video/:sessionId', { - preHandler: server.rateLimit({ + preHandler: s.rateLimit({ max: 30, timeWindow: '1 minute' }) @@ -345,10 +325,32 @@ export async function start( request: FastifyRequest<{ Params: { sessionId: string } }>, reply ) => { - const { sessionId } = request.params - return serveVideo(sessionId, reply) + return serveVideo(request.params.sessionId, reply) } ) +} + +export async function start( + opts: DevtoolsBackendOptions = {} +): Promise<{ server: FastifyInstance; port: number }> { + const host = opts.hostname || 'localhost' + const preferredPort = opts.port || DEFAULT_PORT + const port = await getPort({ port: preferredPort }) + if (opts.port && port !== opts.port) { + log.warn(`Port ${opts.port} is already in use, using port ${port} instead`) + } + + const appPath = await getDevtoolsApp() + server = Fastify({ logger: true }) + await server.register(rateLimit, { max: 100, timeWindow: '1 minute' }) + await server.register(websocket) + await server.register(staticServer, { root: appPath }) + + registerTestRoutes(server, host, port) + registerBaselineRoutes(server) + registerClientWebSocket(server) + registerWorkerWebSocket(server) + registerVideoRoute(server) log.info(`Starting WebdriverIO Devtools application on port ${port}`) await server.listen({ port, host }) diff --git a/packages/backend/src/runner.ts b/packages/backend/src/runner.ts index 4fc20f07..da1104b5 100644 --- a/packages/backend/src/runner.ts +++ b/packages/backend/src/runner.ts @@ -2,145 +2,43 @@ 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 +} 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 + * Detect a `--name "{{testName}}"` slot anywhere in `template`, with optional + * surrounding whitespace. Uses linear-time string scanning (split/indexOf) so + * the user-supplied rerun template can't trigger backtracking regardless of + * how many spaces it contains. See CodeQL js/polynomial-redos for context. */ -function escapeRegex(str: string): string { - return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +const NAME_SLOT = '--name "{{testName}}"' +function hasNameTestNameSlot(template: string): boolean { + return template.includes(NAME_SLOT) } - -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 +function stripNameTestNameSlot(template: string): string { + const idx = template.indexOf(NAME_SLOT) + if (idx === -1) { + return template } - - // Suite-level rerun - if (specArg) { - filters.push('--spec', specArg) + // Trim adjacent whitespace on the left of the slot so we don't leave a + // double space behind. The right side is left intact — the caller appends + // the feature path after this segment. + let leftEdge = idx + while (leftEdge > 0 && /\s/.test(template[leftEdge - 1])) { + leftEdge-- } - 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 -}) + return template.slice(0, leftEdge) + template.slice(idx + NAME_SLOT.length) +} class TestRunner { #child?: ChildProcess @@ -162,6 +60,79 @@ class TestRunner { } } + #buildReuseEnv(payload: RunnerRequestBody): NodeJS.ProcessEnv { + const childEnv: NodeJS.ProcessEnv = { ...process.env } + if (payload.devtoolsHost && payload.devtoolsPort) { + childEnv[REUSE_ENV.HOST] = payload.devtoolsHost + childEnv[REUSE_ENV.PORT] = String(payload.devtoolsPort) + childEnv[REUSE_ENV.REUSE] = '1' + } + return childEnv + } + + #spawnGeneric( + payload: RunnerRequestBody, + childEnv: NodeJS.ProcessEnv + ): ChildProcess { + const command = this.#resolveGenericCommand(payload) + this.#baseDir = process.env[RUNNER_ENV.RUNNER_CWD] || process.cwd() + const { file, args } = this.#parseGenericCommand(command) + return spawn(file, args, { + cwd: this.#baseDir, + env: childEnv, + stdio: 'inherit', + detached: false + }) + } + + #spawnFramework( + payload: RunnerRequestBody, + childEnv: NodeJS.ProcessEnv, + isNightwatch: boolean + ): ChildProcess { + const configPath = this.#resolveConfigPath(payload) + this.#baseDir = + process.env[RUNNER_ENV.RUNNER_CWD] || path.dirname(configPath) + const args: string[] = isNightwatch + ? [ + resolveNightwatchBin(this.#baseDir), + '--config', + configPath, + ...this.#buildFilters(payload) + ].filter(Boolean) + : [wdioBin, 'run', configPath, ...this.#buildFilters(payload)].filter( + Boolean + ) + if (isNightwatch) { + if (payload.entryType === 'test' && payload.label) { + childEnv[REUSE_ENV.RERUN_ENTRY_TYPE] = 'test' + childEnv[REUSE_ENV.RERUN_LABEL] = payload.label + } else { + delete childEnv[REUSE_ENV.RERUN_ENTRY_TYPE] + delete childEnv[REUSE_ENV.RERUN_LABEL] + } + } + return spawn(process.execPath, args, { + cwd: this.#baseDir, + env: childEnv, + stdio: 'inherit', + detached: false + }) + } + + #waitForSpawn(child: ChildProcess): Promise { + return new Promise((resolve, reject) => { + child.once('spawn', resolve) + child.once('error', (error) => { + this.#child = undefined + this.#lastPayload = undefined + this.#baseDir = process.cwd() + this.#expectingRerunChild = false + reject(error) + }) + }) + } + async run(payload: RunnerRequestBody) { if (this.#child) { this.stop() @@ -171,94 +142,38 @@ class TestRunner { this.#expectingRerunChild = Boolean( payload.devtoolsHost && payload.devtoolsPort ) - const isNightwatch = (payload.framework || '') .toLowerCase() .startsWith('nightwatch') - // Used when a plugin supplies its own rerun template (e.g. selenium — - // runs under mocha/jest/vitest/cucumber, none of which use wdioBin). + // Plugins that supply their own rerun template (e.g. selenium — runs + // under mocha/jest/vitest/cucumber, none of which use wdioBin). const isGenericShell = !isNightwatch && Boolean(payload.rerunCommand || payload.launchCommand) - 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' - } - - let child: ChildProcess - if (isGenericShell) { - const command = this.#resolveGenericCommand(payload) - this.#baseDir = process.env.DEVTOOLS_RUNNER_CWD || process.cwd() - const { file, args } = this.#parseGenericCommand(command) - child = spawn(file, args, { - cwd: this.#baseDir, - env: childEnv, - stdio: 'inherit', - detached: false - }) - } else { - const configPath = this.#resolveConfigPath(payload) - this.#baseDir = - process.env.DEVTOOLS_RUNNER_CWD || path.dirname(configPath) - let args: string[] - if (isNightwatch) { - const nightwatchBin = resolveNightwatchBin(this.#baseDir) - args = [ - nightwatchBin, - '--config', - configPath, - ...this.#buildFilters(payload) - ].filter(Boolean) - } else { - args = [ - wdioBin, - 'run', - configPath, - ...this.#buildFilters(payload) - ].filter(Boolean) - } - if (isNightwatch) { - if (payload.entryType === 'test' && payload.label) { - childEnv.DEVTOOLS_RERUN_ENTRY_TYPE = 'test' - childEnv.DEVTOOLS_RERUN_LABEL = payload.label - } else { - delete childEnv.DEVTOOLS_RERUN_ENTRY_TYPE - delete childEnv.DEVTOOLS_RERUN_LABEL - } - } - child = spawn(process.execPath, args, { - cwd: this.#baseDir, - env: childEnv, - stdio: 'inherit', - detached: false - }) - } + const childEnv = this.#buildReuseEnv(payload) + const child = isGenericShell + ? this.#spawnGeneric(payload, childEnv) + : this.#spawnFramework(payload, childEnv, isNightwatch) this.#child = child this.#lastPayload = payload - child.once('close', () => { this.#child = undefined this.#lastPayload = undefined this.#baseDir = process.cwd() }) - - await new Promise((resolve, reject) => { - child.once('spawn', resolve) - child.once('error', (error) => { - this.#child = undefined - this.#lastPayload = undefined - this.#baseDir = process.cwd() - this.#expectingRerunChild = false - reject(error) - }) - }) + await this.#waitForSpawn(child) } // 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 +181,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 || '' + } + // 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) && + hasNameTestNameSlot(template) + if (isCucumberFeatureRerun && featureSpec) { + const stripped = stripNameTestNameSlot(template) + return `${stripped} ${shellQuote([featureSpec])}` } - return fallback || template || '' + const name = payload.label || payload.fullTitle || '' + return template.replace(/\{\{testName\}\}/g, name) } #parseGenericCommand(command: string): { file: string; args: string[] } { @@ -325,16 +258,15 @@ class TestRunner { : specFile : undefined - const candidateBuilder = FRAMEWORK_FILTERS.get(framework) - const builder = - typeof candidateBuilder === 'function' - ? candidateBuilder - : DEFAULT_FILTERS + // framework is `string` from the HTTP payload; getFilterBuilder + // validates it against its known-runner Map and falls back to the + // default spec-only builder for anything unrecognised. + const builder = getFilterBuilder(framework) 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 +333,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 +410,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..ef643f9a --- /dev/null +++ b/packages/backend/src/worker-message-handler.ts @@ -0,0 +1,90 @@ +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 +} + +// Returns true if the message was fully handled and shouldn't be forwarded. +function tryHandleControlMessage( + parsed: { scope?: string; data?: Record }, + ctx: WorkerMessageContext +): boolean { + if (parsed.scope === WS_SCOPE.clearCommands) { + const testUid = parsed.data?.testUid + log.info(`Clearing commands for test: ${testUid || 'all'}`) + // Clearing without a uid is a full reset; wipe the baseline accumulator. + if (!testUid) { + ctx.baselineStore.resetActiveRun() + } + ctx.broadcastToClients( + JSON.stringify({ + scope: WS_SCOPE.clearExecutionData, + data: { uid: testUid } + }) + ) + return true + } + if (parsed.scope === 'config' && parsed.data?.configFile) { + const configFile = String(parsed.data.configFile) + ctx.testRunner.registerConfigFile(configFile) + log.info(`Registered config file for reruns: ${configFile}`) + return true + } + // Screencast: store the absolute videoPath in the registry (backend-only), + // then forward only the sessionId so the UI can fetch via /api/video/:sessionId. + if (parsed.scope === 'screencast' && parsed.data?.sessionId) { + const sessionId = String(parsed.data.sessionId) + const videoPath = + typeof parsed.data.videoPath === 'string' + ? parsed.data.videoPath + : undefined + if (videoPath) { + ctx.videoRegistry.set(sessionId, videoPath) + log.info(`Screencast registered for session ${sessionId}: ${videoPath}`) + } + ctx.broadcastToClients( + JSON.stringify({ scope: 'screencast', data: { sessionId } }) + ) + return true + } + return false +} + +/** + * 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` this feeds the worker's stream 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 (tryHandleControlMessage(parsed, ctx)) { + return + } + // Tee the event into the baseline accumulator for time-window + // partitioning at preserve time. 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. + } + ctx.broadcastToClients(message.toString()) + } +} diff --git a/packages/backend/tests/baselineStore.test.ts b/packages/backend/tests/baselineStore.test.ts index e7012cdf..619d0f65 100644 --- a/packages/backend/tests/baselineStore.test.ts +++ b/packages/backend/tests/baselineStore.test.ts @@ -111,7 +111,7 @@ describe('baselineStore', () => { }) }) - it('preserve refuses an empty-command snapshot (the 409 case)', () => { + it('preserve refuses an empty-command snapshot', () => { baselineStore.recordEvent('suites', suite({ start: 100, end: 200 })) expect(baselineStore.preserve(TEST_UID, 'test')).toBeUndefined() expect(baselineStore.get(TEST_UID)).toBeUndefined() @@ -129,4 +129,249 @@ describe('baselineStore', () => { expect(baselineStore.clearAll()).toEqual([TEST_UID]) expect(baselineStore.get(TEST_UID)).toBeUndefined() }) + + it('clear(uid) removes only the named baseline and returns whether it existed', () => { + baselineStore.recordEvent('commands', [ + { timestamp: 150, command: 'click', args: [] } + ]) + baselineStore.recordEvent('suites', suite({ start: 100, end: 200 })) + baselineStore.preserve(TEST_UID, 'test') + + expect(baselineStore.clear(TEST_UID)).toBe(true) + expect(baselineStore.clear(TEST_UID)).toBe(false) + expect(baselineStore.get(TEST_UID)).toBeUndefined() + }) + + it('getPair returns the stored baseline and a fresh snapshot of the latest run', () => { + // First run: capture + preserve a baseline + baselineStore.recordEvent('commands', [ + { timestamp: 150, command: 'first', args: [] } + ]) + baselineStore.recordEvent('suites', suite({ start: 100, end: 200 })) + const baseline = baselineStore.preserve(TEST_UID, 'test')! + + // Second run: new commands within a new time window + baselineStore.recordEvent( + 'suites', + suite({ start: 500, end: 600, childState: 'passed' }) + ) + baselineStore.recordEvent('commands', [ + { timestamp: 550, command: 'second', args: [] } + ]) + + const pair = baselineStore.getPair(TEST_UID, 'test') + expect(pair.baseline).toBe(baseline) + expect(pair.latest?.commands.map((c) => c.command)).toEqual(['second']) + }) + + it('getPair returns latest only when no baseline has been preserved', () => { + baselineStore.recordEvent('commands', [ + { timestamp: 150, command: 'live', args: [] } + ]) + baselineStore.recordEvent('suites', suite({ start: 100, end: 200 })) + + const pair = baselineStore.getPair(TEST_UID, 'test') + expect(pair.baseline).toBeUndefined() + expect(pair.latest?.commands.map((c) => c.command)).toEqual(['live']) + }) + + it('rolls a passing descendant up to a suite without explicit state', () => { + baselineStore.recordEvent('commands', [ + { timestamp: 150, command: 'ok', args: [] } + ]) + // Suite has no explicit state; child passes → rollup yields 'passed' + baselineStore.recordEvent('suites', [ + { + [SUITE_UID]: { + uid: SUITE_UID, + title: 'parent', + file: '/spec.ts', + start: 100, + end: 200, + tests: [ + { + uid: TEST_UID, + title: 'child', + fullTitle: 'parent child', + start: 100, + end: 200, + state: 'passed' + } + ], + suites: [] + } + } + ]) + + const snap = baselineStore.snapshot(SUITE_UID, 'suite')! + expect(snap.test.state).toBe('passed') + }) + + it('filters consoleLogs to the test time window', () => { + baselineStore.recordEvent('consoleLogs', [ + { type: 'log', args: ['before'], timestamp: 100 }, + { type: 'log', args: ['inside'], timestamp: 250 }, + { type: 'log', args: ['after'], timestamp: 900 } + ]) + baselineStore.recordEvent('commands', [ + { timestamp: 250, command: 'click', args: [] } + ]) + baselineStore.recordEvent('suites', suite({ start: 200, end: 300 })) + + const snap = baselineStore.snapshot(TEST_UID, 'test')! + expect(snap.consoleLogs.map((c) => c.args[0])).toEqual(['inside']) + }) + + it('filters mutations to the test time window', () => { + baselineStore.recordEvent('mutations', [ + { type: 'attributes', timestamp: 100, addedNodes: [], removedNodes: [] }, + { type: 'attributes', timestamp: 250, addedNodes: [], removedNodes: [] }, + { type: 'attributes', timestamp: 900, addedNodes: [], removedNodes: [] } + ]) + baselineStore.recordEvent('commands', [ + { timestamp: 250, command: 'click', args: [] } + ]) + baselineStore.recordEvent('suites', suite({ start: 200, end: 300 })) + + const snap = baselineStore.snapshot(TEST_UID, 'test')! + expect( + snap.mutations.map((m) => (m as { timestamp: number }).timestamp) + ).toEqual([250]) + }) + + it('filters networkRequests by span overlap with the window', () => { + baselineStore.recordEvent('networkRequests', [ + // ends before window + { + id: '1', + startTime: 50, + endTime: 150, + url: '/a', + method: 'GET', + timestamp: 50, + type: 'fetch' + }, + // overlaps window + { + id: '2', + startTime: 250, + endTime: 280, + url: '/b', + method: 'GET', + timestamp: 250, + type: 'fetch' + }, + // starts after window + { + id: '3', + startTime: 500, + endTime: 600, + url: '/c', + method: 'GET', + timestamp: 500, + type: 'fetch' + } + ]) + baselineStore.recordEvent('commands', [ + { timestamp: 250, command: 'click', args: [] } + ]) + baselineStore.recordEvent('suites', suite({ start: 200, end: 300 })) + + const snap = baselineStore.snapshot(TEST_UID, 'test')! + expect(snap.networkRequests.map((r) => r.url)).toEqual(['/b']) + }) + + it('preserve returns undefined when the uid has no recorded node', () => { + expect(baselineStore.preserve('unknown-uid', 'test')).toBeUndefined() + }) + + it('preserve at suite scope captures the parent windowing leaf commands', () => { + baselineStore.recordEvent('commands', [ + { timestamp: 150, command: 'one', args: [] }, + { timestamp: 250, command: 'two', args: [] } + ]) + baselineStore.recordEvent('suites', suite({ start: 100, end: 300 })) + + const attempt = baselineStore.preserve(SUITE_UID, 'suite')! + expect(attempt.scope).toBe('suite') + expect(attempt.commands.map((c) => c.command)).toEqual(['one', 'two']) + }) + + it('recordEvent ignores falsy data', () => { + // No throw and no side effect + baselineStore.recordEvent('commands', null) + baselineStore.recordEvent('commands', undefined) + baselineStore.recordEvent('commands', 0 as never) + + baselineStore.recordEvent('suites', suite({ start: 100, end: 200 })) + // Empty array also no-ops without throwing + baselineStore.recordEvent('commands', []) + expect(baselineStore.snapshot(TEST_UID, 'test')?.commands ?? []).toEqual([]) + }) + + it('recordEvent merges sources via assign', () => { + baselineStore.recordEvent('sources', { '/a.ts': 'A' }) + baselineStore.recordEvent('sources', { '/b.ts': 'B' }) + baselineStore.recordEvent('commands', [ + { timestamp: 150, command: 'click', args: [] } + ]) + baselineStore.recordEvent('suites', suite({ start: 100, end: 200 })) + + const snap = baselineStore.snapshot(TEST_UID, 'test')! + expect(snap.sources).toEqual({ '/a.ts': 'A', '/b.ts': 'B' }) + }) + + it('networkRequests are deduped by id across multiple recordEvent calls', () => { + const base = { + startTime: 250, + endTime: 260, + method: 'GET', + timestamp: 250, + type: 'fetch' + } + baselineStore.recordEvent('networkRequests', [ + { id: '1', url: '/a', ...base } + ]) + baselineStore.recordEvent('networkRequests', [ + { id: '1', url: '/a-updated', ...base }, + { id: '2', url: '/b', ...base } + ]) + baselineStore.recordEvent('commands', [ + { timestamp: 250, command: 'click', args: [] } + ]) + baselineStore.recordEvent('suites', suite({ start: 200, end: 300 })) + + const snap = baselineStore.snapshot(TEST_UID, 'test')! + expect(snap.networkRequests.map((r) => r.url)).toEqual(['/a-updated', '/b']) + }) + + it('rolls a running descendant up so a suite without explicit state shows running', () => { + baselineStore.recordEvent('commands', [ + { timestamp: 150, command: 'go', args: [] } + ]) + baselineStore.recordEvent('suites', [ + { + [SUITE_UID]: { + uid: SUITE_UID, + title: 'parent', + file: '/spec.ts', + start: 100, + end: 200, + tests: [ + { + uid: TEST_UID, + title: 'child', + fullTitle: 'parent child', + start: 100, + state: 'running' + } + ], + suites: [] + } + } + ]) + + const snap = baselineStore.snapshot(SUITE_UID, 'suite')! + expect(snap.test.state).toBe('running') + }) }) diff --git a/packages/backend/tests/bin-resolver.test.ts b/packages/backend/tests/bin-resolver.test.ts new file mode 100644 index 00000000..9e70eb73 --- /dev/null +++ b/packages/backend/tests/bin-resolver.test.ts @@ -0,0 +1,150 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { RUNNER_ENV } from '@wdio/devtools-shared' +import { resolveNightwatchBin, resolveWdioBin } from '../src/bin-resolver.js' + +let tmpDir: string +let savedEnv: NodeJS.ProcessEnv + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'bin-resolver-')) + // Snapshot the resolver-relevant env so each test starts clean. + savedEnv = { ...process.env } + delete process.env[RUNNER_ENV.NIGHTWATCH_BIN] + delete process.env[RUNNER_ENV.WDIO_BIN] +}) +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }) + process.env = savedEnv +}) + +// Lay out a minimal node_modules/nightwatch/{package.json + bin/nightwatch.js} +function plantNightwatch(at: string, binEntry: unknown = 'bin/nightwatch.js') { + const pkgDir = path.join(at, 'node_modules', 'nightwatch') + fs.mkdirSync(path.join(pkgDir, 'bin'), { recursive: true }) + fs.writeFileSync( + path.join(pkgDir, 'package.json'), + JSON.stringify({ name: 'nightwatch', bin: binEntry }) + ) + fs.writeFileSync( + path.join(pkgDir, 'bin', 'nightwatch.js'), + '#!/usr/bin/env node\n' + ) + return path.join(pkgDir, 'bin', 'nightwatch.js') +} + +describe('resolveNightwatchBin — env override (DEVTOOLS_NIGHTWATCH_BIN)', () => { + it('honors an absolute env override that exists on disk', () => { + const fake = path.join(tmpDir, 'my-nightwatch.js') + fs.writeFileSync(fake, '') + process.env[RUNNER_ENV.NIGHTWATCH_BIN] = fake + expect(resolveNightwatchBin('/anywhere')).toBe(fake) + }) + + it('honors a relative env override (resolved from cwd)', () => { + const fake = path.join(tmpDir, 'rel-nightwatch.js') + fs.writeFileSync(fake, '') + // Make the override relative by stripping cwd off the path + const rel = path.relative(process.cwd(), fake) + process.env[RUNNER_ENV.NIGHTWATCH_BIN] = rel + expect(resolveNightwatchBin('/anywhere')).toBe(path.resolve(rel)) + }) + + it('falls through to walk-up when the env-override path does not exist', () => { + process.env[RUNNER_ENV.NIGHTWATCH_BIN] = '/totally/missing.js' + const expected = plantNightwatch(tmpDir) + expect(resolveNightwatchBin(tmpDir)).toBe(expected) + }) +}) + +describe('resolveNightwatchBin — walk-up node_modules search', () => { + it('finds nightwatch when planted in the start directory', () => { + const expected = plantNightwatch(tmpDir) + expect(resolveNightwatchBin(tmpDir)).toBe(expected) + }) + + it('walks up parent directories until it finds node_modules/nightwatch', () => { + const child = path.join(tmpDir, 'a', 'b', 'c') + fs.mkdirSync(child, { recursive: true }) + const expected = plantNightwatch(tmpDir) + expect(resolveNightwatchBin(child)).toBe(expected) + }) + + it('supports object-form bin: { nightwatch: ... }', () => { + const expected = plantNightwatch(tmpDir, { + nightwatch: 'bin/nightwatch.js' + }) + expect(resolveNightwatchBin(tmpDir)).toBe(expected) + }) + + it('supports object-form bin: { nw: ... } as a fallback', () => { + const pkgDir = path.join(tmpDir, 'node_modules', 'nightwatch') + fs.mkdirSync(path.join(pkgDir, 'bin'), { recursive: true }) + fs.writeFileSync( + path.join(pkgDir, 'package.json'), + JSON.stringify({ name: 'nightwatch', bin: { nw: 'bin/nw-entry.js' } }) + ) + fs.writeFileSync(path.join(pkgDir, 'bin', 'nw-entry.js'), '') + expect(resolveNightwatchBin(tmpDir)).toBe( + path.join(pkgDir, 'bin', 'nw-entry.js') + ) + }) + + it('throws a helpful error mentioning DEVTOOLS_NIGHTWATCH_BIN when nothing is found', () => { + // tmpDir has no nightwatch planted; walk-up hits filesystem root + expect(() => resolveNightwatchBin(tmpDir)).toThrow( + /DEVTOOLS_NIGHTWATCH_BIN/ + ) + }) + + it('skips malformed package.json silently and continues walking', () => { + const child = path.join(tmpDir, 'inner') + fs.mkdirSync(path.join(child, 'node_modules', 'nightwatch'), { + recursive: true + }) + // Write garbage JSON at the inner level + fs.writeFileSync( + path.join(child, 'node_modules', 'nightwatch', 'package.json'), + '{ this is not valid json' + ) + // Plant a valid one at the parent level + const expected = plantNightwatch(tmpDir) + expect(resolveNightwatchBin(child)).toBe(expected) + }) +}) + +describe('resolveWdioBin — env override (DEVTOOLS_WDIO_BIN)', () => { + it('honors an absolute env override that exists', () => { + const fake = path.join(tmpDir, 'my-wdio.js') + fs.writeFileSync(fake, '') + process.env[RUNNER_ENV.WDIO_BIN] = fake + expect(resolveWdioBin()).toBe(fake) + }) + + it('throws a helpful error when env-override path does NOT exist (does NOT fall back to require.resolve)', () => { + process.env[RUNNER_ENV.WDIO_BIN] = '/totally/missing-wdio.js' + expect(() => resolveWdioBin()).toThrow(/does not exist|not accessible/) + }) + + it('resolves a relative env override from cwd', () => { + const fake = path.join(tmpDir, 'rel-wdio.js') + fs.writeFileSync(fake, '') + const rel = path.relative(process.cwd(), fake) + process.env[RUNNER_ENV.WDIO_BIN] = rel + expect(resolveWdioBin()).toBe(path.resolve(rel)) + }) +}) + +describe('resolveWdioBin — @wdio/cli derivation', () => { + // @wdio/cli IS installed in this workspace (a real dep), so the derivation + // succeeds and returns the published bin path. We assert the file exists + + // looks like the wdio entry, without locking to a specific absolute path + // that varies per machine. + it('derives bin/wdio.js from @wdio/cli when no env override is set', () => { + const resolved = resolveWdioBin() + expect(resolved.endsWith('bin/wdio.js')).toBe(true) + expect(fs.existsSync(resolved)).toBe(true) + }) +}) diff --git a/packages/backend/tests/framework-filters.test.ts b/packages/backend/tests/framework-filters.test.ts new file mode 100644 index 00000000..faae96b7 --- /dev/null +++ b/packages/backend/tests/framework-filters.test.ts @@ -0,0 +1,221 @@ +import { describe, expect, it } from 'vitest' +import type { RunnerRequestBody } from '@wdio/devtools-shared' +import { getFilterBuilder } from '../src/framework-filters.js' + +function payload( + overrides: Partial = {} +): RunnerRequestBody { + return { + entryType: 'test', + label: '', + framework: 'mocha', + ...overrides + } as RunnerRequestBody +} + +describe('getFilterBuilder fallback (DEFAULT_FILTERS)', () => { + it('passes --spec when specArg is given', () => { + const fn = getFilterBuilder(undefined) + expect(fn({ specArg: '/a.test.ts', payload: payload() })).toEqual([ + '--spec', + '/a.test.ts' + ]) + }) + + it('returns [] when no specArg is given', () => { + const fn = getFilterBuilder(undefined) + expect(fn({ specArg: undefined, payload: payload() })).toEqual([]) + }) + + it('uses default for unknown runner ids', () => { + const fn = getFilterBuilder('unknown-runner' as never) + expect(fn({ specArg: '/x.ts', payload: payload() })).toEqual([ + '--spec', + '/x.ts' + ]) + }) +}) + +describe('mocha filter builder', () => { + const fn = getFilterBuilder('mocha') + + it('adds --spec + --mochaOpts.grep when both are set', () => { + expect( + fn({ specArg: '/a.test.ts', payload: payload({ fullTitle: 'Login >' }) }) + ).toEqual(['--spec', '/a.test.ts', '--mochaOpts.grep', 'Login >']) + }) + + it('omits --mochaOpts.grep when fullTitle is empty', () => { + expect(fn({ specArg: '/a.test.ts', payload: payload() })).toEqual([ + '--spec', + '/a.test.ts' + ]) + }) + + it('omits --spec when specArg is undefined', () => { + expect( + fn({ specArg: undefined, payload: payload({ fullTitle: 'X' }) }) + ).toEqual(['--mochaOpts.grep', 'X']) + }) +}) + +describe('jasmine filter builder', () => { + const fn = getFilterBuilder('jasmine') + it('mirrors mocha shape with --jasmineOpts.grep', () => { + expect( + fn({ specArg: '/a.ts', payload: payload({ fullTitle: 'A' }) }) + ).toEqual(['--spec', '/a.ts', '--jasmineOpts.grep', 'A']) + }) +}) + +describe('nightwatch filter builder', () => { + const fn = getFilterBuilder('nightwatch') + + it('strips trailing :line from specArg (Nightwatch does not support it)', () => { + expect( + fn({ + specArg: '/a.test.ts:42', + payload: payload({ entryType: 'test', label: 'should pass' }) + }) + ).toEqual(['/a.test.ts', '--testcase', 'should pass']) + }) + + it('passes positional spec without --testcase for suite entryType', () => { + expect( + fn({ + specArg: '/a.test.ts', + payload: payload({ entryType: 'suite' as never }) + }) + ).toEqual(['/a.test.ts']) + }) + + it('returns empty filters when no specArg and no label', () => { + expect(fn({ specArg: undefined, payload: payload() })).toEqual([]) + }) +}) + +describe('cucumber filter builder', () => { + const fn = getFilterBuilder('cucumber') + + it('feature-level: strips line and runs the whole feature', () => { + expect( + fn({ + specArg: '/login.feature:10', + payload: payload({ suiteType: 'feature' as never }) + }) + ).toEqual(['--spec', '/login.feature']) + }) + + it('scenario file:line takes priority when feature/line are provided', () => { + expect( + fn({ + specArg: '/a.feature', + payload: payload({ + featureFile: '/login.feature', + featureLine: 12 + } as never) + }) + ).toEqual(['--spec', '/login.feature:12']) + }) + + it('test entry with row number: uses anchored regex --cucumberOpts.name', () => { + const result = fn({ + specArg: '/login.feature', + payload: payload({ + entryType: 'test', + fullTitle: '3: User signs in with valid creds' + } as never) + }) + expect(result).toEqual([ + '--spec', + '/login.feature', + '--cucumberOpts.name', + '^3:\\s*User signs in with valid creds$' + ]) + }) + + it('test entry with no row prefix uses plain name filter', () => { + const result = fn({ + specArg: '/login.feature', + payload: payload({ + entryType: 'test', + fullTitle: 'Plain scenario' + } as never) + }) + expect(result).toEqual([ + '--spec', + '/login.feature', + '--cucumberOpts.name', + 'Plain scenario' + ]) + }) + + it('test entry with non-numeric prefix falls back to plain name', () => { + const result = fn({ + specArg: '/login.feature', + payload: payload({ + entryType: 'test', + fullTitle: 'foo: bar' + } as never) + }) + // colon present but rowNumber is "foo" not digits → plain name path + expect(result.slice(-2)).toEqual(['--cucumberOpts.name', 'foo: bar']) + }) + + it('suite-level: only spec, no name filter', () => { + expect( + fn({ + specArg: '/login.feature', + payload: payload({ entryType: 'suite' as never }) + }) + ).toEqual(['--spec', '/login.feature']) + }) + + it('escapes regex metacharacters in scenario name', () => { + const result = fn({ + specArg: '/x.feature', + payload: payload({ + entryType: 'test', + fullTitle: '1: a.b*c' + } as never) + }) + expect(result[result.length - 1]).toBe('^1:\\s*a\\.b\\*c$') + }) +}) + +describe('nightwatch-cucumber filter builder', () => { + const fn = getFilterBuilder('nightwatch-cucumber') + + it('adds --name with anchored regex for scenario-level reruns', () => { + expect( + fn({ specArg: undefined, payload: payload({ fullTitle: 'My Scenario' }) }) + ).toEqual(['--name', '^My Scenario$']) + }) + + it('skips --name for feature-level (suiteType=feature)', () => { + expect( + fn({ + specArg: undefined, + payload: payload({ + suiteType: 'feature' as never, + fullTitle: 'unused' + }) + }) + ).toEqual([]) + }) + + it('skips --name when runAll is set', () => { + expect( + fn({ + specArg: undefined, + payload: payload({ runAll: true as never, fullTitle: 'unused' }) + }) + ).toEqual([]) + }) + + it('escapes regex metacharacters in the scenario name', () => { + expect( + fn({ specArg: undefined, payload: payload({ fullTitle: 'a.b*c' }) }) + ).toEqual(['--name', '^a\\.b\\*c$']) + }) +}) diff --git a/packages/backend/tests/worker-message-handler.test.ts b/packages/backend/tests/worker-message-handler.test.ts new file mode 100644 index 00000000..9b171d99 --- /dev/null +++ b/packages/backend/tests/worker-message-handler.test.ts @@ -0,0 +1,158 @@ +import { describe, it, expect, vi } from 'vitest' +import { WS_SCOPE } from '@wdio/devtools-shared' +import { + createWorkerMessageHandler, + type WorkerMessageContext +} from '../src/worker-message-handler.js' + +function makeCtx(overrides: Partial = {}) { + const broadcastToClients = vi.fn() + const baselineStore = { + resetActiveRun: vi.fn(), + recordEvent: vi.fn() + } + const testRunner = { + registerConfigFile: vi.fn() + } + const videoRegistry = new Map() + const ctx: WorkerMessageContext = { + baselineStore: + baselineStore as unknown as WorkerMessageContext['baselineStore'], + testRunner: testRunner as unknown as WorkerMessageContext['testRunner'], + videoRegistry, + broadcastToClients, + clientCount: () => 1, + ...overrides + } + return { ctx, broadcastToClients, baselineStore, testRunner, videoRegistry } +} + +const buf = (obj: unknown) => Buffer.from(JSON.stringify(obj)) + +describe('createWorkerMessageHandler — clearCommands', () => { + it('with a testUid: broadcasts clearExecutionData scoped to that uid; does NOT reset the baseline accumulator', () => { + const { ctx, broadcastToClients, baselineStore } = makeCtx() + const handler = createWorkerMessageHandler(ctx) + handler(buf({ scope: WS_SCOPE.clearCommands, data: { testUid: 't-1' } })) + expect(baselineStore.resetActiveRun).not.toHaveBeenCalled() + expect(broadcastToClients).toHaveBeenCalledWith( + JSON.stringify({ + scope: WS_SCOPE.clearExecutionData, + data: { uid: 't-1' } + }) + ) + }) + + it('without a testUid: resets baseline AND broadcasts a full-clear', () => { + const { ctx, broadcastToClients, baselineStore } = makeCtx() + const handler = createWorkerMessageHandler(ctx) + handler(buf({ scope: WS_SCOPE.clearCommands, data: {} })) + expect(baselineStore.resetActiveRun).toHaveBeenCalledOnce() + expect(broadcastToClients).toHaveBeenCalledWith( + JSON.stringify({ + scope: WS_SCOPE.clearExecutionData, + data: { uid: undefined } + }) + ) + }) +}) + +describe('createWorkerMessageHandler — config scope', () => { + it('registers the config file and does NOT broadcast (control message)', () => { + const { ctx, broadcastToClients, testRunner } = makeCtx() + const handler = createWorkerMessageHandler(ctx) + handler(buf({ scope: 'config', data: { configFile: '/p/wdio.conf.ts' } })) + expect(testRunner.registerConfigFile).toHaveBeenCalledWith( + '/p/wdio.conf.ts' + ) + expect(broadcastToClients).not.toHaveBeenCalled() + }) + + it('ignores config messages without a configFile', () => { + const { ctx, testRunner } = makeCtx() + const handler = createWorkerMessageHandler(ctx) + handler(buf({ scope: 'config', data: {} })) + expect(testRunner.registerConfigFile).not.toHaveBeenCalled() + }) +}) + +describe('createWorkerMessageHandler — screencast scope (the videoPath strip)', () => { + it('stores videoPath in the backend registry and forwards only sessionId to clients', () => { + const { ctx, broadcastToClients, videoRegistry } = makeCtx() + const handler = createWorkerMessageHandler(ctx) + handler( + buf({ + scope: 'screencast', + data: { + sessionId: 'sess-x', + videoPath: '/abs/path/to/video.webm', + videoFile: 'video.webm', + frameCount: 42 + } + }) + ) + // Backend keeps the absolute path private (security + path stripping) + expect(videoRegistry.get('sess-x')).toBe('/abs/path/to/video.webm') + // UI only ever sees the sessionId — never the path + expect(broadcastToClients).toHaveBeenCalledWith( + JSON.stringify({ + scope: 'screencast', + data: { sessionId: 'sess-x' } + }) + ) + }) + + it('still broadcasts even when videoPath is missing (e.g. for re-fired notifications)', () => { + const { ctx, broadcastToClients, videoRegistry } = makeCtx() + const handler = createWorkerMessageHandler(ctx) + handler(buf({ scope: 'screencast', data: { sessionId: 'sess-y' } })) + expect(videoRegistry.has('sess-y')).toBe(false) + expect(broadcastToClients).toHaveBeenCalledWith( + JSON.stringify({ + scope: 'screencast', + data: { sessionId: 'sess-y' } + }) + ) + }) + + it('ignores screencast messages without a sessionId', () => { + const { ctx, broadcastToClients, videoRegistry } = makeCtx() + const handler = createWorkerMessageHandler(ctx) + handler(buf({ scope: 'screencast', data: { videoPath: '/x' } })) + expect(videoRegistry.size).toBe(0) + // No special-case broadcast — falls through to the generic forward + expect(broadcastToClients).toHaveBeenCalledTimes(1) + }) +}) + +describe('createWorkerMessageHandler — pass-through behavior', () => { + it('forwards unknown scopes verbatim to clients AND tees them into the baseline accumulator', () => { + const { ctx, broadcastToClients, baselineStore } = makeCtx() + const handler = createWorkerMessageHandler(ctx) + const msg = buf({ scope: 'commands', data: [{ command: 'click' }] }) + handler(msg) + expect(broadcastToClients).toHaveBeenCalledWith(msg.toString()) + expect(baselineStore.recordEvent).toHaveBeenCalledWith('commands', [ + { command: 'click' } + ]) + }) + + it('does NOT tee control-frame scopes (clearCommands/config/screencast) into the accumulator', () => { + const { ctx, baselineStore } = makeCtx() + const handler = createWorkerMessageHandler(ctx) + handler(buf({ scope: WS_SCOPE.clearCommands, data: {} })) + handler(buf({ scope: 'config', data: { configFile: '/p/wdio.conf.ts' } })) + handler(buf({ scope: 'screencast', data: { sessionId: 's' } })) + expect(baselineStore.recordEvent).not.toHaveBeenCalled() + }) + + it('forwards non-JSON messages verbatim without crashing', () => { + const { ctx, broadcastToClients, baselineStore } = makeCtx() + const handler = createWorkerMessageHandler(ctx) + const garbage = Buffer.from('not-json-at-all{{') + handler(garbage) + // Falls through to the catch + raw forward branch + expect(broadcastToClients).toHaveBeenCalledWith(garbage.toString()) + expect(baselineStore.recordEvent).not.toHaveBeenCalled() + }) +}) 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..b637cd88 --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,35 @@ +{ + "name": "@wdio/devtools-core", + "version": "1.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": { + "@types/ws": "^8.18.1", + "@wdio/devtools-script": "workspace:*", + "@wdio/devtools-shared": "workspace:^", + "stacktrace-parser": "^0.1.11", + "ws": "^8.21.0" + } +} diff --git a/packages/core/src/assert-patcher.ts b/packages/core/src/assert-patcher.ts new file mode 100644 index 00000000..10e18a4b --- /dev/null +++ b/packages/core/src/assert-patcher.ts @@ -0,0 +1,177 @@ +import { createRequire } from 'node:module' +import { getCallSourceFromStack } from './stack.js' +import { toError } from './error.js' + +const require = createRequire(import.meta.url) + +/** Per-process guard so a second `patchNodeAssert()` call is a no-op. */ +export const ASSERT_PATCHED_SYMBOL = Symbol.for( + '@wdio/devtools-core/assert-patched' +) + +/** node:assert methods the patcher wraps. */ +export const TRACKED_ASSERT_METHODS = [ + 'equal', + 'strictEqual', + 'deepEqual', + 'deepStrictEqual', + 'notEqual', + 'notStrictEqual', + 'notDeepEqual', + 'notDeepStrictEqual', + 'ok', + 'fail', + 'throws', + 'doesNotThrow', + 'rejects', + 'doesNotReject', + 'match', + 'doesNotMatch' +] as const + +/** + * Minimum shape `patchNodeAssert` emits. Adapters that need extra bookkeeping + * (selenium adds `fromElement` and `rawResult`) wrap the callback to extend + * the object before forwarding to their own `onCommand` sink. + */ +export interface CapturedAssert { + command: string + args: unknown[] + result: 'passed' | undefined + error: Error | undefined + callSource: string | undefined + timestamp: number +} + +/** + * JSON-safe stringify of an assert argument. Non-serialisable inputs degrade + * gracefully: functions → '[Function]', RegExp → `/.../i`, cyclic objects → + * `String(value)`. Exported so adapters can mirror the shape if they wrap. + */ +export function safeSerializeAssertArg(value: unknown): unknown { + if (value === null || value === undefined) { + return value + } + if (value instanceof RegExp) { + return value.toString() + } + if (typeof value === 'function') { + return '[Function]' + } + if (typeof value === 'object') { + try { + return JSON.parse(JSON.stringify(value)) + } catch { + return String(value) + } + } + return value +} + +function makePatchedAssertMethod( + methodName: string, + original: (...a: unknown[]) => unknown, + onCommand: (cmd: CapturedAssert) => void +): (...args: unknown[]) => unknown { + return function patchedAssert(this: unknown, ...args: unknown[]) { + const callInfo = getCallSourceFromStack() + const startedAt = Date.now() + const sanitizedArgs = args.map(safeSerializeAssertArg) + const passed = () => + onCommand({ + command: `assert.${methodName}`, + args: sanitizedArgs, + result: 'passed', + error: undefined, + callSource: callInfo.callSource, + timestamp: startedAt + }) + const failed = (err: unknown) => + onCommand({ + command: `assert.${methodName}`, + args: sanitizedArgs, + result: undefined, + error: toError(err), + callSource: callInfo.callSource, + timestamp: startedAt + }) + + try { + const result = original.apply(this, args) + // Async assert methods (rejects/doesNotReject) return a Promise. + const maybe = result as { then?: unknown } | null | undefined + if (maybe && typeof maybe.then === 'function') { + return (result as Promise).then( + (v) => { + passed() + return v + }, + (err) => { + failed(err) + throw err + } + ) + } + passed() + return result + } catch (err) { + failed(err) + throw err + } + } +} + +/** + * Patch `node:assert` so each tracked method emits a `CapturedAssert` to the + * supplied hook. Idempotent across calls (guarded by `ASSERT_PATCHED_SYMBOL`). + * Returns `true` on success, `false` when node:assert can't be resolved + * (rare — browser-only Node-incompatible runtimes). + * + * Wraps both the function-form (`assert(...)`) and the namespace methods + * (`assert.equal(...)`). User code that imported the methods BEFORE this + * patcher loaded keeps stale references — adapters should import node:assert + * from their main entry before user test files load. + * + * @param onCommand Callback invoked once per assert call (sync OR async). + * Receives the captured shape; do NOT throw — the wrapper + * re-throws the original assert error after the callback. + * @param onLog Optional logger for lifecycle events. Default: silent. + */ +export function patchNodeAssert( + onCommand: (cmd: CapturedAssert) => void, + onLog?: (level: 'info' | 'warn', message: string) => void +): boolean { + const log = (level: 'info' | 'warn', message: string) => + onLog?.(level, message) + + let assertModule: unknown + try { + assertModule = require('node:assert') + } catch { + log('warn', 'node:assert not available — skipping assertion capture') + return false + } + + // Node's `assert` is a function with methods on it — cast once for the + // symbol + dynamic method access we do here. + const assertObj = assertModule as Record + if (assertObj[ASSERT_PATCHED_SYMBOL]) { + return true + } + assertObj[ASSERT_PATCHED_SYMBOL] = true + + for (const methodName of TRACKED_ASSERT_METHODS) { + const original = assertObj[methodName] + if (typeof original !== 'function') { + continue + } + assertObj[methodName] = makePatchedAssertMethod( + methodName, + original as (...a: unknown[]) => unknown, + onCommand + ) + } + + log('info', `Patched ${TRACKED_ASSERT_METHODS.length} node:assert method(s)`) + return true +} diff --git a/packages/core/src/bidi.ts b/packages/core/src/bidi.ts new file mode 100644 index 00000000..39ce416f --- /dev/null +++ b/packages/core/src/bidi.ts @@ -0,0 +1,314 @@ +import { createRequire } from 'node:module' +import type { + ConsoleLog, + LogLevel, + NetworkRequest +} from '@wdio/devtools-shared' +import { + LOG_SOURCES, + chromeLogLevelToLogLevel, + type LogSource +} from './console.js' +import { errorMessage } from './error.js' +import { getRequestType } from './net.js' + +/** + * Generic sinks the BiDi handlers push into. Each adapter wires these to its + * own SessionCapturer state — selenium's `buildBidiSinks` is the canonical + * example; nightwatch can mirror the pattern when it wires up BiDi. + */ +export interface BidiHandlerSinks { + pushConsoleLog: (entry: ConsoleLog) => void + pushNetworkRequest: (entry: NetworkRequest) => void + replaceNetworkRequest: (id: string, entry: NetworkRequest) => void +} + +/** + * Resolve a `selenium-webdriver/` module from the user's install + * (preferred) or the package's local install (fallback). Returns `null` if + * neither resolves — caller should treat as "BiDi not available on this + * runtime" and degrade gracefully. + * + * Used by both selenium-devtools and (when wired up) nightwatch-devtools — + * both ship selenium-webdriver-style drivers under the hood. + */ +export function loadSeleniumSubmodule(subpath: string): T | null { + try { + const userRequire = createRequire(`${process.cwd()}/`) + return userRequire(`selenium-webdriver/${subpath}`) as T + } catch { + try { + const localRequire = createRequire(import.meta.url) + return localRequire(`selenium-webdriver/${subpath}`) as T + } catch { + return null + } + } +} + +type BidiLogger = (level: 'info' | 'warn', message: string) => void +type InspectorFactory = (driver: unknown) => Promise + +interface LogInspector { + onConsoleEntry: (cb: (entry: unknown) => void) => Promise + onJavascriptException: (cb: (exc: unknown) => void) => Promise +} + +interface NetworkInspector { + beforeRequestSent: (cb: (e: unknown) => void) => Promise + responseCompleted: (cb: (e: unknown) => void) => Promise +} + +export function handleBidiConsoleEntry( + rawEntry: unknown, + sinks: BidiHandlerSinks, + log: BidiLogger +): void { + const entry = rawEntry as { + level?: string + type?: string + text?: string + message?: string + timestamp?: number + } + try { + const level = (entry?.level ?? entry?.type ?? 'info').toString() + const text = entry?.text ?? entry?.message ?? '' + sinks.pushConsoleLog({ + timestamp: Number(entry?.timestamp) || Date.now(), + type: chromeLogLevelToLogLevel(level) as LogLevel, + args: [text], + source: LOG_SOURCES.BROWSER as LogSource + }) + } catch (err) { + log('warn', `onConsoleEntry handler threw: ${errorMessage(err)}`) + } +} + +export function handleBidiJsException( + rawExc: unknown, + sinks: BidiHandlerSinks, + log: BidiLogger +): void { + const exception = rawExc as { text?: string; message?: string } + try { + const text = exception?.text ?? exception?.message ?? String(rawExc) + const trimmed = String(text).replace(/\s+/g, ' ').slice(0, 200) + log( + 'warn', + `🐛 JS error in page: ${trimmed}${String(text).length > 200 ? '…' : ''}` + ) + sinks.pushConsoleLog({ + timestamp: Date.now(), + type: 'error', + args: [text], + source: LOG_SOURCES.BROWSER as LogSource + }) + } catch (err) { + log('warn', `onJavascriptException handler threw: ${errorMessage(err)}`) + } +} + +async function attachLogInspector( + driver: unknown, + factory: InspectorFactory, + sinks: BidiHandlerSinks, + log: BidiLogger +): Promise { + try { + const inspector = (await factory(driver)) as LogInspector + await inspector.onConsoleEntry((e) => handleBidiConsoleEntry(e, sinks, log)) + await inspector.onJavascriptException((e) => + handleBidiJsException(e, sinks, log) + ) + log('info', '✓ BiDi LogInspector attached (console + JS exceptions)') + return true + } catch (err) { + log('warn', `BiDi LogInspector attach failed: ${errorMessage(err)}`) + return false + } +} + +interface BeforeRequestSentEvent { + request?: { + request?: string + url?: string + method?: string + headers?: unknown + } + id?: string + timestamp?: number +} + +interface ResponseCompletedEvent { + request?: { request?: string } + id?: string + timestamp?: number + response?: { + status?: number + statusText?: string + headers?: unknown + mimeType?: string + bytesReceived?: number + } +} + +export function handleBidiRequestSent( + rawEvent: unknown, + pending: Map, + sinks: BidiHandlerSinks, + log: BidiLogger +): void { + const event = rawEvent as BeforeRequestSentEvent + try { + const requestId = String(event?.request?.request ?? event?.id ?? '') + if (!requestId) { + return + } + const entry: NetworkRequest = { + id: requestId, + url: event?.request?.url ?? '', + method: event?.request?.method ?? 'GET', + requestHeaders: arrayHeadersToObject(event?.request?.headers), + timestamp: Date.now(), + startTime: Number(event?.timestamp ?? Date.now()), + type: getRequestType(event?.request?.url ?? '') + } + pending.set(requestId, entry) + sinks.pushNetworkRequest(entry) + } catch (err) { + log('warn', `beforeRequestSent threw: ${errorMessage(err)}`) + } +} + +export function handleBidiResponseCompleted( + rawEvent: unknown, + pending: Map, + sinks: BidiHandlerSinks, + log: BidiLogger +): void { + const event = rawEvent as ResponseCompletedEvent + try { + const requestId = String(event?.request?.request ?? event?.id ?? '') + const previous = pending.get(requestId) + if (!previous) { + return + } + const finalized: NetworkRequest = { + ...previous, + status: Number(event?.response?.status) || previous.status, + statusText: event?.response?.statusText ?? previous.statusText, + responseHeaders: arrayHeadersToObject(event?.response?.headers), + type: getRequestType(previous.url, event?.response?.mimeType), + endTime: Number(event?.timestamp ?? Date.now()), + time: Number(event?.timestamp ?? Date.now()) - previous.startTime, + size: Number(event?.response?.bytesReceived) || undefined + } + pending.delete(requestId) + sinks.replaceNetworkRequest(requestId, finalized) + } catch (err) { + log('warn', `responseCompleted threw: ${errorMessage(err)}`) + } +} + +async function attachNetworkInspector( + driver: unknown, + factory: InspectorFactory, + sinks: BidiHandlerSinks, + log: BidiLogger +): Promise { + try { + const inspector = (await factory(driver)) as NetworkInspector + const pending = new Map() + await inspector.beforeRequestSent((e) => + handleBidiRequestSent(e, pending, sinks, log) + ) + await inspector.responseCompleted((e) => + handleBidiResponseCompleted(e, pending, sinks, log) + ) + log('info', '✓ BiDi NetworkInspector attached (request + response)') + return true + } catch (err) { + log('warn', `BiDi NetworkInspector attach failed: ${errorMessage(err)}`) + return false + } +} + +/** + * Attach the selenium-webdriver BiDi LogInspector + NetworkInspector to a + * driver and route their events into the given sinks. Returns `true` when at + * least one inspector connected — caller can disable the equivalent + * script-injection collectors to avoid duplicates. + * + * Tolerant of older / non-BiDi runtimes: if either submodule fails to load + * or the inspector factory throws (driver session doesn't have webSocketUrl + * capability set, etc.), the corresponding stream is silently skipped and + * the function returns false. + * + * @param onLog Optional callback for adapter-side logging. Receives ('info' | + * 'warn', message) on lifecycle events. Default: silent — adapters wire their + * own logger when they want visibility into BiDi attach state. + */ +export async function attachBidiHandlers( + driver: unknown, + sinks: BidiHandlerSinks, + onLog?: (level: 'info' | 'warn', message: string) => void +): Promise { + const log: BidiLogger = (level, message) => onLog?.(level, message) + const logFactory = + loadSeleniumSubmodule('bidi/logInspector') + const networkFactory = loadSeleniumSubmodule( + 'bidi/networkInspector' + ) + + let attached = 0 + if (typeof logFactory === 'function') { + if (await attachLogInspector(driver, logFactory, sinks, log)) { + attached++ + } + } else { + log('info', 'selenium-webdriver/bidi/logInspector not available — skipping') + } + if (typeof networkFactory === 'function') { + if (await attachNetworkInspector(driver, networkFactory, sinks, log)) { + attached++ + } + } else { + log( + 'info', + 'selenium-webdriver/bidi/networkInspector not available — skipping' + ) + } + return attached > 0 +} + +/** + * Flatten BiDi's `Array<{name, value:{value|type}}>` header shape to a + * lowercased `Record`. Exported so adapter-side helpers can + * reuse it for their own header normalization. + */ +export function arrayHeadersToObject( + headers: unknown +): Record | undefined { + if (!Array.isArray(headers)) { + return undefined + } + const out: Record = {} + for (const h of headers as Array<{ + name?: string + value?: string | { value?: string; type?: string } + }>) { + const name = String(h?.name ?? '').toLowerCase() + if (!name) { + continue + } + const v = h?.value + out[name] = + typeof v === 'string' + ? v + : typeof (v as { value?: string })?.value === 'string' + ? (v as { value: string }).value + : JSON.stringify(v ?? '') + } + return out +} diff --git a/packages/core/src/console.ts b/packages/core/src/console.ts new file mode 100644 index 00000000..96d572ee --- /dev/null +++ b/packages/core/src/console.ts @@ -0,0 +1,150 @@ +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: unknown[], + source: LogSource = LOG_SOURCES.TEST +): ConsoleLog { + return { timestamp: Date.now(), type, args, source } +} + +/** + * Map raw Chrome browser-log entries (the shape returned by both + * `driver.manage().logs().get('browser')` in selenium-webdriver and + * `browser.getLog('browser')` in nightwatch) into the dashboard's typed + * ConsoleLog shape, tagged as source='browser'. Each entry's Chrome level + * (`SEVERE` / `WARNING` / `INFO` / `DEBUG`) is normalised through + * {@link chromeLogLevelToLogLevel}. + */ +export function mapChromeBrowserLogs( + entries: Array<{ level: unknown; message: string; timestamp: number }> +): ConsoleLog[] { + return entries.map((entry) => ({ + timestamp: entry.timestamp, + type: chromeLogLevelToLogLevel( + entry.level as string | { value?: number; name?: string } + ), + args: [entry.message], + source: LOG_SOURCES.BROWSER + })) +} + +/** + * 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..e34b6ac4 --- /dev/null +++ b/packages/core/src/index.ts @@ -0,0 +1,21 @@ +// Framework-agnostic capture/reporter logic shared by @wdio/devtools-* +// adapters. See ARCHITECTURE.md §2 and CLAUDE.md §2.2. + +export * from './assert-patcher.js' +export * from './bidi.js' +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 './output-dir.js' +export * from './performance-capture.js' +export * from './retry-tracker.js' +export * from './screencast.js' +export * from './script-loader.js' +export * from './session-capturer.js' +export * from './suite-helpers.js' +export * from './test-discovery.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/output-dir.ts b/packages/core/src/output-dir.ts new file mode 100644 index 00000000..fa659ace --- /dev/null +++ b/packages/core/src/output-dir.ts @@ -0,0 +1,86 @@ +import fs from 'node:fs' +import path from 'node:path' + +export interface ResolveAdapterOutputDirInput { + /** + * Honored as-is if set — used by adapters that expose a user-facing + * `outputDir` option (e.g. WDIO). Skips all other resolution steps. + */ + userConfiguredDir?: string + /** + * Absolute path to the current test file. When known, the video / trace + * lands in the same folder as the spec the user just ran. This is the + * preferred location across adapters. + */ + testFilePath?: string + /** + * Absolute path to the resolved framework config file (wdio.conf.ts, + * nightwatch.conf.cjs, etc.). Used as a fallback when the test file + * isn't known. + */ + configPath?: string + /** Last-resort fallback. Defaults to `process.cwd()`. */ + fallbackDir?: string +} + +const NODE_MODULES_SEGMENT = `${path.sep}node_modules${path.sep}` + +function isWritable(dir: string): boolean { + try { + fs.accessSync(dir, fs.constants.W_OK) + return true + } catch { + return false + } +} + +/** + * Resolve the directory where an adapter should write output files + * (screencast .webm, trace JSON, etc.). + * + * Priority: + * 1. `userConfiguredDir` — explicit opt-in, honored as-is. + * 2. `dirname(testFilePath)` — same folder as the spec that just ran. + * 3. `dirname(configPath)` — fallback to the framework config dir. + * 4. `fallbackDir` (default `process.cwd()`). + * + * Any candidate inside a `node_modules/` segment is skipped — this can + * happen in symlinked workspaces where the test file resolves through a + * linked dependency. Each candidate must also be writable; non-writable + * dirs fall through to the next. + * + * Shared by all three adapters (service / nightwatch-devtools / + * selenium-devtools) so the output location stays consistent regardless + * of where the user invoked the runner from. See CLAUDE.md §2.2. + */ +export function resolveAdapterOutputDir( + input: ResolveAdapterOutputDirInput = {} +): string { + const fallback = input.fallbackDir ?? process.cwd() + // userConfiguredDir bypasses the node_modules and writability filters + // because the user opted into it explicitly — surprising overrides are + // worse than failing loudly here. + if (input.userConfiguredDir) { + return input.userConfiguredDir + } + const candidates: string[] = [] + if (input.testFilePath) { + candidates.push(path.dirname(input.testFilePath)) + } + if (input.configPath) { + candidates.push(path.dirname(input.configPath)) + } + candidates.push(fallback) + for (const dir of candidates) { + if (!dir) { + continue + } + if (dir.includes(NODE_MODULES_SEGMENT)) { + continue + } + if (isWritable(dir)) { + return dir + } + } + return fallback +} diff --git a/packages/core/src/performance-capture.ts b/packages/core/src/performance-capture.ts new file mode 100644 index 00000000..0e1f5b3f --- /dev/null +++ b/packages/core/src/performance-capture.ts @@ -0,0 +1,102 @@ +import type { + CommandLog, + DocumentInfo, + PerformanceData +} from '@wdio/devtools-shared' + +/** + * JS source that captures Performance API data, cookies, and document info + * from the page under test. Passed as a string to the adapter's `execute`/ + * `executeScript` driver method so the browser-only types (PerformanceEntry, + * Document) don't leak into the Node-side type-checker. + * + * Returns the bag shape consumed by {@link applyPerformanceData}. + * Framework-agnostic — all three adapters can use it. + */ +export const CAPTURE_PERFORMANCE_SCRIPT = ` + (function() { + const performance = window.performance; + const navigation = performance.getEntriesByType?.('navigation')?.[0]; + const resources = performance.getEntriesByType?.('resource') || []; + + return { + navigation: navigation ? { + url: window.location.href, + timing: { + loadTime: navigation.loadEventEnd - navigation.fetchStart, + domReady: navigation.domContentLoadedEventEnd - navigation.fetchStart, + responseTime: navigation.responseEnd - navigation.requestStart, + dnsLookup: navigation.domainLookupEnd - navigation.domainLookupStart, + tcpConnection: navigation.connectEnd - navigation.connectStart, + serverResponse: navigation.responseEnd - navigation.responseStart + } + } : undefined, + resources: resources.map(function(resource) { + return { + url: resource.name, + duration: resource.duration, + size: resource.transferSize || 0, + type: resource.initiatorType, + startTime: resource.startTime, + responseEnd: resource.responseEnd + }; + }), + cookies: (function() { + try { return document.cookie; } catch (e) { return ''; } + })(), + documentInfo: { + url: window.location.href, + title: document.title, + headers: { + userAgent: navigator.userAgent, + language: navigator.language, + platform: navigator.platform + }, + documentInfo: { + readyState: document.readyState, + referrer: document.referrer, + characterSet: document.characterSet + } + } + }; + })() +` + +/** Untyped bag returned by {@link CAPTURE_PERFORMANCE_SCRIPT}. */ +export interface CapturedPerformancePayload { + navigation?: PerformanceData['navigation'] + resources?: PerformanceData['resources'] + cookies?: string + documentInfo?: DocumentInfo +} + +/** + * Apply a captured performance payload onto a CommandLog entry in-place, + * setting `performance`, `cookies`, `documentInfo`, and a synthesized `result` + * matching nightwatch's existing dashboard shape. Returns `true` if anything + * was applied — caller can branch on this to skip further work. + */ +export function applyPerformanceData( + command: CommandLog, + payload: CapturedPerformancePayload | undefined, + navigatedUrl?: string +): boolean { + if (!payload || !payload.navigation) { + return false + } + command.performance = { + navigation: payload.navigation, + resources: payload.resources + } + command.cookies = payload.cookies + command.documentInfo = payload.documentInfo + command.result = { + url: navigatedUrl, + loadTime: payload.navigation?.timing?.loadTime, + resources: payload.resources, + resourceCount: payload.resources?.length, + cookies: payload.cookies, + title: payload.documentInfo?.title + } + return true +} 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 `