diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index cb6e0bca..ada1260d 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -18,6 +18,7 @@ body: - WebdriverIO (@wdio/devtools-service) - Nightwatch (@wdio/nightwatch-devtools) - Selenium WebDriver (@wdio/selenium-devtools) + - None โ€” @wdio/elements used standalone validations: required: true @@ -40,7 +41,7 @@ body: id: devtools-version attributes: label: DevTools package version - description: The version of the adapter package above (check your package.json or lockfile). + description: The version of the package you selected above (check your package.json or lockfile). placeholder: e.g. 0.4.2 validations: required: true @@ -72,6 +73,7 @@ body: options: - Dashboard UI (rendering, controls, rerun/stop) - Captured data (network, console, screencast, trace) + - Element snapshot / locators (getSnapshot, getElements, accessibility tree) - Backend / server (connection, websocket, crashes) - Test launch / lifecycle (hooks, session, capabilities) - Not sure diff --git a/.github/ISSUE_TEMPLATE/feature-request.yml b/.github/ISSUE_TEMPLATE/feature-request.yml index 90131b21..4686b807 100644 --- a/.github/ISSUE_TEMPLATE/feature-request.yml +++ b/.github/ISSUE_TEMPLATE/feature-request.yml @@ -37,6 +37,7 @@ body: options: - Dashboard UI - Capture / reporting core + - Element snapshot / locator API (@wdio/elements) - WebdriverIO adapter - Nightwatch adapter - Selenium adapter diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index d7c1078d..ad6fb778 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -19,6 +19,7 @@ - [ ] `shared` (types and contracts) - [ ] `core` (framework-agnostic capture/reporting) +- [ ] `elements` (published element/snapshot API โ€” `@wdio/elements`) - [ ] `service` (WebdriverIO adapter) - [ ] `nightwatch-devtools` (Nightwatch adapter) - [ ] `selenium-devtools` (Selenium adapter) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8cda61f1..fe79b621 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,6 +8,9 @@ on: - v[0-9]+.[0-9]+.[0-9]+* pull_request: +permissions: + contents: read + jobs: build: runs-on: ubuntu-latest @@ -23,9 +26,9 @@ jobs: cache-name: cache-pnpm-modules with: path: ~/.pnpm-store - key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ matrix.node-version }}-${{ hashFiles('**/pnpm-lock.yaml') }} + key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/pnpm-lock.yaml') }} restore-keys: | - ${{ runner.os }}-build-${{ env.cache-name }}-${{ matrix.node-version }}- + ${{ runner.os }}-build-${{ env.cache-name }}- - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 with: version: 10.26.1 @@ -36,5 +39,82 @@ jobs: run: pnpm lint - name: ๐Ÿ“ฆ Bundle run: pnpm -r --workspace-concurrency=1 build + # Four tsc programs โ€” see the `typecheck` script. Runs before the tests + # because it is fast and its failures are cheaper to read. + - name: ๐Ÿ”Ž Typecheck + run: pnpm typecheck + # `test:coverage`, not `test`: same specs plus the coverage floor, ~8s + # more. Without it the thresholds in vitest.config.ts gate nothing. - name: ๐Ÿงช Run Tests - run: pnpm test + run: pnpm test:coverage + + # Separate job because these are the only tests that need a real browser, and + # they parallelise with `build` instead of extending it. No bundle step: the + # specs and packages/app/src reach @wdio/devtools-shared through its source + # exports, so Vite compiles everything from source. Chrome comes preinstalled + # on the runner image; wdio fetches the matching driver itself. + component-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Use Node.js + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + with: + node-version-file: .nvmrc + - name: Cache pnpm modules + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + env: + cache-name: cache-pnpm-modules + with: + path: ~/.pnpm-store + key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-build-${{ env.cache-name }}- + - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 + with: + version: 10.26.1 + run_install: false + - name: Install dependencies + run: pnpm install --frozen-lockfile + # Two workers, not the runner default: each carries a Vite server plus a + # Chrome, and past 2 the heaviest specs time out under that contention. + - name: ๐Ÿงฉ Component Tests + run: pnpm test:ui --maxInstances 2 + + # The component suite again, instrumented. Separate from `component-tests` so + # a coverage-only flake can never mask a real component failure: + # instrumentation costs ~16% wall clock and loses roughly one spec per run to + # a BiDi navigate timeout โ€” a different one each run, which is why this is + # report-only for now. It measures packages/app/src, the rendering layer + # vitest's denominator excludes on purpose (a node test cannot import a Lit + # element). Drop continue-on-error and add thresholds once that flake is fixed. + component-coverage: + runs-on: ubuntu-latest + continue-on-error: true + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Use Node.js + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + with: + node-version-file: .nvmrc + - name: Cache pnpm modules + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + env: + cache-name: cache-pnpm-modules + with: + path: ~/.pnpm-store + key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-build-${{ env.cache-name }}- + - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 + with: + version: 10.26.1 + run_install: false + - name: Install dependencies + run: pnpm install --frozen-lockfile + # Two workers, not the runner default: each carries a Vite server plus a + # Chrome, and past 2 the heaviest specs time out under that contention. + - name: ๐Ÿ“Š Component Coverage + env: + COVERAGE: '1' + run: pnpm test:ui --maxInstances 2 diff --git a/.gitignore b/.gitignore index 4bcee08d..3cee77f8 100644 --- a/.gitignore +++ b/.gitignore @@ -23,8 +23,13 @@ dist-ssr *.njsproj *.sln *.sw? +# Scratch scripts and dumps dropped at the repo root. Real root-level config +# has to be un-ignored by name: a new one is otherwise silently never added, +# and CI then runs with different settings than everyone's checkout. /*.ts /*.json +!/vitest.config.ts +!/tsconfig.dts.json *.tgz examples/wdio/wdio-*.json examples/wdio/wdio-*.webm @@ -55,6 +60,5 @@ examples/**/allure-results*/ examples/**/allure-report*/ packages/**/allure-results*/ packages/**/allure-report*/ -examples/playwright-allure/ examples/wdio/cucumber/wdio.allure.conf.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index a7ec337b..aab07153 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -142,9 +142,11 @@ The execution environment is the browser, not Node, so this package cannot impor ### `packages/elements` -Element-detection scripts (`@wdio/elements`). Produces the per-action data that feeds the snapshot pipeline: `getSnapshot`, the depth-indented accessibility-tree serializer, and the interactable-element list, plus locator generation. +The WDIO-typed element-detection API (`@wdio/elements`): `getSnapshot`, `getElements`, the accessibility-tree and interactable-element readers. Unlike `shared` and `core` this package **is** published, so its exported names are public API. -Consumed by `core` (the element-snapshot builder) and by the adapters' per-action capture (`captureActionSnapshot`). The DOM-walking scripts run in the page via `browser.execute`, so โ€” like `script` โ€” they avoid Node-only APIs. +Imports from: `core`. The script bodies, serializers, types, and locator generation all live in `core` (`element-scripts.ts`, `element-snapshot.ts`, `element-types.ts`, `locators/`) because they're framework-agnostic; this package holds only the wrappers that take a `WebdriverIO.Browser` โ€” which `core` can't type against. The arrow never reverses: `core` does not import `elements`, and the adapters' per-action capture (`captureActionSnapshot`) calls core's script bodies directly rather than going through this package. `elements/snapshot.ts` and `elements/locators/index.ts` are re-export shims kept for API stability. + +The DOM-walking scripts run in the page via `browser.execute`, so โ€” like `script` โ€” they avoid Node-only APIs. ### `examples/` diff --git a/CLAUDE.md b/CLAUDE.md index 71bd7d1f..af76f8da 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -240,6 +240,8 @@ Documented divergences from the conventions above. They exist today as debt to b - **WDIO + Selenium: verified end-to-end** (manual fail-then-pass runs: retained under `retain-on-first-failure`, dropped under `retain-on-failure`). - **Nightwatch: `retain-on-failure` works; the other retry-aware policies degrade.** Its `--retries` re-runs the testcase *internally* without re-firing the plugin's per-test hooks, and the per-testcase results carry no attempt/retry field (retries live only in undocumented, version-varying Nightwatch internals โ€” `suiteRetries.testRetriesCount` / `reporter.testResults.retryTest`), so the ledger sees only the final attempt for the `describe/it` and exports-object interfaces. Cucumber scenarios expose per-scenario hooks, so the feed captures their attempts. Not cleanly fixable without depending on those internals. - WDIO `specFileRetries` spawns a fresh worker per retry, so cross-process attempts aren't in the (process-scoped) ledger. +- Run identity across worker sockets is env-propagated. `core/run-id.ts` `resolveRunId()` publishes `DEVTOOLS_RUN_ID` (`RUNNER_ENV.RUN_ID`) and every worker socket carries it as `?runId=` (`WORKER_WS_QUERY`), so the backend keeps accumulated run state when the *next spec's* worker connects and wipes it only for a genuinely new run. Without it every connect read as a new run: Preserve & Rerun 409'd for every spec except the last one that ran, and a dashboard opened mid-run replayed only the current spec. The WDIO service stamps it in the launcher's `onPrepare`, before workers fork, so all workers of one run agree; single-process adapters self-stamp on first use. **Gap: multi-process parallel runs in Selenium/Nightwatch** (jest/vitest workers, nightwatch `test_workers`) load the plugin per worker with no launcher-side hook to stamp first, so each worker generates its own id and still reads as a new run โ€” the pre-fix behaviour, not a regression. Deriving the fallback from `process.ppid` would group those siblings, but would also make two sequential single-process runs share an id and inherit each other's state against a standalone dashboard, so the per-process fallback stands. +- **Chrome 150 headless drops trusted input once capture traffic is running** โ€” a click dispatches but never navigates, so a live-mode example fails on its second test with a missing-element error that looks like a devtools bug. It is a browser regression (fixed in 151), not fixable client-side; `browser.reloadSession()` between tests works around it. Pin the example's `browserVersion` to 149 when reproducing live-mode behaviour on a 150 machine. - Eager per-test trace slice (Nightwatch + Selenium) can drop an action snapshot whose fire-and-forget capture hasn't resolved by `afterEach` / scenario end โ€” the slice is written from whatever snapshots exist at flush time. The WDIO service is immune because it awaits each snapshot inline before flushing. - The per-test `screenshot` and `video` options live on the **WDIO `ServiceOptions` only** โ€” not `BaseDevToolsOptions` โ€” because only the service implements them (an option belongs on an adapter until a second adapter consumes it, mirroring the core-helper rule; putting them on the shared base made them appear available in Selenium/Nightwatch and broke those adapters' `Required<>` option types). The policy *types* (`TraceScreenshotPolicy`/`TraceVideoPolicy`) and the capture/slice/encode logic (`core/screenshot-artifact.ts`, `core/video-slice.ts`) are framework-agnostic, so Selenium/Nightwatch adoption was wiring-only โ€” now done (Selenium adds the options on its own `DevToolsOptions` with full inline attach; Nightwatch adds them produce-only โ€” see the Allure-attach entry below). All are gated to `traceGranularity:'test'` (per-test inline Allure); coarser granularities keep artifacts in the manifest. Video records the screencast continuously and slices per-test by wall-time โ€” the session frame buffer is bounded by `maxBufferFrames` (default 2000; decimates keeping first/last), and on non-Chrome the polling recorder issues many `takeScreenshot`s that flood `@wdio/allure-reporter` (pair with `disableWebdriverStepsReporting`). - The `filmstrip` option (dense screencast into the trace) is on **`BaseDevToolsOptions`** โ€” the counterexample to the screenshot/video entry above โ€” because all three adapters implement it (the "second consumer โ†’ base" rule realized). Core owns the work (`core/screencast-trace.ts` `thinScreencastFrames`/`buildDenseScreencast`; slice windowing in `spec-trace-helpers.ts`); adapters only default the option, un-gate the recorder in trace mode when it's set, and feed `recorder.frames` into the finalize context. Each adapter captures frames while the recorder is still alive (service `onReload` โ†’ `#filmstripFrames`; Selenium `onDriverEnd` drain before nulling; Nightwatch `#finalizeCurrentScreencast` snapshot before delegating), and each finalize context spreads `[...accumulated, ...(live recorder frames)]` so a **mid-run** per-spec/per-test slice flush (which fires before the recorder is drained) isn't blank. When dense frames are present they **supersede** the sparse per-action filmstrip (the per-action DOM `elements`/`snapshot` are carried independently by the `frame-snapshot` events, so no DOM data is lost); a run without dense frames keeps the sparse filmstrip, byte-stable with before. Thinning is applied at export; the live session frame buffer is bounded by `maxBufferFrames` (default 2000; see the screenshot/video entry above). Per-test filmstrip slicing follows the same per-test-hook availability as `traceGranularity:'test'` (works for WDIO mocha/cucumber, Selenium mocha, Nightwatch exports-object/cucumber; Nightwatch BDD `describe/it` degrades to session scope per the entry below), and non-Chrome polling carries the same reporter-noise caveat. @@ -248,6 +250,7 @@ Documented divergences from the conventions above. They exist today as debt to b - Nightwatch native asserts are buffered at call time and emitted in one batch at test-end (Nightwatch exposes no per-assertion execution/outcome hook reachable from the plugin โ€” `client.queue.tree` / `client.reporter` aren't on `browser`). Streaming mid-run would flash every assert green before its outcome is known, so rows are held and reconciled against `results.testcases[*].assertions` at `afterEach` (the flat `results.assertions` only reflects the last testcase). The exporter re-sorts commands by timestamp (`buildActionEvents`) so the batched rows land at their real timeline positions rather than clustering at flush time. `actual` is parsed from the failure message (`but got: โ€ฆ`, failures only) into a collapsed `{passed, expected, actual}` result; `expected` is the assertion arg โ€” the label still mirrors the call args, not the derived values. - Nightwatch's BDD `describe/it` interface fires the plugin's global `beforeEach`/`afterEach` **once per module** (with an empty `currentTest.name`), not per `it` โ€” Nightwatch runs the individual `it`s internally with no per-testcase hook or event reachable from the plugin (the lib emits only transport-level events). So `traceGranularity:'test'` records a single slice boundary (the first test) and collapses to one session-scoped slice; retry-aware/per-test retention (`tracePolicy`) degrades to session scope for this interface. Per-test slicing works for adapters/styles that expose per-test hooks (WDIO mocha/cucumber, Selenium mocha; likely Nightwatch's exports-object style, unverified). Session-granularity trace is unaffected. **No per-test support has been built for this interface** โ€” real per-`it` slicing needs a hook Nightwatch doesn't surface; deferred to a full-picture pass, not attempted yet. - **Empirically confirmed** (BDD example, `mode:'trace'` + `traceGranularity:'test'` + `tracePolicy:'retain-on-failure'` + `screenshot:'on'` + `video:'retain-on-failure'`): the one collapsed slice is keyed to the **first** test's uid (the artifacts manifest's `tests[]` still carries every testcase with correct `state`, so metadata capture is fine โ€” it's the *slice/artifact* keying that collapses). Consequence: with a passing first test and a failing later test, the screenshot **produces** (policy `'on'` captures regardless of outcome, keyed to the first test) but the **trace zip and video are dropped** โ€” `retain-on-failure` evaluates the first (passing) test's outcome, and the actually-failing test never gets its own slice to retain. The per-test produce-only path itself (screenshot/video write + manifest) is correct; only the BDD slice-keying limits it. +- **Nightwatch cucumber: traces now generate + capture asserts; DOM mutations still missing** (via the `test/` harness, 2026-07). Three fixes made the cucumber runner emit a useful trace: **(1) build** โ€” `packages/nightwatch-devtools/tsup.config.ts` now compiles `src/helpers/cucumberHooks.cts` โ†’ `dist/helpers/cucumberHooks.cjs` as a self-contained CJS bundle (`@cucumber/cucumber` external). Previously the build ran only `tsup src/index.ts --clean`, so that file never existed and Cucumber's `require:[cucumberHooksPath]` registered *no* hooks (glob matched nothing) โ†’ zero capture. `PLUGIN_GLOBAL_KEY` moved to a leaf module `plugin-global-key.ts` so the hooks bundle stays tiny and CJS-safe (importing it via `constants.ts` dragged in core โ†’ `createRequire(import.meta.url)`, which throws when bundled to CJS). **(2) capture ordering** โ€” a pre-quit cucumber `After` hook (`order:1000`, `captureCucumberScenarioBeforeQuit`) runs the trace capture + slice flush while the per-scenario browser session is live (the `order:-1` finalize is post browser-quit, so the flush bailed on the absent `sessionId`). Requires `traceGranularity:'test'` (per-scenario slices). **(3) native asserts** โ€” the same pre-quit hook drains `browserProxy.drainNativeAssertCalls()` and calls `captureNativeAssertions` (the `afterEach` path early-returns for cucumber), so `assert.*` rows now appear. **Remaining gaps:** (a) assert **pass/fail isn't correlated** for cucumber โ€” `captureNativeAssertions` is passed `currentTest: undefined` (cucumber has no `browser.currentTest.results`), so rows render neutral; cucumber needs a reconcile against its own scenario result. (b) DOM **`mutations` aren't captured** (`hasMutations:false`) โ€” the page collector fails to (re)inject on the per-scenario re-navigated session (`ECONNRESET` / "collector not found" during teardown), and that noisy teardown-race is itself worth cleaning up. Console/network (BiDi) + commands + asserts + frames + sources + transcript are captured. BDD and live mode are unaffected. - Service renders expect-webdriverio matchers as single `expect.` rows by **folding**, not stack/depth suppression (the old `#assertionDepth`/`#matcherStarted`/self-heal machinery is gone). The matcher's value-read (`toHaveText`โ†’`getText`, `toExist`โ†’`isExisting`, โ€ฆ) is captured as a normal command; `afterAssertion` then coalesces the synthesized `expect.*` row into that read in place โ€” inheriting its callSource, screenshot, and timeline position โ€” and the fold replaces **by timestamp, never a public `id`**: `id` is the per-worker `commandCounter`, which resets per spec, so stamping one lets the app's id-first `replaceCommand` swap a same-id row from another spec (duplicate rows + a fold from another spec vanishing, in multi-spec live mode). `beforeAssertion` arms the pending matcher (depth-counted so aliases like `toBeChecked`โ†’`toBeSelected` fold once); a matcher that **hard-throws** โ€” element never resolves, so expect-webdriverio's `waitUntil` rethrows and `afterAssertion` never fires โ€” is synthesized at `afterTest`/`afterStep` from the throwing read, so a failing assertion renders as `expect.` whether or not the element existed. Two limits: its error is then the read's (`Can't call getText on โ€ฆ element wasn't found`), not an assertion-phrased message; and `MATCHER_READ_COMMANDS` is a hand-maintained allowlist, so a matcher whose read isn't listed leaves its raw read visible alongside the `expect.*` row. Plain-value jest matchers (`expect(x).toBe(y)`) don't fire the ewdio hooks, so they aren't captured as rows. ### File-size (raw line counts; soft cap is 500 logic lines) diff --git a/examples/nightwatch/cucumber/features/login.feature b/examples/nightwatch/cucumber/features/login.feature new file mode 100644 index 00000000..05fdd88d --- /dev/null +++ b/examples/nightwatch/cucumber/features/login.feature @@ -0,0 +1,15 @@ +Feature: Example site smoke test + + Mirrors the Nightwatch BDD smoke-test: open a site, wait for the body to be + visible, and assert the page title. Runs as a Cucumber scenario so the + cross-adapter harness has a nightwatch-cucumber trace fixture. + + Scenario Outline: I can open a site and read its title + Given I navigate to "" + When the page body becomes visible + Then the page title contains "" + + Examples: + | url | title | + | https://example.com | Example | + | https://example.org | Example | diff --git a/examples/nightwatch/cucumber/features/step_definitions/login.steps.js b/examples/nightwatch/cucumber/features/step_definitions/login.steps.js new file mode 100644 index 00000000..00e11202 --- /dev/null +++ b/examples/nightwatch/cucumber/features/step_definitions/login.steps.js @@ -0,0 +1,18 @@ +// Step definitions for the example-site smoke scenario. +// +// Nightwatch's Cucumber integration injects the Nightwatch `browser` onto the +// World, so steps reach it via `this.browser` โ€” hence regular (non-arrow) +// functions so `this` binds to the World. +const { Given, When, Then } = require('@cucumber/cucumber') + +Given(/^I navigate to "([^"]*)"$/, async function (url) { + await this.browser.url(url) +}) + +When(/^the page body becomes visible$/, async function () { + await this.browser.waitForElementVisible('body', 5000) +}) + +Then(/^the page title contains "([^"]*)"$/, async function (title) { + await this.browser.assert.titleContains(title) +}) diff --git a/examples/nightwatch/cucumber/nightwatch.cucumber.conf.cjs b/examples/nightwatch/cucumber/nightwatch.cucumber.conf.cjs new file mode 100644 index 00000000..e03dd12f --- /dev/null +++ b/examples/nightwatch/cucumber/nightwatch.cucumber.conf.cjs @@ -0,0 +1,70 @@ +// Simple import - just require the package +const path = require('node:path') +const nightwatchDevtools = require('@wdio/nightwatch-devtools').default +const { cucumberHooksPath } = require('@wdio/nightwatch-devtools') + +const featuresDir = path.resolve(__dirname, 'features') + +module.exports = { + // Resolve relative to this config file so the paths hold regardless of CWD. + src_folders: [path.resolve(featuresDir, 'step_definitions')], + output_folder: false, // Skip generating nightwatch reports for this example + custom_commands_path: [], + custom_assertions_path: [], + + test_runner: { + type: 'cucumber', + options: { + feature_path: featuresDir, + // Registers the DevTools Before/After/Step scenario hooks so the plugin + // sees each scenario as a test unit. + require: [cucumberHooksPath] + } + }, + + webdriver: { + start_process: true, + // server_path: '/opt/homebrew/bin/chromedriver', + port: 9515 + }, + + test_settings: { + default: { + // Ensure all tests run even if one fails + skip_testcases_on_fail: false, + + 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', + '--no-sandbox', + '--disable-dev-shm-usage', + '--window-size=1600,900' + ] + }, + 'goog:loggingPrefs': { performance: 'ALL' } + }, + // 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, + // Cucumber exposes per-scenario hooks, so each scenario is captured as + // its own test unit. Trace when the harness sets DEVTOOLS_MODE=trace, + // otherwise stream live to the backend/UI. + mode: process.env.DEVTOOLS_MODE === 'trace' ? 'trace' : 'live', + // 'test' (not 'session'): Nightwatch quits the browser per scenario, so a + // single run-end session write has no live session. Per-scenario 'test' + // slices flush a zip at each scenario's pre-quit hook, while the session + // is still alive. + traceGranularity: 'test', + bidi: true + }) + } + } +} diff --git a/examples/nightwatch/nightwatch.conf.cjs b/examples/nightwatch/nightwatch.conf.cjs index 63f13171..bcbe00da 100644 --- a/examples/nightwatch/nightwatch.conf.cjs +++ b/examples/nightwatch/nightwatch.conf.cjs @@ -52,7 +52,8 @@ module.exports = { // NOTE: the BDD describe/it interface fires the plugin's beforeEach once // per module (no per-`it` hook), so traceGranularity:'test' collapses to // a single session-scoped slice here. See CLAUDE.md ยง Known debt. - mode: 'trace', + // Trace by default (regen/demo); DEVTOOLS_MODE=live flips to live for live-parity recording. + mode: process.env.DEVTOOLS_MODE === 'live' ? 'live' : 'trace', traceGranularity: 'session', // tracePolicy: 'retain-on-first-failure', bidi: true diff --git a/examples/selenium/cucumber-test/features/support/setup.js b/examples/selenium/cucumber-test/features/support/setup.js index 18f1e762..907dfb90 100644 --- a/examples/selenium/cucumber-test/features/support/setup.js +++ b/examples/selenium/cucumber-test/features/support/setup.js @@ -6,7 +6,10 @@ import { DevTools } from '@wdio/selenium-devtools' +// mode defaults to 'live' (the demo default); the verification harness sets +// DEVTOOLS_MODE=trace to produce a golden fixture from this same example. DevTools.configure({ + mode: process.env.DEVTOOLS_MODE === 'trace' ? 'trace' : 'live', screencast: { enabled: true, quality: 70, maxWidth: 1280, maxHeight: 720 }, headless: true }) diff --git a/examples/selenium/mocha-test/test/example.js b/examples/selenium/mocha-test/test/example.js index 25fe5a28..eef04a75 100644 --- a/examples/selenium/mocha-test/test/example.js +++ b/examples/selenium/mocha-test/test/example.js @@ -20,7 +20,8 @@ import { DevTools } from '@wdio/selenium-devtools' // 4 fail: { mode: 'trace', traceGranularity: 'test', tracePolicy: 'retain-on-failure' } // 5 retry: { mode: 'trace', traceGranularity: 'test', tracePolicy: 'on-first-retry' } DevTools.configure({ - mode: 'trace', + // Trace by default (regen); DEVTOOLS_MODE=live flips to live for live-parity recording. + mode: process.env.DEVTOOLS_MODE === 'live' ? 'live' : 'trace', traceGranularity: 'session', tracePolicy: 'retain-on-first-failure', headless: true diff --git a/examples/wdio/cucumber/wdio.conf.ts b/examples/wdio/cucumber/wdio.conf.ts index 814d0b03..e6fc82aa 100644 --- a/examples/wdio/cucumber/wdio.conf.ts +++ b/examples/wdio/cucumber/wdio.conf.ts @@ -1,22 +1,14 @@ import path from 'node:path' -import type { Options } from '@wdio/types' const __dirname = path.resolve(path.dirname(new URL(import.meta.url).pathname)) -export const config: Options.Testrunner = { +export const config: WebdriverIO.Config = { // // ==================== // Runner Configuration // ==================== // WebdriverIO supports running e2e tests as well as unit and component tests. runner: 'local', - autoCompileOpts: { - autoCompile: true, - tsNodeOpts: { - project: './tsconfig.json', - transpileOnly: true - } - }, // // ================== // Specify Test Files diff --git a/examples/wdio/cucumber/wdio.mobile.conf.ts b/examples/wdio/cucumber/wdio.mobile.conf.ts index 13618ffe..3fea1d5a 100644 --- a/examples/wdio/cucumber/wdio.mobile.conf.ts +++ b/examples/wdio/cucumber/wdio.mobile.conf.ts @@ -14,11 +14,10 @@ // extraction) and the trace's context naming accordingly. import path from 'node:path' -import type { Options } from '@wdio/types' const __dirname = path.resolve(path.dirname(new URL(import.meta.url).pathname)) -export const config: Options.Testrunner = { +export const config: WebdriverIO.Config = { runner: 'local', specs: ['./features/**/*.feature'], diff --git a/examples/wdio/cucumber/wdio.trace.conf.ts b/examples/wdio/cucumber/wdio.trace.conf.ts index 346ebaf0..4af123dd 100644 --- a/examples/wdio/cucumber/wdio.trace.conf.ts +++ b/examples/wdio/cucumber/wdio.trace.conf.ts @@ -36,7 +36,10 @@ export const config: WebdriverIO.Config = { [ 'devtools', { - mode: 'trace' as const + // Trace by default (regen); DEVTOOLS_MODE=live flips to live for live-parity recording. + mode: (process.env.DEVTOOLS_MODE === 'live' ? 'live' : 'trace') as + | 'live' + | 'trace' // traceFormat: 'ndjson-directory' } ] diff --git a/examples/wdio/mocha/wdio.conf.ts b/examples/wdio/mocha/wdio.conf.ts index f6d9e779..e6724f21 100644 --- a/examples/wdio/mocha/wdio.conf.ts +++ b/examples/wdio/mocha/wdio.conf.ts @@ -1,16 +1,7 @@ -import type { Options } from '@wdio/types' - // Mocha counterpart to wdio.conf.ts (which runs the Cucumber example). Same // capabilities and devtools service; only the framework + spec layout differ. -export const config: Options.Testrunner = { +export const config: WebdriverIO.Config = { runner: 'local', - autoCompileOpts: { - autoCompile: true, - tsNodeOpts: { - project: './tsconfig.json', - transpileOnly: true - } - }, specs: ['./specs/**/*.e2e.ts'], exclude: [], // Live mode drives a single-session dashboard; >1 worker streams two sessions @@ -19,7 +10,6 @@ export const config: Options.Testrunner = { capabilities: [ { browserName: 'chrome', - // browserVersion: '147.0.7727.56', 'goog:chromeOptions': { args: [ '--headless', diff --git a/examples/wdio/mocha/wdio.retry.conf.ts b/examples/wdio/mocha/wdio.retry.conf.ts index d9c873bb..77098a9b 100644 --- a/examples/wdio/mocha/wdio.retry.conf.ts +++ b/examples/wdio/mocha/wdio.retry.conf.ts @@ -1,20 +1,11 @@ -import type { Options } from '@wdio/types' - // Disposable harness for verifying B4 (retry-aware trace policies). Runs the // deterministically-flaky spec (fails once, passes on retry) alongside a clean // passing spec at spec granularity. With tracePolicy 'on-first-retry' only the // flaky spec's trace is retained โ€” proving the retry attempt is captured and is // distinct from failure (the flaky test ends PASSED, so retain-on-failure would // drop it). Flip tracePolicy below to exercise the other retry-aware policies. -export const config: Options.Testrunner = { +export const config: WebdriverIO.Config = { runner: 'local', - autoCompileOpts: { - autoCompile: true, - tsNodeOpts: { - project: './tsconfig.json', - transpileOnly: true - } - }, specs: ['./retry/flaky.e2e.ts', './specs/login.e2e.ts'], exclude: [], maxInstances: 1, diff --git a/examples/wdio/mocha/wdio.trace.conf.ts b/examples/wdio/mocha/wdio.trace.conf.ts new file mode 100644 index 00000000..b89aa411 --- /dev/null +++ b/examples/wdio/mocha/wdio.trace.conf.ts @@ -0,0 +1,60 @@ +// Trace-mode variant of wdio.conf.ts, used by the verification harness +// (`pnpm fixtures:regen`) to produce a deterministic golden trace.zip. Same +// capabilities and specs as the live config; session granularity writes one zip +// per run. Kept separate so the demo config's live default stays untouched. +export const config: WebdriverIO.Config = { + runner: 'local', + specs: ['./specs/**/*.e2e.ts'], + exclude: [], + maxInstances: 1, + capabilities: [ + { + browserName: 'chrome', + 'goog:chromeOptions': { + args: [ + '--headless', + '--disable-gpu', + '--remote-allow-origins=*', + '--window-size=1600,900' + ] + } + } + ], + logLevel: 'warn', + bail: 0, + baseUrl: 'http://localhost', + waitforTimeout: 10000, + connectionRetryTimeout: 120000, + connectionRetryCount: 3, + services: [ + [ + 'devtools', + { + // Trace by default (regen); DEVTOOLS_MODE=live flips to live for live-parity recording. + mode: (process.env.DEVTOOLS_MODE === 'live' ? 'live' : 'trace') as + | 'live' + | 'trace', + // Granularity/policy default to session/on and are env-overridable so + // one config can walk the whole grid, e.g. + // DEVTOOLS_TRACE_GRANULARITY=test DEVTOOLS_TRACE_POLICY=retain-on-failure + traceGranularity: (process.env.DEVTOOLS_TRACE_GRANULARITY ?? + 'session') as 'session' | 'spec' | 'test', + tracePolicy: (process.env.DEVTOOLS_TRACE_POLICY ?? 'on') as + | 'on' + | 'retain-on-failure' + | 'retain-on-first-failure' + | 'on-first-retry' + | 'on-all-retries' + | 'retain-on-failure-and-retries', + // Always emit the manifest so scenario-parity can read the artifact set. + emitArtifactsManifest: true + } + ] + ], + framework: 'mocha', + reporters: ['spec'], + mochaOpts: { + ui: 'bdd', + timeout: 60000 + } +} diff --git a/package.json b/package.json index a2d08b44..92eb3fe8 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,8 @@ "test": "vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage", - "test:ui": "vitest --ui", + "typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p packages/app/test-ui/tsconfig.json && tsc --noEmit -p tsconfig.dts.json && tsc --noEmit -p examples/wdio/tsconfig.json", + "test:ui": "wdio run packages/app/wdio.conf.ts", "test:debug": "node --inspect-brk ./node_modules/.bin/vitest run", "lint": "pnpm --parallel lint", "watch": "pnpm build --watch", @@ -37,10 +38,10 @@ "@changesets/cli": "^2.31.0", "@eslint/js": "^10.0.1", "@types/node": "25.9.3", + "@types/ws": "^8.18.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", "@wdio/allure-reporter": "^9.29.1", "autoprefixer": "^10.5.0", @@ -62,7 +63,8 @@ "unplugin-icons": "^23.0.1", "vite": "^8.0.16", "vitest": "^4.1.8", - "webdriverio": "^9.27.2" + "webdriverio": "^9.27.2", + "ws": "^8.21.0" }, "dependencies": { "@wdio/cli": "9.28.0" diff --git a/packages/app/package.json b/packages/app/package.json index 041f52f0..523e7fd8 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -35,9 +35,13 @@ "license": "MIT", "devDependencies": { "@tailwindcss/postcss": "^4.3.0", + "@wdio/browser-runner": "^9.30.0", "@wdio/devtools-shared": "workspace:^", + "@wdio/globals": "^9.29.1", + "@wdio/mocha-framework": "^9.30.0", "@wdio/reporter": "9.28.0", "autoprefixer": "^10.5.0", + "expect": "30.2.0", "postcss": "^8.5.15", "postcss-import": "^16.1.1", "rollup": "^4.61.0", diff --git a/packages/app/src/app.ts b/packages/app/src/app.ts index d8076a4b..c5c5fac6 100644 --- a/packages/app/src/app.ts +++ b/packages/app/src/app.ts @@ -9,39 +9,16 @@ import { KeyboardController, KBD } from './controller/keyboard.js' import { DragController, Direction } from './utils/DragController.js' import { SIDEBAR_MIN_WIDTH, - SIDEBAR_DEFAULT_WIDTH, - DARK_MODE_KEY + SIDEBAR_DEFAULT_WIDTH } from './controller/constants.js' +import { + applyDarkMode, + onThemeChange, + prefersDarkMode +} from './controller/theme.js' +import { elapsedSince } from './utils/elapsed.js' import { POPOUT_QUERY } from './components/workbench/compare/constants.js' -// Bootstrap the dark-mode class on <body> as early as possible so popout -// windows (which don't render the header) still get themed consistently -// with the main dashboard. The header still owns the toggle. -const darkModeInit = localStorage.getItem(DARK_MODE_KEY) -const isDarkMode = - typeof darkModeInit === 'string' - ? darkModeInit === 'true' - : window.matchMedia('(prefers-color-scheme: dark)').matches -if (isDarkMode) { - document.body.classList.add('dark') -} -// Cross-window sync: when the user toggles dark mode in the main dashboard, -// the storage event fires in OTHER windows (popouts) and we mirror the -// theme change there too. -window.addEventListener('storage', (e) => { - if (e.key === DARK_MODE_KEY) { - document.body.classList.toggle('dark', e.newValue === 'true') - } -}) -// Follow live OS theme changes while the user hasn't set an explicit override. -window - .matchMedia('(prefers-color-scheme: dark)') - .addEventListener('change', (e) => { - if (localStorage.getItem(DARK_MODE_KEY) === null) { - document.body.classList.toggle('dark', e.matches) - } - }) - import './components/header.js' import './components/sidebar.js' import './components/workbench.js' @@ -49,6 +26,14 @@ import './components/onboarding/start.js' import './components/workbench/compare.js' import './components/shortcuts-overlay.js' +// Bootstrap the dark-mode class on <body> before the first render so popout +// windows (which don't render the header) still get themed consistently with +// the main dashboard. The header owns the toggle; `controller/theme` owns what +// "dark" resolves to and every source that can change it, so the document and +// the header's icon follow one answer instead of two. +applyDarkMode(prefersDarkMode()) +onThemeChange(applyDarkMode) + @customElement('wdio-devtools') export class WebdriverIODevtoolsApplication extends Element { dataManager = new DataManagerController(this) @@ -119,6 +104,11 @@ export class WebdriverIODevtoolsApplication extends Element { 'clear-execution-data', this.#clearExecutionData.bind(this) ) + // The sidebar row announces the selection; nothing else forwards it into + // the context, so without this the selection only ever moved on a preserve + // or a popout โ€” and the Compare tab, which keys on it, kept showing the + // previously selected test's baseline after clicking a different test. + this.addEventListener('app-test-select', this.#onTestSelect) window.addEventListener('show-command', this.#onShowCommand) window.addEventListener(KBD.step, this.#onKbdStep) window.addEventListener(KBD.jump, this.#onKbdJump) @@ -132,11 +122,16 @@ export class WebdriverIODevtoolsApplication extends Element { disconnectedCallback(): void { super.disconnectedCallback() + this.removeEventListener('app-test-select', this.#onTestSelect) window.removeEventListener('show-command', this.#onShowCommand) window.removeEventListener(KBD.step, this.#onKbdStep) window.removeEventListener(KBD.jump, this.#onKbdJump) } + #onTestSelect = (event: Event): void => { + this.dataManager.setSelectedTestUid((event as CustomEvent<string>).detail) + } + #onShowCommand = (event: Event): void => { const command = (event as CustomEvent<{ command?: CommandLog }>).detail ?.command @@ -154,12 +149,15 @@ export class WebdriverIODevtoolsApplication extends Element { #selectCommand(command: CommandLog): void { this.#activeCommandTs = command.timestamp - // Mirror actions.ts: elapsed time is the command's offset from the first - // command, so keyboard selection shows the same duration as a mouse click. - const baseline = this.#sortedCommands[0]?.timestamp ?? 0 - const elapsedTime = (command.timestamp ?? baseline) - baseline + // Timed against the commands, so keyboard selection badges the same offset + // the actions list shows for a mouse click on the same row. window.dispatchEvent( - new CustomEvent('show-command', { detail: { command, elapsedTime } }) + new CustomEvent<CommandEventProps>('show-command', { + detail: { + command, + elapsedTime: elapsedSince(this.#sortedCommands, command) + } + }) ) } diff --git a/packages/app/src/components/browser/snapshot.ts b/packages/app/src/components/browser/snapshot.ts index 76f16712..d31bcd66 100644 --- a/packages/app/src/components/browser/snapshot.ts +++ b/packages/app/src/components/browser/snapshot.ts @@ -3,7 +3,11 @@ import { html, nothing } from 'lit' import { consume } from '@lit/context' import { snapshotStyles } from './snapshot-styles.js' import { renderBrowserChrome } from './browser-chrome.js' -import { drawElementOverlay, clearElementOverlay } from './element-overlay.js' +import { + drawElementOverlay, + clearElementOverlay, + resolveTestSelector +} from './element-overlay.js' import { commandPageUrl } from './url-at-timestamp.js' import { mutationForCommand } from './mutation-at-command.js' import { imageMime } from './trace-timeline-utils.js' @@ -12,6 +16,10 @@ import { type ComponentChildren, h, render, type VNode } from 'preact' import { customElement, query } from 'lit/decorators.js' import { transform } from './vnode-transform.js' import type { SimplifiedVNode } from '../../../../script/types' +// Type-only, like the `script/types` import above: the collector owns the +// characterData wire shape (parent ref + child index), so the replay reads it +// from the same declaration that produces it. +import type { TextMutation } from '../../../../script/src/mutations.js' import type { CommandLog } from '@wdio/devtools-shared' import { @@ -28,6 +36,22 @@ import '~icons/mdi/cursor-default-click-outline.js' const MUTATION_SELECTOR = '__mutation-highlight__' +/** A characterData mutation the collector could address. Older traces (and any + * producer that only sets `target`) carry no `childIndex`, and there is no + * child to patch without one โ€” patching the parent instead would delete its + * element children. */ +function isAddressedText(mutation: TraceMutation): mutation is TextMutation { + return ( + typeof mutation.target === 'string' && + typeof mutation.childIndex === 'number' + ) +} + +const textChildren = (el: Node) => + Array.from(el.childNodes).filter( + (node): node is Text => node.nodeType === Node.TEXT_NODE + ) + declare global { interface WindowEventMap { 'screencast-ready': CustomEvent<{ @@ -92,29 +116,42 @@ export class DevtoolsBrowser extends Element { @query('section') section?: HTMLElement + /** The window events the player handles while connected, as one table so its + * registration and its teardown cannot drift. Every handler is a per-instance + * arrow field, so the reference removeEventListener gets is the one that was + * added โ€” a bound method would produce a new function per call and never + * detach. */ + #windowListeners(): ReadonlyArray<readonly [string, EventListener]> { + return [ + ['resize', this.#handleResize], + ['window-drag', this.#handleResize], + ['app-mutation-highlight', this.#highlightMutation], + ['app-mutation-select', this.#handleMutationSelect], + ['a11y-highlight', this.#highlightBySelector], + ['show-command', this.#handleShowCommand], + ['screencast-ready', this.#handleScreencastReady] + ] + } + async connectedCallback() { super.connectedCallback() - window.addEventListener('resize', this.#setIframeSize.bind(this)) - window.addEventListener('window-drag', this.#setIframeSize.bind(this)) - window.addEventListener( - 'app-mutation-highlight', - this.#highlightMutation.bind(this) - ) - window.addEventListener('app-mutation-select', (ev) => - this.#renderBrowserState(ev.detail) - ) - window.addEventListener('a11y-highlight', this.#highlightBySelector) - window.addEventListener( - 'show-command', - this.#handleShowCommand as EventListener - ) - window.addEventListener( - 'screencast-ready', - this.#handleScreencastReady as EventListener - ) + for (const [type, handler] of this.#windowListeners()) { + window.addEventListener(type, handler) + } await this.updateComplete } + // Lit calls connectedCallback again on every re-connect, so a listener left + // behind keeps a discarded player working โ€” it still replays into its detached + // iframe and collects arriving recordings โ€” and makes the re-connected one + // handle every event twice. + disconnectedCallback() { + super.disconnectedCallback() + for (const [type, handler] of this.#windowListeners()) { + window.removeEventListener(type, handler) + } + } + #setIframeSize() { if (!this.section || !this.header) { return @@ -221,6 +258,11 @@ export class DevtoolsBrowser extends Element { } } + #handleResize = () => this.#setIframeSize() + + #handleMutationSelect = (event: Event) => + this.#renderBrowserState((event as CustomEvent<TraceMutation>).detail) + #handleShowCommand = (event: Event) => this.#renderCommandScreenshot( (event as CustomEvent<{ command?: CommandLog }>).detail?.command @@ -378,12 +420,34 @@ export class DevtoolsBrowser extends Element { } #handleCharacterDataMutation(mutation: TraceMutation) { - const el = this.#queryElement(mutation.target!) + if (!isAddressedText(mutation)) { + return + } + const el = this.#queryElement(mutation.target) if (!el) { return } + // Patch the addressed node's data only. Assigning `textContent` on the + // parent would replace ALL of its children โ€” the element ones included. + const node = this.#textNodeAt(el, mutation) + if (!node) { + return + } + node.data = mutation.newTextContent || '' + } - el.textContent = mutation.newTextContent || '' + /** The Text node a characterData mutation addressed. `childIndex` counts the + * CAPTURED parent's childNodes, and the replayed parent can hold fewer (the + * player strips the page's `<script>` children), so an index that no longer + * lands on a text node falls back to the parent's only one โ€” and to nothing + * when several make that ambiguous. */ + #textNodeAt(el: HTMLElement, mutation: TextMutation): Text | undefined { + const addressed = el.childNodes[mutation.childIndex] + if (addressed?.nodeType === Node.TEXT_NODE) { + return addressed as Text + } + const texts = textChildren(el) + return texts.length === 1 ? texts[0] : undefined } #handleAttributeMutation(mutation: TraceMutation) { @@ -428,6 +492,11 @@ export class DevtoolsBrowser extends Element { return } + // Before the insertions: a removed TEXT node is matched positionally (see + // #removeChildren), so a text node added here would be a candidate for it + // and `el.textContent = 'new'` would replay as the old text plus the new. + this.#removeChildren(el, mutation) + // Insert added nodes at their captured position via a detached holder. // render(vnode, el) can't append โ€” Preact treats `el` as its render root and // reconciles its children, replacing el's first child instead of inserting @@ -446,12 +515,20 @@ export class DevtoolsBrowser extends Element { } } }) + } + /** Drop what a childList mutation removed. An element is found by its ref; a + * removed TEXT node has none โ€” `getRef` yields null for it โ€” so each null + * entry drops one text child, in the order the collector reported them. */ + #removeChildren(el: HTMLElement, mutation: TraceMutation) { + const texts = textChildren(el) + let next = 0 mutation.removedNodes.forEach((ref) => { - const child = this.#queryElement(ref, el) - if (child) { - child.remove() + if (!ref) { + texts[next++]?.remove() + return } + this.#queryElement(ref, el)?.remove() }) } @@ -469,8 +546,11 @@ export class DevtoolsBrowser extends Element { ?.remove() } - /** Draw the outline box over an element in the replayed iframe. */ - #outline(el: HTMLElement) { + /** Draw the outline box over an element in the replayed iframe. Takes any DOM + * element โ€” the text-locator resolver matches on content, so what it finds + * need not be an `HTMLElement`. Spelled `globalThis.Element` because the Lit + * base class imported here shadows the DOM one. */ + #outline(el: globalThis.Element) { const docEl = this.iframe?.contentDocument if (!docEl) { return @@ -490,21 +570,22 @@ export class DevtoolsBrowser extends Element { docEl.body.appendChild(highlight) } - #highlightMutation(ev: CustomEvent<TraceMutation | null>) { - if (!ev.detail) { + #highlightMutation = (event: Event) => { + const mutation = (event as CustomEvent<TraceMutation | null>).detail + if (!mutation) { this.#clearHighlight() return } - const el = ev.detail.target - ? this.#queryElement(ev.detail.target) - : undefined + const el = mutation.target ? this.#queryElement(mutation.target) : undefined if (el) { this.#outline(el) } } - /** Outline the element for an a11y-tree locator (CSS selectors only; WDIO - * locators like `button*=Login` can't resolve via querySelector โ†’ no-op). */ + /** Outline the element for an a11y-tree locator. Resolved through the same + * resolver the forward direction (the element overlay) uses, because the tree + * captures interactive elements as WDIO text locators (`button*=Login`) that + * querySelector cannot parse. */ #highlightBySelector = (ev: Event) => { const detail = (ev as CustomEvent<{ selector?: string } | null>).detail const docEl = this.iframe?.contentDocument @@ -515,12 +596,7 @@ export class DevtoolsBrowser extends Element { if (!detail?.selector) { return } - let el: HTMLElement | null = null - try { - el = docEl.querySelector(detail.selector) - } catch { - return - } + const el = resolveTestSelector(docEl, detail.selector) if (el) { this.#outline(el) } diff --git a/packages/app/src/components/browser/trace-timeline.ts b/packages/app/src/components/browser/trace-timeline.ts index 1eb7e351..81167d8d 100644 --- a/packages/app/src/components/browser/trace-timeline.ts +++ b/packages/app/src/components/browser/trace-timeline.ts @@ -6,6 +6,7 @@ import type { CommandLog, TracePlayerFrame } from '@wdio/devtools-shared' import { isKeyboardCommand } from '@wdio/devtools-shared' import { commandContext, framesContext } from '../../controller/context.js' +import { elapsedSince } from '../../utils/elapsed.js' import { activeSpanAt } from '../workbench/active-entry.js' import { KBD } from '../../controller/keyboard.js' import { @@ -236,8 +237,13 @@ export class TraceTimeline extends Element { } this.#started = true this.#activeCommand = command + // Timed against the commands, NOT against `#start`: the strip's window origin + // also counts captured frames, which begin before the first command, so + // measuring from it would badge the same action differently in the two panes. window.dispatchEvent( - new CustomEvent('show-command', { detail: { command } }) + new CustomEvent<CommandEventProps>('show-command', { + detail: { command, elapsedTime: elapsedSince(sorted, command) } + }) ) } diff --git a/packages/app/src/components/header.ts b/packages/app/src/components/header.ts index 05b7f1f4..77c69dd6 100644 --- a/packages/app/src/components/header.ts +++ b/packages/app/src/components/header.ts @@ -6,22 +6,44 @@ import '~icons/custom/logo.svg' import '~icons/mdi/white-balance-sunny.js' import '~icons/mdi/moon-waning-crescent.js' -import { DARK_MODE_KEY } from '../controller/constants.js' - -const darkModeInitValue = localStorage.getItem(DARK_MODE_KEY) +import { + applyDarkMode, + onThemeChange, + prefersDarkMode, + storeDarkMode +} from '../controller/theme.js' @customElement('wdio-devtools-header') export class DevtoolsHeader extends Element { - #darkMode = - typeof darkModeInitValue === 'string' - ? darkModeInitValue === 'true' - : window.matchMedia('(prefers-color-scheme: dark)').matches + // Read per instance, not once per module load: a header built after the theme + // changed elsewhere would otherwise render the icon of the old one. + #darkMode = prefersDarkMode() + + #unwatchTheme?: () => void constructor() { super() - if (this.#darkMode) { - document.querySelector('body')?.classList.add('dark') - } + applyDarkMode(this.#darkMode) + } + + connectedCallback(): void { + super.connectedCallback() + // Another window stored a theme, or the OS flipped while nothing is stored: + // either way the icon has to follow, or it contradicts the document. + this.#unwatchTheme = onThemeChange((dark) => this.#renderTheme(dark)) + } + + disconnectedCallback(): void { + super.disconnectedCallback() + this.#unwatchTheme?.() + this.#unwatchTheme = undefined + } + + // The one place the header adopts a theme: icon and document move together. + #renderTheme(dark: boolean): void { + this.#darkMode = dark + applyDarkMode(dark) + this.requestUpdate() } static styles = [ @@ -83,11 +105,9 @@ export class DevtoolsHeader extends Element { } #switchMode() { - const body = document.querySelector('body') - body?.classList.toggle('dark') - this.#darkMode = !this.#darkMode - localStorage.setItem(DARK_MODE_KEY, this.#darkMode ? 'true' : 'false') - this.requestUpdate() + const dark = !this.#darkMode + storeDarkMode(dark) + this.#renderTheme(dark) } } diff --git a/packages/app/src/components/onboarding/start.ts b/packages/app/src/components/onboarding/start.ts index 4e113e44..0c78d6c4 100644 --- a/packages/app/src/components/onboarding/start.ts +++ b/packages/app/src/components/onboarding/start.ts @@ -23,21 +23,23 @@ export class DevtoolsStart extends Element { render() { return html` - <div class="h-full flex-1 flex justify-center items-center bg-sideBarBackground"> + <div + class="h-full flex-1 flex justify-center items-center bg-sideBarBackground" + > <h1 class="border-r-2 pr-12 mr-12 border-panelBorder"> <img src="/robot.png" width="200px" /> </h1> <section> <h2 class="text-4xl font-bold">WebdriverIO Devtools</h2> - <p class="py-4"> + <div class="py-4"> <h3 class="font-bold text-xl">Embed into Project</h3> - First install WebdriverIO Devtools via: + <p>First install WebdriverIO Devtools via:</p> <pre>npm install @wdio/devtools</pre> - </p> - <p class="py-4"> - Then add it as a service: + </div> + <div class="py-4"> + <p>Then add it as a service:</p> <pre class="w-full align-left">${CONFIG_CODE_EXAMPLE}</pre> - </p> + </div> </section> </div> ` diff --git a/packages/app/src/components/placeholder.ts b/packages/app/src/components/placeholder.ts index ae12642e..de1177b3 100644 --- a/packages/app/src/components/placeholder.ts +++ b/packages/app/src/components/placeholder.ts @@ -1,11 +1,26 @@ import { Element } from '@core/element' -import { html, unsafeCSS } from 'lit' -import { customElement } from 'lit/decorators.js' +import { html, nothing, unsafeCSS, type TemplateResult } from 'lit' +import { customElement, property } from 'lit/decorators.js' import placeholderLoadingCSS from 'placeholder-loading/dist/css/placeholder-loading.css?inline' +/** Panel filler in one of two modes: a loading skeleton while data is still on + * its way, or โ€” once a caller supplies `heading`/`description` โ€” an empty state + * that says why the panel has nothing to show. `heading` rather than `title` so + * the copy doesn't double as a native tooltip on the host. */ @customElement('wdio-devtools-placeholder') export class DevtoolsPlaceholder extends Element { + /** Empty-state glyph, in the same spirit as the panels' own `.empty-state` + * blocks (console's `๐Ÿ“‹`, errors' `โœ“`). */ + @property({ type: String }) + icon?: string + + @property({ type: String }) + heading?: string + + @property({ type: String }) + description?: string + static styles = [ unsafeCSS(placeholderLoadingCSS), unsafeCSS(` @@ -24,10 +39,41 @@ export class DevtoolsPlaceholder extends Element { .ph-item div { opacity: .6; } + + .empty-state { + box-sizing: border-box; + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 10px; + padding: 16px; + text-align: center; + color: var(--vscode-descriptionForeground, #8b8b96); + } + + .empty-state-icon { + font-size: 40px; + line-height: 1; + opacity: .3; + } + + .empty-state-text { + font-size: 14px; + opacity: .6; + } + + .empty-state-detail { + font-size: 12px; + opacity: .5; + max-width: 46ch; + line-height: 1.5; + } `) ] - render() { + #skeleton(): TemplateResult { return html` <div class="ph-item"> <div class="ph-col-12"> @@ -44,6 +90,25 @@ export class DevtoolsPlaceholder extends Element { </div> ` } + + render() { + if (!this.heading && !this.description) { + return this.#skeleton() + } + return html` + <div class="empty-state"> + ${this.icon + ? html`<div class="empty-state-icon">${this.icon}</div>` + : nothing} + ${this.heading + ? html`<div class="empty-state-text">${this.heading}</div>` + : nothing} + ${this.description + ? html`<div class="empty-state-detail">${this.description}</div>` + : nothing} + </div> + ` + } } declare global { diff --git a/packages/app/src/components/sidebar/collapseableEntry.ts b/packages/app/src/components/sidebar/collapseableEntry.ts index 3d50f2ef..d9c90082 100644 --- a/packages/app/src/components/sidebar/collapseableEntry.ts +++ b/packages/app/src/components/sidebar/collapseableEntry.ts @@ -2,7 +2,9 @@ import { html } from 'lit' import { Element } from '@core/element' export class CollapseableEntry extends Element { - allowCollapseAll = false + /** A tree renders its children until something collapses them, so the control + * on offer starts out as collapse-all. */ + allowCollapseAll = true connectedCallback(): void { super.connectedCallback() @@ -21,8 +23,10 @@ export class CollapseableEntry extends Element { return false } + // `wdio-test-entry` reflects `is-collapsed`, so the attribute's presence โ€” + // not its value โ€” is the row's state. return [...this.shadowRoot.querySelectorAll('wdio-test-entry')].some( - (el) => el.getAttribute('is-collapsed') === 'false' + (el) => !el.hasAttribute('is-collapsed') ) } @@ -31,14 +35,13 @@ export class CollapseableEntry extends Element { return } const entries = [...this.shadowRoot.querySelectorAll('wdio-test-entry')] - entries.forEach((el) => el.setAttribute('is-collapsed', `${!shouldExpand}`)) + entries.forEach((el) => el.toggleAttribute('is-collapsed', !shouldExpand)) this.allowCollapseAll = shouldExpand this.requestUpdate() } renderCollapseOrExpandIcon(iconClass = '') { - return this.allowCollapseAll || - this.getAttribute('is-collapsed') === 'false' + return this.allowCollapseAll ? html`<icon-mdi-collapse-all @click="${() => this.collapseOrExpand(false)}" class="${iconClass}" diff --git a/packages/app/src/components/sidebar/constants.ts b/packages/app/src/components/sidebar/constants.ts index 46e6f67a..a346b352 100644 --- a/packages/app/src/components/sidebar/constants.ts +++ b/packages/app/src/components/sidebar/constants.ts @@ -1,12 +1,3 @@ -import { TestState } from './types.js' -import type { TestStatus } from './types.js' - -export const STATE_MAP: Record<string, TestStatus> = { - running: TestState.RUNNING, - failed: TestState.FAILED, - passed: TestState.PASSED, - skipped: TestState.SKIPPED -} import type { RunCapabilities } from './types.js' export const DEFAULT_CAPABILITIES: RunCapabilities = { diff --git a/packages/app/src/components/sidebar/explorer.ts b/packages/app/src/components/sidebar/explorer.ts index 9c367045..f23d1c5a 100644 --- a/packages/app/src/components/sidebar/explorer.ts +++ b/packages/app/src/components/sidebar/explorer.ts @@ -407,7 +407,6 @@ export class DevtoolsSidebarExplorer extends CollapseableEntry { const runBtnCls = canRunAll ? 'hover:bg-toolbarHoverBackground' : 'opacity-30 cursor-not-allowed' - const iconCls = (color: string) => (canRunAll ? `group-hover:${color}` : '') return html` <nav class="flex ml-auto gap-0.5 text-[16px] text-descriptionForeground"> <button @@ -416,15 +415,18 @@ export class DevtoolsSidebarExplorer extends CollapseableEntry { title="Run all" @click="${() => this.#runAllSuites()}" > - <icon-mdi-play class="${iconCls('text-chartsGreen')}"></icon-mdi-play> + <icon-mdi-play + class="${canRunAll ? 'group-hover:text-chartsGreen' : ''}" + ></icon-mdi-play> </button> + <!-- Not gated on a run capability: those describe what a framework can + launch, and stopping needs none (see runnerCapabilities.ts). --> <button - class="p-1 rounded group ${runBtnCls}" - ?disabled=${!canRunAll} + class="p-1 rounded group hover:bg-toolbarHoverBackground" title="Stop" @click="${() => this.#stopActiveRun()}" > - <icon-mdi-stop class="${iconCls('text-chartsRed')}"></icon-mdi-stop> + <icon-mdi-stop class="group-hover:text-chartsRed"></icon-mdi-stop> </button> <button class="p-1 rounded hover:bg-toolbarHoverBackground group" diff --git a/packages/app/src/components/sidebar/runnerCapabilities.ts b/packages/app/src/components/sidebar/runnerCapabilities.ts index 3c66b64d..f4866f09 100644 --- a/packages/app/src/components/sidebar/runnerCapabilities.ts +++ b/packages/app/src/components/sidebar/runnerCapabilities.ts @@ -3,6 +3,11 @@ * (and tests) to decide whether the Run/Rerun buttons should be enabled. * Extracted from explorer.ts so the Lit component stays under the * file-size cap. + * + * Every capability here is about *launching*. Stopping deliberately has none: + * `POST /api/tests/stop` takes no body and kills whatever child the backend + * spawned, so no framework varies on it and a `canStop` field would be `true` + * forever. A run that can be started must always be stoppable. */ import type { Metadata } from '@wdio/devtools-shared' diff --git a/packages/app/src/components/sidebar/suite-summary.ts b/packages/app/src/components/sidebar/suite-summary.ts index fabc7669..78537142 100644 --- a/packages/app/src/components/sidebar/suite-summary.ts +++ b/packages/app/src/components/sidebar/suite-summary.ts @@ -2,57 +2,36 @@ import type { SuiteStatsFragment, TestStatsFragment } from '../../controller/types.js' -import { TestState } from './types.js' +import { + emptyTally, + tallyOutcomes, + type GroupOutcome, + type StateTally +} from '../../utils/test-outcome.js' -export interface SuiteSummary { - passed: number - failed: number - running: number - skipped: number - pending: number - total: number -} - -export type RunStatus = 'running' | 'failed' | 'passed' | 'idle' +/** The card counts states the same way the tree derives them, so the tally is + * the shared one under a name the summary reads better with. */ +export type SuiteSummary = StateTally +export type RunStatus = GroupOutcome -const emptySummary = (): SuiteSummary => ({ - passed: 0, - failed: 0, - running: 0, - skipped: 0, - pending: 0, - total: 0 -}) - -function tally(test: TestStatsFragment, summary: SuiteSummary): void { - summary.total += 1 - switch (test.state) { - case TestState.PASSED: - summary.passed += 1 - break - case TestState.FAILED: - summary.failed += 1 - break - case TestState.RUNNING: - summary.running += 1 - break - case TestState.SKIPPED: - summary.skipped += 1 - break - default: - summary.pending += 1 - } -} +/** + * The headline run state shown in the status pill โ€” the same derivation the + * tree rows use, so a run can never read "Idle" next to a green suite. + */ +export { deriveOutcome as deriveRunStatus } from '../../utils/test-outcome.js' -function walk(suite: SuiteStatsFragment, summary: SuiteSummary): void { +function collectTests( + suite: SuiteStatsFragment, + tests: TestStatsFragment[] +): void { for (const test of suite.tests ?? []) { if (test) { - tally(test, summary) + tests.push(test) } } for (const child of suite.suites ?? []) { if (child) { - walk(child, summary) + collectTests(child, tests) } } } @@ -66,9 +45,8 @@ function walk(suite: SuiteStatsFragment, summary: SuiteSummary): void { export function computeSuiteSummary( suites: Record<string, SuiteStatsFragment>[] | undefined ): SuiteSummary { - const summary = emptySummary() if (!suites) { - return summary + return emptyTally() } const roots = suites .flatMap((chunk) => Object.values(chunk)) @@ -76,30 +54,9 @@ export function computeSuiteSummary( const unique = Array.from( new Map(roots.map((suite) => [suite.uid, suite])).values() ) + const tests: TestStatsFragment[] = [] for (const suite of unique) { - walk(suite, summary) - } - return summary -} - -/** - * The headline run state shown in the status pill. Running wins over a stale - * terminal count (a rerun leaves old passed/failed values until results - * arrive); a finished run is failed if any test failed, otherwise passed. - */ -export function deriveRunStatus(summary: SuiteSummary): RunStatus { - const terminal = summary.passed + summary.failed + summary.skipped - if (summary.total === 0) { - return 'idle' - } - if (summary.running > 0 || (summary.pending > 0 && terminal > 0)) { - return 'running' - } - if (summary.failed > 0) { - return 'failed' - } - if (terminal === 0) { - return 'idle' + collectTests(suite, tests) } - return 'passed' + return tallyOutcomes(tests) } diff --git a/packages/app/src/components/sidebar/summary.ts b/packages/app/src/components/sidebar/summary.ts index 56fabca8..61f000d1 100644 --- a/packages/app/src/components/sidebar/summary.ts +++ b/packages/app/src/components/sidebar/summary.ts @@ -19,6 +19,7 @@ const STATUS_LABEL: Record<RunStatus, string> = { running: 'Running', failed: 'Failed', passed: 'Passed', + skipped: 'Skipped', idle: 'Idle' } @@ -173,6 +174,8 @@ export class DevtoolsSidebarSummary extends Element { return 'var(--vscode-charts-red)' case 'passed': return 'var(--vscode-charts-green)' + case 'skipped': + return 'var(--vscode-charts-yellow)' case 'running': return 'var(--vscode-charts-blue)' default: diff --git a/packages/app/src/components/sidebar/test-entry-state.ts b/packages/app/src/components/sidebar/test-entry-state.ts index af7b6112..0e3d9614 100644 --- a/packages/app/src/components/sidebar/test-entry-state.ts +++ b/packages/app/src/components/sidebar/test-entry-state.ts @@ -2,127 +2,37 @@ import type { SuiteStatsFragment, TestStatsFragment } from '../../controller/types.js' -import { STATE_MAP } from './constants.js' +import { + OUTCOME, + deriveEntryOutcome, + isSuiteEntry, + type EntryOutcome +} from '../../utils/test-outcome.js' import { TestState } from './types.js' import type { TestEntry, TestStatus } from './types.js' type Fragment = TestStatsFragment | SuiteStatsFragment -/** A suite is "running" when there are pending children + at least one - * terminal child, or when the suite itself is marked running with pending - * children. Tests fall through to their explicit state. */ -export function isRunning(entry: Fragment): boolean { - if ('tests' in entry) { - if ( - (entry.tests ?? []).some((t) => t.state === 'running') || - (entry.suites ?? []).some((s) => isRunning(s)) - ) { - return true - } - - const hasPendingTests = (entry.tests ?? []).some( - (t) => t.state === 'pending' - ) - const hasPendingSuites = (entry.suites ?? []).some((s) => hasPending(s)) - const suiteState = entry.state - - if (suiteState === 'running' && (hasPendingTests || hasPendingSuites)) { - return true - } - - // Mixed terminal + pending = run in progress regardless of explicit suite - // state (Nightwatch-Cucumber leaves feature.state undefined in the JSON). - const allDescendants = [...(entry.tests ?? []), ...(entry.suites ?? [])] - const hasSomeTerminal = allDescendants.some( - (t) => - t.state === 'passed' || t.state === 'failed' || t.state === 'skipped' - ) - if ((hasPendingTests || hasPendingSuites) && hasSomeTerminal) { - return true - } - return false - } - return entry.state === 'running' +/** Narrowing wrapper over the shared predicate so the tree keeps one rule for + * "is this a suite?". */ +function isSuiteFragment(entry: Fragment): entry is SuiteStatsFragment { + return isSuiteEntry(entry) } -export function hasPending(entry: Fragment): boolean { - if ('tests' in entry) { - if (entry.state === 'pending') { - return true - } - if ((entry.tests ?? []).some((t) => t.state === 'pending')) { - return true - } - if ((entry.suites ?? []).some((s) => hasPending(s))) { - return true - } - return false - } - return entry.state === 'pending' -} - -export function hasFailed(entry: Fragment): boolean { - if ('tests' in entry) { - if ((entry.tests ?? []).find((t) => t.state === 'failed')) { - return true - } - if ((entry.suites ?? []).some((s) => hasFailed(s))) { - return true - } - return false - } - return entry.state === 'failed' +/** How an outcome renders in the tree: a queued entry spins because the run + * reached it, and an entry nothing has reported on shows the not-run circle + * rather than a green check. */ +const OUTCOME_STATE: Record<EntryOutcome, TestStatus> = { + [OUTCOME.PASSED]: TestState.PASSED, + [OUTCOME.FAILED]: TestState.FAILED, + [OUTCOME.SKIPPED]: TestState.SKIPPED, + [OUTCOME.RUNNING]: TestState.RUNNING, + [OUTCOME.QUEUED]: TestState.RUNNING, + [OUTCOME.IDLE]: TestState.PENDING } export function computeEntryState(entry: Fragment): TestStatus { - // Suites: check running from children FIRST. A rerun clears end times but - // not stale 'passed'/'failed' state โ€” show the spinner before falling - // through to the cached terminal value. - if ('tests' in entry && isRunning(entry)) { - return TestState.RUNNING - } - - const state = entry.state - - // 'pending' on a suite = backend signaling a new run starting. Skip - // children check; stale terminal children must not flip suite to passed. - if ('tests' in entry && state === 'pending') { - return TestState.RUNNING - } - - // Suite with no explicit terminal state โ€” derive from children. If any - // child is non-terminal, the run is still in progress. - if ('tests' in entry && (state === null || state === 'running')) { - const allDescendants = [...(entry.tests ?? []), ...(entry.suites ?? [])] - if (allDescendants.length > 0) { - const allTerminal = allDescendants.every( - (t) => - t.state === 'passed' || t.state === 'failed' || t.state === 'skipped' - ) - if (!allTerminal) { - return TestState.RUNNING - } - } - } - - const mappedState = state ? STATE_MAP[state] : undefined - if (mappedState) { - return mappedState - } - - if ('tests' in entry) { - if (hasFailed(entry)) { - return TestState.FAILED - } - return TestState.PASSED - } - - // Leaf test: pending โ†’ spinner (run is in progress), NOT circle (which - // would imply "never run"). - if (state === 'pending') { - return TestState.RUNNING - } - return entry.end ? TestState.PASSED : 'pending' + return OUTCOME_STATE[deriveEntryOutcome(entry)] } /** @@ -135,7 +45,7 @@ export function getTestEntry( entry: Fragment, filterEntry: (entry: TestEntry) => boolean ): TestEntry { - if ('tests' in entry) { + if (isSuiteFragment(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 diff --git a/packages/app/src/components/sidebar/test-suite.ts b/packages/app/src/components/sidebar/test-suite.ts index 9cc7e0ee..16231218 100644 --- a/packages/app/src/components/sidebar/test-suite.ts +++ b/packages/app/src/components/sidebar/test-suite.ts @@ -46,8 +46,10 @@ const ACTION_BTN = const ACTION_ICON = 'w-[15px] h-[15px]' @customElement(TEST_ENTRY) export class ExplorerTestEntry extends CollapseableEntry { - @property({ attribute: 'is-collapsed' }) - isCollapsed = 'false' + /** Present โ‡” the children are hidden. Reflected so the tree-wide controls in + * `CollapseableEntry` can read a row's state straight off the DOM. */ + @property({ type: Boolean, reflect: true, attribute: 'is-collapsed' }) + isCollapsed = false @property({ type: String }) uid?: string @@ -161,20 +163,20 @@ export class ExplorerTestEntry extends CollapseableEntry { ] #toggleEntry() { - this.setAttribute('is-collapsed', `${!(this.isCollapsed === 'true')}`) - const isCollapsed = this.isCollapsed === 'true' + // Toggle the attribute, not the property: Lit reflects a property on the + // next update, but the listeners below read the DOM synchronously. + this.toggleAttribute('is-collapsed', !this.isCollapsed) + // The row's own control mirrors its own children, not its descendants'. + this.allowCollapseAll = !this.isCollapsed this.dispatchEvent( new CustomEvent('entry-collapse-change', { detail: { - isCollapsed, + isCollapsed: this.isCollapsed, entry: this }, bubbles: true }) ) - if (isCollapsed) { - this.allowCollapseAll = false - } this.requestUpdate() } @@ -229,7 +231,9 @@ export class ExplorerTestEntry extends CollapseableEntry { #stopEntry(event: Event) { event.stopPropagation() - if (!this.uid || this.runDisabled) { + // No `runDisabled` guard: stopping is not a launch capability (see + // #renderRunStopButtons). + if (!this.uid) { return } const detail: TestRunDetail = { @@ -357,8 +361,10 @@ export class ExplorerTestEntry extends CollapseableEntry { } #renderRunStopButtons() { + // `runDisabled` gates LAUNCHING only: /api/tests/stop takes no body and + // stops the whole run, so a run in flight is always stoppable. if (this.isRunning) { - return this.runDisabled ? nothing : this.#renderStopButton() + return this.#renderStopButton() } return html` ${this.#renderRunButton()} @@ -402,7 +408,7 @@ export class ExplorerTestEntry extends CollapseableEntry { render() { const hasNoChildren = !this.hasChildren - const isCollapsed = this.isCollapsed === 'true' + const isCollapsed = this.isCollapsed return html` <section class="row flex w-full items-start text-sm group/sidebar rounded-md my-0.5 px-1 py-1 cursor-pointer hover:bg-toolbarHoverBackground" diff --git a/packages/app/src/components/tabs.ts b/packages/app/src/components/tabs.ts index 9b4d2249..30313fa5 100644 --- a/packages/app/src/components/tabs.ts +++ b/packages/app/src/components/tabs.ts @@ -128,10 +128,7 @@ export class DevtoolsTabs extends Element { if (!activeTab) { return } - this.#activeTab = tabId - this.tabs.forEach((el) => el.removeAttribute('active')) - activeTab?.setAttribute('active', '') - this.requestUpdate() + this.#openTab(activeTab, tabId) /** * cache tab id in local storage @@ -141,11 +138,33 @@ export class DevtoolsTabs extends Element { } } + #openTab(tab: Element, label: string) { + this.#activeTab = label + this.tabs.forEach((el) => el.removeAttribute('active')) + tab.setAttribute('active', '') + this.requestUpdate() + } + + // A conditional tab (Compare) can unmount while it is the open one, and a + // remembered label can name a tab this mount never renders โ€” both would leave + // the strip with no panel open. The cached choice is left as the user set it. + #openFallbackTabWhenActiveIsGone() { + if (this.#activeTab && this.#tabList.includes(this.#activeTab)) { + return + } + const fallback = + this.tabs.find((el) => el.hasAttribute('active')) ?? this.tabs[0] + const label = fallback?.getAttribute('label') + if (fallback && label) { + this.#openTab(fallback, label) + } + } + #refreshTabList() { - this.#tabList = - this.tabs - .map((el) => el.getAttribute('label') as string) - .filter(Boolean) || [] + this.#tabList = this.tabs + .map((el) => el.getAttribute('label') as string) + .filter(Boolean) + this.#openFallbackTabWhenActiveIsGone() this.requestUpdate() } @@ -153,28 +172,17 @@ export class DevtoolsTabs extends Element { super.connectedCallback() window.addEventListener('open-dock-tab', this.#onOpenTab as EventListener) setTimeout(() => { - // wait till innerHTML is parsed + // wait till innerHTML is parsed; this opens the tab claiming to be active, + // or the first one this.#refreshTabList() /** - * get tab id either from local storage or a tab element that - * has an "active" attribute - */ - this.#activeTab = - (this.cacheId && localStorage.getItem(this.cacheId)) || - this.tabs - .find((el) => el.hasAttribute('active')) - ?.getAttribute('label') || - undefined - - /** - * set active tab or first tab as active + * reopen the remembered tab; a label this mount doesn't render is ignored, + * which leaves the tab opened above in place */ - if (!this.#activeTab) { - this.#activeTab = this.#tabList[0] - this.tabs[0]?.setAttribute('active', '') - } else { - this.activateTab(this.#activeTab) + const remembered = this.cacheId && localStorage.getItem(this.cacheId) + if (remembered) { + this.activateTab(remembered) } this.requestUpdate() diff --git a/packages/app/src/components/workbench.ts b/packages/app/src/components/workbench.ts index c4fdad53..bbd5651e 100644 --- a/packages/app/src/components/workbench.ts +++ b/packages/app/src/components/workbench.ts @@ -10,11 +10,14 @@ import { networkRequestContext, baselineContext, commandContext, + selectedTestUidContext, suiteContext } from '../controller/context.js' import type { CommandLog, + ConsoleLog, Metadata, + NetworkRequest, PreservedAttempt } from '@wdio/devtools-shared' import type { SuiteStatsFragment } from '../controller/types.js' @@ -74,7 +77,7 @@ export class DevtoolsWorkbench extends Element { @consume({ context: consoleLogContext, subscribe: true }) @state() - consoleLogs: ConsoleLogs[] | undefined = undefined + consoleLogs: ConsoleLog[] | undefined = undefined @consume({ context: networkRequestContext, subscribe: true }) @state() @@ -84,6 +87,10 @@ export class DevtoolsWorkbench extends Element { @state() baselines: Map<string, PreservedAttempt> | undefined = undefined + @consume({ context: selectedTestUidContext, subscribe: true }) + @state() + selectedTestUid: string | undefined = undefined + @consume({ context: commandContext, subscribe: true }) @state() commands: CommandLog[] | undefined = undefined @@ -309,12 +316,15 @@ export class DevtoolsWorkbench extends Element { ` } - #renderCompareTabIfAvailable() { - if ((this.baselines?.size || 0) === 0) { + // The panel renders the baseline of the SELECTED test and no other, so a + // baseline held for a different test is nothing this tab could open onto. + // Uncounted on purpose: one tab is one comparison. + #renderCompareTabForSelectedTest() { + if (!this.selectedTestUid || !this.baselines?.has(this.selectedTestUid)) { return nothing } return html` - <wdio-devtools-tab label="Compare" .badge="${this.baselines?.size || 0}"> + <wdio-devtools-tab label="Compare"> <wdio-devtools-compare></wdio-devtools-compare> </wdio-devtools-tab> ` @@ -378,7 +388,7 @@ export class DevtoolsWorkbench extends Element { <wdio-devtools-transcript></wdio-devtools-transcript> </wdio-devtools-tab>` : nothing} - ${this.#renderCompareTabIfAvailable()} + ${this.#renderCompareTabForSelectedTest()} ` } diff --git a/packages/app/src/components/workbench/a11y-tree.ts b/packages/app/src/components/workbench/a11y-tree.ts index 4220b6a4..bfa9db3c 100644 --- a/packages/app/src/components/workbench/a11y-tree.ts +++ b/packages/app/src/components/workbench/a11y-tree.ts @@ -3,8 +3,8 @@ import { html, css, nothing, type TemplateResult } from 'lit' import { customElement, state } from 'lit/decorators.js' import { + isSnapshotHeaderLine, SNAPSHOT_INDENT_UNIT, - SNAPSHOT_PAGE_HEADER, SNAPSHOT_LOCATOR_DELIM } from '@wdio/devtools-shared' import type { CommandLog } from '@wdio/devtools-shared' @@ -13,6 +13,7 @@ import '../placeholder.js' const COMPONENT = 'wdio-devtools-a11y' const NAME_MAX = 64 +const UNAVAILABLE_GLYPH = '๐ŸŒณ' interface A11yNode { depth: number @@ -276,7 +277,7 @@ export class DevtoolsA11yTree extends Element { * blank lines return null. */ #parse(line: string): A11yNode | null { const trimmed = line.trimStart() - if (!trimmed || trimmed.startsWith(SNAPSHOT_PAGE_HEADER)) { + if (!trimmed || isSnapshotHeaderLine(trimmed)) { return null } const indent = line.length - trimmed.length @@ -344,15 +345,32 @@ export class DevtoolsA11yTree extends Element { >` } + /** Why the panel is empty. A selected command with no `snapshotText` is a + * capture gap, not an idle panel: per-command a11y capture is WDIO-only, so + * Selenium and Nightwatch traces always land here. */ + #unavailable(): TemplateResult { + return this.active + ? html`<wdio-devtools-placeholder + icon="${UNAVAILABLE_GLYPH}" + heading="No accessibility snapshot for this command" + description="Per-command accessibility capture is WebdriverIO-only โ€” Selenium and Nightwatch traces do not include it." + ></wdio-devtools-placeholder>` + : html`<wdio-devtools-placeholder + icon="${UNAVAILABLE_GLYPH}" + heading="No command selected" + description="Select a command in the Actions tab to see the accessibility tree captured for it." + ></wdio-devtools-placeholder>` + } + render() { const text = this.active?.snapshotText if (!text) { - return html`<wdio-devtools-placeholder></wdio-devtools-placeholder>` + return this.#unavailable() } const lines = text.split('\n') - const header = lines[0]?.startsWith(SNAPSHOT_PAGE_HEADER) - ? lines[0] - : undefined + // Web captures head `[Page: <title> โ€” <url>]`, native ones `[<platform> โ€ฆ]`. + const header = + lines[0] && isSnapshotHeaderLine(lines[0]) ? lines[0] : undefined const nodes = lines .map((l) => this.#parse(l)) .filter((n): n is A11yNode => n !== null) diff --git a/packages/app/src/components/workbench/actionItems/command.ts b/packages/app/src/components/workbench/actionItems/command.ts index 02631b0d..fdcff3c9 100644 --- a/packages/app/src/components/workbench/actionItems/command.ts +++ b/packages/app/src/components/workbench/actionItems/command.ts @@ -47,13 +47,22 @@ export class CommandItem extends ActionItem { } #highlightLine() { - const event = new CustomEvent('show-command', { - detail: { - command: this.entry, - elapsedTime: this.elapsedTime - } - }) - window.dispatchEvent(event) + if (!this.entry) { + return + } + window.dispatchEvent( + // Typed as the contract, which is what makes the two nullable fields below + // a compile error rather than a blank chip in the Log tab. + new CustomEvent<CommandEventProps>('show-command', { + detail: { + command: this.entry, + // The offset this row displays. A row that was handed none is at the + // start of its list, so it reads zero like every other emitter's first + // action โ€” `undefined` would drop the Log tab's chip entirely. + elapsedTime: this.elapsedTime ?? 0 + } + }) + ) } #renderIcon(command: string): TemplateResult { diff --git a/packages/app/src/components/workbench/actionItems/duration.ts b/packages/app/src/components/workbench/actionItems/duration.ts index f0dd5d63..0ab601c0 100644 --- a/packages/app/src/components/workbench/actionItems/duration.ts +++ b/packages/app/src/components/workbench/actionItems/duration.ts @@ -5,17 +5,25 @@ export type DurationHeat = 'fast' | 'mid' | 'slow' const ONE_SECOND = 1000 const ONE_MINUTE = ONE_SECOND * 60 -/** Human-readable duration: `ms` under a second, `s` under a minute, `m s` - * above. Rounds first โ€” reconstructed traces carry fractional-ms clocks. */ +/** Seconds to 2dp, TRUNCATED rather than rounded: `1999` must not print the + * `2.00s` that `2000` prints, or the same label appears in two heat colours and + * a step reads as longer than it ran. */ +const truncateToSeconds = (ms: number): string => + (Math.floor(ms / 10) / 100).toFixed(2) + +/** Human-readable duration: `ms` below a second, `s` below a minute, `m s` from a + * minute up. Rounds the input first โ€” reconstructed traces carry fractional-ms + * clocks โ€” and each unit takes over AT its boundary, so `1000` is `1.00s` rather + * than a four-digit `1000ms`. */ export function formatDuration(ms: number): string { const rounded = Math.round(ms) - if (rounded > ONE_MINUTE) { + if (rounded >= ONE_MINUTE) { const minutes = Math.floor(rounded / ONE_MINUTE) const seconds = Math.floor((rounded - minutes * ONE_MINUTE) / ONE_SECOND) return `${minutes}m ${seconds}s` } - if (rounded > ONE_SECOND) { - return `${(rounded / ONE_SECOND).toFixed(2)}s` + if (rounded >= ONE_SECOND) { + return `${truncateToSeconds(rounded)}s` } return `${rounded}ms` } diff --git a/packages/app/src/components/workbench/actions.ts b/packages/app/src/components/workbench/actions.ts index 5e630c8e..c00c26d9 100644 --- a/packages/app/src/components/workbench/actions.ts +++ b/packages/app/src/components/workbench/actions.ts @@ -18,6 +18,7 @@ import '../placeholder.js' import './actionItems/command.js' import './actionItems/group.js' import './actionItems/mutation.js' +import { elapsedSince } from '../../utils/elapsed.js' import { entryDuration, stepDurations } from './actionItems/duration.js' import { activeSpanAt } from './active-entry.js' import { @@ -195,17 +196,15 @@ export class DevtoolsActions extends Element { this.expandOverrides.get(group.callId) ?? defaultExpanded(group, activeIndex >= 0 ? activeIndex : undefined) const rows = flattenActionTree(rootChildren, isExpanded) - const baseline = commands[0]?.timestamp ?? 0 const gaps = stepDurations(commands.map((command) => command.timestamp)) return html`<div class="timeline tree" @group-toggle=${this.#onGroupToggle}> - ${rows.map((row) => this.#renderTreeRow(row, commands, baseline, gaps))} + ${rows.map((row) => this.#renderTreeRow(row, commands, gaps))} </div>` } #renderTreeRow( row: ActionTreeRow, commands: CommandLog[], - baseline: number, gaps: Array<number | undefined> ) { const indent = `padding-left: ${row.depth * TREE_INDENT_PX}px` @@ -227,7 +226,7 @@ export class DevtoolsActions extends Element { return html` <wdio-devtools-command-item style=${indent} - elapsedTime=${entry.timestamp - baseline} + elapsedTime=${elapsedSince(commands, entry)} .duration=${duration} .entry=${entry} ?active=${entry === this.activeEntry} @@ -244,11 +243,12 @@ export class DevtoolsActions extends Element { if (!entries.length) { return html`<wdio-devtools-placeholder></wdio-devtools-placeholder>` } - const baselineTimestamp = entries[0]?.timestamp ?? 0 const durations = stepDurations(entries.map((entry) => entry.timestamp)) const rows = entries.map((entry, index) => { - const elapsedTime = entry.timestamp - baselineTimestamp + // Timed against the merged list, so the top row always reads zero โ€” a + // document load can precede the first command. + const elapsedTime = elapsedSince(entries, entry) const duration = entryDuration(entry, durations[index]) const active = entry === this.activeEntry diff --git a/packages/app/src/components/workbench/compare.ts b/packages/app/src/components/workbench/compare.ts index 4f040398..a56164e5 100644 --- a/packages/app/src/components/workbench/compare.ts +++ b/packages/app/src/components/workbench/compare.ts @@ -14,8 +14,6 @@ import { baselineContext, selectedTestUidContext, commandContext, - consoleLogContext, - networkRequestContext, suiteContext } from '../../controller/context.js' import type { SuiteStatsFragment } from '../../controller/types.js' @@ -23,18 +21,10 @@ import { pairSteps, classifyDivergence, cleanErrorMessage, + firstDivergentIndex, 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' @@ -48,6 +38,17 @@ import { renderDetailBlock } from './compare/renderDetailBlock.js' const COMPONENT = 'wdio-devtools-compare' +/** What both cells of one step row share: the pairing, the row's divergence + * kind, and the row-level flags the cell renderers branch on. */ +interface RenderPairCtx { + pair: ComparePairedStep + kind: DivergenceKind + isTruncation: boolean + oneSideEntirelyEmpty: boolean + expanded: boolean + isFirstDivergent: boolean +} + @customElement(COMPONENT) export class DevtoolsCompare extends Element { static styles = [...Element.styles, compareStyles] @@ -64,14 +65,6 @@ export class DevtoolsCompare extends Element { @state() liveCommands: CommandLog[] | undefined = undefined - @consume({ context: consoleLogContext, subscribe: true }) - @state() - liveConsoleLogs: ConsoleLogs[] | undefined = undefined - - @consume({ context: networkRequestContext, subscribe: true }) - @state() - liveNetwork: NetworkRequest[] | undefined = undefined - @consume({ context: suiteContext, subscribe: true }) @state() liveSuites: Record<string, SuiteStatsFragment>[] | undefined = undefined @@ -137,14 +130,18 @@ export class DevtoolsCompare extends Element { * test's step time windows (mirrors the backend's snapshot filter). */ #liveCommandsForSelectedUid(): CommandLog[] { const all = this.liveCommands || [] - const steps = this.#liveStepsForSelectedUid() + // A step the runner reported with neither bound windows nothing, and must + // not widen the window of a step that does carry one. + const steps = this.#liveStepsForSelectedUid().filter( + (s) => typeof s.start === 'number' || typeof s.end === 'number' + ) if (steps.length === 0) { return all } let start = Number.POSITIVE_INFINITY let end = 0 for (const s of steps) { - if (s.start !== null && s.start !== undefined && s.start < start) { + if (typeof s.start === 'number' && s.start < start) { start = s.start } const candidateEnd = s.end ?? Date.now() @@ -152,8 +149,10 @@ export class DevtoolsCompare extends Element { end = candidateEnd } } + // An unreported start is unknown, not unbounded: keep filtering by the end + // rather than re-admitting the commands of whichever test ran next. if (!Number.isFinite(start)) { - return all + start = 0 } return all.filter( (c) => c.timestamp !== null && c.timestamp >= start && c.timestamp <= end @@ -281,7 +280,7 @@ export class DevtoolsCompare extends Element { const visiblePairs = this.differencesOnly ? pairs.filter((p) => p.divergent || !p.baseline || !p.latest) : pairs - const firstDivergent = pairs.findIndex((p) => p.divergent) + const firstDivergent = firstDivergentIndex(pairs) const errorMessage = baseline.test.error?.message ? cleanErrorMessage(baseline.test.error.message) : undefined diff --git a/packages/app/src/components/workbench/compare/compareUtils.ts b/packages/app/src/components/workbench/compare/compareUtils.ts index a1dc34fd..b8197574 100644 --- a/packages/app/src/components/workbench/compare/compareUtils.ts +++ b/packages/app/src/components/workbench/compare/compareUtils.ts @@ -1,5 +1,7 @@ import type { CommandLog } from '@wdio/devtools-shared' +import { stripAnsi } from '../console-filter.js' + export interface ComparePairedStep { index: number baseline?: CommandLog @@ -49,9 +51,18 @@ export function commandsEqual( return false } // Skip `result` comparison: W3C element refs get a fresh id each session. - const aErr = a.error ? a.error.message || String(a.error) : '' - const bErr = b.error ? b.error.message || String(b.error) : '' - return aErr === bErr + return errorText(a.error) === errorText(b.error) +} + +/** Message โ†’ name โ†’ `String()`, mirroring the Errors panel's chain. A + * message-less error must not fall straight through to `[object Object]`: + * that equalises two genuinely different errors, so the divergence they + * represent never surfaces. */ +export function errorText(error: CommandLog['error']): string { + if (!error) { + return '' + } + return error.message?.trim() || error.name?.trim() || String(error) } export function classifyDivergence( @@ -67,9 +78,7 @@ export function classifyDivergence( if (stableStringify(a.args) !== stableStringify(b.args)) { return 'args' } - const aErr = a.error ? a.error.message || String(a.error) : '' - const bErr = b.error ? b.error.message || String(b.error) : '' - if (aErr !== bErr) { + if (errorText(a.error) !== errorText(b.error)) { return 'error' } return 'none' @@ -96,15 +105,15 @@ export function safeJson(value: unknown): string { /** Strip ANSI escapes and collapse blank-line runs so the error banner * doesn't grow tall from formatting whitespace. */ export function cleanErrorMessage(msg: string): string { - return msg - .replace(/\[[0-9;]*m/g, '') - .replace(/\[\d+m/g, '') + return stripAnsi(msg) .replace(/\n{3,}/g, '\n\n') .trim() } +// Single spaces, not `\s+`: the step text is whitespace-normalised before it is +// matched, which keeps every quantifier out of the optional groups. const STEP_VERB_RE = - /^(?:I should see|should see|should have|should be|should contain|should equal|should match|see|have|equals?|matches?|contains?)\s+(?:a\s+)?(?:flash\s+message\s+saying\s+|text\s+|message\s+saying\s+|message\s+|value\s+)?(.+)$/i + /^(?:I should see|should see|should have|should be|should contain|should equal|should match|see|have|equals?|matches?|contains?) (?:a )?(?:flash message saying |text |message saying |message |value )?(.+)$/i /** Best-effort extraction of the expected value from a Cucumber step title * (strip the keyword + common verb phrase, return the parameterized tail). */ @@ -117,6 +126,7 @@ export function extractExpectedFromStepText( const stripped = stepText .replace(/^\d+:\s*/, '') .replace(/^(Given|When|Then|And|But)\s+/i, '') + .replace(/\s+/g, ' ') .trim() const m = stripped.match(STEP_VERB_RE) if (m && m[1]) { diff --git a/packages/app/src/components/workbench/compare/constants.ts b/packages/app/src/components/workbench/compare/constants.ts index b6dbb6b2..598d7671 100644 --- a/packages/app/src/components/workbench/compare/constants.ts +++ b/packages/app/src/components/workbench/compare/constants.ts @@ -4,7 +4,7 @@ export const POPOUT_QUERY = { uidKey: 'uid' } as const -export const POPOUT_WINDOW = { +const POPOUT_WINDOW = { width: 1400, height: 900, features: 'resizable=yes,scrollbars=yes' diff --git a/packages/app/src/components/workbench/compare/renderDetailBlock.ts b/packages/app/src/components/workbench/compare/renderDetailBlock.ts index 81dfcd80..f6bfa121 100644 --- a/packages/app/src/components/workbench/compare/renderDetailBlock.ts +++ b/packages/app/src/components/workbench/compare/renderDetailBlock.ts @@ -10,7 +10,7 @@ import type { PreservedAttempt, PreservedStep } from '@wdio/devtools-shared' -import { cleanErrorMessage, safeJson } from './compareUtils.js' +import { cleanErrorMessage, errorText, safeJson } from './compareUtils.js' import { computeDetailBlockData } from './stepResolution.js' /** Assertion commands carry `[actual, expected]` in args (mirrors the Errors @@ -144,7 +144,7 @@ export function renderDetailBlock( : html` ${cmd.error ? html`<pre style="color:var(--vscode-charts-red,#f48771);"> -error: ${cleanErrorMessage(cmd.error.message || String(cmd.error))}</pre +error: ${cleanErrorMessage(errorText(cmd.error))}</pre >` : html`<pre>result: ${data.resultStr}</pre>`} ${renderExpectedActualAssertion( diff --git a/packages/app/src/components/workbench/compare/stepResolution.ts b/packages/app/src/components/workbench/compare/stepResolution.ts index 54c36469..bb324cf6 100644 --- a/packages/app/src/components/workbench/compare/stepResolution.ts +++ b/packages/app/src/components/workbench/compare/stepResolution.ts @@ -3,7 +3,10 @@ import type { PreservedAttempt, PreservedStep } from '@wdio/devtools-shared' -import type { SuiteStatsFragment } from '../../../controller/types.js' +import type { + SuiteStatsFragment, + TestStatsFragment +} from '../../../controller/types.js' import { cleanErrorMessage, extractExpectedFromStepText, @@ -37,29 +40,57 @@ function findSuiteByUid( return undefined } +/** One live test as the step shape the panel compares against a baseline. */ +function testToStep(t: TestStatsFragment): PreservedStep { + return { + 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, + error: t.error + ? { + message: t.error.message, + name: t.error.name, + stack: t.error.stack + } + : 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 - }) + out.push(testToStep(t)) } for (const child of s.suites ?? []) { flattenSuiteTests(child, out) } } +/** A single live test by uid. Preserving from a test row records that test's + * uid, which names no suite โ€” without this the panel finds no live steps and + * falls back to the whole unwindowed command stream. */ +function findTestByUid( + s: SuiteStatsFragment | undefined, + uid: string +): TestStatsFragment | undefined { + if (!s) { + return undefined + } + const hit = (s.tests ?? []).find((t) => t.uid === uid) + if (hit) { + return hit + } + for (const child of s.suites ?? []) { + const nested = findTestByUid(child, uid) + if (nested) { + return nested + } + } + return undefined +} + export function liveStepsForUid( selectedTestUid: string | undefined, liveSuites: Array<Record<string, SuiteStatsFragment | undefined>> | undefined @@ -79,12 +110,23 @@ export function liveStepsForUid( break } } - if (!foundRoot) { - return [] + if (foundRoot) { + const out: PreservedStep[] = [] + flattenSuiteTests(foundRoot, out) + return out + } + // Preserving from a test row records the TEST's uid, so the suite walk above + // finds nothing. Resolve it as a single step: without this the panel reports no + // live steps and compares the baseline against every command in the run. + for (const chunk of liveSuites) { + for (const root of Object.values(chunk)) { + const test = findTestByUid(root, selectedTestUid) + if (test) { + return [testToStep(test)] + } + } } - const out: PreservedStep[] = [] - flattenSuiteTests(foundRoot, out) - return out + return [] } /** @@ -103,14 +145,17 @@ export function findStepFor( } 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 + return steps.find((s) => containsTimestamp(s, ts)) +} + +/** A step whose window never opened (a test still running, or one the runner + * reported without times) contains nothing. */ +function containsTimestamp(step: PreservedStep, ts: number): boolean { + return ( + typeof step.start === 'number' && + typeof step.end === 'number' && + ts >= step.start && + ts <= step.end ) } @@ -192,21 +237,15 @@ export function isFailureSite( 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 + // Several commands can share the step's last wall-clock ms, so the site is + // the last of them in capture order โ€” `cmd` is an element of the same list. + const site = allCommandsOnSide.reduce<CommandLog | undefined>( + (latest, c) => + containsTimestamp(step, c.timestamp) && + (!latest || c.timestamp >= latest.timestamp) + ? c + : latest, + undefined + ) + return !!site && site === cmd } diff --git a/packages/app/src/components/workbench/compare/styles.ts b/packages/app/src/components/workbench/compare/styles.ts index 9fe9ceb1..6c4085c7 100644 --- a/packages/app/src/components/workbench/compare/styles.ts +++ b/packages/app/src/components/workbench/compare/styles.ts @@ -225,14 +225,6 @@ export const compareStyles = css` color: var(--vscode-charts-red); background: color-mix(in srgb, var(--vscode-charts-red) 16%, transparent); } - .marker.result { - color: var(--vscode-charts-yellow); - background: color-mix( - in srgb, - var(--vscode-charts-yellow) 16%, - transparent - ); - } .marker.info { color: var(--vscode-charts-yellow); background: color-mix( diff --git a/packages/app/src/components/workbench/console-filter.ts b/packages/app/src/components/workbench/console-filter.ts index 2a325ef0..2787ea85 100644 --- a/packages/app/src/components/workbench/console-filter.ts +++ b/packages/app/src/components/workbench/console-filter.ts @@ -3,8 +3,10 @@ * matching logic can be unit-tested without rendering the component. */ +import type { ConsoleLog, LogLevel } from '@wdio/devtools-shared' + /** Level filter options โ€” `all` plus one per captured log type. */ -export type ConsoleLevelFilter = 'all' | 'error' | 'warn' | 'info' | 'log' +export type ConsoleLevelFilter = 'all' | LogLevel /** Ordered level filters for the Console toolbar: filter key + display label. */ export const CONSOLE_LEVEL_FILTERS: ReadonlyArray<{ @@ -18,13 +20,18 @@ export const CONSOLE_LEVEL_FILTERS: ReadonlyArray<{ { key: 'log', label: 'Logs' } ] -// SGR escape sequences (e.g. ``) from the WDIO terminal logger render -// as stray `[31m` once the invisible ESC is dropped โ€” strip them for display. -const ANSI_SGR_RE = /\[[0-9;]*m/g +// Terminal escape sequences from the runner's logger and from framework error +// messages (node's AssertionError diff is colour-coded). Written with `\x1b` +// rather than a raw ESC byte: a literal control character here is invisible in +// an editor and in most diffs. Mirrors core's ANSI_REGEX โ€” any trailing letter +// counts, so cursor sequences (`\x1b[2K`) go too, not just SGR colour (`m`). +// The app depends on shared only and cannot import core, so the pattern is +// duplicated deliberately; keep the two in step. +const ANSI_RE = /\x1b\[[?]?[0-9;]*[A-Za-z]/g -/** Remove terminal ANSI color codes so logger output reads cleanly in the UI. */ +/** Remove terminal ANSI codes so logger output reads cleanly in the UI. */ export function stripAnsi(value: string): string { - return value.replace(ANSI_SGR_RE, '') + return value.replace(ANSI_RE, '') } /** Render a log entry's args into one string for display and search. */ @@ -50,10 +57,10 @@ export function formatConsoleArgs(args: unknown): string { /** Filter logs by level and a case-insensitive substring of the message. */ export function filterConsoleLogs( - logs: ConsoleLogs[], + logs: ConsoleLog[], level: ConsoleLevelFilter, search: string -): ConsoleLogs[] { +): ConsoleLog[] { const needle = search.trim().toLowerCase() return logs.filter((log) => { if (level !== 'all' && (log.type || 'log') !== level) { diff --git a/packages/app/src/components/workbench/console.ts b/packages/app/src/components/workbench/console.ts index ae986562..a9492d69 100644 --- a/packages/app/src/components/workbench/console.ts +++ b/packages/app/src/components/workbench/console.ts @@ -2,6 +2,7 @@ import { Element } from '@core/element' import { html, css } from 'lit' import { customElement, state } from 'lit/decorators.js' import { consume } from '@lit/context' +import type { ConsoleLog } from '@wdio/devtools-shared' import { consoleLogContext } from '../../controller/context.js' import { LOG_ICONS, CONSOLE_SOURCE_BADGE } from '../../controller/constants.js' @@ -199,7 +200,7 @@ export class DevtoolsConsoleLogs extends Element { ] @consume({ context: consoleLogContext, subscribe: true }) - logs: ConsoleLogs[] | undefined = undefined + logs: ConsoleLog[] | undefined = undefined @state() private searchText = '' @@ -256,7 +257,7 @@ export class DevtoolsConsoleLogs extends Element { ` } - #renderLogEntry(log: ConsoleLogs) { + #renderLogEntry(log: ConsoleLog) { const icon = LOG_ICONS[log.type] || LOG_ICONS.log const badge = log.source ? CONSOLE_SOURCE_BADGE[log.source] : undefined return html` diff --git a/packages/app/src/components/workbench/errors.ts b/packages/app/src/components/workbench/errors.ts index ef497850..736c844d 100644 --- a/packages/app/src/components/workbench/errors.ts +++ b/packages/app/src/components/workbench/errors.ts @@ -6,7 +6,11 @@ import { consume } from '@lit/context' import type { CommandLog } from '@wdio/devtools-shared' import { commandContext, suiteContext } from '../../controller/context.js' import type { SuiteStatsFragment } from '../../controller/types.js' -import { collectErrors, type CollectedError } from './errors/collect.js' +import { + collectErrors, + GENERIC_ERROR_TITLE, + type CollectedError +} from './errors/collect.js' const COMPONENT = 'wdio-devtools-errors' @@ -16,11 +20,6 @@ function shortSource(callSource: string): string { return callSource.split(/[\\/]/).slice(-3).join('/') } -/** Show assertion values readably: quote strings, stringify everything else. */ -function fmtValue(value: unknown): string { - return typeof value === 'string' ? `'${value}'` : String(value) -} - @customElement(COMPONENT) export class DevtoolsErrors extends Element { @consume({ context: commandContext, subscribe: true }) @@ -53,6 +52,16 @@ export class DevtoolsErrors extends Element { gap: 8px; } + /* Which action, step or test failed โ€” the row heading. Monospace and + neutral so the red message below it stays the loudest thing in the row. */ + .error-title { + font-family: var(--vscode-editor-font-family); + font-size: 12.5px; + font-weight: 700; + color: var(--vscode-foreground); + word-break: break-word; + } + /* Clickable source anchor (@ path:line) โ€” the affordance that opens the Source tab at the exact line. Styled as a link, not selected text. */ .error-loc { @@ -78,7 +87,7 @@ export class DevtoolsErrors extends Element { border-radius: 2px; } - .error-title { + .error-message { font-size: 12.5px; font-weight: 600; color: var(--vscode-charts-red); @@ -155,6 +164,18 @@ export class DevtoolsErrors extends Element { ) } + /** The headline is the message with the Expected/Received block already + * stripped, so it shows alongside a diff without repeating it. The one case to + * hide is the generic fallback: `Error` above a diff is noise, but with no diff + * it is all the row says about the failure. */ + #showsHeadline(error: CollectedError): boolean { + if (!error.message) { + return false + } + const hasDiff = error.expected !== undefined || error.actual !== undefined + return !(hasDiff && error.message === GENERIC_ERROR_TITLE) + } + #renderDiff(error: CollectedError): TemplateResult | typeof nothing { if (error.expected === undefined && error.actual === undefined) { return nothing @@ -162,11 +183,11 @@ export class DevtoolsErrors extends Element { return html`<div class="error-diff"> ${error.actual !== undefined ? html`<span class="label">Actual</span - ><span class="received">${fmtValue(error.actual)}</span>` + ><span class="received">${error.actual}</span>` : nothing} ${error.expected !== undefined ? html`<span class="label">Expected</span - ><span class="expected">${fmtValue(error.expected)}</span>` + ><span class="expected">${error.expected}</span>` : nothing} </div>` } @@ -174,6 +195,9 @@ export class DevtoolsErrors extends Element { #renderEntry(error: CollectedError): TemplateResult { return html` <div class="error-entry"> + ${error.title + ? html`<div class="error-title">${error.title}</div>` + : nothing} ${error.callSource ? html`<button class="error-loc" @@ -183,8 +207,8 @@ export class DevtoolsErrors extends Element { @${shortSource(error.callSource)} </button>` : nothing} - ${error.expected === undefined && error.actual === undefined - ? html`<div class="error-title">${error.message}</div>` + ${this.#showsHeadline(error) + ? html`<div class="error-message">${error.message}</div>` : nothing} ${this.#renderDiff(error)} ${error.stack diff --git a/packages/app/src/components/workbench/errors/collect.ts b/packages/app/src/components/workbench/errors/collect.ts index 27d7d0ec..db41926e 100644 --- a/packages/app/src/components/workbench/errors/collect.ts +++ b/packages/app/src/components/workbench/errors/collect.ts @@ -16,16 +16,13 @@ import { stripAnsi } from '../console-filter.js' export interface CollectedError { /** Failing action/step or test title โ€” the row heading. */ title: string - /** Error message shown message-first, monospace. */ + /** Error headline, with any Expected/Received block already split off. */ message: string - /** Optional stack, rendered under the message when present. */ + /** Optional stack, rendered in a collapsed section at the end of the row. */ stack?: string /** `file:line:col` source anchor for the "open source" link. */ callSource?: string - /** The failing command, when the error came from one โ€” lets the tab dispatch - * `show-command` to select and scroll to that action. */ - command?: CommandLog - /** Command timestamp; drives ordering and the `show-command` elapsed time. */ + /** Command timestamp; orders the command rows. Test rows carry none. */ timestamp?: number /** Assertion expected value, rendered as a labelled row when present. */ expected?: string @@ -35,13 +32,21 @@ export interface CollectedError { const ASSERTION_COMMAND_RE = /^(expect|assert|verify)\./ -/** Display string for an expected/actual value that may already be serialized. */ +/** Headline used when the whole message body was the Expected/Received block, so + * there is nothing left to head the row with. */ +export const GENERIC_ERROR_TITLE = 'Error' + +/** Display string for a RAW expected/actual value โ€” one this app is printing for + * the first time. Strings are quoted so an empty or space-padded value is + * visible. Values lifted out of a matcher message must NOT come through here: + * the matcher already printed them (`"Secure Area"`), and quoting again renders + * `'"Secure Area"'`. Provenance is resolved at the single call site below. */ function displayValue(value: unknown): string | undefined { if (value === undefined || value === null) { return undefined } if (typeof value === 'string') { - return value + return `'${value}'` } try { return JSON.stringify(value) @@ -157,7 +162,7 @@ function readError(error: unknown): const { body, stack } = splitStack(stripAnsi(raw)) const diff = extractDiff(body) return { - message: diff.headline || 'Error', + message: diff.headline || GENERIC_ERROR_TITLE, stack: e.stack ? stripAnsi(e.stack) : stack, expected: diff.expected ?? displayValue(e.expected), actual: diff.actual ?? displayValue(e.actual) @@ -202,7 +207,6 @@ function commandErrors(commands: CommandLog[] | undefined): CollectedError[] { message: read.message, stack: read.stack, callSource: command.callSource, - command, timestamp: command.timestamp, expected: values.expected ?? read.expected, actual: values.actual ?? read.actual @@ -245,9 +249,9 @@ function isAssertionEcho( /** * Build the Errors-tab list from the live/player contexts. * - * Command failures come first (time-ordered) because they carry the clickable - * action; a failed test that only echoes a command's failure is dropped so the - * same failure isn't listed twice (e.g. a Cucumber `Then` fails as both the + * Command failures come first (time-ordered) because they pinpoint the action + * that failed; a failed test that only echoes a command's failure is dropped so + * the same failure isn't listed twice (e.g. a Cucumber `Then` fails as both the * assertion command and the step). The echo is detected two ways because the * frameworks reword the test-level message: a normalized-message match (robust * to the `Error:` prefix Cucumber adds), and โ€” for assertions โ€” the command's diff --git a/packages/app/src/components/workbench/metadata.ts b/packages/app/src/components/workbench/metadata.ts index 0bae239d..9d5f7bb4 100644 --- a/packages/app/src/components/workbench/metadata.ts +++ b/packages/app/src/components/workbench/metadata.ts @@ -13,8 +13,10 @@ import { PENDING_SESSION_KEY } from '../../controller/contextUpdates.js' import '../placeholder.js' import '~icons/mdi/chevron-right.js' -const SOURCE_COMPONENT = 'wdio-devtools-metadata' -@customElement(SOURCE_COMPONENT) +const COMPONENT = 'wdio-devtools-metadata' +const EMPTY_GLYPH = '๐Ÿงพ' + +@customElement(COMPONENT) export class DevtoolsMetadata extends Element { /** Latest/active session metadata โ€” fallback when no per-session map exists * (e.g. a loaded single-session trace without a sessionId). */ @@ -166,6 +168,11 @@ export class DevtoolsMetadata extends Element { if (m.url) { sessionInfo.URL = m.url } + // A viewport can arrive before its dimensions are serialized, and a + // `0 ร— 0 px` row would read as a captured value rather than a missing one. + if (m.viewport?.width && m.viewport.height) { + sessionInfo.Viewport = `${m.viewport.width} ร— ${m.viewport.height} px` + } return sessionInfo } @@ -205,13 +212,13 @@ export class DevtoolsMetadata extends Element { ` } - #renderSection(label: string, data: unknown) { - // Metadata's capability/option bags are typed `unknown` upstream; narrow to - // a record here so the section can iterate their key/value pairs. - const entries = Object.entries((data ?? {}) as Record<string, unknown>) - if (entries.length === 0) { - return nothing - } + /** Metadata's capability/option bags are typed `unknown` upstream; narrowed to + * a record here so a section can iterate their key/value pairs. */ + #entriesOf(data: unknown): Array<[string, unknown]> { + return Object.entries((data ?? {}) as Record<string, unknown>) + } + + #renderSection(label: string, entries: Array<[string, unknown]>) { const open = !this.#collapsed.has(label) return html` <div class="meta-sec"> @@ -295,29 +302,41 @@ export class DevtoolsMetadata extends Element { ` } + /** Sections in display order, dropping every bag the capture left empty โ€” so + * metadata carrying nothing renderable yields none and the panel shows its + * empty state instead of a blank pane. */ + #renderSections(active: Metadata): TemplateResult[] { + const bags: Array<[string, unknown]> = [ + ['Session', this.#buildSessionInfo(active)], + ['Capabilities', active.capabilities], + ['Desired Capabilities', active.desiredCapabilities], + ['Options', active.options] + ] + return bags + .map(([label, data]) => [label, this.#entriesOf(data)] as const) + .filter(([, entries]) => entries.length > 0) + .map(([label, entries]) => this.#renderSection(label, entries)) + } + render() { const sessions = this.#sessions() const active = this.#activeMetadata(sessions) - if (!active) { - return html`<wdio-devtools-placeholder></wdio-devtools-placeholder>` + const sections = active ? this.#renderSections(active) : [] + if (sections.length === 0) { + return html`<wdio-devtools-placeholder + icon="${EMPTY_GLYPH}" + heading="No session metadata captured" + description="Capabilities, options and session details are recorded when the driver session starts โ€” this run carries none." + ></wdio-devtools-placeholder>` } return html` - <div class="meta"> - ${this.#renderSessionSelect(sessions)} - ${this.#renderSection('Session', this.#buildSessionInfo(active))} - ${this.#renderSection('Capabilities', active.capabilities)} - ${this.#renderSection( - 'Desired Capabilities', - active.desiredCapabilities - )} - ${this.#renderSection('Options', active.options)} - </div> + <div class="meta">${this.#renderSessionSelect(sessions)}${sections}</div> ` } } declare global { interface HTMLElementTagNameMap { - [SOURCE_COMPONENT]: DevtoolsMetadata + [COMPONENT]: DevtoolsMetadata } } diff --git a/packages/app/src/components/workbench/network.ts b/packages/app/src/components/workbench/network.ts index e3ac90c1..312dad2f 100644 --- a/packages/app/src/components/workbench/network.ts +++ b/packages/app/src/components/workbench/network.ts @@ -1,4 +1,5 @@ import { Element } from '@core/element' +import type { NetworkRequest } from '@wdio/devtools-shared' import { html, nothing } from 'lit' import { networkStyles } from './network/styles.js' import { customElement, state } from 'lit/decorators.js' @@ -6,7 +7,8 @@ import { consume } from '@lit/context' import { networkRequestContext } from '../../controller/context.js' import { RESOURCE_TYPES, - TYPE_DOT_CLASS + TYPE_DOT_CLASS, + type ResourceFilter } from '../../utils/network-constants.js' import { formatBytes, @@ -38,7 +40,7 @@ export class DevtoolsNetwork extends Element { selectedRequest?: NetworkRequest @state() - filterType: string = 'All' + filterType: ResourceFilter = 'All' @state() searchQuery: string = '' @@ -186,8 +188,8 @@ export class DevtoolsNetwork extends Element { if (!this.networkRequests || this.networkRequests.length === 0) { return html` <wdio-devtools-placeholder - icon="network" - title="No network requests captured" + icon="๐ŸŒ" + heading="No network requests captured" description="Network requests will appear here as your tests run" ></wdio-devtools-placeholder> ` diff --git a/packages/app/src/components/workbench/network/request-detail.ts b/packages/app/src/components/workbench/network/request-detail.ts index feeb3fdb..99e49a06 100644 --- a/packages/app/src/components/workbench/network/request-detail.ts +++ b/packages/app/src/components/workbench/network/request-detail.ts @@ -3,6 +3,7 @@ // network drawer (TraceTimeline). The returned markup relies on `networkStyles` // being present in the host component's shadow root. +import type { NetworkRequest } from '@wdio/devtools-shared' import { html, nothing, type TemplateResult } from 'lit' import { formatBytes, diff --git a/packages/app/src/components/workbench/source.ts b/packages/app/src/components/workbench/source.ts index 5b336bda..e76295ab 100644 --- a/packages/app/src/components/workbench/source.ts +++ b/packages/app/src/components/workbench/source.ts @@ -34,6 +34,8 @@ const CATEGORY_VAR: Record<ActionCategory, string> = { other: 'var(--vscode-descriptionForeground)' } +const EMPTY_GLYPH = '๐Ÿ“„' + /** Sets/clears the highlighted call-site line (1-based, or null to clear). */ const setCallSite = StateEffect.define<number | null>() @@ -362,7 +364,11 @@ export class DevtoolsSource extends Element { render() { const active = this.#effectiveFile if (!active) { - return html`<wdio-devtools-placeholder></wdio-devtools-placeholder>` + return html`<wdio-devtools-placeholder + icon="${EMPTY_GLYPH}" + heading="No source to show" + description="A file appears here once the run captures a spec's source or a command reports the line it ran from โ€” this run carries neither." + ></wdio-devtools-placeholder>` } const hasContent = this.#contentFor(active) !== undefined return html`<div class="source-root"> diff --git a/packages/app/src/components/workbench/transcript.ts b/packages/app/src/components/workbench/transcript.ts index 4b0805d8..60f3812f 100644 --- a/packages/app/src/components/workbench/transcript.ts +++ b/packages/app/src/components/workbench/transcript.ts @@ -11,6 +11,7 @@ import '~icons/mdi/content-copy.js' import '~icons/mdi/check.js' const COMPONENT = 'wdio-devtools-transcript' +const EMPTY_GLYPH = '๐Ÿ“' /** Player-only panel: renders the run's `transcript.md` and offers a one-click * "Copy prompt" that bundles the transcript with any failing-command errors โ€” @@ -114,7 +115,11 @@ export class DevtoolsTranscript extends Element { render() { if (!this.transcript) { - return html`<wdio-devtools-placeholder></wdio-devtools-placeholder>` + return html`<wdio-devtools-placeholder + icon="${EMPTY_GLYPH}" + heading="No transcript in this trace" + description="A run writes transcript.md from the steps it captured โ€” this trace carries none, so there is no prompt to copy." + ></wdio-devtools-placeholder>` } return html` <button diff --git a/packages/app/src/controller/DataManager.ts b/packages/app/src/controller/DataManager.ts index e5e40972..a199303c 100644 --- a/packages/app/src/controller/DataManager.ts +++ b/packages/app/src/controller/DataManager.ts @@ -1,8 +1,10 @@ import { ContextProvider, type Context, type ContextType } from '@lit/context' import type { ReactiveController, ReactiveControllerHost } from 'lit' import type { + ConsoleLog, Metadata, CommandLog, + NetworkRequest, TraceLog, PreservedAttempt, TracePlayerData @@ -325,7 +327,7 @@ export class DataManagerController implements ReactiveController { } else if (scope === 'metadata') { this.#handleMetadataUpdate(data as Metadata) } else if (scope === 'consoleLogs') { - this.#handleConsoleLogsUpdate(data as string[]) + this.#handleConsoleLogsUpdate(data as ConsoleLog[]) } else if (scope === 'networkRequests') { this.#handleNetworkRequestsUpdate(data as NetworkRequest[]) } else if (scope === 'sources') { @@ -414,7 +416,7 @@ export class DataManagerController implements ReactiveController { ) } - #handleConsoleLogsUpdate(data: string[]) { + #handleConsoleLogsUpdate(data: ConsoleLog[]) { this.consoleLogsContextProvider.setValue([ ...(this.consoleLogsContextProvider.value || []), ...data diff --git a/packages/app/src/controller/constants.ts b/packages/app/src/controller/constants.ts index 6992ebf4..d43e22d0 100644 --- a/packages/app/src/controller/constants.ts +++ b/packages/app/src/controller/constants.ts @@ -1,3 +1,5 @@ +import type { LogSource } from '@wdio/devtools-shared' + export const CACHE_ID = 'wdio-trace-cache' export const SIDEBAR_MIN_WIDTH = 250 export const DARK_MODE_KEY = 'darkMode' @@ -30,7 +32,7 @@ export const LOG_ICONS: Record<string, string> = { /** Console-tab badge per log source: short label + style class. */ export const CONSOLE_SOURCE_BADGE: Record< - NonNullable<ConsoleLogs['source']>, + LogSource, { label: string; class: string } > = { test: { label: 'TEST', class: 'b-test' }, diff --git a/packages/app/src/controller/context.ts b/packages/app/src/controller/context.ts index 91fe35de..332e2621 100644 --- a/packages/app/src/controller/context.ts +++ b/packages/app/src/controller/context.ts @@ -1,8 +1,10 @@ import { createContext } from '@lit/context' import type { + ConsoleLog, Metadata, MetadataBySession, CommandLog, + NetworkRequest, PreservedAttempt, TraceActionChild, TracePlayerFrame @@ -13,7 +15,7 @@ export const mutationContext = createContext<TraceMutation[]>( Symbol('mutationContext') ) export const logContext = createContext<string[]>(Symbol('logContext')) -export const consoleLogContext = createContext<ConsoleLogs[]>( +export const consoleLogContext = createContext<ConsoleLog[]>( Symbol('consoleLogContext') ) export const networkRequestContext = createContext<NetworkRequest[]>( diff --git a/packages/app/src/controller/mark-running.ts b/packages/app/src/controller/mark-running.ts index 346c365e..7e350889 100644 --- a/packages/app/src/controller/mark-running.ts +++ b/packages/app/src/controller/mark-running.ts @@ -1,3 +1,11 @@ +import { TEST_STATE } from '@wdio/devtools-shared' + +import { + hasFailure, + hasInFlight, + isInFlight, + tallyOutcomes +} from '../utils/test-outcome.js' import type { SuiteStatsFragment, TestStatsFragment } from './types.js' /** @@ -142,49 +150,52 @@ export function markSpecificRunning( }) } +/** A test the run never settled is cancelled by the stop; one that already + * reported an outcome keeps it. */ +const stopTest = (test: TestStatsFragment): TestStatsFragment => + test && isInFlight(test) + ? { + ...test, + end: new Date(), + state: TEST_STATE.FAILED, + error: { message: 'Test execution stopped', name: 'TestStoppedError' } + } + : test + /** - * 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. + * Mark every test the run never settled 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. A test that + * already reported an outcome keeps it: a skipped test carries no `end` stamp, + * so keying the flip off `end` used to relabel every skipped test as failed. * - * 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. + * The suite's state is derived from its updated children: if any child failed + * (or the suite itself was 'running' with no live children left), the suite + * ends up failed โ€” a stopped run is not a passing one. 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 updatedTests = s.tests?.map(stopTest) 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 + const childStates = tallyOutcomes([ + ...(updatedTests || []), + ...(updatedNestedSuites || []) + ]) + const stillRunning = hasInFlight(childStates) + const derivedState: SuiteStatsFragment['state'] = stillRunning ? s.state - : hasFailed - ? 'failed' - : s.state === 'running' - ? 'failed' + : hasFailure(childStates) + ? TEST_STATE.FAILED + : s.state === TEST_STATE.RUNNING + ? TEST_STATE.FAILED : s.state return { ...s, state: derivedState, - ...(!hasRunning && !s.end ? { end: new Date() } : {}), + ...(!stillRunning && !s.end ? { end: new Date() } : {}), tests: updatedTests || [], suites: updatedNestedSuites || [] } diff --git a/packages/app/src/controller/suite-merge.ts b/packages/app/src/controller/suite-merge.ts index 3cacaecd..2904d625 100644 --- a/packages/app/src/controller/suite-merge.ts +++ b/packages/app/src/controller/suite-merge.ts @@ -1,4 +1,11 @@ +import { TEST_STATE } from '@wdio/devtools-shared' + import { getTimestamp } from '../utils/helpers.js' +import { + hasInFlight, + settledOutcome, + tallyOutcomes +} from '../utils/test-outcome.js' import type { SuiteStatsFragment, TestStatsFragment } from './types.js' /** @@ -153,40 +160,6 @@ export function mergeChildSuites( 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 โ€” @@ -236,34 +209,28 @@ export function mergeSuite( const incomingStateIsUnset = incoming.state === null || incoming.state === undefined - const { hasInProgressChildren, hasFailedChildren, allChildrenTerminal } = - summarizeChildStates(mergedTests, mergedSuites) + const childStates = tallyOutcomes([...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 + incomingStateIsPendingOrUnset && hasInFlight(childStates) // 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 derivedCompletedState = incomingStateIsUnset + ? settledOutcome(childStates) + : undefined const finalSuites = resetStaleChildrenOnRerun(mergedSuites, incoming, ctx) return { ...existing, ...incomingProps, - ...(keepRunningState && hasInProgressChildren - ? { state: 'running' as const } - : incomingStateIsPendingOrUnset && - !hasInProgressChildren && - derivedCompletedState + ...(keepRunningState + ? { state: TEST_STATE.RUNNING } + : incomingStateIsPendingOrUnset && derivedCompletedState ? { state: derivedCompletedState } : {}), tests: mergedTests, diff --git a/packages/app/src/controller/theme.ts b/packages/app/src/controller/theme.ts new file mode 100644 index 00000000..395c98b2 --- /dev/null +++ b/packages/app/src/controller/theme.ts @@ -0,0 +1,83 @@ +import { DARK_MODE_KEY } from './constants.js' + +/** + * The app's theme: what it resolves to, where it is stored, and every source that + * can change it. The header's icon, the `<body>` class the Tailwind `dark:` + * variants read, and a popout window that renders no header all resolve it here, + * so they cannot disagree about it. + */ + +const OS_DARK_QUERY = '(prefers-color-scheme: dark)' + +export type ThemeListener = (dark: boolean) => void + +const listeners = new Set<ThemeListener>() + +/** Kept rather than re-created per call: every `matchMedia()` returns its OWN + * MediaQueryList, so a second one is a second subscription that nothing else + * can see โ€” including a spec driving the OS setting. */ +let osQuery: MediaQueryList | undefined + +const osDarkQuery = (): MediaQueryList => + (osQuery ??= window.matchMedia(OS_DARK_QUERY)) + +/** The theme to render: the choice the user stored, else the OS setting. */ +export function prefersDarkMode(): boolean { + const stored = localStorage.getItem(DARK_MODE_KEY) + return stored === null ? osDarkQuery().matches : stored === 'true' +} + +/** Remember the user's choice, so the next visit opens in the same theme. */ +export function storeDarkMode(dark: boolean): void { + localStorage.setItem(DARK_MODE_KEY, dark ? 'true' : 'false') +} + +/** Theme the document. The class lands on `<body>` because that is what the + * Tailwind `dark:` variants and the popout windows read. */ +export function applyDarkMode(dark: boolean): void { + document.body.classList.toggle('dark', dark) +} + +/** One resolved answer per change, handed to every subscriber, so no two of them + * re-derive it and land on different values. */ +function announceTheme(): void { + const dark = prefersDarkMode() + for (const listener of listeners) { + listener(dark) + } +} + +// `storage` fires only for writes made in ANOTHER window โ€” a popout, a second +// dashboard tab. +const onStorage = (event: StorageEvent): void => { + if (event.key === DARK_MODE_KEY) { + announceTheme() + } +} + +// Installed once and left in place: the sources belong to the document, not to +// whichever component happened to subscribe first. +let watching = false + +function watchThemeSources(): void { + if (watching) { + return + } + watching = true + window.addEventListener('storage', onStorage) + osDarkQuery().addEventListener('change', announceTheme) +} + +/** + * Follow the theme the app should render. Both sources are here โ€” the user + * storing a choice in another window, and the OS flipping while no choice is + * stored โ€” because a subscriber watching only one of them ends up disagreeing + * with one watching the other. Returns the unsubscribe. + */ +export function onThemeChange(listener: ThemeListener): () => void { + watchThemeSources() + listeners.add(listener) + return () => { + listeners.delete(listener) + } +} diff --git a/packages/app/src/utils/DragController.ts b/packages/app/src/utils/DragController.ts index e74d8d6e..a9f058c4 100644 --- a/packages/app/src/utils/DragController.ts +++ b/packages/app/src/utils/DragController.ts @@ -63,22 +63,14 @@ export class DragController implements ReactiveController { Promise.all([this.#getDraggableEl(), options.getContainerEl()]).then( ([draggableEl, containerEl]) => { if (!draggableEl || !containerEl) { - // Retry after a short delay + // Retry after a short delay. Quietly: a host renders only the sliders + // its current mode needs, so a handle whose pane is absent is expected + // โ€” hostUpdated โ†’ #maybeReinit picks it up if it ever appears. setTimeout(async () => { const [retryDraggableEl, retryContainerEl] = await Promise.all([ this.#getDraggableEl(), options.getContainerEl() ]) - if (!retryDraggableEl) { - console.warn( - 'getDraggableEl() did not return an element HTMLElement' - ) - } - if (!retryContainerEl) { - console.warn( - 'getContainerEl() did not return an element HTMLElement' - ) - } if (retryDraggableEl && retryContainerEl) { this.#draggableEl = retryDraggableEl as HTMLElement this.#containerEl = retryContainerEl as HTMLElement diff --git a/packages/app/src/utils/elapsed.ts b/packages/app/src/utils/elapsed.ts new file mode 100644 index 00000000..41bf2cf3 --- /dev/null +++ b/packages/app/src/utils/elapsed.ts @@ -0,0 +1,60 @@ +/** + * The app's single answer to "how far into the timeline did this happen?". Four + * views badge that offset โ€” the flat actions list, the player's action tree, the + * player's timeline strip and the dashboard's keyboard navigation โ€” and each used + * to derive it inline, so the same action could read differently depending on + * which one announced it. + * + * The series an entry is timed against stays the caller's choice, because the + * views legitimately present different lists. The flat actions list renders + * document loads as rows of its own, so it measures the MERGED list: a + * commands-only baseline would print a negative offset for a document load that + * precedes the first command, which is routine โ€” a command's timestamp is when it + * ENDED, and a navigation's DOM is captured before `url` returns. Tree mode has + * no mutation rows, and the timeline strip and keyboard navigation carry no + * mutation stream at all, so all three measure the commands. What must not differ + * is the arithmetic, and that lives here. + * + * A player's window ORIGIN is a different concept: the timeline strip's window + * also spans captured frames, which start before the first command, so its origin + * sits earlier than any baseline here. Measuring an elapsed badge from that origin + * is what made the same action read 780ms in the strip and 380ms in the actions + * list โ€” the frames are rows in no list, so no pane ever displayed that offset. + */ + +/** Anything on a timeline: a captured command, a DOM mutation, a frame. */ +export interface TimedEntry { + timestamp: number +} + +/** + * Wall clock a series is measured from โ€” its earliest entry, whatever order the + * entries arrived in. Capture order is not timeline order (a replayed slice or a + * reversed reader hands the views either), so taking the first element would + * make the baseline depend on delivery order and badge negative offsets. + * `undefined` for an empty series: there is no clock to measure against, and 0 + * would read as the epoch. + */ +export function timelineStart( + entries: readonly TimedEntry[] +): number | undefined { + let start: number | undefined + for (const entry of entries) { + if (start === undefined || entry.timestamp < start) { + start = entry.timestamp + } + } + return start +} + +/** + * How far into its series an entry sits. An entry timed against nothing โ€” an + * empty series โ€” is its own baseline and reads zero, rather than leaking a + * wall-clock timestamp into a duration badge. + */ +export function elapsedSince( + entries: readonly TimedEntry[], + entry: TimedEntry +): number { + return entry.timestamp - (timelineStart(entries) ?? entry.timestamp) +} diff --git a/packages/app/src/utils/network-constants.ts b/packages/app/src/utils/network-constants.ts index 0c6537d3..d694c96c 100644 --- a/packages/app/src/utils/network-constants.ts +++ b/packages/app/src/utils/network-constants.ts @@ -1,40 +1,10 @@ +import type { RequestType } from '@wdio/devtools-shared' + /** - * Resource type patterns for network request classification + * The Network list's display buckets โ€” one row colour each. `Other` is the + * residual for a request whose captured type the panel doesn't recognise. */ -export const RESOURCE_TYPE_PATTERNS = { - HTML: { - contentTypes: ['text/html'], - extensions: ['.html', '.htm'] - }, - CSS: { - contentTypes: ['text/css'], - extensions: ['.css'] - }, - JS: { - contentTypes: ['javascript', 'ecmascript'], - extensions: ['.js', '.mjs'] - }, - Image: { - contentTypes: ['image/'], - extensions: ['.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.ico'] - }, - Font: { - contentTypes: ['font/', 'woff'], - extensions: ['.woff', '.woff2', '.ttf', '.eot', '.otf'] - }, - Fetch: { - contentTypes: ['application/json'], - extensions: [] - } -} as const - -export const OTHER_RESOURCE_TYPE = 'Other' - -export type ResourceType = - | keyof typeof RESOURCE_TYPE_PATTERNS - | typeof OTHER_RESOURCE_TYPE - -export const TYPE_DOT_CLASS: Record<ResourceType, string> = { +export const TYPE_DOT_CLASS = { HTML: 'type-html', CSS: 'type-css', JS: 'type-js', @@ -42,10 +12,37 @@ export const TYPE_DOT_CLASS: Record<ResourceType, string> = { Font: 'type-font', Fetch: 'type-fetch', Other: 'type-other' +} as const + +export type ResourceType = keyof typeof TYPE_DOT_CLASS + +export const OTHER_RESOURCE_TYPE: ResourceType = 'Other' + +/** + * The captured `NetworkRequest.type` vocabulary mapped onto the display + * buckets. Classification happens once, on the capture side; this table only + * translates its words, so the panel never re-derives a type from headers core + * already read. Keyed by the full `RequestType` union so a new capture-side + * category fails this file at compile time instead of falling silently into + * `Other`. `fetch` and `xhr` share the Fetch bucket: both mean "data request", + * a split the list has no separate colour or tab for. + */ +export const RESOURCE_TYPE_BY_REQUEST_TYPE: Readonly< + Record<RequestType, ResourceType> +> = { + document: 'HTML', + stylesheet: 'CSS', + script: 'JS', + image: 'Image', + font: 'Font', + fetch: 'Fetch', + xhr: 'Fetch', + other: OTHER_RESOURCE_TYPE } /** - * Available resource types for filtering + * Filter tabs, in list order. Every entry but `All` is a bucket the table above + * can produce โ€” a tab naming an unproducible bucket could never match a row. */ export const RESOURCE_TYPES = [ 'All', @@ -55,7 +52,10 @@ export const RESOURCE_TYPES = [ 'CSS', 'Font', 'Image' -] as const +] as const satisfies readonly ('All' | ResourceType)[] + +/** The active filter: a bucket, or `All` for no filtering. */ +export type ResourceFilter = (typeof RESOURCE_TYPES)[number] /** Inclusive lower bounds of the HTTP status-code ranges. */ export const HTTP_STATUS = { diff --git a/packages/app/src/utils/network-helpers.ts b/packages/app/src/utils/network-helpers.ts index 5e22314d..8406b1fd 100644 --- a/packages/app/src/utils/network-helpers.ts +++ b/packages/app/src/utils/network-helpers.ts @@ -1,5 +1,6 @@ +import { isRequestType, type NetworkRequest } from '@wdio/devtools-shared' import { - RESOURCE_TYPE_PATTERNS, + RESOURCE_TYPE_BY_REQUEST_TYPE, OTHER_RESOURCE_TYPE, HTTP_STATUS, STATUS_KIND, @@ -82,36 +83,24 @@ export function getStatusClass(status?: number): string { } /** - * Determine resource type from network request + * Bucket a request into the display type the list colours and filters by, + * translating the type the capture side already classified it as. Header and + * extension sniffing deliberately doesn't happen here โ€” that is core's + * `getRequestType`, and a second copy of it drifted from the first. The guard is + * for wire data only: `RequestType` covers every word a current producer emits, + * but an older trace file can still carry one it doesn't. */ export function getResourceType(request: NetworkRequest): ResourceType { - const url = request.url.toLowerCase() - const contentType = - request.responseHeaders?.['content-type']?.toLowerCase() || '' - const entries = Object.entries(RESOURCE_TYPE_PATTERNS) as [ - keyof typeof RESOURCE_TYPE_PATTERNS, - (typeof RESOURCE_TYPE_PATTERNS)[keyof typeof RESOURCE_TYPE_PATTERNS] - ][] - - // Check by content-type first - for (const [type, patterns] of entries) { - if (patterns.contentTypes.some((ct) => contentType.includes(ct))) { - return type - } + const captured = + typeof request.type === 'string' ? request.type.toLowerCase() : '' + if (isRequestType(captured)) { + return RESOURCE_TYPE_BY_REQUEST_TYPE[captured] } - - // Fallback to URL extension - for (const [type, patterns] of entries) { - if (patterns.extensions.some((ext) => url.endsWith(ext))) { - return type - } - } - - // Check by request type - if (request.type === 'fetch' || request.method !== 'GET') { + // An unrecognised type still tells us this much: a body-carrying method is a + // data request, never a static resource. + if (request.method !== 'GET') { return 'Fetch' } - return OTHER_RESOURCE_TYPE } @@ -129,7 +118,7 @@ export function contentType(request: NetworkRequest): string { * Extract filename from URL */ export function getFileName(url: string): string { - if (!url || url === '' || url === 'event') { + if (!url || url === 'event') { return '-' } @@ -139,12 +128,12 @@ export function getFileName(url: string): string { const parts = pathname.split('/').filter(Boolean) const fileName = parts[parts.length - 1] - // If there's a query string and no filename, show the host + path - if (!fileName || fileName === '' || pathname === '/') { - if (urlObj.search) { - return `${urlObj.hostname}${pathname.length > 1 ? pathname : ''}` - } - return urlObj.hostname + // No named segment: the host identifies the row, plus a query-bearing path + // that is nothing but separators. + if (!fileName) { + return urlObj.search && pathname.length > 1 + ? `${urlObj.hostname}${pathname}` + : urlObj.hostname } return fileName diff --git a/packages/app/src/utils/test-outcome.ts b/packages/app/src/utils/test-outcome.ts new file mode 100644 index 00000000..75902044 --- /dev/null +++ b/packages/app/src/utils/test-outcome.ts @@ -0,0 +1,210 @@ +import { TEST_STATE } from '@wdio/devtools-shared' +import type { TestStatus } from '@wdio/devtools-shared' + +/** + * The app's single answer to "how did this test or suite end up?". The sidebar + * tree, the run summary card, the suite merge and the stop handler all read it + * from here โ€” four private copies used to disagree, so the same stateless suite + * showed a green check in the tree and "Idle" in the summary. + */ + +/** Structural shape of anything carrying an outcome: a leaf test, a suite, or a + * merged fragment. `null` state and `null` end are off-contract, but real + * reporter payloads carry both. */ +export interface OutcomeEntry { + state?: TestStatus | null + end?: Date | number | null + tests?: (OutcomeEntry | undefined)[] + suites?: (OutcomeEntry | undefined)[] +} + +export const OUTCOME = { + PASSED: 'passed', + FAILED: 'failed', + SKIPPED: 'skipped', + RUNNING: 'running', + IDLE: 'idle', + QUEUED: 'queued' +} as const + +export type EntryOutcome = (typeof OUTCOME)[keyof typeof OUTCOME] + +/** What a group of entries can settle on. `queued` is leaf-only: a reporter + * marking a test `pending` means the run reached it, which reads as running + * for anything above it. */ +export type GroupOutcome = Exclude<EntryOutcome, typeof OUTCOME.QUEUED> + +/** A verdict โ€” the group ran and this is how it went. */ +export type SettledOutcome = Exclude< + GroupOutcome, + typeof OUTCOME.RUNNING | typeof OUTCOME.IDLE +> + +/** Reported states that speak for themselves. Anything else โ€” unset, `null`, + * or a state this app has no rendering for โ€” falls back to the `end` stamp. */ +const REPORTED: Partial<Record<TestStatus, EntryOutcome>> = { + [TEST_STATE.PASSED]: OUTCOME.PASSED, + [TEST_STATE.FAILED]: OUTCOME.FAILED, + [TEST_STATE.SKIPPED]: OUTCOME.SKIPPED, + [TEST_STATE.RUNNING]: OUTCOME.RUNNING, + [TEST_STATE.PENDING]: OUTCOME.QUEUED +} + +/** `queued` and `pending` are both unfinished but say opposite things about the + * run: `queued` counts entries a reporter marked pending, so the run reached + * them, while `pending` counts entries nothing has reported on at all. */ +export interface StateTally { + passed: number + failed: number + running: number + skipped: number + queued: number + pending: number + total: number +} + +export const emptyTally = (): StateTally => ({ + passed: 0, + failed: 0, + running: 0, + skipped: 0, + queued: 0, + pending: 0, + total: 0 +}) + +/** Suite-shaped: it carries children arrays, however empty. A fragment missing + * both keys is a leaf. */ +export function isSuiteEntry(entry: OutcomeEntry): boolean { + return 'tests' in entry || 'suites' in entry +} + +const childrenOf = (entry: OutcomeEntry): (OutcomeEntry | undefined)[] => [ + ...(entry.tests ?? []), + ...(entry.suites ?? []) +] + +/** A leaf whose state nobody reported still counts as finished once it carries + * an end stamp. Leaf-only: a suite's end stamp says its hooks finished, not + * that anything inside it was verified. */ +const byEndStamp = (entry: OutcomeEntry): EntryOutcome => + entry.end ? OUTCOME.PASSED : OUTCOME.IDLE + +/** + * One entry's outcome. A suite defers to its children where it has no usable + * state of its own, and children still in flight outrank whatever state it + * carries โ€” a rerun clears end stamps but leaves the previous run's + * `passed`/`failed` behind. A suite with no settled child has no verdict to + * report, however finished it looks: an ended `describe` whose tests were all + * filtered out verified nothing, and calling that green is the false green this + * module exists to prevent. + */ +export function deriveEntryOutcome(entry: OutcomeEntry): EntryOutcome { + const reported = entry.state ? REPORTED[entry.state] : undefined + if (!isSuiteEntry(entry)) { + return reported ?? byEndStamp(entry) + } + const children = deriveGroupOutcome(childrenOf(entry)) + // `pending` on a suite is the backend announcing a new run: stale terminal + // children must not flip it back to passed. + if (children === OUTCOME.RUNNING || reported === OUTCOME.QUEUED) { + return OUTCOME.RUNNING + } + return reported ?? children +} + +/** True until an entry settles: running, queued, or nothing reported yet. */ +export function isInFlight(entry: OutcomeEntry): boolean { + const outcome = deriveEntryOutcome(entry) + return ( + outcome === OUTCOME.RUNNING || + outcome === OUTCOME.QUEUED || + outcome === OUTCOME.IDLE + ) +} + +/** Count entries by outcome. Nullish entries are skipped, not counted โ€” the + * registry can hold holes and they say nothing about the run. */ +export function tallyOutcomes( + entries: readonly (OutcomeEntry | undefined)[] +): StateTally { + const tally = emptyTally() + for (const entry of entries) { + if (!entry) { + continue + } + tally.total += 1 + switch (deriveEntryOutcome(entry)) { + case OUTCOME.PASSED: + tally.passed += 1 + break + case OUTCOME.FAILED: + tally.failed += 1 + break + case OUTCOME.RUNNING: + tally.running += 1 + break + case OUTCOME.SKIPPED: + tally.skipped += 1 + break + case OUTCOME.QUEUED: + tally.queued += 1 + break + default: + // Idle: nothing has reported on this entry at all. + tally.pending += 1 + } + } + return tally +} + +/** + * The outcome of a counted group. Running outranks a stale terminal count (a + * rerun keeps the old numbers until fresh results land); a queued child means + * the run reached the group, so the group runs while its leaves spin; a group + * nothing has reported on is idle rather than green; and a group that only ever + * skipped is `skipped`, because reporting "passed" for a run that verified + * nothing reads as a false green. + */ +export function deriveOutcome(tally: StateTally): GroupOutcome { + if (tally.total === 0) { + return OUTCOME.IDLE + } + const settled = tally.passed + tally.failed + tally.skipped + const reached = tally.running + tally.queued + if (reached > 0 || (tally.pending > 0 && settled > 0)) { + return OUTCOME.RUNNING + } + if (settled === 0) { + return OUTCOME.IDLE + } + if (tally.failed > 0) { + return OUTCOME.FAILED + } + if (tally.passed > 0) { + return OUTCOME.PASSED + } + return OUTCOME.SKIPPED +} + +export function deriveGroupOutcome( + entries: readonly (OutcomeEntry | undefined)[] +): GroupOutcome { + return deriveOutcome(tallyOutcomes(entries)) +} + +/** The verdict of a group that has finished, or undefined while any part of it + * is still to come. */ +export function settledOutcome(tally: StateTally): SettledOutcome | undefined { + const outcome = deriveOutcome(tally) + return outcome === OUTCOME.RUNNING || outcome === OUTCOME.IDLE + ? undefined + : outcome +} + +/** Did anything in this group fail? */ +export const hasFailure = (tally: StateTally): boolean => tally.failed > 0 + +/** Is anything in this group still to come โ€” running, queued, or unreported? */ +export const hasInFlight = (tally: StateTally): boolean => + tally.running + tally.queued + tally.pending > 0 diff --git a/packages/app/src/vite-env.d.ts b/packages/app/src/vite-env.d.ts index 6ddba2c7..cb4f92bc 100644 --- a/packages/app/src/vite-env.d.ts +++ b/packages/app/src/vite-env.d.ts @@ -1,6 +1,14 @@ /* eslint-disable @typescript-eslint/consistent-type-imports */ /// <reference types="vite/client" /> +// unplugin-icons virtual modules. The plugin runs with `compiler: +// 'web-components'` + `autoDefine: true`, so importing one only registers a +// custom element โ€” there is nothing to bind. An empty module body (rather than a +// shorthand `declare module '~icons/*'`, which would type the module `any`) is +// what lets the side-effect imports resolve under noUncheckedSideEffectImports +// while still rejecting `import Icon from '~icons/...'`. +declare module '~icons/*' {} + interface CommandEventProps { command: import('@wdio/devtools-shared').CommandLog elapsedTime: number @@ -21,10 +29,10 @@ interface GlobalEventHandlersEventMap { > 'app-test-select': CustomEvent<string> 'app-test-run': CustomEvent< - import('./components/sidebar/test-suite').TestRunDetail + import('./components/sidebar/types').TestRunDetail > 'app-test-stop': CustomEvent< - import('./components/sidebar/test-suite').TestRunDetail + import('./components/sidebar/types').TestRunDetail > 'app-logs': CustomEvent<string> 'show-command': CustomEvent<CommandEventProps> diff --git a/packages/app/test-ui/COMPONENT-TESTING.md b/packages/app/test-ui/COMPONENT-TESTING.md new file mode 100644 index 00000000..c1a855f0 --- /dev/null +++ b/packages/app/test-ui/COMPONENT-TESTING.md @@ -0,0 +1,447 @@ +# Component testing plan โ€” `packages/app` + +Automated regression cover for the dashboard's rendering, so a UI change is +verified by running one test instead of clicking through the app. This document +is the plan and the component inventory; it is the single source of truth for +what is covered and what is next. + +These are now the **only** automated UI tests in the repo. The previous +fixture-driven harness โ€” Layer A (`pnpm verify`, golden `trace.zip` capture +snapshots) and Layer B (`pnpm verify:ui`, 54 whole-app pixel baselines) โ€” was +removed along with its fixtures, recorders and CI gate. Layer B's baselines moved +with every fixture re-record, so a diff could never distinguish a data change +from a UI regression; Layer A was dropped with it. + +**What no longer has automated cover**, so it is a conscious gap rather than a +forgotten one: + +| Gone with the harness | Now caught by | +|---|---| +| Trace-format / reader contract across the six adapter+runner combinations | nothing automated โ€” a `trace.zip` shape regression surfaces when someone opens a trace | +| CSS, spacing and theme regressions | nothing automated โ€” visual review | +| Retention-policy wiring (granularity ร— policy โ†’ artifact set) | `core`'s unit tests for `trace-retention` / `trace-finalizer`, which cover the policy matrix but not the adapterโ†’finalizerโ†’manifest path | +| Live dashboard wiring (the behavioural specs) | nothing automated | + +Running the real thing โ€” `pnpm demo:wdio:mocha` and the other +`examples/<framework>/` projects โ€” is what covers those now. + +--- + +## Why + +| | Count | +|---|---| +| Custom elements in `packages/app/src` | **31** | +| Non-component modules (helpers + 3 base classes) | 50 | +| Test files in `packages/app/tests` | 22 | +| Elements with a **rendering** test | **13** (was 0 before Phase 0) | + +The helpers extracted *out of* components are well covered โ€” `duration`, +`tree-filter`, `mutation-at-command`, `network-helpers` and 18 more. What they +don't touch is whether a component renders those values correctly, reacts to +input, or emits the right events โ€” which is what the specs below now assert. Until now that gap was filled by 54 whole-app pixel +baselines coupled to 6 trace recordings โ€” the wrong granularity for developing a +single component, since it can't tell you *which* component broke and a +re-recorded fixture invalidated it wholesale. + +Note two files register two elements each, so element count โ‰  file count: +`sidebar/test-suite.ts` defines `wdio-test-suite` + `wdio-test-entry`, and +`tabs.ts` defines `wdio-devtools-tabs` + `wdio-devtools-tab`. + +## Tooling + +**`@wdio/browser-runner`** (WDIO Component Testing) โ€” Vite-backed, runs specs +inside a real browser, first-class Lit support, no fixture or backend involved. +Mount a component with explicit inputs, assert its shadow DOM. + +- Headless by default; `HEADED=1` to watch a component render. +- `--spec` takes a path relative to the REPO ROOT (not this package) and must be + repeated per file โ€” the folder-glob form matches nothing under + `@wdio/config@9.28`. `--mochaOpts.grep` runs one case. +- Deterministic (DOM assertions, not pixels), so it gates CI: the + `component-tests` job in `.github/workflows/ci.yml` runs the suite at + `--maxInstances 2`, the ceiling above which the heaviest specs time out. + +`@vitest/browser` was an unused devDependency defaulting to a Playwright +provider; removed in Phase 0 to keep one browser stack. + +### Three config facts worth knowing + +- `packages/app/wdio.conf.ts` hands the runner the app's **own** `vite.config.ts` + (the `viteConfig` option takes an inline config). The components import + `~icons/*` via unplugin-icons, Tailwind through postcss, and the + `@` / `@core` / `@components` aliases โ€” without that config every spec fails at + import. Relatedly, every path in that Vite config is absolute + (`collectionsNodeResolvePath`, the postcss config, the icon loader): the runner + starts a Vite server per worker with the repo root as cwd, and cwd-relative + defaults make every icon fail to resolve. +- Specs carry their own `test-ui/tsconfig.json` (mocha + `expect` globals via + `@wdio/globals/types`), and `packages/app/tsconfig.json` **excludes** + `test-ui/` so the app build never depends on test-runner types. +- `packages/app` devDepends on **`expect` pinned to an exact version**. The + runner lists `expect` in `optimizeDeps.include` because it is CJS and must be + pre-bundled to ESM; under pnpm it isn't resolvable from the Vite root, so Vite + skips it and the browser's `import expect from 'expect'` dies with *"does not + provide an export named 'default'"* โ€” every spec fails at load. The pin must + match the version `expect-webdriverio` resolves, or Vite pre-bundles a second + copy and the error returns. Check with + `node -e "console.log(require('expect/package.json').version)"` from + `packages/app` against the `expect@x.y.z` path in the error. + +--- + +## Composition tree + +Derived from the render templates, not assumed. The test folders mirror this. + +``` +wdio-devtools app.ts +โ”œโ”€ wdio-devtools-header +โ”œโ”€ wdio-devtools-sidebar sidebar.ts +โ”‚ โ”œโ”€ wdio-devtools-sidebar-filter +โ”‚ โ”œโ”€ wdio-devtools-sidebar-summary +โ”‚ โ””โ”€ wdio-devtools-sidebar-explorer +โ”‚ โ””โ”€ wdio-test-suite +โ”‚ โ””โ”€ wdio-test-entry +โ”œโ”€ wdio-devtools-workbench workbench.ts +โ”‚ โ”œโ”€ wdio-devtools-tabs +โ”‚ โ”‚ โ””โ”€ wdio-devtools-tab +โ”‚ โ”œโ”€ wdio-devtools-browser snapshot.ts โ€” the viewer +โ”‚ โ”‚ โ””โ”€ wdio-devtools-screencast-player +โ”‚ โ”œโ”€ wdio-devtools-trace-timeline +โ”‚ โ”œโ”€ wdio-devtools-trace-player-controls +โ”‚ โ”œโ”€ wdio-devtools-actions +โ”‚ โ”‚ โ”œโ”€ wdio-devtools-command-item +โ”‚ โ”‚ โ”œโ”€ wdio-devtools-mutation-item +โ”‚ โ”‚ โ””โ”€ wdio-devtools-group-item +โ”‚ โ”œโ”€ wdio-devtools-console-logs +โ”‚ โ”œโ”€ wdio-devtools-network +โ”‚ โ”œโ”€ wdio-devtools-errors +โ”‚ โ”œโ”€ wdio-devtools-a11y +โ”‚ โ”œโ”€ wdio-devtools-metadata +โ”‚ โ”œโ”€ wdio-devtools-source +โ”‚ โ”œโ”€ wdio-devtools-logs +โ”‚ โ”œโ”€ wdio-devtools-transcript +โ”‚ โ””โ”€ wdio-devtools-compare +โ”œโ”€ wdio-devtools-compare also mounted at app level +โ”œโ”€ wdio-devtools-shortcuts +โ””โ”€ wdio-devtools-start + +wdio-devtools-placeholder shared leaf: browser, and the a11y-tree, + actions, metadata, network, source and + transcript panels +``` + +## Layout + +One folder per parent component, its children inside. Nesting stops at three +levels so the tree stays navigable. + +``` +packages/app/ +โ”œโ”€ wdio.conf.ts component-test runner config +โ””โ”€ test-ui/ + โ”œโ”€ tsconfig.json mocha + expect globals for specs only + โ”œโ”€ support/ + โ”‚ โ”œโ”€ mount.ts mount(tag, props) / mountWithContext(); auto-teardown + โ”‚ โ”œโ”€ builders.ts commandLog(), mutation(), documentLoaded() + โ”‚ โ””โ”€ queries.ts deep shadow-DOM query, rowTexts(), โ€ฆ + โ”œโ”€ shell/ + โ”‚ โ”œโ”€ app.test.ts wdio-devtools + โ”‚ โ”œโ”€ header.test.ts + โ”‚ โ”œโ”€ shortcuts-overlay.test.ts + โ”‚ โ””โ”€ start.test.ts + โ”œโ”€ sidebar/ + โ”‚ โ”œโ”€ sidebar.test.ts parent: composition + collapse + โ”‚ โ”œโ”€ filter.test.ts + โ”‚ โ”œโ”€ summary.test.ts + โ”‚ โ”œโ”€ fixtures.ts suite trees, per-test states + โ”‚ โ””โ”€ explorer/ + โ”‚ โ”œโ”€ explorer.test.ts + โ”‚ โ”œโ”€ test-suite.test.ts + โ”‚ โ””โ”€ test-entry.test.ts + โ”œโ”€ workbench/ + โ”‚ โ”œโ”€ workbench.test.ts parent: panel composition, dock, layout modes + โ”‚ โ”œโ”€ fixtures.ts command/mutation/network arrays + โ”‚ โ”œโ”€ tabs/ + โ”‚ โ”‚ โ”œโ”€ tabs.test.ts + โ”‚ โ”‚ โ””โ”€ tab.test.ts + โ”‚ โ”œโ”€ actions/ + โ”‚ โ”‚ โ”œโ”€ actions.test.ts parent of the row components + โ”‚ โ”‚ โ”œโ”€ command-item.test.ts + โ”‚ โ”‚ โ”œโ”€ mutation-item.test.ts + โ”‚ โ”‚ โ””โ”€ group-item.test.ts + โ”‚ โ”œโ”€ player/ + โ”‚ โ”‚ โ”œโ”€ snapshot.test.ts wdio-devtools-browser + โ”‚ โ”‚ โ”œโ”€ screencast-player.test.ts + โ”‚ โ”‚ โ”œโ”€ trace-timeline.test.ts + โ”‚ โ”‚ โ””โ”€ trace-player-controls.test.ts + โ”‚ โ””โ”€ panels/ + โ”‚ โ”œโ”€ console.test.ts + โ”‚ โ”œโ”€ network.test.ts + โ”‚ โ”œโ”€ errors.test.ts + โ”‚ โ”œโ”€ a11y-tree.test.ts + โ”‚ โ”œโ”€ metadata.test.ts + โ”‚ โ”œโ”€ source.test.ts + โ”‚ โ”œโ”€ logs.test.ts + โ”‚ โ”œโ”€ transcript.test.ts + โ”‚ โ””โ”€ compare.test.ts + โ””โ”€ shared/ + โ””โ”€ placeholder.test.ts used by seven parents, owned by none +``` + +Why grouped rather than colocated in `src/`: + +- A parent and its children are run and reviewed as one unit โ€” one `--spec` glob + covers the whole subtree. +- **Per-parent fixtures.** Sidebar specs need suite trees; workbench specs need + command and mutation arrays. Those builders live in the folder that uses them + (`sidebar/fixtures.ts`) rather than swelling one shared file. +- Parent specs can mount the real children and assert composition, while the + child specs test the same components in isolation โ€” the folder makes that pair + obvious. + +Two consequences to accept: a rename in `src/` needs a matching move here, and +the directory is deliberately **not** under `tests/` because the node runner's +glob is `packages/**/tests/**/*.test.ts` and would otherwise try to run browser +specs in node. + +## Conventions + +1. One spec per element, named after it โ€” including the second element in a + two-element file (`test-entry.test.ts`, `tab.test.ts`). +2. Mount with explicit inputs โ†’ assert output. No trace fixtures, no WebSocket, + no backend. +3. Assert **semantics** โ€” text, counts, attributes, emitted events, ARIA โ€” never + pixels. Nothing here pixel-diffs. +4. Cover the contract: inputs (properties + context), outputs (dispatched + events), and the states that get forgotten โ€” empty, single item, overflow, + error, active/selected. +5. Pure logic stays in the node suite. If a spec starts asserting a computation, + extract the helper instead โ€” that is where the existing 22 tests came from. +6. Shared data builders in `support/builders.ts`; anything used by one folder + only lives in that folder's `fixtures.ts`. +7. No DOM-string snapshots. They churn like pixel baselines and hide the intent. + +## Running + +```sh +pnpm test:ui # everything +pnpm test:ui --spec 'packages/app/test-ui/sidebar/**/*.test.ts' # one subtree +pnpm test:ui --spec packages/app/test-ui/workbench/actions/actions.test.ts +pnpm test:ui --mochaOpts.grep "renders one row per command" # one case +HEADED=1 pnpm test:ui --spec '**/player/snapshot.test.ts' # watch it render +``` + +--- + +## Inventory + +All 31 elements, by folder. **Tier** is regression risk ร— time on screen, not +file size. **Inherits** lists node-level helper tests that already cover that +component's extracted logic โ€” a spec need not re-assert those. + +### `test-ui/workbench/player/` (4) + +| Tier | Element | Source | LOC | The spec covers | Inherits | +|---|---|---|---|---|---| +| 1 | `wdio-devtools-browser` | `browser/snapshot.ts` | 821 | Document-anchor rebuild, incremental mutation replay (attribute/childList/characterData), field-state (`value`/`checked`) application, screenshot fallback when no mutation is in range, URL bar resolution, view-mode switching | `mutation-at-command`, `url-at-timestamp`, `element-overlay` | +| 1 | `wdio-devtools-trace-timeline` | `browser/trace-timeline.ts` | 421 | Row ordering, duration bars, active-entry highlight, click โ†’ selection event | `trace-timeline-utils` | +| 2 | `wdio-devtools-screencast-player` | `browser/screencast-player.ts` | 343 | Frame selection by time, scrub โ†’ progress event, no-frames state | `scrubber` | +| 2 | `wdio-devtools-trace-player-controls` | `browser/trace-player-controls.ts` | 137 | Play/pause/step state, disabled at bounds, emitted control events | โ€” | + +### `test-ui/workbench/actions/` (4) + +| Tier | Element | Source | LOC | The spec covers | Inherits | +|---|---|---|---|---|---| +| 1 | `wdio-devtools-actions` | `workbench/actions.ts` | 284 | Commands + document-load mutations merged in timestamp order, flat vs tree mode, per-row duration, active row scroll-into-view, empty state | `action-tree`, `active-entry`, `duration`, `category`, `call-source` | +| 1 | `wdio-devtools-command-item` | `workbench/actionItems/command.ts` | 127 | Command label + args, elapsed vs own-duration, duration heat bucket, error/active styling, selection event | `duration`, `category`, `call-source` | +| 1 | `wdio-devtools-mutation-item` | `workbench/actionItems/mutation.ts` | 114 | "Document loaded" for a URL-bearing childList, attribute-change label, added/removed node counts, hover โ†’ highlight event | `duration` | +| 2 | `wdio-devtools-group-item` | `workbench/actionItems/group.ts` | 69 | Group label, expanded/collapsed chevron, toggle event | โ€” | + +### `test-ui/workbench/panels/` (9) + +| Tier | Element | Source | LOC | The spec covers | Inherits | +|---|---|---|---|---|---| +| 1 | `wdio-devtools-console-logs` | `workbench/console.ts` | 302 | One row per entry, level styling, browser/terminal source split, filter narrowing, empty state | `console-filter` | +| 1 | `wdio-devtools-network` | `workbench/network.ts` | 228 | One row per request, status/method/type columns, waterfall geometry, row โ†’ detail expansion | `network-helpers`, `waterfall` | +| 2 | `wdio-devtools-errors` | `workbench/errors.ts` | 218 | Grouping and de-duplication, stack rendering, assertion `Expected/Received` block, empty state | `errors-collect` | +| 2 | `wdio-devtools-a11y` | `workbench/a11y-tree.ts` | 373 | Tree nesting, role/name rendering, node selection, unavailable state (Selenium/Nightwatch traces carry no snapshot) | โ€” | +| 2 | `wdio-devtools-metadata` | `workbench/metadata.ts` | 323 | Capability/option rows, per-session grouping, missing-field tolerance | โ€” | +| 2 | `wdio-devtools-source` | `workbench/source.ts` | 383 | Highlighted call-site line, file switching, source-unavailable state | โ€” | +| 2 | `wdio-devtools-logs` | `workbench/logs.ts` | 288 | Parameter/result rendering per command, long-value truncation, empty state | โ€” | +| 2 | `wdio-devtools-transcript` | `workbench/transcript.ts` | 139 | Step ordering and formatting, empty state | โ€” | +| 2 | `wdio-devtools-compare` | `workbench/compare.ts` | 459 | Baseline-vs-current pairing, marker placement, detail blocks, no-baseline state | `compareUtils` | + +### `test-ui/workbench/` + `tabs/` (3) + +| Tier | Element | Source | LOC | The spec covers | Inherits | +|---|---|---|---|---|---| +| 2 | `wdio-devtools-workbench` | `workbench.ts` | 498 | Panel composition, dock tab switching, player vs live layout, split/resize persistence | โ€” | +| 2 | `wdio-devtools-tabs` | `tabs.ts` | 261 | Active tab, per-tab count badges, `open-dock-tab` event handling (the programmatic tab switch) | โ€” | +| 2 | `wdio-devtools-tab` | `tabs.ts` | โ†‘ | Single tab: label, count badge, active/disabled state, click event | โ€” | + +### `test-ui/sidebar/` (6) + +| Tier | Element | Source | LOC | The spec covers | Inherits | +|---|---|---|---|---|---| +| 1 | `wdio-devtools-sidebar-explorer` | `sidebar/explorer.ts` | 481 | Suite tree construction, filter narrowing, run/stop/rerun controls, running-state transitions | `tree-filter`, `contextUpdates`, `mark-running`, `run-detection`, `suite-merge` | +| 1 | `wdio-test-suite` | `sidebar/test-suite.ts` | 449 | Suite grouping, expand/collapse, child entry ordering, selection event | โ€” | +| 1 | `wdio-test-entry` | `sidebar/test-suite.ts` | โ†‘ | Per-test row: state icon (passed/failed/running/skipped/pending), title, run/stop/rerun controls, collapse, click event. **No retry marker exists** โ€” an earlier draft of this table claimed one | โ€” (`test-entry-state` has no unit tests; this spec is its only cover) | +| 2 | `wdio-devtools-sidebar-summary` | `sidebar/summary.ts` | 252 | Pass/fail/running/skipped counts, progress bar proportions, runner capability chips | `suite-summary`, `runnerCapabilities` | +| 2 | `wdio-devtools-sidebar-filter` | `sidebar/filter.ts` | 111 | Query input โ†’ filter event, tag syntax, clear control | โ€” | +| 3 | `wdio-devtools-sidebar` | `sidebar.ts` | 56 | Composition, and the cross-child wiring it owns: the filter narrows the explorer, a summary status chip narrows it too. No collapse state โ€” the explorer rows collapse, not this element. Spec lives at `test-ui/shell/sidebar.test.ts` | `summary`, `filter`, `explorer` | + +### `test-ui/shell/` (5) + +| Tier | Element | Source | LOC | The spec covers | Inherits | +|---|---|---|---|---|---| +| 3 | `wdio-devtools` | `app.ts` | 259 | Player vs live routing, connected/disconnected state, overlay mounting | โ€” | +| 3 | `wdio-devtools-header` | `header.ts` | 98 | Title, theme toggle, connection indicator | โ€” | +| 3 | `wdio-devtools-shortcuts` | `shortcuts-overlay.ts` | 141 | Open/close, keybinding list rendering | โ€” | +| 3 | `wdio-devtools-start` | `onboarding/start.ts` | 45 | Onboarding copy: the install command and service snippet, in document order. The element is inert โ€” no CTA, no button, no dispatched event | โ€” | + +### `test-ui/shared/` (1) + +| Tier | Element | Source | LOC | The spec covers | Inherits | +|---|---|---|---|---|---| +| 2 | `wdio-devtools-placeholder` | `placeholder.ts` | 118 | Both modes: the loading skeleton it draws with no copy, and the empty state it draws with a heading/description โ€” including the boundary where only one is set. Asserted once here rather than in every parent | โ€” | + +**Tier totals:** 10 ยท 15 ยท 6 = 31. **Covered: 13** (all of Tier 1, plus +`group-item`, `placeholder`, `screencast-player` from Tier 2). + +### Base classes (3) + +Not registered elements; covered through their subclasses, no specs of their own. + +| File | LOC | Subclassed by | +|---|---|---| +| `workbench/actionItems/item.ts` | 110 | `command-item`, `mutation-item`, `group-item` | +| `sidebar/collapseableEntry.ts` | 51 | sidebar tree entries | +| `core/element.ts` | 17 | most components (shared styles) | + +--- + +## Helper gaps (node tests, no browser) + +Meaningful logic in `packages/app` with no test at all. Cheaper than component +specs, and they belong in the existing node suite, not here. `test-entry-state` +is the one to do first โ€” it drives the state icons in `wdio-test-entry`, a Tier 1 +element that inherits no helper coverage today. + +| Module | LOC | +|---|---| +| `controller/DataManager.ts` | 559 | +| `utils/DragController.ts` | 320 | +| `workbench/compare/stepResolution.ts` | 212 | +| `sidebar/test-entry-state.ts` | 174 | +| `workbench/compare/renderDetailBlock.ts` | 167 | +| `workbench/compare/markers.ts` | 118 | +| `controller/keyboard.ts` | 105 | +| `workbench/network/request-detail.ts` | 98 | +| `browser/vnode-transform.ts` | 40 | + +## Phases + +**Phase 0 โ€” foundation. Landed and green:** 4/4 specs, **77 tests in 19s** +(command-item 32, mutation-item 16, actions 15, group-item 14). Runner config, +`support/` harness, the `test:ui` script, and `workbench/actions/` built end to +end plus `workbench/fixtures.ts`. All three shapes are proven โ€” presentational +row, data-driven list, and a parent mounting its real children. + +Harness API as built: + +```ts +mount<T>(tag, props?) // props assigned as properties, awaits first render +mountWithContext<T>(tag, [{context, value}], props?) +settle(el) // await a re-render after mutating props +shadow(host, sel) / shadowAll(host, sel) // shadow-root first, light-DOM fallback +text(el) / texts(host, sel) // whitespace-collapsed +commandLog(o?) / mutation(o?) / documentLoaded(url, o?) +``` + +Mounted elements are torn down by a module-scope `afterEach` in `mount.ts`, so +specs never write cleanup. `mountWithContext` provides values through a real +`ContextProvider` on a wrapper host, which is what makes `subscribe: true` +consumers update โ€” the actions panel takes **all** its data by context +(`mutationContext`, `commandContext`, `actionGroupsContext`), not properties. + +Two limits to know: context values can't be updated after mount (re-mount +instead), and `shadow()` doesn't pierce a nested component's shadow root โ€” call +it again on the child. + +### What `HEADED=1` shows + +The runner serves its own page โ€” `<mocha-framework>` in an otherwise empty body, +with `body { width: calc(100% - 500px) }` so mounted elements clear the fixed +reporter panel. The panel on the right (logo, spinner, step control) is that +reporter, visible only when headed. The empty region on the left is where +`mount()` puts each element, and `mount.ts`'s `afterEach` removes it again โ€” so +between tests the body really is empty. + +Nothing here composes the dashboard; component tests never boot the app. Plain +`HEADED=1 pnpm test:ui` opens one window per spec file, so narrow with `--spec`. + +**The left region looks empty because it is.** The teardown removes each element +after its test, and a test lasts tens of milliseconds โ€” by the time the run ends +there is nothing left to see. To freeze one component on screen, pause inside the +test with the element still mounted: + +```ts +const el = await mount('wdio-devtools-group-item', { group: โ€ฆ }) +await browser.debug() // holds the browser open, REPL in the terminal +``` + +```sh +HEADED=1 pnpm test:ui --spec '**/group-item.test.ts' \ + --mochaOpts.grep "renders the group title" --mochaOpts.timeout 600000 +``` + +The timeout override is required: mocha's timeout is **not** suspended during +`debug()`, so the default 30s kills the pause. The reporter panel stays visible +while paused (`HIDE_REPORTER_FOR_COMMANDS` covers only `saveScreenshot`/ +`savePDF`), and the component renders in the left region beside it. + +`--watch` keeps the session alive and re-runs on file changes, which is good for +iterating โ€” but it does not show you a component, because teardown still empties +the body between runs. The pause is what freezes it. + +### Expected noise on a green run + +All five of these appear on a fully passing run. None indicate a problem; they +cost four debugging rounds to establish, so don't re-investigate them. Set +`logLevel: 'error'` in `wdio.conf.ts` to drop the first four if the volume +bothers you. + +| Line | Why | +|---|---| +| `ShadowRootManager: Expected element with shadow root but found <icon-mdi-โ€ฆ>` | Icons are configured with `shadow: false`, so those elements genuinely have no shadow root. One per icon rendered. | +| `Lit is in dev mode` | The dev server serves Lit's development build. | +| `BiDi setCookies failed, falling back to classic` | WebdriverIO falls back and proceeds. | +| `optimizeDeps.esbuildOptions โ€ฆ deprecated, use rolldownOptions` ร—4 | The runner targets Vite 5; the repo pins Vite 8, where the dep optimizer is Rolldown. The option is ignored โ€” its plugins target Stencil/Vue, not us. | +| `ERROR โ€ฆ No environment found for non determined environment` ร—4 | Nothing to do with Vite. The runner's middleware looks up a test session by `cid` (query param or `WDIO_CID` cookie) on *every* request; one without either logs this and calls `next()`, handing the request to normal Vite handling. A mislabelled debug line on non-spec requests. | +| `Failed to resolve dependency: p-iteration` | The one entry in the runner's CJS list that resolves nowhere in this tree. Unreachable, therefore never imported. | + +**Phase 1 โ€” Tier 1. Landed and green:** 11 spec files, **292 tests in ~45s** +(explorer 48, snapshot 37, test-entry 36, network 33, command-item 32, +trace-timeline 25, console 25, mutation-item 16, actions 15, group-item 14, +test-suite 11). + +Coverage is **13 of 31 elements** โ€” the 10 Tier 1 elements plus three that render +inside their parents' specs: `group-item` (own spec), `placeholder` (asserted as +the empty state in actions/network/snapshot) and `screencast-player` (mounted by +the snapshot view-mode tests). That incidental coverage is the +parent-mounts-children pattern paying off. + +**Phase 2 โ€” Tier 2.** 15 elements across the remaining folders. + +## Expansion rule + +This is what actually retires manual regression: + +- A component you touch gets a spec, or a new case in its existing spec, in the + same change. +- A UI bug gets a failing spec before the fix. +- A new component ships with a spec, in its parent's folder. + +Tier 3 is added on contact. There is no target of 31 specs. diff --git a/packages/app/test-ui/shared/placeholder.test.ts b/packages/app/test-ui/shared/placeholder.test.ts new file mode 100644 index 00000000..41ee5566 --- /dev/null +++ b/packages/app/test-ui/shared/placeholder.test.ts @@ -0,0 +1,182 @@ +import '@components/placeholder.js' +import type { DevtoolsPlaceholder } from '@components/placeholder.js' + +import { mount, settle } from '../support/mount.js' +import { shadow, shadowAll, text } from '../support/queries.js' + +const COMPONENT = 'wdio-devtools-placeholder' +const SKELETON = '.ph-item' +const SKELETON_ROW = '.ph-item .ph-row' +const EMPTY_STATE = '.empty-state' +const EMPTY_STATE_BLOCK = '.empty-state > div' +const EMPTY_ICON = '.empty-state-icon' +const EMPTY_HEADING = '.empty-state-text' +const EMPTY_DETAIL = '.empty-state-detail' + +/** A real caller's copy โ€” the a11y panel's unavailable state. */ +const GLYPH = '๐ŸŒณ' +const HEADING = 'No accessibility snapshot for this command' +const DETAIL = + 'Per-command accessibility capture is WebdriverIO-only โ€” Selenium and Nightwatch traces do not include it.' + +const mountPlaceholder = (props?: Record<string, unknown>) => + mount<DevtoolsPlaceholder>(COMPONENT, props) + +/** Copy set the way a panel sets it: static attributes in a lit template, which + * only reach the render when the component declares the properties behind + * them. Mounting through properties alone would pass either way. */ +async function mountWithAttributes( + attributes: Record<string, string> +): Promise<DevtoolsPlaceholder> { + const el = await mountPlaceholder() + for (const [name, value] of Object.entries(attributes)) { + el.setAttribute(name, value) + } + await settle(el) + return el +} + +const blockClasses = (el: DevtoolsPlaceholder) => + shadowAll(el, EMPTY_STATE_BLOCK).map((block) => block.className) + +describe('wdio-devtools-placeholder', () => { + describe('loading skeleton', () => { + it('draws the skeleton when it is given no copy', async () => { + const el = await mountPlaceholder() + + expect(shadowAll(el, SKELETON)).toHaveLength(1) + expect(shadowAll(el, EMPTY_STATE)).toHaveLength(0) + }) + + it('draws skeleton rows rather than any text', async () => { + const el = await mountPlaceholder() + + expect(shadowAll(el, SKELETON_ROW)).toHaveLength(1) + expect(text(shadow(el, SKELETON))).toBe('') + }) + + // A glyph says nothing about why the panel is empty, so it is not on its own + // a reason to stop waiting. + it('keeps the skeleton when only a glyph is given', async () => { + const el = await mountPlaceholder({ icon: GLYPH }) + + expect(shadowAll(el, SKELETON)).toHaveLength(1) + expect(shadowAll(el, EMPTY_STATE)).toHaveLength(0) + expect(shadowAll(el, EMPTY_ICON)).toHaveLength(0) + }) + }) + + describe('empty state', () => { + it('renders the copy it is given', async () => { + const el = await mountPlaceholder({ + icon: GLYPH, + heading: HEADING, + description: DETAIL + }) + + expect(text(shadow(el, EMPTY_ICON))).toBe(GLYPH) + expect(text(shadow(el, EMPTY_HEADING))).toBe(HEADING) + expect(text(shadow(el, EMPTY_DETAIL))).toBe(DETAIL) + }) + + // The regression test for inert copy: the component declared no properties, + // so every panel's attributes were dropped and all of them drew skeletons. + it('renders the copy a panel sets as attributes', async () => { + const el = await mountWithAttributes({ + icon: GLYPH, + heading: HEADING, + description: DETAIL + }) + + expect(text(shadow(el, EMPTY_HEADING))).toBe(HEADING) + expect(text(shadow(el, EMPTY_DETAIL))).toBe(DETAIL) + expect(text(shadow(el, EMPTY_ICON))).toBe(GLYPH) + }) + + it('draws no skeleton once it has copy', async () => { + const el = await mountPlaceholder({ heading: HEADING }) + + expect(shadowAll(el, EMPTY_STATE)).toHaveLength(1) + expect(shadowAll(el, SKELETON)).toHaveLength(0) + expect(shadowAll(el, SKELETON_ROW)).toHaveLength(0) + }) + + it('renders the glyph, heading and description in that order', async () => { + const el = await mountPlaceholder({ + icon: GLYPH, + heading: HEADING, + description: DETAIL + }) + + expect(blockClasses(el)).toEqual([ + 'empty-state-icon', + 'empty-state-text', + 'empty-state-detail' + ]) + }) + + it('keeps the copy out of the host tooltip', async () => { + const el = await mountPlaceholder({ + heading: HEADING, + description: DETAIL + }) + + expect(el.getAttribute('title')).toBe(null) + }) + }) + + describe('partial copy', () => { + it('renders an empty state from a heading alone', async () => { + const el = await mountPlaceholder({ heading: HEADING }) + + expect(text(shadow(el, EMPTY_HEADING))).toBe(HEADING) + expect(shadowAll(el, EMPTY_DETAIL)).toHaveLength(0) + expect(blockClasses(el)).toEqual(['empty-state-text']) + }) + + it('renders an empty state from a description alone', async () => { + const el = await mountPlaceholder({ description: DETAIL }) + + expect(text(shadow(el, EMPTY_DETAIL))).toBe(DETAIL) + expect(shadowAll(el, EMPTY_HEADING)).toHaveLength(0) + expect(blockClasses(el)).toEqual(['empty-state-detail']) + }) + + it('leaves out the glyph when none was given', async () => { + const el = await mountPlaceholder({ + heading: HEADING, + description: DETAIL + }) + + expect(shadowAll(el, EMPTY_ICON)).toHaveLength(0) + expect(blockClasses(el)).toEqual([ + 'empty-state-text', + 'empty-state-detail' + ]) + }) + }) + + describe('switching modes', () => { + it('swaps the skeleton for the empty state when copy arrives', async () => { + const el = await mountPlaceholder() + expect(shadowAll(el, SKELETON)).toHaveLength(1) + + el.heading = HEADING + await settle(el) + + expect(shadowAll(el, SKELETON)).toHaveLength(0) + expect(text(shadow(el, EMPTY_HEADING))).toBe(HEADING) + }) + + it('falls back to the skeleton when the copy is taken away', async () => { + const el = await mountWithAttributes({ heading: HEADING }) + expect(shadowAll(el, EMPTY_STATE)).toHaveLength(1) + + el.removeAttribute('heading') + await settle(el) + + expect(shadowAll(el, SKELETON)).toHaveLength(1) + expect(shadowAll(el, EMPTY_STATE)).toHaveLength(0) + }) + }) +}) diff --git a/packages/app/test-ui/shell/app.test.ts b/packages/app/test-ui/shell/app.test.ts new file mode 100644 index 00000000..8122d875 --- /dev/null +++ b/packages/app/test-ui/shell/app.test.ts @@ -0,0 +1,665 @@ +import type { TracePlayerData } from '@wdio/devtools-shared' + +import '@/app.js' +import type { WebdriverIODevtoolsApplication } from '@/app.js' +import { + CACHE_ID, + DARK_MODE_KEY, + SIDEBAR_DEFAULT_WIDTH +} from '@/controller/constants.js' +import { POPOUT_QUERY } from '@components/workbench/compare/constants.js' +import type { DevtoolsHeader } from '@components/header.js' +import type { DevtoolsSidebar } from '@components/sidebar.js' +import type { DevtoolsSidebarExplorer } from '@components/sidebar/explorer.js' +import type { ExplorerTestEntry } from '@components/sidebar/test-suite.js' +import type { DevtoolsShortcuts } from '@components/shortcuts-overlay.js' +import type { DevtoolsWorkbench } from '@components/workbench.js' +import type { DevtoolsCompare } from '@components/workbench/compare.js' + +import { mount, settle } from '../support/mount.js' +import { shadow, shadowAll, text, texts } from '../support/queries.js' +import { + installFakeBackend, + loginCommands, + loginRun, + openClientSocket, + standaloneMetadata, + testrunnerMetadata, + tracePlayerData, + waitFor +} from './fixtures.js' +import type { FakeBackend } from './fixtures.js' + +const APP = 'wdio-devtools' +const HEADER = 'wdio-devtools-header' +const START = 'wdio-devtools-start' +const SIDEBAR = 'wdio-devtools-sidebar' +const EXPLORER = 'wdio-devtools-sidebar-explorer' +const WORKBENCH = 'wdio-devtools-workbench' +const SHORTCUTS = 'wdio-devtools-shortcuts' +const COMPARE = 'wdio-devtools-compare' +const MAIN = 'section[data-resizer-window]' +const SIDEBAR_SLIDER = + 'section[data-resizer-window] > button[data-draggable-id]' +const ROW = 'wdio-test-entry' +const ROW_LABEL = 'wdio-test-entry > label' +const BACKDROP = '.backdrop' +const PLAYER_CONTROLS = 'wdio-devtools-trace-player-controls' +const EMPTY_COMPARISON = '.empty-state' + +/** A `[scope, data]` pair as it arrives on the dashboard's client socket. */ +type Frame = [string, unknown] + +const METADATA: Frame = ['metadata', testrunnerMetadata] +const STANDALONE: Frame = ['metadata', standaloneMetadata] +const SUITES: Frame = ['suites', loginRun.frame] +const COMMANDS: Frame = ['commands', loginCommands] + +let backend: FakeBackend | undefined + +afterEach(() => { + backend?.restore() + backend = undefined + // The player path caches the trace it loaded, and the sidebar remembers its + // width โ€” both would otherwise decide what the next mount renders. + localStorage.removeItem(CACHE_ID) + localStorage.removeItem('sidebarWidth') +}) + +async function mountApp(options: { trace?: TracePlayerData } = {}) { + backend = installFakeBackend(options) + return mount<WebdriverIODevtoolsApplication>(APP) +} + +/** The live dashboard: the app boots, finds no trace to play, and subscribes. + * Frames are pushed after `open` because that is when the app starts reading + * the socket. */ +async function liveApp(...frames: Frame[]) { + const app = await mountApp() + const socket = await openClientSocket(backend!) + await settle(app) + for (const [scope, data] of frames) { + socket.send(scope, data) + } + await settle(app) + await settleTree(app) + return { app, socket } +} + +async function playerApp(trace = tracePlayerData()) { + const app = await mountApp({ trace }) + await waitFor(() => app.dataManager.playerMode, 'the served trace to load') + await settle(app) + return app +} + +function workbench(app: Element): DevtoolsWorkbench { + const el = shadow<DevtoolsWorkbench>(app, WORKBENCH) + if (!el) { + throw new Error('the app rendered no workbench') + } + return el +} + +function explorerOf(app: Element): DevtoolsSidebarExplorer { + const sidebar = shadow<DevtoolsSidebar>(app, SIDEBAR) + if (!sidebar) { + throw new Error('the app rendered no sidebar') + } + const explorer = shadow<DevtoolsSidebarExplorer>(sidebar, EXPLORER) + if (!explorer) { + throw new Error('the sidebar rendered no explorer') + } + return explorer +} + +/** Each element down the tree renders in its own update cycle. */ +async function settleTree(app: WebdriverIODevtoolsApplication): Promise<void> { + const sidebar = shadow<DevtoolsSidebar>(app, SIDEBAR) + if (!sidebar) { + return + } + await settle(sidebar) + const explorer = explorerOf(app) + await settle(explorer) + for (const row of shadowAll<ExplorerTestEntry>(explorer, ROW)) { + await settle(row) + } +} + +const treeLabels = (app: Element) => texts(explorerOf(app), ROW_LABEL) + +const rowState = (app: Element, uid: string) => + shadow(explorerOf(app), `${ROW}[uid="${uid}"]`)?.getAttribute('state') + +function overlay(app: Element): DevtoolsShortcuts { + const el = shadow<DevtoolsShortcuts>(app, SHORTCUTS) + if (!el) { + throw new Error('the app rendered no shortcuts overlay') + } + return el +} + +const press = (key: string) => + window.dispatchEvent(new KeyboardEvent('keydown', { key, cancelable: true })) + +/** `show-command` is the app's own output for keyboard navigation. Typed with the + * event map's own `CommandEventProps`, so a detail missing a field the contract + * declares is a spec failure rather than a silently absent property. */ +async function showCommand( + app: WebdriverIODevtoolsApplication, + act: () => void +): Promise<CommandEventProps[]> { + const received: CommandEventProps[] = [] + const listener = (event: Event) => + received.push((event as CustomEvent<CommandEventProps>).detail) + window.addEventListener('show-command', listener) + try { + act() + await settle(app) + } finally { + window.removeEventListener('show-command', listener) + } + return received +} + +describe('wdio-devtools', () => { + describe('boot', () => { + it('renders the header and the onboarding screen until a session connects', async () => { + const app = await mountApp() + await waitFor( + () => backend!.sockets.length > 0, + 'the client subscription' + ) + await settle(app) + + expect(shadowAll(app, HEADER)).toHaveLength(1) + expect(shadowAll(app, START)).toHaveLength(1) + expect(shadowAll(app, MAIN)).toHaveLength(0) + expect(shadowAll(app, WORKBENCH)).toHaveLength(0) + }) + + it('subscribes to the dashboard client socket', async () => { + const app = await mountApp() + await waitFor( + () => backend!.sockets.length > 0, + 'the client subscription' + ) + await settle(app) + + expect(backend!.sockets).toHaveLength(1) + expect(backend!.sockets[0].url).toContain('/client') + }) + + it('closes its socket when it leaves the DOM', async () => { + const app = await mountApp() + const socket = await openClientSocket(backend!) + await settle(app) + + app.remove() + + expect(socket.closed).toBe(true) + }) + + it('swaps the onboarding screen for the workbench once connected', async () => { + const { app } = await liveApp() + + expect(shadowAll(app, START)).toHaveLength(0) + expect(shadowAll(app, MAIN)).toHaveLength(1) + expect(shadowAll(app, WORKBENCH)).toHaveLength(1) + expect(shadowAll(app, HEADER)).toHaveLength(1) + }) + }) + + describe('live testrunner session', () => { + it('renders the test tree beside the workbench', async () => { + const { app } = await liveApp(METADATA) + + expect(shadowAll(app, SIDEBAR)).toHaveLength(1) + expect(shadowAll(app, WORKBENCH)).toHaveLength(1) + }) + + it('sizes the tree with its resizable default width', async () => { + const { app } = await liveApp(METADATA) + + expect(shadow<HTMLElement>(app, SIDEBAR)?.style.flexBasis).toBe( + `${SIDEBAR_DEFAULT_WIDTH}px` + ) + expect(shadowAll(app, SIDEBAR_SLIDER)).toHaveLength(1) + }) + + it('renders no test tree before the session reports what captured it', async () => { + const { app } = await liveApp() + + expect(shadowAll(app, SIDEBAR)).toHaveLength(0) + expect(shadowAll(app, SIDEBAR_SLIDER)).toHaveLength(0) + expect(shadowAll(app, WORKBENCH)).toHaveLength(1) + }) + + it('renders no test tree for a standalone capture, which has no runner', async () => { + const { app } = await liveApp(STANDALONE) + + expect(shadowAll(app, SIDEBAR)).toHaveLength(0) + expect(shadowAll(app, WORKBENCH)).toHaveLength(1) + }) + + it('keeps the workbench out of player layout', async () => { + const { app } = await liveApp(METADATA) + + expect(workbench(app).playerMode).toBe(false) + expect(shadowAll(workbench(app), PLAYER_CONTROLS)).toHaveLength(0) + }) + + it('hands the reported suites down to the test tree', async () => { + const { app } = await liveApp(METADATA, SUITES) + + expect(treeLabels(app)).toEqual(loginRun.rowLabels) + }) + + it('ignores a frame that carries no data', async () => { + const { app, socket } = await liveApp(METADATA, SUITES) + + socket.send('suites', null) + await settle(app) + await settleTree(app) + + expect(treeLabels(app)).toEqual(loginRun.rowLabels) + }) + }) + + describe('clearing execution data', () => { + it('marks the whole tree running again when a rerun clears it', async () => { + const { app } = await liveApp(METADATA, SUITES) + + app.dispatchEvent( + new CustomEvent('clear-execution-data', { + detail: { uid: '*', entryType: 'suite' } + }) + ) + await settle(app) + await settleTree(app) + + // A full rerun drops the previous run's leaf results, so the suite row is + // all that is left โ€” and it is running. + expect(treeLabels(app)).toEqual([loginRun.suite.title]) + expect(rowState(app, loginRun.suite.uid)).toBe('running') + }) + + it('marks only the named test running when one test reruns', async () => { + const { app } = await liveApp(METADATA, SUITES) + + app.dispatchEvent( + new CustomEvent('clear-execution-data', { + detail: { uid: loginRun.passing.uid, entryType: 'test' } + }) + ) + await settle(app) + await settleTree(app) + + expect(treeLabels(app)).toEqual(loginRun.rowLabels) + expect(rowState(app, loginRun.passing.uid)).toBe('running') + expect(rowState(app, loginRun.failing.uid)).toBe('failed') + }) + }) + + describe('trace player', () => { + it('plays the trace the backend serves instead of subscribing', async () => { + const app = await playerApp() + + expect(backend!.sockets).toHaveLength(0) + expect(shadowAll(app, START)).toHaveLength(0) + expect(shadowAll(app, WORKBENCH)).toHaveLength(1) + }) + + it('puts the workbench in player layout', async () => { + const app = await playerApp() + const panel = workbench(app) + await settle(panel) + + expect(panel.playerMode).toBe(true) + expect(shadowAll(panel, PLAYER_CONTROLS)).toHaveLength(1) + }) + + it('renders no test tree, even for a testrunner-captured trace', async () => { + const app = await playerApp() + + // The trace carries testrunner metadata and a suite registry; the player + // still drops the tree because it has no run/rerun affordances. + expect(app.dataManager.traceType).toBe(testrunnerMetadata.type) + expect(shadowAll(app, SIDEBAR)).toHaveLength(0) + }) + + it('tells the shortcuts overlay the playback keys are live', async () => { + const app = await playerApp() + + expect(overlay(app).playerMode).toBe(true) + }) + }) + + describe('shortcuts overlay', () => { + it('mounts the overlay closed', async () => { + const { app } = await liveApp(METADATA) + + expect(overlay(app).open).toBe(false) + expect(shadowAll(overlay(app), BACKDROP)).toHaveLength(0) + }) + + it('opens the overlay on ?', async () => { + const { app } = await liveApp(METADATA) + + press('?') + await settle(app) + await settle(overlay(app)) + + expect(overlay(app).open).toBe(true) + expect(shadowAll(overlay(app), BACKDROP)).toHaveLength(1) + }) + + it('closes it again on a second ?', async () => { + const { app } = await liveApp(METADATA) + + press('?') + await settle(app) + press('?') + await settle(app) + await settle(overlay(app)) + + expect(overlay(app).open).toBe(false) + expect(shadowAll(overlay(app), BACKDROP)).toHaveLength(0) + }) + + it('closes it when the overlay asks to close', async () => { + const { app } = await liveApp(METADATA) + press('?') + await settle(app) + + overlay(app).dispatchEvent(new CustomEvent('close')) + await settle(app) + await settle(overlay(app)) + + expect(overlay(app).open).toBe(false) + }) + + it('tells the overlay the playback keys are dead in the live dashboard', async () => { + const { app } = await liveApp(METADATA) + + expect(overlay(app).playerMode).toBe(false) + }) + }) + + describe('keyboard command navigation', () => { + it('surfaces the first command on the first step forward', async () => { + const { app } = await liveApp(METADATA, COMMANDS) + + const received = await showCommand(app, () => press('ArrowRight')) + + expect(received).toHaveLength(1) + expect(received[0].command.command).toBe(loginCommands[0].command) + expect(received[0].elapsedTime).toBe(0) + }) + + it('steps to the next command, timed from the first', async () => { + const { app } = await liveApp(METADATA, COMMANDS) + + press('ArrowRight') + await settle(app) + const received = await showCommand(app, () => press('ArrowRight')) + + expect(received[0].command.command).toBe(loginCommands[1].command) + expect(received[0].elapsedTime).toBe( + loginCommands[1].timestamp - loginCommands[0].timestamp + ) + }) + + it('stops at the last command', async () => { + const { app } = await liveApp(METADATA, COMMANDS) + + press('ArrowRight') + press('ArrowRight') + await settle(app) + const received = await showCommand(app, () => press('ArrowRight')) + + expect(received[0].command.command).toBe(loginCommands[1].command) + }) + + it('jumps to the last command on End and back on Home', async () => { + const { app } = await liveApp(METADATA, COMMANDS) + + const last = await showCommand(app, () => press('End')) + const first = await showCommand(app, () => press('Home')) + + expect(last[0].command.command).toBe(loginCommands[1].command) + expect(first[0].command.command).toBe(loginCommands[0].command) + expect(first[0].elapsedTime).toBe(0) + }) + + it('walks the commands in captured order even when they arrive reversed', async () => { + const { app } = await liveApp(METADATA, [ + 'commands', + [loginCommands[1], loginCommands[0]] + ]) + + const received = await showCommand(app, () => press('ArrowRight')) + + expect(received[0].command.command).toBe(loginCommands[0].command) + }) + + it('surfaces nothing while no command has been captured', async () => { + const { app } = await liveApp(METADATA) + + const received = await showCommand(app, () => press('ArrowRight')) + + expect(received).toHaveLength(0) + }) + + it('leaves the keys to the timeline in the trace player', async () => { + const app = await playerApp() + + const received = await showCommand(app, () => press('ArrowRight')) + + // The app steps only in the live dashboard. In the player the timeline + // answers the same key, stepping on from the action it announced at mount โ€” + // the app's own first step would surface the FIRST command instead, so a + // second emitter would show up here. + expect(received).toHaveLength(1) + expect(received[0].command.command).toBe(loginCommands[1].command) + // Both emitters time an action from the first command, so the Logs panel + // badges the same offset whichever one answered the key. + expect(received[0].elapsedTime).toBe( + loginCommands[1].timestamp - loginCommands[0].timestamp + ) + }) + }) + + // The sidebar row announces a selection as a bubbling, composed + // `app-test-select`; only the shell can turn that into the selected-test + // context the Compare tab and panel key on. Without this the selection moved + // only on a preserve or a popout, so clicking a different test left Compare + // showing the previous test's baseline. + describe('test selection', () => { + it('publishes a selected row as the selected test', async () => { + const { app } = await liveApp(METADATA, SUITES) + // The sidebar only appears once the metadata frame has been ingested, + // which can land a tick after `liveApp` settles โ€” without waiting on it + // the row query races the mount and fails intermittently. + await waitFor( + () => Boolean(shadow(app, SIDEBAR)), + 'the sidebar to render' + ) + await settleTree(app) + + // The row sits two shadow roots down (app โ†’ sidebar โ†’ explorer), so it + // is reached through the explorer; the event's own `composed` flag is + // what carries it back out to the shell. + const row = shadow<HTMLElement>( + explorerOf(app), + `wdio-test-entry[uid="${loginRun.passing.uid}"]` + ) + if (!row) { + throw new Error('the explorer rendered no row for the passing test') + } + row.dispatchEvent( + new CustomEvent('app-test-select', { + detail: loginRun.passing.uid, + bubbles: true, + composed: true + }) + ) + await settle(app) + + expect(app.dataManager.selectedTestUidContextProvider.value).toBe( + loginRun.passing.uid + ) + }) + + it('stops listening once it leaves the page', async () => { + const { app } = await liveApp(METADATA, SUITES) + app.remove() + + app.dispatchEvent( + new CustomEvent('app-test-select', { + detail: loginRun.passing.uid, + bubbles: true, + composed: true + }) + ) + + expect( + app.dataManager.selectedTestUidContextProvider.value + ).toBeUndefined() + }) + }) + + describe('theme', () => { + const SUN = 'icon-mdi-white-balance-sunny' + let osThemeEmulated = false + + /** Really flip the OS-level setting: `prefers-color-scheme` is overridden + * through CDP, so the page's live MediaQueryLists fire `change` the way they + * do when the user switches their system theme. */ + async function osThemeBecomes(theme: 'dark' | 'light'): Promise<void> { + await browser.sendCommand('Emulation.setEmulatedMedia', { + features: [{ name: 'prefers-color-scheme', value: theme }] + }) + osThemeEmulated = true + expect(window.matchMedia('(prefers-color-scheme: dark)').matches).toBe( + theme === 'dark' + ) + } + + /** Flip it and wait for the page to hand the change to its listeners: the CDP + * command returns as soon as `matches` reports the new value, which is before + * the media query notifies anyone. Waiting on a listener of this spec's own is + * what keeps the assertions below from racing the shell and the header. */ + async function osThemeFlipsTo(theme: 'dark' | 'light'): Promise<void> { + const delivered = new Promise<void>((resolve) => { + window + .matchMedia('(prefers-color-scheme: dark)') + .addEventListener('change', () => resolve(), { once: true }) + }) + await osThemeBecomes(theme) + await delivered + } + + function header(app: Element): DevtoolsHeader { + const el = shadow<DevtoolsHeader>(app, HEADER) + if (!el) { + throw new Error('the app rendered no header') + } + return el + } + + /** The theme the header's control renders: the sun offers a way out of dark. */ + const headerTheme = (app: Element) => + shadow(header(app), SUN)?.className === 'show' ? 'dark' : 'light' + + const documentTheme = () => + document.body.classList.contains('dark') ? 'dark' : 'light' + + afterEach(async () => { + localStorage.removeItem(DARK_MODE_KEY) + document.body.classList.remove('dark') + if (osThemeEmulated) { + osThemeEmulated = false + await browser.sendCommand('Emulation.setEmulatedMedia', { + features: [] + }) + } + }) + + it('opens the dashboard in the stored dark theme', async () => { + localStorage.setItem(DARK_MODE_KEY, 'true') + + const { app } = await liveApp(METADATA) + + // The stored theme is read when the shell renders, not when its module was + // loaded โ€” this value was written long after that. + expect(shadowAll(app, HEADER)).toHaveLength(1) + expect(document.body.classList.contains('dark')).toBe(true) + }) + + it('leaves the dashboard light when the stored theme says light', async () => { + localStorage.setItem(DARK_MODE_KEY, 'false') + document.body.classList.add('dark') + + await liveApp(METADATA) + + expect(document.body.classList.contains('dark')).toBe(false) + }) + + it('keeps the document and the header in step when the OS theme flips', async () => { + localStorage.removeItem(DARK_MODE_KEY) + await osThemeBecomes('light') + const { app } = await liveApp(METADATA) + expect([documentTheme(), headerTheme(app)]).toEqual(['light', 'light']) + + await osThemeFlipsTo('dark') + await settle(header(app)) + + // Regression: the shell followed the OS on its own, so the document went + // dark while the header it renders kept showing the light-mode icon. + expect([documentTheme(), headerTheme(app)]).toEqual(['dark', 'dark']) + }) + }) + + describe('compare popout', () => { + const search = window.location.search + + afterEach(() => { + history.replaceState(null, '', `${window.location.pathname}${search}`) + }) + + it('renders only the comparison when opened as a popout', async () => { + const params = new URLSearchParams(search) + params.set(POPOUT_QUERY.viewKey, POPOUT_QUERY.viewValue) + params.set(POPOUT_QUERY.uidKey, loginRun.failing.uid) + history.replaceState(null, '', `?${params.toString()}`) + + const { app } = await liveApp(METADATA, SUITES) + + expect(shadowAll(app, COMPARE)).toHaveLength(1) + expect(shadowAll(app, HEADER)).toHaveLength(0) + expect(shadowAll(app, SIDEBAR)).toHaveLength(0) + expect(shadowAll(app, WORKBENCH)).toHaveLength(0) + }) + + it('selects the test the parent window was viewing', async () => { + const params = new URLSearchParams(search) + params.set(POPOUT_QUERY.viewKey, POPOUT_QUERY.viewValue) + params.set(POPOUT_QUERY.uidKey, loginRun.failing.uid) + history.replaceState(null, '', `?${params.toString()}`) + + const { app } = await liveApp(METADATA, SUITES) + const compare = shadow<DevtoolsCompare>(app, COMPARE) + await settle(compare!) + + expect(app.dataManager.selectedTestUidContextProvider.value).toBe( + loginRun.failing.uid + ) + // Nothing was preserved in this session, so the comparison says so. + expect(text(shadow(compare!, EMPTY_COMPARISON))).toContain( + 'No baseline preserved.' + ) + }) + }) +}) diff --git a/packages/app/test-ui/shell/fixtures.ts b/packages/app/test-ui/shell/fixtures.ts new file mode 100644 index 00000000..440f8c70 --- /dev/null +++ b/packages/app/test-ui/shell/fixtures.ts @@ -0,0 +1,297 @@ +// Inputs for the app-shell specs. Everything here is a WIRE shape โ€” a `suites` +// WS frame, a `metadata` frame, the `TracePlayerData` the backend serves at +// TRACE_API.get โ€” so a spec drives the real DataManager/component derivation +// instead of hand-building the state those paths are supposed to produce. + +import { TRACE_API, TraceType, WS_PATHS } from '@wdio/devtools-shared' +import type { + CommandLog, + Metadata, + TraceLog, + TracePlayerData +} from '@wdio/devtools-shared' + +import type { + SuiteStatsFragment, + TestStatsFragment +} from '@/controller/types.js' + +/** The project's standing demo target. */ +export const LOGIN_URL = 'https://the-internet.herokuapp.com/login' +export const SECURE_URL = 'https://the-internet.herokuapp.com/secure' + +export const SPEC_FILE = '/repo/test/login.e2e.ts' +export const SESSION_ID = 'session-shell-1' + +/** The shell only checks *whether* a test carries an `end` stamp. */ +const FINISHED_AT = new Date(1_700_000_000_000) +const RUN_START = 1_700_000_000_000 + +function testFragment( + uid: string, + title: string, + overrides: Omit<Partial<TestStatsFragment>, 'uid' | 'title'> = {} +): TestStatsFragment & { title: string } { + return { uid, title, fullTitle: title, file: SPEC_FILE, ...overrides } +} + +/** `tests` is defaulted rather than omitted: the sidebar tells a suite from a + * test with `'tests' in entry`, so a fragment without the key reads as a leaf. + * Real `SuiteStats` always carries the array. */ +function suiteFragment( + uid: string, + title: string, + overrides: Omit<Partial<SuiteStatsFragment>, 'uid' | 'title'> = {} +): SuiteStatsFragment & { title: string } { + return { + uid, + title, + fullTitle: title, + file: SPEC_FILE, + tests: [], + ...overrides + } +} + +/** The `suites` frame shape: uid-keyed chunks, one entry per registered suite. */ +export function suiteRegistry( + ...suites: SuiteStatsFragment[] +): Record<string, SuiteStatsFragment>[] { + return [Object.fromEntries(suites.map((suite) => [suite.uid, suite]))] +} + +const LOGIN_TITLE = 'Login page' + +const passing = testFragment('login-valid', 'signs in with valid credentials', { + state: 'passed', + end: FINISHED_AT +}) + +const failing = testFragment('login-flash', 'shows the flash message', { + state: 'failed', + end: FINISHED_AT +}) + +const running = testFragment('login-logout', 'logs out again', { + state: 'running' +}) + +const skipped = testFragment('login-remember', 'remembers the session', { + state: 'skipped' +}) + +const loginSuite = suiteFragment('login-suite', LOGIN_TITLE, { + tests: [passing, failing, running, skipped] +}) + +/** One root suite carrying every per-test state the sidebar renders. No two + * titles share a distinctive word, so a filter assertion has one right answer + * and the suite title matches none of its own tests. */ +export const loginRun = { + /** The payload of a `suites` WS frame. */ + frame: suiteRegistry(loginSuite), + suite: loginSuite, + passing, + failing, + running, + skipped, + /** Row labels top to bottom: the root suite, then its tests in frame order. */ + rowLabels: [ + LOGIN_TITLE, + passing.title, + failing.title, + running.title, + skipped.title + ] +} + +/** The payload of a `metadata` WS frame for a live testrunner session. */ +export const testrunnerMetadata: Metadata = { + type: TraceType.Testrunner, + url: LOGIN_URL, + sessionId: SESSION_ID, + options: { + framework: 'mocha', + configFilePath: '/repo/wdio.conf.ts' + } +} + +/** A standalone (non-testrunner) capture โ€” the shell renders no test tree. */ +export const standaloneMetadata: Metadata = { + type: TraceType.Standalone, + url: LOGIN_URL, + sessionId: SESSION_ID +} + +/** Two commands a keyboard step can walk between, 800ms apart. */ +export const loginCommands: CommandLog[] = [ + { + command: 'url', + args: [LOGIN_URL], + timestamp: RUN_START + }, + { + command: 'click', + args: ['#login button'], + timestamp: RUN_START + 800 + } +] + +export function traceLog(overrides: Partial<TraceLog> = {}): TraceLog { + return { + mutations: [], + logs: [], + consoleLogs: [], + networkRequests: [], + metadata: testrunnerMetadata, + commands: loginCommands, + sources: {}, + suites: loginRun.frame, + ...overrides + } +} + +/** What the backend serves at TRACE_API.get in trace-player mode. */ +export function tracePlayerData( + overrides: Partial<TracePlayerData> = {} +): TracePlayerData { + return { + trace: traceLog(), + frames: [], + startTime: RUN_START, + duration: 800, + ...overrides + } +} + +export interface RecordedRequest { + url: string + body: Record<string, unknown> +} + +/** The dashboard's `/client` socket. The app subscribes on `open` only, so a + * frame sent before `open()` is deliberately unobserved. */ +export class FakeClientSocket extends EventTarget { + readonly url: string + closed = false + + constructor(url: string) { + super() + this.url = url + } + + close(): void { + this.closed = true + } + + open(): void { + this.dispatchEvent(new Event('open')) + } + + /** Simulate a failed upgrade โ€” the app then falls back to its cached trace. */ + fail(): void { + this.dispatchEvent(new Event('error')) + } + + send(scope: string, data: unknown): void { + this.dispatchEvent( + new MessageEvent('message', { data: JSON.stringify({ scope, data }) }) + ) + } +} + +export interface FakeBackend { + /** Every `/client` socket the app opened, newest last. */ + sockets: FakeClientSocket[] + /** POSTs the shell's children made to `/api/*`. */ + requests: RecordedRequest[] + restore(): void +} + +/** + * Stands in for the backend the app boots against: the trace probe at + * TRACE_API.get decides player vs live mode, and the `/client` upgrade is where + * live data arrives. Only those two are intercepted โ€” every other request and + * socket goes to the real implementation, because the component runner serves + * the spec itself over both. + */ +export function installFakeBackend( + options: { trace?: TracePlayerData } = {} +): FakeBackend { + const nativeFetch = globalThis.fetch + const NativeSocket = globalThis.WebSocket + const backend: FakeBackend = { + sockets: [], + requests: [], + restore() { + globalThis.fetch = nativeFetch + globalThis.WebSocket = NativeSocket + } + } + + globalThis.fetch = (input: RequestInfo | URL, init?: RequestInit) => { + const url = String( + typeof input === 'string' ? input : (input as Request).url + ) + if (url === TRACE_API.get) { + return Promise.resolve( + options.trace + ? new Response(JSON.stringify(options.trace), { status: 200 }) + : // 204: the route exists in live mode but serves no trace. + new Response(null, { status: 204 }) + ) + } + if (url.startsWith('/api/')) { + backend.requests.push({ + url, + body: init?.body + ? (JSON.parse(String(init.body)) as Record<string, unknown>) + : {} + }) + return Promise.resolve(new Response('{}', { status: 200 })) + } + return nativeFetch(input, init) + } + + // A Proxy rather than a subclass so the runner's own sockets keep the real + // constructor, statics and prototype. + globalThis.WebSocket = new Proxy(NativeSocket, { + construct(target, args: [string, (string | string[])?]) { + if (!String(args[0]).endsWith(WS_PATHS.client)) { + return new target(...args) + } + const socket = new FakeClientSocket(String(args[0])) + backend.sockets.push(socket) + return socket as unknown as WebSocket + } + }) + + return backend +} + +/** One macrotask โ€” enough for the awaited trace probe inside the boot path. */ +export const flush = (): Promise<void> => + new Promise((resolve) => setTimeout(resolve, 0)) + +/** Wait for a condition the boot path reaches asynchronously. */ +export async function waitFor( + predicate: () => boolean, + what: string +): Promise<void> { + for (let tick = 0; tick < 50 && !predicate(); tick += 1) { + await flush() + } + if (!predicate()) { + throw new Error(`timed out waiting for ${what}`) + } +} + +/** The app's live socket, opened โ€” the point at which it reports a connection. */ +export async function openClientSocket( + backend: FakeBackend +): Promise<FakeClientSocket> { + await waitFor(() => backend.sockets.length > 0, 'the app to open /client') + const socket = backend.sockets[backend.sockets.length - 1] + socket.open() + return socket +} diff --git a/packages/app/test-ui/shell/header.test.ts b/packages/app/test-ui/shell/header.test.ts new file mode 100644 index 00000000..049b1d7a --- /dev/null +++ b/packages/app/test-ui/shell/header.test.ts @@ -0,0 +1,375 @@ +import { DARK_MODE_KEY } from '@/controller/constants.js' +import { prefersDarkMode } from '@/controller/theme.js' +import '@components/header.js' +import type { DevtoolsHeader } from '@components/header.js' + +import { mount, settle } from '../support/mount.js' +import { shadow, shadowAll, text } from '../support/queries.js' + +const HEADER = 'wdio-devtools-header' +const LOGO = 'icon-custom-logo' +const TITLE = 'h1' +const THEME_BUTTON = 'nav button' +const MOON = 'icon-mdi-moon-waning-crescent' +const SUN = 'icon-mdi-white-balance-sunny' +const SHOWN = '.show' +const HIDDEN = '.hidden' + +const isDark = () => document.body.classList.contains('dark') +const storedTheme = () => localStorage.getItem(DARK_MODE_KEY) +const osPrefersDark = () => + window.matchMedia('(prefers-color-scheme: dark)').matches + +/** Each header derives its theme when it is CONSTRUCTED, so a spec picks the + * theme it mounts into by storing one first โ€” no module-load ordering to work + * around, and both initial states fit in one file. */ +function store(theme: 'dark' | 'light'): void { + localStorage.setItem(DARK_MODE_KEY, theme === 'dark' ? 'true' : 'false') +} + +async function mountHeader(): Promise<DevtoolsHeader> { + const header = await mount<DevtoolsHeader>(HEADER) + await settle(header) + return header +} + +async function toggleTheme(header: DevtoolsHeader): Promise<void> { + shadow(header, THEME_BUTTON)?.click() + await settle(header) +} + +/** A theme change made in ANOTHER window: the value is already stored by the + * time the event arrives, which is what the header re-reads. */ +async function themeChangedElsewhere( + header: DevtoolsHeader, + key: string, + theme: 'dark' | 'light' +): Promise<void> { + store(theme) + window.dispatchEvent( + new StorageEvent('storage', { + key, + newValue: theme === 'dark' ? 'true' : 'false' + }) + ) + await settle(header) +} + +const shownIcon = (header: DevtoolsHeader) => + shadow(header, SUN)?.className === 'show' ? 'sun' : 'moon' + +let osThemeEmulated = false + +/** + * Really flip the OS-level setting: `prefers-color-scheme` is overridden through + * CDP, so every live MediaQueryList in the page fires `change` exactly as it does + * when the user switches their system theme. Nothing lighter reaches the + * subscription under test โ€” `matchMedia()` hands out a NEW MediaQueryList per + * call, so a stubbed one is invisible to whoever already subscribed. + */ +async function osThemeBecomes(theme: 'dark' | 'light'): Promise<void> { + await browser.sendCommand('Emulation.setEmulatedMedia', { + features: [{ name: 'prefers-color-scheme', value: theme }] + }) + osThemeEmulated = true + // The drive itself, asserted: without it the tests below would pass on any + // header, since nothing would have changed. + expect(osPrefersDark()).toBe(theme === 'dark') +} + +/** Resolves once the page has delivered the flip to a media query of this spec's + * own โ€” which is proof that the one `controller/theme` subscribed to, notified in + * the same page task, has been called too. The CDP command returns as soon as + * `matches` reports the new value, BEFORE that delivery, so awaiting the command + * alone makes these specs race the listener under test. */ +function osThemeDelivered(): Promise<void> { + return new Promise((resolve) => { + window + .matchMedia('(prefers-color-scheme: dark)') + .addEventListener('change', () => resolve(), { once: true }) + }) +} + +/** Flip the OS setting and wait for the page to hand it to its listeners. Only + * for a flip that really changes the value โ€” an unchanged setting fires no + * `change`, so this would wait for an event that never comes. */ +async function osThemeFlipsTo(theme: 'dark' | 'light'): Promise<void> { + const delivered = osThemeDelivered() + await osThemeBecomes(theme) + await delivered +} + +/** Hand the setting back to the machine running the suite. */ +async function restoreOsTheme(): Promise<void> { + if (!osThemeEmulated) { + return + } + osThemeEmulated = false + await browser.sendCommand('Emulation.setEmulatedMedia', { features: [] }) +} + +/** Nothing carries over: the next test picks its own theme, and the document and + * the OS setting are left as the runner found them. */ +afterEach(async () => { + document.body.classList.remove('dark') + localStorage.removeItem(DARK_MODE_KEY) + await restoreOsTheme() +}) + +describe('wdio-devtools-header', () => { + describe('title bar', () => { + it('renders the product logo and name', async () => { + const header = await mountHeader() + + expect(shadowAll(header, LOGO)).toHaveLength(1) + expect(text(shadow(header, TITLE))).toBe('WebdriverIO Devtools') + }) + + it('renders a single theme control', async () => { + const header = await mountHeader() + + expect(shadowAll(header, THEME_BUTTON)).toHaveLength(1) + }) + + it('renders both theme icons but shows only one', async () => { + const header = await mountHeader() + + expect(shadowAll(header, MOON)).toHaveLength(1) + expect(shadowAll(header, SUN)).toHaveLength(1) + expect(shadowAll(header, SHOWN)).toHaveLength(1) + expect(shadowAll(header, HIDDEN)).toHaveLength(1) + }) + }) + + describe('theme on mount', () => { + it('opens in the stored dark theme', async () => { + store('dark') + + const header = await mountHeader() + + expect(shownIcon(header)).toBe('sun') + expect(isDark()).toBe(true) + }) + + it('opens in the stored light theme', async () => { + store('light') + + const header = await mountHeader() + + expect(shownIcon(header)).toBe('moon') + expect(isDark()).toBe(false) + }) + + it('reads the stored theme per header, not once per module load', async () => { + store('light') + const light = await mountHeader() + + store('dark') + const dark = await mountHeader() + + // Both were constructed from the same loaded module, so a snapshot taken at + // import time would render these two the same way. + expect(shownIcon(light)).toBe('moon') + expect(shownIcon(dark)).toBe('sun') + }) + + it('follows the OS setting until the user has picked a theme', async () => { + localStorage.removeItem(DARK_MODE_KEY) + + const header = await mountHeader() + + expect(shownIcon(header)).toBe(osPrefersDark() ? 'sun' : 'moon') + expect(isDark()).toBe(osPrefersDark()) + }) + + it('clears a stale dark document when the stored theme says light', async () => { + document.body.classList.add('dark') + store('light') + + const header = await mountHeader() + + // The header applies the theme it renders, so its icon and the document + // cannot disagree. + expect(shownIcon(header)).toBe('moon') + expect(isDark()).toBe(false) + }) + }) + + describe('theme toggle', () => { + it('offers the moon while the dashboard is light', async () => { + store('light') + const header = await mountHeader() + + expect(shadow(header, MOON)?.className).toBe('show') + expect(shadow(header, SUN)?.className).toBe('hidden') + expect(isDark()).toBe(false) + }) + + it('darkens the dashboard when the control is clicked', async () => { + store('light') + const header = await mountHeader() + + await toggleTheme(header) + + expect(isDark()).toBe(true) + expect(shadow(header, SUN)?.className).toBe('show') + expect(shadow(header, MOON)?.className).toBe('hidden') + }) + + it('lightens it again on the next click', async () => { + store('light') + const header = await mountHeader() + + await toggleTheme(header) + await toggleTheme(header) + + expect(isDark()).toBe(false) + expect(shadow(header, MOON)?.className).toBe('show') + }) + + it('stores the choice so the next visit opens in the same theme', async () => { + store('light') + const header = await mountHeader() + expect(storedTheme()).toBe('false') + + await toggleTheme(header) + expect(storedTheme()).toBe('true') + + await toggleTheme(header) + expect(storedTheme()).toBe('false') + }) + + it('themes the document rather than only itself', async () => { + store('light') + const header = await mountHeader() + + await toggleTheme(header) + + // The dark class lands on <body>, which is what the app's Tailwind + // `dark:` variants and the popout windows read. + expect(isDark()).toBe(true) + expect(header.classList.contains('dark')).toBe(false) + }) + }) + + describe('theme changed in another window', () => { + it('switches to the theme the other window stored', async () => { + store('light') + const header = await mountHeader() + + await themeChangedElsewhere(header, DARK_MODE_KEY, 'dark') + + expect(shownIcon(header)).toBe('sun') + }) + + it('switches back when the other window returns to light', async () => { + store('dark') + const header = await mountHeader() + + await themeChangedElsewhere(header, DARK_MODE_KEY, 'light') + + expect(shownIcon(header)).toBe('moon') + }) + + it('ignores a change to an unrelated stored setting', async () => { + store('light') + const header = await mountHeader() + + // The theme now says dark, but this event is not about the theme โ€” so the + // header must not re-read it. + await themeChangedElsewhere(header, 'sidebarWidth', 'dark') + + expect(shownIcon(header)).toBe('moon') + }) + + it('stops listening once it leaves the DOM', async () => { + store('light') + const header = await mountHeader() + + header.remove() + await themeChangedElsewhere(header, DARK_MODE_KEY, 'dark') + + expect(shownIcon(header)).toBe('moon') + }) + }) + + describe('OS theme flipped', () => { + it('follows the OS while the user has not picked a theme', async () => { + localStorage.removeItem(DARK_MODE_KEY) + await osThemeBecomes('light') + const header = await mountHeader() + expect(shownIcon(header)).toBe('moon') + + await osThemeFlipsTo('dark') + await settle(header) + + // Regression: only the document used to follow the OS (the shell listens + // for it), so a mounted header kept offering to switch to the theme the + // dashboard was already showing. + expect(shownIcon(header)).toBe('sun') + expect(isDark()).toBe(true) + }) + + it('follows it back to light again', async () => { + localStorage.removeItem(DARK_MODE_KEY) + await osThemeBecomes('dark') + const header = await mountHeader() + expect(shownIcon(header)).toBe('sun') + + await osThemeFlipsTo('light') + await settle(header) + + expect(shownIcon(header)).toBe('moon') + expect(isDark()).toBe(false) + }) + + it('ignores the OS once the user has picked a theme', async () => { + await osThemeBecomes('light') + store('light') + const header = await mountHeader() + + await osThemeFlipsTo('dark') + await settle(header) + + // The stored choice is the answer while it exists โ€” the OS is only the + // fallback, so this flip must not undo what the user picked. The flip was + // delivered before this ran, so an unchanged icon is a decision, not a race. + expect(shownIcon(header)).toBe('moon') + expect(isDark()).toBe(false) + }) + + it('stops following the OS once it leaves the DOM', async () => { + localStorage.removeItem(DARK_MODE_KEY) + await osThemeBecomes('light') + const header = await mountHeader() + + header.remove() + await osThemeFlipsTo('dark') + await settle(header) + + expect(shownIcon(header)).toBe('moon') + }) + }) + + describe('theme preference', () => { + it('prefers the theme the user stored', () => { + store('dark') + expect(prefersDarkMode()).toBe(true) + + store('light') + expect(prefersDarkMode()).toBe(false) + }) + + it('falls back to the OS setting while nothing is stored', () => { + localStorage.removeItem(DARK_MODE_KEY) + + expect(prefersDarkMode()).toBe(osPrefersDark()) + }) + + it('reads anything other than the stored `true` as light', () => { + localStorage.setItem(DARK_MODE_KEY, 'yes') + + expect(prefersDarkMode()).toBe(false) + }) + }) +}) diff --git a/packages/app/test-ui/shell/shortcuts-overlay.test.ts b/packages/app/test-ui/shell/shortcuts-overlay.test.ts new file mode 100644 index 00000000..408aabe4 --- /dev/null +++ b/packages/app/test-ui/shell/shortcuts-overlay.test.ts @@ -0,0 +1,209 @@ +import '@components/shortcuts-overlay.js' +import type { DevtoolsShortcuts } from '@components/shortcuts-overlay.js' + +import { mount, settle } from '../support/mount.js' +import { shadow, shadowAll, text, texts } from '../support/queries.js' + +const OVERLAY = 'wdio-devtools-shortcuts' +const BACKDROP = '.backdrop' +const PANEL = '.panel' +const TITLE = 'header' +const CLOSE = 'header .x' +const KEY = 'dt kbd' +const DESCRIPTION = 'dd' +const DIMMED = 'dd.dim' + +/** The overlay's own table, restated: `SHORTCUTS` is module-private and this is + * the copy the user reads. Player-only rows come first. */ +const KEYS = ['Space', 'โ† / โ†’', 'Home / End', ', / .', '/', '?'] +const DESCRIPTIONS = [ + 'Play / pause', + 'Previous / next action', + 'First / last action', + 'Slower / faster', + 'Focus filter', + 'Toggle this help' +] +const PLAYER_ONLY = DESCRIPTIONS.slice(0, 4) + +async function mountOverlay( + props: { open?: boolean; playerMode?: boolean } = {} +): Promise<DevtoolsShortcuts> { + const overlay = await mount<DevtoolsShortcuts>(OVERLAY, { + open: true, + ...props + }) + await settle(overlay) + return overlay +} + +/** `close` is dispatched on the overlay itself and does not bubble. */ +async function closeEvents( + overlay: DevtoolsShortcuts, + act: () => void +): Promise<Event[]> { + const received: Event[] = [] + const listener = (event: Event) => received.push(event) + overlay.addEventListener('close', listener) + try { + act() + await settle(overlay) + } finally { + overlay.removeEventListener('close', listener) + } + return received +} + +const pressEscape = (): KeyboardEvent => { + const event = new KeyboardEvent('keydown', { + key: 'Escape', + cancelable: true + }) + window.dispatchEvent(event) + return event +} + +describe('wdio-devtools-shortcuts', () => { + describe('open state', () => { + it('renders nothing at all while it is closed', async () => { + const overlay = await mountOverlay({ open: false }) + + expect(shadowAll(overlay, BACKDROP)).toHaveLength(0) + expect(shadowAll(overlay, KEY)).toHaveLength(0) + }) + + it('renders the dialog over a backdrop once it is opened', async () => { + const overlay = await mountOverlay({ open: false }) + + overlay.open = true + await settle(overlay) + + expect(shadowAll(overlay, BACKDROP)).toHaveLength(1) + expect(shadowAll(overlay, PANEL)).toHaveLength(1) + expect(text(shadow(overlay, TITLE))).toBe('Keyboard shortcuts โœ•') + }) + + it('tears the dialog back down when it is closed again', async () => { + const overlay = await mountOverlay() + + overlay.open = false + await settle(overlay) + + expect(shadowAll(overlay, BACKDROP)).toHaveLength(0) + }) + }) + + describe('keybinding list', () => { + it('lists every shortcut key once, in order', async () => { + const overlay = await mountOverlay() + + expect(texts(overlay, KEY)).toEqual(KEYS) + }) + + it('pairs each key with the action it performs', async () => { + const overlay = await mountOverlay() + + expect(texts(overlay, DESCRIPTION)).toEqual(DESCRIPTIONS) + }) + + it('dims the playback shortcuts in the live dashboard, where they no-op', async () => { + const overlay = await mountOverlay({ playerMode: false }) + + expect(texts(overlay, DIMMED)).toEqual(PLAYER_ONLY) + }) + + it('dims nothing in the trace player, where every key works', async () => { + const overlay = await mountOverlay({ playerMode: true }) + + expect(shadowAll(overlay, DIMMED)).toHaveLength(0) + expect(texts(overlay, DESCRIPTION)).toEqual(DESCRIPTIONS) + }) + + it('undims the playback shortcuts when the player takes over', async () => { + const overlay = await mountOverlay({ playerMode: false }) + + overlay.playerMode = true + await settle(overlay) + + expect(shadowAll(overlay, DIMMED)).toHaveLength(0) + }) + }) + + describe('closing', () => { + it('asks to close when the backdrop is clicked', async () => { + const overlay = await mountOverlay() + + const received = await closeEvents(overlay, () => + shadow(overlay, BACKDROP)?.click() + ) + + expect(received).toHaveLength(1) + }) + + it('asks to close when the โœ• is clicked', async () => { + const overlay = await mountOverlay() + + const received = await closeEvents(overlay, () => + shadow(overlay, CLOSE)?.click() + ) + + expect(received).toHaveLength(1) + }) + + it('stays open when the dialog itself is clicked', async () => { + const overlay = await mountOverlay() + + const received = await closeEvents(overlay, () => + shadow(overlay, PANEL)?.click() + ) + + expect(received).toHaveLength(0) + expect(shadowAll(overlay, BACKDROP)).toHaveLength(1) + }) + + it('asks to close on Escape and swallows the key', async () => { + const overlay = await mountOverlay() + let escape: KeyboardEvent | undefined + + const received = await closeEvents(overlay, () => { + escape = pressEscape() + }) + + expect(received).toHaveLength(1) + expect(escape?.defaultPrevented).toBe(true) + }) + + it('ignores Escape while it is closed', async () => { + const overlay = await mountOverlay({ open: false }) + let escape: KeyboardEvent | undefined + + const received = await closeEvents(overlay, () => { + escape = pressEscape() + }) + + expect(received).toHaveLength(0) + expect(escape?.defaultPrevented).toBe(false) + }) + + it('ignores any other key while it is open', async () => { + const overlay = await mountOverlay() + + const received = await closeEvents(overlay, () => + window.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Enter', cancelable: true }) + ) + ) + + expect(received).toHaveLength(0) + }) + + it('stops listening for Escape once it leaves the DOM', async () => { + const overlay = await mountOverlay() + overlay.remove() + + const received = await closeEvents(overlay, () => pressEscape()) + + expect(received).toHaveLength(0) + }) + }) +}) diff --git a/packages/app/test-ui/shell/sidebar.test.ts b/packages/app/test-ui/shell/sidebar.test.ts new file mode 100644 index 00000000..78405b25 --- /dev/null +++ b/packages/app/test-ui/shell/sidebar.test.ts @@ -0,0 +1,257 @@ +import type { Metadata } from '@wdio/devtools-shared' + +import { metadataContext, suiteContext } from '@/controller/context.js' +import type { SuiteStatsFragment } from '@/controller/types.js' +import '@components/sidebar.js' +import type { DevtoolsSidebar } from '@components/sidebar.js' +import type { DevtoolsSidebarExplorer } from '@components/sidebar/explorer.js' +import type { DevtoolsSidebarFilter } from '@components/sidebar/filter.js' +import type { DevtoolsSidebarSummary } from '@components/sidebar/summary.js' +import type { ExplorerTestEntry } from '@components/sidebar/test-suite.js' +import { + computeSuiteSummary, + deriveRunStatus +} from '@components/sidebar/suite-summary.js' + +import { mount, mountWithContext, settle } from '../support/mount.js' +import type { ContextValue } from '../support/mount.js' +import { shadow, shadowAll, text, texts } from '../support/queries.js' +import { loginRun, testrunnerMetadata } from './fixtures.js' + +const SIDEBAR = 'wdio-devtools-sidebar' +const TOP = '.top' +const FILTER = 'wdio-devtools-sidebar-filter' +const SUMMARY = 'wdio-devtools-sidebar-summary' +const EXPLORER = 'wdio-devtools-sidebar-explorer' +const QUERY_INPUT = 'input[name="filter"]' +const STATUS_PILL = '.pill' +const PASSED_COUNT = '.count' +const PROGRESS_PASSED = '.seg-passed' +const PROGRESS_FAILED = '.seg-failed' +const PROGRESS_RUNNING = '.seg-running' +const FAILED_CHIP = '.legend button.failed' +const ROW = 'wdio-test-entry' +const ROW_LABEL = 'wdio-test-entry > label' +const EMPTY_STATE = 'p.text-disabledForeground' + +/** The counts the sidebar is expected to show, taken from the same helper the + * summary renders with rather than restated. */ +const SUMMARY_OF_RUN = computeSuiteSummary(loginRun.frame) + +interface SidebarParts { + sidebar: DevtoolsSidebar + filter: DevtoolsSidebarFilter + summary: DevtoolsSidebarSummary + explorer: DevtoolsSidebarExplorer +} + +function part<T extends Element>(sidebar: DevtoolsSidebar, tag: string): T { + const el = shadow<T>(sidebar, tag) + if (!el) { + throw new Error(`the sidebar rendered no ${tag}`) + } + return el +} + +/** Every child renders in its own update cycle, so each has to settle before + * its shadow content is there to assert. */ +async function mountSidebar( + registry?: Record<string, SuiteStatsFragment>[], + metadata: Metadata = testrunnerMetadata +): Promise<SidebarParts> { + const contexts: ContextValue[] = [ + { context: metadataContext, value: metadata } + ] + if (registry) { + contexts.push({ context: suiteContext, value: registry }) + } + const sidebar = await mountWithContext<DevtoolsSidebar>(SIDEBAR, contexts) + await settle(sidebar) + const parts: SidebarParts = { + sidebar, + filter: part<DevtoolsSidebarFilter>(sidebar, FILTER), + summary: part<DevtoolsSidebarSummary>(sidebar, SUMMARY), + explorer: part<DevtoolsSidebarExplorer>(sidebar, EXPLORER) + } + await settleTree(parts) + return parts +} + +async function settleTree(parts: SidebarParts): Promise<void> { + await settle(parts.filter) + await settle(parts.summary) + await settle(parts.explorer) + for (const row of shadowAll<ExplorerTestEntry>(parts.explorer, ROW)) { + await settle(row) + } +} + +/** Type into the real filter field โ€” the sidebar's own child โ€” rather than + * dispatching the event it emits. */ +async function typeFilter(parts: SidebarParts, query: string): Promise<void> { + const input = shadow<HTMLInputElement>(parts.filter, QUERY_INPUT) + if (!input) { + throw new Error('the filter rendered no query input') + } + input.value = query + input.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true })) + await settleTree(parts) +} + +const rowLabels = (parts: SidebarParts) => texts(parts.explorer, ROW_LABEL) + +const widthOf = (host: Element, selector: string) => + shadow<HTMLElement>(host, selector)?.style.width + +const pct = (n: number) => `${(n / SUMMARY_OF_RUN.total) * 100}%` + +describe('wdio-devtools-sidebar', () => { + describe('composition', () => { + it('renders the filter and the summary above the test tree', async () => { + const { sidebar } = await mountSidebar(loginRun.frame) + + const structure = [...(sidebar.shadowRoot?.children ?? [])] + .filter((el) => el.tagName !== 'STYLE') + .map((el) => el.tagName.toLowerCase()) + expect(structure).toEqual(['div', EXPLORER]) + expect( + shadowAll(sidebar, `${TOP} > *`).map((el) => el.tagName.toLowerCase()) + ).toEqual([FILTER, SUMMARY]) + }) + + it('renders exactly one of each child', async () => { + const { sidebar } = await mountSidebar(loginRun.frame) + + expect(shadowAll(sidebar, FILTER)).toHaveLength(1) + expect(shadowAll(sidebar, SUMMARY)).toHaveLength(1) + expect(shadowAll(sidebar, EXPLORER)).toHaveLength(1) + }) + + it('keeps the filter field on screen before any run data arrives', async () => { + const { sidebar, filter, summary, explorer } = await mountSidebar() + + expect(shadowAll(filter, QUERY_INPUT)).toHaveLength(1) + // No registry at all: the summary has nothing to total and the explorer + // has no tree, so both render empty inside a sidebar that still stands. + expect(summary.shadowRoot?.querySelector(STATUS_PILL)).toBe(null) + expect(shadowAll(explorer, 'header')).toHaveLength(0) + expect(shadowAll(sidebar, TOP)).toHaveLength(1) + }) + + it('shows the tree empty state once the registry arrives with no suites', async () => { + const { summary, explorer } = await mountSidebar([]) + + expect(text(shadow(explorer, EMPTY_STATE))).toBe('No tests to display') + expect(summary.shadowRoot?.querySelector(STATUS_PILL)).toBe(null) + }) + }) + + describe('run data', () => { + it('tallies the run in the summary', async () => { + const { summary } = await mountSidebar(loginRun.frame) + + expect(text(shadow(summary, PASSED_COUNT))).toBe( + `${SUMMARY_OF_RUN.passed}/${SUMMARY_OF_RUN.total} passed` + ) + expect(text(shadow(summary, PASSED_COUNT))).toBe('1/4 passed') + }) + + it('reports the headline run state while a test is still running', async () => { + const { summary } = await mountSidebar(loginRun.frame) + + expect(summary.getAttribute('data-status')).toBe( + deriveRunStatus(SUMMARY_OF_RUN) + ) + expect(text(shadow(summary, STATUS_PILL))).toBe('Running') + }) + + it('sizes the progress segments to the per-state counts', async () => { + const { summary } = await mountSidebar(loginRun.frame) + + expect(widthOf(summary, PROGRESS_PASSED)).toBe(pct(SUMMARY_OF_RUN.passed)) + expect(widthOf(summary, PROGRESS_FAILED)).toBe(pct(SUMMARY_OF_RUN.failed)) + expect(widthOf(summary, PROGRESS_RUNNING)).toBe( + pct(SUMMARY_OF_RUN.running) + ) + expect(widthOf(summary, PROGRESS_PASSED)).toBe('25%') + }) + + it('renders the suite and its tests in the explorer', async () => { + const parts = await mountSidebar(loginRun.frame) + + expect(rowLabels(parts)).toEqual(loginRun.rowLabels) + }) + }) + + describe('filter reaches the tree', () => { + it('narrows the tree to what the filter field matches', async () => { + const parts = await mountSidebar(loginRun.frame) + + await typeFilter(parts, 'flash') + + expect(rowLabels(parts)).toEqual([ + loginRun.suite.title, + loginRun.failing.title + ]) + }) + + it('restores the whole tree when the field is emptied', async () => { + const parts = await mountSidebar(loginRun.frame) + + await typeFilter(parts, 'flash') + await typeFilter(parts, '') + + expect(rowLabels(parts)).toEqual(loginRun.rowLabels) + }) + + it('leaves the summary totals alone while the tree is filtered', async () => { + const parts = await mountSidebar(loginRun.frame) + + await typeFilter(parts, 'flash') + + // The summary counts the run, not the visible rows. + expect(text(shadow(parts.summary, PASSED_COUNT))).toBe('1/4 passed') + }) + }) + + describe('summary reaches the tree', () => { + it('narrows the tree to the status chip that was pressed', async () => { + const parts = await mountSidebar(loginRun.frame) + + shadow(parts.summary, FAILED_CHIP)?.click() + await settleTree(parts) + + expect(rowLabels(parts)).toEqual([ + loginRun.suite.title, + loginRun.failing.title + ]) + expect( + shadow(parts.summary, FAILED_CHIP)?.getAttribute('aria-pressed') + ).toBe('true') + }) + + it('restores the whole tree when the chip is pressed again', async () => { + const parts = await mountSidebar(loginRun.frame) + + shadow(parts.summary, FAILED_CHIP)?.click() + await settleTree(parts) + shadow(parts.summary, FAILED_CHIP)?.click() + await settleTree(parts) + + expect(rowLabels(parts)).toEqual(loginRun.rowLabels) + expect( + shadow(parts.summary, FAILED_CHIP)?.getAttribute('aria-pressed') + ).toBe('false') + }) + }) + + describe('without a provider', () => { + it('still renders its three children when nothing provides context', async () => { + const sidebar = await mount<DevtoolsSidebar>(SIDEBAR) + + expect(shadowAll(sidebar, FILTER)).toHaveLength(1) + expect(shadowAll(sidebar, SUMMARY)).toHaveLength(1) + expect(shadowAll(sidebar, EXPLORER)).toHaveLength(1) + }) + }) +}) diff --git a/packages/app/test-ui/shell/start.test.ts b/packages/app/test-ui/shell/start.test.ts new file mode 100644 index 00000000..51babf63 --- /dev/null +++ b/packages/app/test-ui/shell/start.test.ts @@ -0,0 +1,166 @@ +import '@components/onboarding/start.js' +import type { DevtoolsStart } from '@components/onboarding/start.js' + +import { mount } from '../support/mount.js' +import { shadow, shadowAll, text, texts } from '../support/queries.js' + +const START = 'wdio-devtools-start' +const HERO = 'img' +const INSTALL_SNIPPET = 'pre' +const CONFIG_SNIPPET = 'pre.w-full' +const COPY_COLUMN = 'section' +/** The block that carries a setup step's vertical spacing. */ +const STEP = 'section > .py-4' + +/** The service snippet as the onboarding screen prints it. Restated because + * `CONFIG_CODE_EXAMPLE` is module-private โ€” this is the copy a first-time user + * copy-pastes, so the exact text is the contract. */ +const CONFIG_CODE_EXAMPLE = `export const config = { + // ... + services: ['devtools'], + // ... +}` + +const mountStart = () => mount<DevtoolsStart>(START) + +/** Tag names of the copy column's direct children โ€” what the PARSER made of the + * markup, which is what the reader sees. Illegal nesting (a `<p>` holding an + * `<h3>` or a `<pre>`) never shows up as a template error: the parser closes + * the `<p>` early and re-parents the rest, so only the DOM can tell. */ +const outline = (start: DevtoolsStart): string[] => + Array.from(shadow(start, COPY_COLUMN)?.children ?? []).map((el) => + el.tagName.toLowerCase() + ) + +/** Copy the column holds directly, outside any element of its own. */ +const looseCopy = (start: DevtoolsStart): string[] => + Array.from(shadow(start, COPY_COLUMN)?.childNodes ?? []) + .filter((node) => node.nodeType === Node.TEXT_NODE) + .map((node) => (node.textContent ?? '').replace(/\s+/g, ' ').trim()) + .filter((value) => value !== '') + +describe('wdio-devtools-start', () => { + describe('onboarding copy', () => { + it('names the product as its heading', async () => { + const start = await mountStart() + + expect(text(shadow(start, 'h2'))).toBe('WebdriverIO Devtools') + }) + + it('renders the hero image the backend serves at /robot.png', async () => { + const start = await mountStart() + + const hero = shadow<HTMLImageElement>(start, HERO) + expect(hero?.getAttribute('src')).toBe('/robot.png') + expect(hero?.getAttribute('width')).toBe('200px') + }) + + it('titles the single setup section', async () => { + const start = await mountStart() + + expect(texts(start, 'h3')).toEqual(['Embed into Project']) + }) + + it('prints the install command and the service snippet, in that order', async () => { + const start = await mountStart() + + const snippets = shadowAll(start, INSTALL_SNIPPET) + expect(snippets.map((pre) => pre.textContent)).toEqual([ + 'npm install @wdio/devtools', + CONFIG_CODE_EXAMPLE + ]) + }) + + it('keeps the service snippet verbatim, newlines included', async () => { + const start = await mountStart() + + expect(shadow(start, CONFIG_SNIPPET)?.textContent).toBe( + CONFIG_CODE_EXAMPLE + ) + expect(shadow(start, CONFIG_SNIPPET)?.textContent).toContain( + "services: ['devtools']" + ) + }) + + it('walks the reader from installing to registering the service', async () => { + const start = await mountStart() + + const copy = text(shadow(start, COPY_COLUMN)) + expect(copy).toContain('First install WebdriverIO Devtools via:') + expect(copy).toContain('Then add it as a service:') + expect(copy.indexOf('First install')).toBeLessThan( + copy.indexOf('Then add it as a service') + ) + }) + }) + + describe('markup', () => { + it('lays the column out as the product name plus one block per setup step', async () => { + const start = await mountStart() + + expect(outline(start)).toEqual(['h2', 'div', 'div']) + }) + + it('keeps each step whole inside the block that spaces it', async () => { + const start = await mountStart() + + const steps = shadowAll(start, STEP) + expect(steps).toHaveLength(2) + // Spacing on a block that holds nothing spaces nothing, so the step's + // heading, prose and snippet all have to sit inside it. + expect(text(steps[0])).toBe( + 'Embed into Project First install WebdriverIO Devtools via: npm install @wdio/devtools' + ) + expect(text(steps[1])).toContain('Then add it as a service:') + expect(steps[1].querySelector('pre')?.textContent).toBe( + CONFIG_CODE_EXAMPLE + ) + }) + + it('nests the heading and the snippets legally, so none is re-parented', async () => { + const start = await mountStart() + + // A `<p>` takes phrasing content only. `<h3>`/`<pre>` inside one closes it + // and leaves both as siblings of the column โ€” the shapes asserted against. + expect(shadowAll(start, 'p h3, p pre')).toHaveLength(0) + expect( + shadowAll(start, `${COPY_COLUMN} > h3, ${COPY_COLUMN} > pre`) + ).toHaveLength(0) + expect(shadowAll(start, `${STEP} > h3`)).toHaveLength(1) + expect(shadowAll(start, `${STEP} > pre`)).toHaveLength(2) + }) + + it('carries every line of prose in a paragraph of its own', async () => { + const start = await mountStart() + + expect(looseCopy(start)).toEqual([]) + expect(texts(start, 'p')).toEqual([ + 'First install WebdriverIO Devtools via:', + 'Then add it as a service:' + ]) + }) + }) + + describe('interaction', () => { + it('offers no control to click โ€” the screen is copy only', async () => { + const start = await mountStart() + + expect( + shadowAll(start, 'button, a, input, [role="button"]') + ).toHaveLength(0) + }) + + it('dispatches nothing while it is on screen', async () => { + const events: Event[] = [] + const record = (event: Event) => events.push(event) + const types = ['click', 'app-logs', 'clear-execution-data'] + types.forEach((type) => window.addEventListener(type, record)) + + const start = await mountStart() + shadow(start, 'section')?.dispatchEvent(new Event('mouseover')) + + types.forEach((type) => window.removeEventListener(type, record)) + expect(events).toHaveLength(0) + }) + }) +}) diff --git a/packages/app/test-ui/sidebar/explorer/explorer.test.ts b/packages/app/test-ui/sidebar/explorer/explorer.test.ts new file mode 100644 index 00000000..485d3454 --- /dev/null +++ b/packages/app/test-ui/sidebar/explorer/explorer.test.ts @@ -0,0 +1,889 @@ +import { BASELINE_API, TESTS_API } from '@wdio/devtools-shared' +import type { Metadata } from '@wdio/devtools-shared' + +import '@components/sidebar/explorer.js' +import type { DevtoolsSidebarExplorer } from '@components/sidebar/explorer.js' +import type { ExplorerTestEntry } from '@components/sidebar/test-suite.js' +import type { + StatusFilterDetail, + TestRunDetail, + TestStatus +} from '@components/sidebar/types.js' +import { metadataContext, suiteContext } from '@/controller/context.js' +import type { SuiteStatsFragment } from '@/controller/types.js' + +import { mount, mountWithContext, settle } from '../../support/mount.js' +import type { ContextValue } from '../../support/mount.js' +import { shadow, shadowAll, text, texts } from '../../support/queries.js' +import { + cucumberMetadata, + FINISHED_AT, + mixedStateRun, + mochaMetadata, + mochaRunnerOptions, + nestedRun, + nightwatchMetadata, + profileSuite, + SPEC_FILE, + suiteFragment, + suiteRegistry, + testFragment +} from '../fixtures.js' + +const EXPLORER = 'wdio-devtools-sidebar-explorer' +const ENTRY = 'wdio-test-entry' +const GROUP = 'wdio-test-suite' +const NESTED_GROUP = 'wdio-test-suite[slot="children"]' +const ROOT_ROW = 'wdio-test-entry[root]' +const TEST_ROW = 'wdio-test-entry[entry-type="test"]' +const SELECTED_ROW = 'wdio-test-entry[selected]' +const ROW_LABEL = 'wdio-test-entry > label' +const EMPTY_STATE = 'p.text-disabledForeground' +const RUN_ALL_BUTTON = 'header button[title="Run all"]' +const STOP_ALL_BUTTON = 'header button[title="Stop"]' +const EXPAND_ALL_ICON = 'header icon-mdi-expand-all' +const COLLAPSE_ALL_ICON = 'header icon-mdi-collapse-all' +const LABEL_SPAN = 'section.row > span' +const CHEVRON_BUTTON = 'section.row > button' +const CHILDREN_SLOT = 'slot[name="children"]' +const RUN_BUTTON = 'nav.row-actions button:has(icon-mdi-play)' +const STOP_BUTTON = 'nav.row-actions button:has(icon-mdi-stop)' +const RERUN_BUTTON = 'nav.row-actions button:has(icon-mdi-bug-play)' +const NOT_RUN_ICON = 'icon-mdi-circle-outline' + +const CUCUMBER_REASON = + 'Single-test execution is not supported by this framework.' + +interface RecordedRequest { + url: string + body: Record<string, unknown> +} + +const nativeFetch = globalThis.fetch + +afterEach(() => { + globalThis.fetch = nativeFetch +}) + +/** The explorer reaches the runner over `fetch`; a component test starts no + * backend, so the requests are recorded rather than sent. */ +function recordBackend(options: { failing?: boolean } = {}): RecordedRequest[] { + const requests: RecordedRequest[] = [] + globalThis.fetch = (input: RequestInfo | URL, init?: RequestInit) => { + requests.push({ + url: String(input), + body: init?.body + ? (JSON.parse(String(init.body)) as Record<string, unknown>) + : {} + }) + return Promise.resolve( + options.failing + ? new Response('runner unavailable', { status: 500 }) + : new Response('{}', { status: 200 }) + ) + } + return requests +} + +/** Let the awaited fetch inside the explorer's handlers settle. */ +const flush = () => new Promise<void>((resolve) => setTimeout(resolve, 0)) + +async function mountExplorer( + registry: Record<string, SuiteStatsFragment>[], + metadata?: Metadata +): Promise<DevtoolsSidebarExplorer> { + const contexts: ContextValue[] = [{ context: suiteContext, value: registry }] + if (metadata) { + contexts.push({ context: metadataContext, value: metadata }) + } + const explorer = await mountWithContext<DevtoolsSidebarExplorer>( + EXPLORER, + contexts + ) + await settle(explorer) + // The rows render in their own update cycle, so their shadow content is only + // there once each has settled too. + for (const row of shadowAll<ExplorerTestEntry>(explorer, ENTRY)) { + await settle(row) + } + return explorer +} + +function rowByUid(explorer: Element, uid: string): ExplorerTestEntry { + const row = shadow<ExplorerTestEntry>(explorer, `${ENTRY}[uid="${uid}"]`) + if (!row) { + throw new Error(`no row rendered for uid "${uid}"`) + } + return row +} + +/** A row renders its group in a second section beside the label row, so the + * children slot is the only stable handle on the thing that hides. */ +const childrenHidden = (row: Element) => + shadow(row, CHILDREN_SLOT)?.parentElement?.classList.contains('hidden') + +/** A tree-wide collapse reaches the rows by attribute, so the explorer settling + * is not enough โ€” every row it just touched has to re-render too. */ +async function settleTree(explorer: DevtoolsSidebarExplorer): Promise<void> { + await settle(explorer) + for (const row of shadowAll<ExplorerTestEntry>(explorer, ENTRY)) { + await settle(row) + } + await settle(explorer) +} + +const rowLabels = (explorer: Element) => texts(explorer, ROW_LABEL) + +const rowStates = (explorer: Element) => + shadowAll(explorer, ENTRY).map((row) => row.getAttribute('state')) + +const rowUids = (explorer: Element, selector = ENTRY) => + shadowAll(explorer, selector).map((row) => row.getAttribute('uid')) + +const applyQuery = (filterQuery: string) => + window.dispatchEvent( + new CustomEvent('app-test-filter', { detail: { filterQuery } }) + ) + +const applyStatus = (status: TestStatus | null) => + window.dispatchEvent( + new CustomEvent<StatusFilterDetail>('app-status-filter', { + detail: { status } + }) + ) + +async function capture<T>( + target: EventTarget, + type: string, + act: () => void +): Promise<CustomEvent<T>[]> { + const received: CustomEvent<T>[] = [] + const listener = (event: Event) => received.push(event as CustomEvent<T>) + target.addEventListener(type, listener) + try { + act() + // Reading a fetch Response body is not a microtask, so a handler that + // awaits `response.text()` before dispatching lands a tick or two after + // the click โ€” one flush is not enough to see it. + for (let tick = 0; tick < 10 && received.length === 0; tick += 1) { + await flush() + } + } finally { + target.removeEventListener(type, listener) + } + return received +} + +describe('wdio-devtools-sidebar-explorer', () => { + describe('tree construction', () => { + it('renders a row for the root suite and one for each of its tests', async () => { + const explorer = await mountExplorer(mixedStateRun.registry) + + expect(rowLabels(explorer)).toEqual(mixedStateRun.rowLabels) + expect(shadowAll(explorer, TEST_ROW)).toHaveLength(4) + }) + + it("nests a suite's tests in a group inside its own row", async () => { + const explorer = await mountExplorer(mixedStateRun.registry) + + expect(shadowAll(explorer, NESTED_GROUP)).toHaveLength(1) + expect(rowUids(explorer, `${NESTED_GROUP} > ${ENTRY}`)).toEqual([ + mixedStateRun.passing.uid, + mixedStateRun.failing.uid, + mixedStateRun.running.uid, + mixedStateRun.skipped.uid + ]) + expect( + rowByUid(explorer, mixedStateRun.suite.uid).hasAttribute('has-children') + ).toBe(true) + }) + + it('marks only the outermost suite as a root row', async () => { + const explorer = await mountExplorer(mixedStateRun.registry) + + expect(rowUids(explorer, ROOT_ROW)).toEqual([mixedStateRun.suite.uid]) + }) + + it('carries the reported state of every test onto its row', async () => { + const explorer = await mountExplorer(mixedStateRun.registry) + + expect(rowStates(explorer)).toEqual([ + 'running', + 'passed', + 'failed', + 'running', + 'skipped' + ]) + }) + + it('derives a running suite from a test that is still running', async () => { + const explorer = await mountExplorer(mixedStateRun.registry) + + expect( + rowByUid(explorer, mixedStateRun.suite.uid).getAttribute('state') + ).toBe('running') + }) + + it('derives a failed suite from a failed test once nothing runs', async () => { + const suite = suiteFragment('report-suite', 'Reporting', { + tests: [ + testFragment('report-summary', 'writes the summary', { + state: 'passed', + end: FINISHED_AT + }), + testFragment('report-failures', 'writes the failures', { + state: 'failed', + end: FINISHED_AT + }) + ] + }) + const explorer = await mountExplorer(suiteRegistry(suite)) + + expect(rowByUid(explorer, suite.uid).getAttribute('state')).toBe('failed') + }) + + it('derives a passed suite when none of its tests failed', async () => { + const explorer = await mountExplorer(suiteRegistry(profileSuite)) + + expect(rowByUid(explorer, profileSuite.uid).getAttribute('state')).toBe( + 'passed' + ) + }) + + it('derives a failed feature from a failure inside one of its scenarios', async () => { + const scenario = suiteFragment('audit-scenario', 'Exports the audit', { + type: 'scenario', + parent: 'Auditing', + tests: [ + testFragment('audit-csv', 'writes a csv', { + state: 'passed', + end: FINISHED_AT + }), + testFragment('audit-signature', 'signs the export', { + state: 'failed', + end: FINISHED_AT + }) + ] + }) + const feature = suiteFragment('audit-feature', 'Auditing', { + suites: [scenario] + }) + const explorer = await mountExplorer(suiteRegistry(feature, scenario)) + + expect(rowByUid(explorer, feature.uid).getAttribute('state')).toBe( + 'failed' + ) + expect(rowByUid(explorer, scenario.uid).getAttribute('state')).toBe( + 'failed' + ) + }) + + it('derives a running suite from a queued test next to a finished one', async () => { + // Nightwatch-Cucumber leaves the feature state undefined, so a terminal + // child alongside a queued one is the only signal the run is in progress. + const suite = suiteFragment('audit-suite', 'Auditing', { + tests: [ + testFragment('audit-csv', 'writes a csv', { + state: 'passed', + end: FINISHED_AT + }), + testFragment('audit-archive', 'archives the export', { + state: 'pending' + }) + ] + }) + const explorer = await mountExplorer(suiteRegistry(suite)) + + expect(rowByUid(explorer, suite.uid).getAttribute('state')).toBe( + 'running' + ) + }) + + it('shows a test that finished without a reported state as passed', async () => { + const finished = testFragment('report-done', 'writes the report', { + end: FINISHED_AT + }) + const suite = suiteFragment('report-suite', 'Reporting', { + tests: [finished] + }) + const explorer = await mountExplorer(suiteRegistry(suite)) + + expect(rowByUid(explorer, finished.uid).getAttribute('state')).toBe( + 'passed' + ) + }) + + it('shows a test that never started as pending with the not-run icon', async () => { + const never = testFragment('report-archive', 'archives the report') + const suite = suiteFragment('report-suite', 'Reporting', { + tests: [never] + }) + const explorer = await mountExplorer(suiteRegistry(suite)) + + const row = rowByUid(explorer, never.uid) + expect(row.getAttribute('state')).toBe('pending') + expect(shadowAll(row, NOT_RUN_ICON)).toHaveLength(1) + }) + + it('tags a suite whose children are suites as a feature', async () => { + const explorer = await mountExplorer(nestedRun.registry) + + expect( + rowByUid(explorer, nestedRun.feature.uid).getAttribute('suite-type') + ).toBe('feature') + expect( + rowByUid(explorer, nestedRun.scenario.uid).getAttribute('suite-type') + ).toBe('scenario') + }) + + it('keeps a nested suite out of the root list even though the registry lists it flat', async () => { + const explorer = await mountExplorer(nestedRun.registry) + + expect(rowUids(explorer, ROOT_ROW)).toEqual([nestedRun.feature.uid]) + expect(rowLabels(explorer)).toEqual(nestedRun.rowLabels) + }) + + it('renders one row for a suite that arrives in more than one registry chunk', async () => { + const explorer = await mountExplorer([ + ...mixedStateRun.registry, + ...mixedStateRun.registry + ]) + + expect(rowLabels(explorer)).toEqual(mixedStateRun.rowLabels) + }) + + it('renders nothing at all until the suite registry arrives', async () => { + const explorer = await mount<DevtoolsSidebarExplorer>(EXPLORER) + + expect(shadowAll(explorer, 'header')).toHaveLength(0) + expect(shadowAll(explorer, GROUP)).toHaveLength(0) + }) + + it('shows the empty state when the registry holds no suites', async () => { + const explorer = await mountExplorer([]) + + expect(text(shadow(explorer, 'header h3'))).toBe('Tests') + expect(text(shadow(explorer, EMPTY_STATE))).toBe('No tests to display') + expect(shadowAll(explorer, ENTRY)).toHaveLength(0) + }) + + it('renders a suite with no tests as a single childless row', async () => { + const empty = suiteFragment('empty-suite', 'Reporting', { tests: [] }) + const explorer = await mountExplorer(suiteRegistry(empty)) + + expect(rowLabels(explorer)).toEqual(['Reporting']) + expect(shadowAll(explorer, NESTED_GROUP)).toHaveLength(0) + expect(rowByUid(explorer, empty.uid).hasAttribute('has-children')).toBe( + false + ) + }) + }) + + describe('filtering', () => { + it('narrows the tree to the tests matching the filter query', async () => { + const explorer = await mountExplorer(mixedStateRun.registry) + + applyQuery('discount') + await settle(explorer) + + expect(rowLabels(explorer)).toEqual([ + mixedStateRun.suite.title, + mixedStateRun.failing.title + ]) + }) + + it('matches the filter query without regard to case', async () => { + const explorer = await mountExplorer(mixedStateRun.registry) + + applyQuery('DISCOUNT') + await settle(explorer) + + expect(rowLabels(explorer)).toEqual([ + mixedStateRun.suite.title, + mixedStateRun.failing.title + ]) + }) + + it('drops a sibling suite when none of its tests match the query', async () => { + const explorer = await mountExplorer( + suiteRegistry(mixedStateRun.suite, profileSuite) + ) + expect(rowUids(explorer, ROOT_ROW)).toHaveLength(2) + + applyQuery('discount') + await settle(explorer) + + expect(rowUids(explorer, ROOT_ROW)).toEqual([mixedStateRun.suite.uid]) + }) + + it('keeps a suite whose own title matches even when none of its tests do', async () => { + const explorer = await mountExplorer(mixedStateRun.registry) + + applyQuery('flow') + await settle(explorer) + + expect(rowLabels(explorer)).toEqual([mixedStateRun.suite.title]) + expect( + rowByUid(explorer, mixedStateRun.suite.uid).hasAttribute('has-children') + ).toBe(false) + }) + + it('shows the empty state when the filter query matches nothing', async () => { + const explorer = await mountExplorer(mixedStateRun.registry) + + applyQuery('regression') + await settle(explorer) + + expect(shadowAll(explorer, ENTRY)).toHaveLength(0) + expect(text(shadow(explorer, EMPTY_STATE))).toBe('No tests to display') + }) + + it('restores the whole tree when the filter query is cleared', async () => { + const explorer = await mountExplorer(mixedStateRun.registry) + + applyQuery('discount') + await settle(explorer) + applyQuery('') + await settle(explorer) + + expect(rowLabels(explorer)).toEqual(mixedStateRun.rowLabels) + }) + + it('narrows the tree to the tests in a single status', async () => { + const explorer = await mountExplorer(mixedStateRun.registry) + + applyStatus('failed') + await settle(explorer) + + expect(rowLabels(explorer)).toEqual([ + mixedStateRun.suite.title, + mixedStateRun.failing.title + ]) + }) + + it('keeps the running test when the status filter is running', async () => { + const explorer = await mountExplorer(mixedStateRun.registry) + + applyStatus('running') + await settle(explorer) + + expect(rowLabels(explorer)).toEqual([ + mixedStateRun.suite.title, + mixedStateRun.running.title + ]) + }) + + it('drops a suite with no test in the filtered status', async () => { + const explorer = await mountExplorer( + suiteRegistry(mixedStateRun.suite, profileSuite) + ) + + applyStatus('failed') + await settle(explorer) + + expect(rowUids(explorer, ROOT_ROW)).toEqual([mixedStateRun.suite.uid]) + }) + + it('shows the empty state when no test is in the filtered status', async () => { + const explorer = await mountExplorer(suiteRegistry(profileSuite)) + + applyStatus('failed') + await settle(explorer) + + expect(shadowAll(explorer, ENTRY)).toHaveLength(0) + expect(text(shadow(explorer, EMPTY_STATE))).toBe('No tests to display') + }) + + it('restores the whole tree when the status filter is cleared', async () => { + const explorer = await mountExplorer(mixedStateRun.registry) + + applyStatus('failed') + await settle(explorer) + applyStatus(null) + await settle(explorer) + + expect(rowLabels(explorer)).toEqual(mixedStateRun.rowLabels) + }) + }) + + describe('running state', () => { + it('highlights the deepest running test while nothing has been selected', async () => { + const explorer = await mountExplorer(mixedStateRun.registry) + + expect(rowUids(explorer, SELECTED_ROW)).toEqual([ + mixedStateRun.running.uid + ]) + }) + + it('renders a stop control on the running row', async () => { + const explorer = await mountExplorer(mixedStateRun.registry) + + const row = rowByUid(explorer, mixedStateRun.running.uid) + expect(shadowAll(row, STOP_BUTTON)).toHaveLength(1) + expect(shadowAll(row, RUN_BUTTON)).toHaveLength(0) + }) + + it('offers a preserve-and-rerun control only on the failed row', async () => { + const explorer = await mountExplorer(mixedStateRun.registry) + + expect( + shadowAll(rowByUid(explorer, mixedStateRun.failing.uid), RERUN_BUTTON) + ).toHaveLength(1) + expect( + shadowAll(rowByUid(explorer, mixedStateRun.passing.uid), RERUN_BUTTON) + ).toHaveLength(0) + }) + + it('moves the highlight to a row that is clicked', async () => { + const explorer = await mountExplorer(mixedStateRun.registry) + + shadow(rowByUid(explorer, mixedStateRun.passing.uid), LABEL_SPAN)?.click() + await settle(explorer) + + expect(rowUids(explorer, SELECTED_ROW)).toEqual([ + mixedStateRun.passing.uid + ]) + }) + }) + + describe('header controls', () => { + it('posts a run request for the whole tree', async () => { + const requests = recordBackend() + const explorer = await mountExplorer( + mixedStateRun.registry, + mochaMetadata + ) + + shadow(explorer, RUN_ALL_BUTTON)?.click() + + expect(requests).toHaveLength(1) + expect(requests[0]?.url).toBe(TESTS_API.run) + expect(requests[0]?.body).toEqual({ + uid: '*', + entryType: 'suite', + runAll: true, + framework: mochaRunnerOptions.framework, + configFile: mochaRunnerOptions.configFilePath, + rerunCommand: mochaRunnerOptions.rerunCommand, + launchCommand: mochaRunnerOptions.launchCommand + }) + }) + + it('clears the execution data before running the whole tree', async () => { + recordBackend() + const explorer = await mountExplorer( + mixedStateRun.registry, + mochaMetadata + ) + + const received = await capture<{ uid?: string; entryType?: string }>( + explorer, + 'clear-execution-data', + () => shadow(explorer, RUN_ALL_BUTTON)?.click() + ) + + expect(received).toHaveLength(1) + expect(received[0]?.detail).toEqual({ uid: '*', entryType: 'suite' }) + }) + + it('posts a stop request for the active run', async () => { + const requests = recordBackend() + const explorer = await mountExplorer( + mixedStateRun.registry, + mochaMetadata + ) + + shadow(explorer, STOP_ALL_BUTTON)?.click() + + expect(requests).toHaveLength(1) + expect(requests[0]?.url).toBe(TESTS_API.stop) + expect(requests[0]?.body).toEqual({}) + }) + + it('disables the run control for a runner that cannot run everything', async () => { + const explorer = await mountExplorer( + mixedStateRun.registry, + nightwatchMetadata + ) + + const run = shadow<HTMLButtonElement>(explorer, RUN_ALL_BUTTON) + expect(run?.disabled).toBe(true) + expect(run?.classList.contains('cursor-not-allowed')).toBe(true) + }) + + it('still stops the run for a runner that cannot run everything', async () => { + // Launching and stopping are separate concerns: /api/tests/stop takes no + // body and kills whatever the backend spawned, so a Nightwatch run โ€” which + // has no run-everything entry point โ€” must still be stoppable from here. + const requests = recordBackend() + const explorer = await mountExplorer( + mixedStateRun.registry, + nightwatchMetadata + ) + + const stop = shadow<HTMLButtonElement>(explorer, STOP_ALL_BUTTON) + expect(stop?.disabled).toBe(false) + expect(stop?.classList.contains('cursor-not-allowed')).toBe(false) + + stop?.click() + await flush() + + expect(requests.map((request) => request.url)).toEqual([TESTS_API.stop]) + }) + + it('sends nothing while the run control is disabled', async () => { + const requests = recordBackend() + const explorer = await mountExplorer( + mixedStateRun.registry, + nightwatchMetadata + ) + + shadow(explorer, RUN_ALL_BUTTON)?.click() + await flush() + + expect(requests).toHaveLength(0) + }) + + it('offers to collapse a freshly rendered tree, which shows its children', async () => { + const explorer = await mountExplorer(mixedStateRun.registry) + + expect(childrenHidden(rowByUid(explorer, mixedStateRun.suite.uid))).toBe( + false + ) + expect(shadowAll(explorer, COLLAPSE_ALL_ICON)).toHaveLength(1) + expect(shadowAll(explorer, EXPAND_ALL_ICON)).toHaveLength(0) + }) + + it('hides every row of children from the header control', async () => { + const explorer = await mountExplorer(mixedStateRun.registry) + const root = rowByUid(explorer, mixedStateRun.suite.uid) + + shadow(explorer, COLLAPSE_ALL_ICON)?.click() + await settleTree(explorer) + + expect(childrenHidden(root)).toBe(true) + expect(shadowAll(explorer, EXPAND_ALL_ICON)).toHaveLength(1) + expect(shadowAll(explorer, COLLAPSE_ALL_ICON)).toHaveLength(0) + }) + + it('shows every row of children again from the header control', async () => { + const explorer = await mountExplorer(mixedStateRun.registry) + const root = rowByUid(explorer, mixedStateRun.suite.uid) + + shadow(explorer, COLLAPSE_ALL_ICON)?.click() + await settleTree(explorer) + shadow(explorer, EXPAND_ALL_ICON)?.click() + await settleTree(explorer) + + expect(childrenHidden(root)).toBe(false) + expect(shadowAll(explorer, COLLAPSE_ALL_ICON)).toHaveLength(1) + expect(shadowAll(explorer, EXPAND_ALL_ICON)).toHaveLength(0) + }) + + it('switches the header control back to collapse-all once a row is expanded by hand', async () => { + const explorer = await mountExplorer(mixedStateRun.registry) + const root = rowByUid(explorer, mixedStateRun.suite.uid) + + shadow(explorer, COLLAPSE_ALL_ICON)?.click() + await settleTree(explorer) + expect(shadowAll(explorer, EXPAND_ALL_ICON)).toHaveLength(1) + + shadow(root, CHEVRON_BUTTON)?.click() + await settleTree(explorer) + + expect(childrenHidden(root)).toBe(false) + expect(shadowAll(explorer, COLLAPSE_ALL_ICON)).toHaveLength(1) + expect(shadowAll(explorer, EXPAND_ALL_ICON)).toHaveLength(0) + }) + }) + + describe('row controls', () => { + it('posts a run request for the row that asked to run', async () => { + const requests = recordBackend() + const explorer = await mountExplorer( + mixedStateRun.registry, + mochaMetadata + ) + + shadow(rowByUid(explorer, mixedStateRun.failing.uid), RUN_BUTTON)?.click() + + expect(requests).toHaveLength(1) + expect(requests[0]?.url).toBe(TESTS_API.run) + expect(requests[0]?.body.uid).toBe(mixedStateRun.failing.uid) + expect(requests[0]?.body.entryType).toBe('test') + expect(requests[0]?.body.specFile).toBe(SPEC_FILE) + expect(requests[0]?.body.runAll).toBe(false) + expect(requests[0]?.body.framework).toBe(mochaRunnerOptions.framework) + expect(requests[0]?.body.preserveBaseline).toBe(false) + }) + + // A suite whose tests are still running renders a stop control instead of a + // run control, so the run path needs a settled suite. + it('posts a run request for a whole suite row', async () => { + const requests = recordBackend() + const explorer = await mountExplorer( + suiteRegistry(profileSuite), + mochaMetadata + ) + + shadow(rowByUid(explorer, profileSuite.uid), RUN_BUTTON)?.click() + + expect(requests).toHaveLength(1) + expect(requests[0]?.url).toBe(TESTS_API.run) + expect(requests[0]?.body.uid).toBe(profileSuite.uid) + expect(requests[0]?.body.entryType).toBe('suite') + expect(requests[0]?.body.runAll).toBe(false) + }) + + it('clears the execution data for the row before rerunning it', async () => { + recordBackend() + const explorer = await mountExplorer( + mixedStateRun.registry, + mochaMetadata + ) + + const received = await capture<{ uid?: string; entryType?: string }>( + explorer, + 'clear-execution-data', + () => + shadow( + rowByUid(explorer, mixedStateRun.failing.uid), + RUN_BUTTON + )?.click() + ) + + expect(received).toHaveLength(1) + expect(received[0]?.detail).toEqual({ + uid: mixedStateRun.failing.uid, + entryType: 'test' + }) + }) + + it('posts a stop request from the running row', async () => { + const requests = recordBackend() + const explorer = await mountExplorer( + mixedStateRun.registry, + mochaMetadata + ) + + shadow( + rowByUid(explorer, mixedStateRun.running.uid), + STOP_BUTTON + )?.click() + + expect(requests).toHaveLength(1) + expect(requests[0]?.url).toBe(TESTS_API.stop) + expect(requests[0]?.body).toEqual({}) + }) + + it('preserves the current run before rerunning a failed row', async () => { + const requests = recordBackend() + const explorer = await mountExplorer( + mixedStateRun.registry, + mochaMetadata + ) + + shadow( + rowByUid(explorer, mixedStateRun.failing.uid), + RERUN_BUTTON + )?.click() + await flush() + + expect(requests.map((request) => request.url)).toEqual([ + BASELINE_API.preserve, + TESTS_API.run + ]) + expect(requests[0]?.body).toEqual({ + testUid: mixedStateRun.failing.uid, + scope: 'test' + }) + expect(requests[1]?.body.preserveBaseline).toBe(true) + }) + + it('skips the rerun when preserving the baseline fails', async () => { + const requests = recordBackend({ failing: true }) + const explorer = await mountExplorer( + mixedStateRun.registry, + mochaMetadata + ) + + const logs = await capture<string>(window, 'app-logs', () => + shadow( + rowByUid(explorer, mixedStateRun.failing.uid), + RERUN_BUTTON + )?.click() + ) + + expect(requests.map((request) => request.url)).toEqual([ + BASELINE_API.preserve + ]) + expect(logs[0]?.detail).toBe( + 'Failed to preserve baseline: runner unavailable' + ) + }) + }) + + describe('runner capabilities', () => { + it('disables the run control on a test row for a runner that cannot run one', async () => { + const explorer = await mountExplorer( + mixedStateRun.registry, + cucumberMetadata + ) + + const button = shadow<HTMLButtonElement>( + rowByUid(explorer, mixedStateRun.failing.uid), + RUN_BUTTON + ) + expect(button?.disabled).toBe(true) + expect(button?.getAttribute('title')).toBe(CUCUMBER_REASON) + }) + + it('leaves the suite row runnable for a runner that can only run suites', async () => { + const explorer = await mountExplorer( + suiteRegistry(profileSuite), + cucumberMetadata + ) + + const button = shadow<HTMLButtonElement>( + rowByUid(explorer, profileSuite.uid), + RUN_BUTTON + ) + expect(button?.disabled).toBe(false) + expect(button?.getAttribute('title')).toBe('Run this entry') + }) + + // Cucumber cannot launch a single test, so the run control is withheld โ€” + // but the step is already running, and stopping is not a launch capability. + it('keeps a running test row stoppable when single tests cannot be run', async () => { + const explorer = await mountExplorer( + mixedStateRun.registry, + cucumberMetadata + ) + + const row = rowByUid(explorer, mixedStateRun.running.uid) + expect(shadowAll(row, STOP_BUTTON)).toHaveLength(1) + expect(shadowAll(row, RUN_BUTTON)).toHaveLength(0) + }) + + it('refuses a run request for a single test and says why', async () => { + const requests = recordBackend() + const explorer = await mountExplorer( + mixedStateRun.registry, + cucumberMetadata + ) + const detail: TestRunDetail = { + uid: mixedStateRun.failing.uid, + entryType: 'test' + } + + const logs = await capture<string>(window, 'app-logs', () => + explorer.dispatchEvent( + new CustomEvent<TestRunDetail>('app-test-run', { detail }) + ) + ) + + expect(logs.map((log) => log.detail)).toEqual([CUCUMBER_REASON]) + expect(requests).toHaveLength(0) + }) + }) +}) diff --git a/packages/app/test-ui/sidebar/explorer/test-entry.test.ts b/packages/app/test-ui/sidebar/explorer/test-entry.test.ts new file mode 100644 index 00000000..ed474b0d --- /dev/null +++ b/packages/app/test-ui/sidebar/explorer/test-entry.test.ts @@ -0,0 +1,539 @@ +import '@components/sidebar/test-suite.js' +import type { ExplorerTestEntry } from '@components/sidebar/test-suite.js' +import type { TestRunDetail } from '@components/sidebar/types.js' + +import { mount, settle } from '../../support/mount.js' +import { shadow, shadowAll, text } from '../../support/queries.js' +import { + CALL_SOURCE, + entryProps, + FEATURE_FILE, + gherkinRun, + mixedStateRun, + neverRanTest, + profileSuite, + rowProps, + type TestEntryProps +} from '../fixtures.js' + +const TAG = 'wdio-test-entry' +const LABEL_SPAN = 'section.row > span' +const CHEVRON_BUTTON = 'section.row > button' +const CHEVRON = 'icon-mdi-menu-down' +const LABEL_SLOT = 'slot[name="label"]' +const CHILDREN_SLOT = 'slot[name="children"]' +const TOOLBAR_BUTTON = 'nav.row-actions button' +const RUN_BUTTON = 'nav.row-actions button:has(icon-mdi-play)' +const STOP_BUTTON = 'nav.row-actions button:has(icon-mdi-stop)' +const RERUN_BUTTON = 'nav.row-actions button:has(icon-mdi-bug-play)' +const RUNNING_DOT = '.run-dot' +const PASSED_ICON = 'icon-mdi-check' +const FAILED_ICON = 'icon-mdi-close' +const SKIPPED_ICON = 'icon-mdi-debug-step-over' +const NOT_RUN_ICON = 'icon-mdi-circle-outline' +const COLLAPSE_ALL_ICON = 'icon-mdi-collapse-all' +const EXPAND_ALL_ICON = 'icon-mdi-expand-all' + +const STEP_FALLBACK_REASON = + 'Single-step execution is controlled by its scenario.' +const CUCUMBER_REASON = + 'Single-test execution is not supported by this framework.' + +/** The row renders its group in a second section next to the label row; the + * slot is the only stable handle on it. */ +const childrenSection = (row: Element) => + shadow(row, CHILDREN_SLOT)?.parentElement + +/** Row props come from `rowProps(fragment)` โ€” the real `getTestEntry` over a + * fragment โ€” so the label the explorer would slot is already in them; a spec + * only names one when it is asserting the projection itself. */ +async function mountRow( + props: TestEntryProps = {}, + label?: string +): Promise<ExplorerTestEntry> { + const resolved = entryProps(props) + const row = await mount<ExplorerTestEntry>(TAG, resolved) + const title = document.createElement('label') + title.slot = 'label' + title.textContent = label ?? resolved.labelText ?? '' + row.append(title) + await settle(row) + return row +} + +function capture<T>( + target: EventTarget, + type: string, + act: () => void +): CustomEvent<T>[] { + const received: CustomEvent<T>[] = [] + const listener = (event: Event) => received.push(event as CustomEvent<T>) + target.addEventListener(type, listener) + try { + act() + } finally { + target.removeEventListener(type, listener) + } + return received +} + +describe('wdio-test-entry', () => { + describe('state icon', () => { + it('shows a check for a passing test', async () => { + const row = await mountRow(rowProps(mixedStateRun.passing)) + + expect(shadowAll(row, PASSED_ICON)).toHaveLength(1) + expect( + shadow(row, PASSED_ICON)?.classList.contains('text-chartsGreen') + ).toBe(true) + }) + + it('shows a cross for a failing test', async () => { + const row = await mountRow(rowProps(mixedStateRun.failing)) + + expect(shadowAll(row, FAILED_ICON)).toHaveLength(1) + expect( + shadow(row, FAILED_ICON)?.classList.contains('text-chartsRed') + ).toBe(true) + }) + + it('shows a step-over arrow for a skipped test', async () => { + const row = await mountRow(rowProps(mixedStateRun.skipped)) + + expect(shadowAll(row, SKIPPED_ICON)).toHaveLength(1) + expect( + shadow(row, SKIPPED_ICON)?.classList.contains('text-chartsYellow') + ).toBe(true) + }) + + it('shows a pulsing dot instead of an icon while a test runs', async () => { + const row = await mountRow(rowProps(mixedStateRun.running)) + + expect(shadowAll(row, RUNNING_DOT)).toHaveLength(1) + expect(shadowAll(row, PASSED_ICON)).toHaveLength(0) + expect(shadowAll(row, NOT_RUN_ICON)).toHaveLength(0) + }) + + it('shows an empty circle for a test that is only pending', async () => { + // A leaf with no state and no end stamp is the one fragment shape that + // derives to 'pending' โ€” a *reported* pending leaf is a run in progress + // and derives to 'running'. + const row = await mountRow(rowProps(neverRanTest)) + + expect(row.state).toBe('pending') + expect(shadowAll(row, NOT_RUN_ICON)).toHaveLength(1) + expect( + shadow(row, NOT_RUN_ICON)?.classList.contains('text-disabledForeground') + ).toBe(true) + }) + + it('shows an empty circle for a test with no state at all', async () => { + // Hand-set: `computeEntryState` always returns a status, so a stateless + // row is only reachable when something other than the explorer mounts it. + const row = await mountRow({ state: undefined }) + + expect(shadowAll(row, NOT_RUN_ICON)).toHaveLength(1) + }) + + it('shows an empty circle for a state it does not recognise', async () => { + const row = await mountRow(rowProps(mixedStateRun.passing)) + + // Hand-set through the attribute: no fragment derives to a state outside + // TestStatus, and the attribute is how the explorer feeds state โ€” so this + // keeps an off-contract value out of the typed property. + row.setAttribute('state', 'aborted') + await settle(row) + + expect(shadowAll(row, NOT_RUN_ICON)).toHaveLength(1) + expect(shadowAll(row, PASSED_ICON)).toHaveLength(0) + }) + + it('swaps the icon when the state changes', async () => { + const row = await mountRow(rowProps(mixedStateRun.running)) + + row.state = rowProps(mixedStateRun.failing).state + await settle(row) + + expect(shadowAll(row, RUNNING_DOT)).toHaveLength(0) + expect(shadowAll(row, FAILED_ICON)).toHaveLength(1) + }) + + it('leaves the state icon off a root row', async () => { + // `root` is hand-set: it marks the row's position in the tree, which is + // the explorer's own knowledge and not part of any fragment. + const row = await mountRow( + rowProps(mixedStateRun.failing, { root: true }) + ) + + expect(row.hasAttribute('root')).toBe(true) + expect(shadowAll(row, FAILED_ICON)).toHaveLength(0) + expect(shadowAll(row, NOT_RUN_ICON)).toHaveLength(0) + }) + }) + + describe('title', () => { + it('renders the title projected into the label slot', async () => { + const row = await mountRow(rowProps(mixedStateRun.failing)) + + const slot = shadow<HTMLSlotElement>(row, LABEL_SLOT) + expect(slot?.assignedElements().map((el) => text(el))).toEqual([ + mixedStateRun.failing.title + ]) + }) + }) + + describe('selection', () => { + it('announces its uid on app-test-select when the row is clicked', async () => { + const row = await mountRow(rowProps(mixedStateRun.failing)) + + const received = capture<string>(row, 'app-test-select', () => + shadow(row, LABEL_SPAN)?.click() + ) + + expect(received).toHaveLength(1) + expect(received[0]?.detail).toBe(mixedStateRun.failing.uid) + // Composed so it survives the explorer's shadow boundary, where the + // explorer listens for it. + expect(received[0]?.bubbles).toBe(true) + expect(received[0]?.composed).toBe(true) + }) + + it('stays silent when the row has no uid', async () => { + // Hand-set: `getTestEntry` always carries the fragment's uid through. + const row = await mountRow({ uid: undefined }) + + const received = capture<string>(row, 'app-test-select', () => + shadow(row, LABEL_SPAN)?.click() + ) + + expect(received).toHaveLength(0) + }) + + it('asks the source view on window to highlight its call site', async () => { + const row = await mountRow(rowProps(mixedStateRun.passing)) + + const received = capture<string>(window, 'app-source-highlight', () => + shadow(row, LABEL_SPAN)?.click() + ) + + expect(received).toHaveLength(1) + expect(received[0]?.detail).toBe(CALL_SOURCE) + }) + + it('leaves the source view alone when the row has no call site', async () => { + // The running fragment reports no call site, so the derived row has none. + const row = await mountRow(rowProps(mixedStateRun.running)) + + const received = capture<string>(window, 'app-source-highlight', () => + shadow(row, LABEL_SPAN)?.click() + ) + + expect(received).toHaveLength(0) + }) + + it('reflects the selected flag so the row can be styled', async () => { + // Hand-set: selection is explorer state (clicked row, or the running row + // it auto-selects), not something a fragment reports. + const row = await mountRow({ selected: true }) + + expect(row.hasAttribute('selected')).toBe(true) + }) + + it('leaves an unselected row without the selected attribute', async () => { + const row = await mountRow() + + expect(row.hasAttribute('selected')).toBe(false) + }) + }) + + describe('row actions', () => { + it('offers a run button on a row that is not running', async () => { + const row = await mountRow(rowProps(mixedStateRun.passing)) + + expect(shadowAll(row, RUN_BUTTON)).toHaveLength(1) + expect(shadow(row, RUN_BUTTON)?.getAttribute('title')).toBe( + 'Run this entry' + ) + expect(shadowAll(row, STOP_BUTTON)).toHaveLength(0) + }) + + it('replaces the run button with a stop button while the row runs', async () => { + const row = await mountRow(rowProps(mixedStateRun.running)) + + expect(shadowAll(row, STOP_BUTTON)).toHaveLength(1) + expect(shadowAll(row, RUN_BUTTON)).toHaveLength(0) + expect(shadowAll(row, RERUN_BUTTON)).toHaveLength(0) + }) + + it('offers a preserve-and-rerun button only after a failure', async () => { + const failed = await mountRow(rowProps(mixedStateRun.failing)) + const passed = await mountRow(rowProps(mixedStateRun.passing)) + + expect(shadowAll(failed, RERUN_BUTTON)).toHaveLength(1) + expect(shadowAll(passed, RERUN_BUTTON)).toHaveLength(0) + }) + + it('emits app-test-run carrying the row identity', async () => { + // The Gherkin scenario is the only fixture carrying feature coordinates, + // and it derives to 'failed' from its one failing step โ€” so the run + // control is present and every identity field below is derived. + const scenario = gherkinRun.scenario + const row = await mountRow(rowProps(scenario)) + + const received = capture<TestRunDetail>(row, 'app-test-run', () => + shadow(row, RUN_BUTTON)?.click() + ) + + expect(received).toHaveLength(1) + expect(received[0]?.detail).toEqual({ + uid: scenario.uid, + entryType: 'suite', + specFile: FEATURE_FILE, + fullTitle: scenario.title, + label: scenario.title, + callSource: scenario.callSource, + featureFile: FEATURE_FILE, + featureLine: scenario.featureLine, + suiteType: scenario.type + }) + expect(received[0]?.composed).toBe(true) + }) + + it('emits app-test-stop for the running row without the feature fields', async () => { + const step = gherkinRun.runningStep + const row = await mountRow(rowProps(step)) + + const received = capture<TestRunDetail>(row, 'app-test-stop', () => + shadow(row, STOP_BUTTON)?.click() + ) + + expect(received).toHaveLength(1) + expect(received[0]?.detail).toEqual({ + uid: step.uid, + entryType: 'test', + specFile: FEATURE_FILE, + fullTitle: step.fullTitle, + label: step.title + }) + // The row does carry them โ€” the stop detail deliberately leaves them out. + expect(row.featureFile).toBe(FEATURE_FILE) + expect(received[0]?.detail.featureFile).toBeUndefined() + expect(received[0]?.detail.featureLine).toBeUndefined() + }) + + it('emits app-test-preserve-rerun for the failed row', async () => { + const scenario = gherkinRun.scenario + const row = await mountRow(rowProps(scenario)) + + const received = capture<TestRunDetail>( + row, + 'app-test-preserve-rerun', + () => shadow(row, RERUN_BUTTON)?.click() + ) + + expect(received).toHaveLength(1) + expect(received[0]?.detail.uid).toBe(scenario.uid) + expect(received[0]?.detail.suiteType).toBe(scenario.type) + expect(received[0]?.composed).toBe(true) + }) + + it('disables the run button and explains why when running is not supported', async () => { + // Hand-set: `runDisabled` comes from the runner's capabilities in the + // metadata context, which no suite or test fragment carries. + const row = await mountRow({ + runDisabled: true, + runDisabledReason: CUCUMBER_REASON + }) + + const button = shadow<HTMLButtonElement>(row, RUN_BUTTON) + expect(button?.disabled).toBe(true) + expect(button?.getAttribute('title')).toBe(CUCUMBER_REASON) + expect(button?.classList.contains('cursor-not-allowed')).toBe(true) + }) + + it('explains a disabled row generically when no reason was supplied', async () => { + // Hand-set for the same reason as above. + const row = await mountRow({ runDisabled: true }) + + expect(shadow(row, RUN_BUTTON)?.getAttribute('title')).toBe( + STEP_FALLBACK_REASON + ) + }) + + it('refuses to emit a run request from a disabled row', async () => { + const row = await mountRow({ runDisabled: true }) + + // dispatchEvent, not click(): click() is a no-op on a disabled button, so + // it would pass without the guard inside the handler being exercised. + const received = capture<TestRunDetail>(row, 'app-test-run', () => + shadow(row, RUN_BUTTON)?.dispatchEvent(new MouseEvent('click')) + ) + + expect(received).toHaveLength(0) + }) + + it('keeps the stop button on a running row the runner cannot start', async () => { + // `runDisabled` is a *launch* capability. A run already in flight is + // stoppable whatever the framework is able to start, so the stop control + // survives it โ€” otherwise a Nightwatch run could never be stopped. + const row = await mountRow( + rowProps(mixedStateRun.running, { runDisabled: true }) + ) + + expect(shadowAll(row, STOP_BUTTON)).toHaveLength(1) + expect(shadowAll(row, RUN_BUTTON)).toHaveLength(0) + }) + + it('emits app-test-stop from a running row the runner cannot start', async () => { + const step = mixedStateRun.running + const row = await mountRow(rowProps(step, { runDisabled: true })) + + const received = capture<TestRunDetail>(row, 'app-test-stop', () => + shadow(row, STOP_BUTTON)?.click() + ) + + expect(received).toHaveLength(1) + expect(received[0]?.detail.uid).toBe(step.uid) + }) + + it('drops the rerun button from a failed row that cannot be run', async () => { + const row = await mountRow( + rowProps(mixedStateRun.failing, { runDisabled: true }) + ) + + expect(shadowAll(row, RERUN_BUTTON)).toHaveLength(0) + expect(shadowAll(row, RUN_BUTTON)).toHaveLength(1) + }) + }) + + describe('collapsing', () => { + it('hides the chevron on a row without children', async () => { + const row = await mountRow(rowProps(mixedStateRun.passing)) + + expect(row.hasChildren).toBe(false) + expect(shadow(row, CHEVRON_BUTTON)?.classList.contains('hidden')).toBe( + true + ) + }) + + it('offers no collapse control on a row without children', async () => { + const row = await mountRow(rowProps(mixedStateRun.passing)) + + expect(shadowAll(row, TOOLBAR_BUTTON)).toHaveLength(1) + expect(shadowAll(row, EXPAND_ALL_ICON)).toHaveLength(0) + expect(shadowAll(row, COLLAPSE_ALL_ICON)).toHaveLength(0) + }) + + it('shows the chevron and a collapse control on a row with children', async () => { + // A suite fragment with tests derives both `hasChildren` and its passed + // state, so the row under test is the one the explorer would render. + const row = await mountRow(rowProps(profileSuite)) + + expect(row.hasChildren).toBe(true) + expect(row.state).toBe('passed') + expect(shadow(row, CHEVRON_BUTTON)?.classList.contains('hidden')).toBe( + false + ) + expect(shadowAll(row, TOOLBAR_BUTTON)).toHaveLength(2) + // The row starts out rendering its children, so the control it offers is + // the one that collapses them. + expect(childrenSection(row)?.classList.contains('hidden')).toBe(false) + expect(shadowAll(row, COLLAPSE_ALL_ICON)).toHaveLength(1) + expect(shadowAll(row, EXPAND_ALL_ICON)).toHaveLength(0) + }) + + it('hides the children from the toolbar collapse control too', async () => { + const row = await mountRow(rowProps(profileSuite)) + + shadow(row, COLLAPSE_ALL_ICON)?.click() + await settle(row) + + expect(childrenSection(row)?.classList.contains('hidden')).toBe(true) + expect(shadowAll(row, EXPAND_ALL_ICON)).toHaveLength(1) + }) + + it('collapses the children section when the chevron is clicked', async () => { + const row = await mountRow(rowProps(profileSuite)) + expect(childrenSection(row)?.classList.contains('hidden')).toBe(false) + + shadow(row, CHEVRON_BUTTON)?.click() + await settle(row) + + expect(row.isCollapsed).toBe(true) + // Reflected, so a tree-wide control can read the row's state off the DOM. + expect(row.hasAttribute('is-collapsed')).toBe(true) + expect(childrenSection(row)?.classList.contains('hidden')).toBe(true) + }) + + it('rotates the chevron while collapsed', async () => { + const row = await mountRow(rowProps(profileSuite)) + expect(shadow(row, CHEVRON)?.classList.contains('-rotate-90')).toBe(false) + + shadow(row, CHEVRON_BUTTON)?.click() + await settle(row) + + expect(shadow(row, CHEVRON)?.classList.contains('-rotate-90')).toBe(true) + }) + + it('expands the children section again on a second click', async () => { + const row = await mountRow(rowProps(profileSuite)) + + shadow(row, CHEVRON_BUTTON)?.click() + await settle(row) + shadow(row, CHEVRON_BUTTON)?.click() + await settle(row) + + expect(row.isCollapsed).toBe(false) + expect(row.hasAttribute('is-collapsed')).toBe(false) + expect(childrenSection(row)?.classList.contains('hidden')).toBe(false) + }) + + it('reports the new collapsed state on entry-collapse-change', async () => { + const row = await mountRow(rowProps(profileSuite)) + + const received = capture<{ isCollapsed: boolean; entry: Element }>( + row, + 'entry-collapse-change', + () => shadow(row, CHEVRON_BUTTON)?.click() + ) + + expect(received).toHaveLength(1) + expect(received[0]?.detail.isCollapsed).toBe(true) + expect(received[0]?.detail.entry).toBe(row) + }) + + it('keeps entry-collapse-change inside the tree that owns the row', async () => { + const row = await mountRow(rowProps(profileSuite)) + + const received = capture<{ isCollapsed: boolean }>( + row, + 'entry-collapse-change', + () => shadow(row, CHEVRON_BUTTON)?.click() + ) + + // Not composed: the explorer listens on its own shadow root, so the event + // must stop at the boundary rather than escaping to the document. + expect(received[0]?.bubbles).toBe(true) + expect(received[0]?.composed).toBe(false) + }) + + it('tracks the children with the toolbar control through a collapse and back', async () => { + const row = await mountRow(rowProps(profileSuite)) + + shadow(row, CHEVRON_BUTTON)?.click() + await settle(row) + + // Children hidden, so the control on offer is the one that shows them. + expect(childrenSection(row)?.classList.contains('hidden')).toBe(true) + expect(shadowAll(row, EXPAND_ALL_ICON)).toHaveLength(1) + expect(shadowAll(row, COLLAPSE_ALL_ICON)).toHaveLength(0) + + shadow(row, CHEVRON_BUTTON)?.click() + await settle(row) + + expect(childrenSection(row)?.classList.contains('hidden')).toBe(false) + expect(shadowAll(row, COLLAPSE_ALL_ICON)).toHaveLength(1) + expect(shadowAll(row, EXPAND_ALL_ICON)).toHaveLength(0) + }) + }) +}) diff --git a/packages/app/test-ui/sidebar/explorer/test-suite.test.ts b/packages/app/test-ui/sidebar/explorer/test-suite.test.ts new file mode 100644 index 00000000..f2788f2c --- /dev/null +++ b/packages/app/test-ui/sidebar/explorer/test-suite.test.ts @@ -0,0 +1,255 @@ +import '@components/sidebar/test-suite.js' +import type { + ExplorerTestEntry, + ExplorerTestSuite +} from '@components/sidebar/test-suite.js' +import type { TestRunDetail } from '@components/sidebar/types.js' + +import { mount, settle } from '../../support/mount.js' +import { shadow, shadowAll, text } from '../../support/queries.js' +import { entryProps, mixedStateRun, nestedRun, rowProps } from '../fixtures.js' +import type { NamedSuite, NamedTest, TestEntryProps } from '../fixtures.js' + +const SUITE = 'wdio-test-suite' +const ENTRY = 'wdio-test-entry' +const GROUP_SLOT = 'slot:not([name])' +const CHILDREN_SLOT = 'slot[name="children"]' +const LABEL_SPAN = 'section.row > span' +const CHEVRON_BUTTON = 'section.row > button' +const RUN_BUTTON = 'nav.row-actions button:has(icon-mdi-play)' + +const assigned = (host: Element, selector: string): Element[] => + shadow<HTMLSlotElement>(host, selector)?.assignedElements() ?? [] + +const uidsOf = (rows: Element[]) => + rows.map((entry) => (entry as ExplorerTestEntry).uid) + +function buildRow(props: TestEntryProps, label?: string): ExplorerTestEntry { + const resolved = entryProps(props) + const entry = document.createElement(ENTRY) + Object.assign(entry, resolved) + const title = document.createElement('label') + title.slot = 'label' + title.textContent = label ?? resolved.labelText ?? '' + entry.append(title) + return entry +} + +/** One row per fragment, with its state, type and label derived by the same + * helpers the explorer uses โ€” the group specs assert composition, so their + * rows should be the rows the explorer would hand them. */ +const rowFor = (fragment: NamedSuite | NamedTest): ExplorerTestEntry => + buildRow(rowProps(fragment)) + +/** Nest a group in a row's `children` slot the way the explorer does for a + * suite that has descendants. */ +function groupUnder( + parent: ExplorerTestEntry, + rows: ExplorerTestEntry[] +): ExplorerTestSuite { + const group = document.createElement(SUITE) + group.slot = 'children' + group.append(...rows) + parent.hasChildren = true + parent.append(group) + return group +} + +/** Every descendant has to finish its own first render โ€” awaiting the group + * alone resolves before the rows it was just handed have rendered. */ +async function mountGroup( + rows: ExplorerTestEntry[] +): Promise<ExplorerTestSuite> { + const group = await mount<ExplorerTestSuite>(SUITE) + group.append(...rows) + await settle(group) + for (const nested of Array.from(group.querySelectorAll(SUITE))) { + await settle(nested) + } + for (const entry of Array.from(group.querySelectorAll(ENTRY))) { + await settle(entry) + } + return group +} + +function capture<T>( + target: EventTarget, + type: string, + act: () => void +): CustomEvent<T>[] { + const received: CustomEvent<T>[] = [] + const listener = (event: Event) => received.push(event as CustomEvent<T>) + target.addEventListener(type, listener) + try { + act() + } finally { + target.removeEventListener(type, listener) + } + return received +} + +const checkoutRows = () => [ + rowFor(mixedStateRun.passing), + rowFor(mixedStateRun.failing), + rowFor(mixedStateRun.running), + rowFor(mixedStateRun.skipped) +] + +/** The shape the explorer builds for a suite with descendants: a row whose + * `children` slot holds its own group. */ +const nestedTree = () => { + const tests = [ + rowFor(nestedRun.signsIn), + rowFor(nestedRun.rejectsBadPassword) + ] + const scenario = rowFor(nestedRun.scenario) + const inner = groupUnder(scenario, tests) + return { tests, scenario, inner } +} + +describe('wdio-test-suite', () => { + describe('grouping', () => { + it('projects its rows in the order they were added', async () => { + const rows = checkoutRows() + const group = await mountGroup(rows) + + const projected = assigned(group, GROUP_SLOT) + expect(projected).toHaveLength(4) + expect(uidsOf(projected)).toEqual([ + mixedStateRun.passing.uid, + mixedStateRun.failing.uid, + mixedStateRun.running.uid, + mixedStateRun.skipped.uid + ]) + expect(projected[0]).toBe(rows[0]) + expect(projected[3]).toBe(rows[3]) + }) + + it('renders every row title in the group', async () => { + const group = await mountGroup(checkoutRows()) + + expect( + Array.from(group.querySelectorAll(`${ENTRY} > label`)).map((label) => + text(label) + ) + ).toEqual([ + mixedStateRun.passing.title, + mixedStateRun.failing.title, + mixedStateRun.running.title, + mixedStateRun.skipped.title + ]) + }) + + it('adds no chrome of its own around the rows', async () => { + const group = await mountGroup(checkoutRows()) + + expect(shadowAll(group, GROUP_SLOT)).toHaveLength(1) + expect(group.shadowRoot?.querySelectorAll('button').length).toBe(0) + // The slot's own text is its fallback content: the group renders none. + expect(text(shadow(group, GROUP_SLOT))).toBe('') + }) + + it('renders an empty group when it holds no rows', async () => { + const group = await mountGroup([]) + + expect(assigned(group, GROUP_SLOT)).toHaveLength(0) + expect(group.querySelectorAll(ENTRY).length).toBe(0) + }) + }) + + describe('nesting', () => { + it("nests a suite's own group inside its row", async () => { + const { scenario, inner, tests } = nestedTree() + await mountGroup([scenario]) + + expect(assigned(scenario, CHILDREN_SLOT)[0]).toBe(inner) + expect(uidsOf(assigned(inner, GROUP_SLOT))).toEqual([ + nestedRun.signsIn.uid, + nestedRun.rejectsBadPassword.uid + ]) + expect(assigned(inner, GROUP_SLOT)[0]).toBe(tests[0]) + }) + + it('keeps each group with the row that owns it', async () => { + const first = nestedTree() + const second = nestedTree() + second.scenario.uid = `${nestedRun.scenario.uid}-2` + await mountGroup([first.scenario, second.scenario]) + + expect(assigned(first.scenario, CHILDREN_SLOT)[0]).toBe(first.inner) + expect(assigned(second.scenario, CHILDREN_SLOT)[0]).toBe(second.inner) + expect(assigned(first.inner, GROUP_SLOT)[0]).toBe(first.tests[0]) + expect(assigned(second.inner, GROUP_SLOT)[0]).toBe(second.tests[0]) + }) + + it('hides the whole group when its parent row collapses', async () => { + const { scenario, inner } = nestedTree() + await mountGroup([scenario]) + const section = shadow(scenario, CHILDREN_SLOT)?.parentElement + expect(section?.classList.contains('hidden')).toBe(false) + + shadow(scenario, CHEVRON_BUTTON)?.click() + await settle(scenario) + + expect(assigned(scenario, CHILDREN_SLOT)[0]).toBe(inner) + expect( + shadow(scenario, CHILDREN_SLOT)?.parentElement?.classList.contains( + 'hidden' + ) + ).toBe(true) + }) + }) + + describe('event delegation', () => { + it("lets a row's selection reach a listener above the group", async () => { + const rows = checkoutRows() + const group = await mountGroup(rows) + + const received = capture<string>(group, 'app-test-select', () => + shadow(rows[1], LABEL_SPAN)?.click() + ) + + expect(received).toHaveLength(1) + expect(received[0]?.detail).toBe(mixedStateRun.failing.uid) + }) + + it("lets a row's run request reach a listener above the group", async () => { + const rows = checkoutRows() + const group = await mountGroup(rows) + + const received = capture<TestRunDetail>(group, 'app-test-run', () => + shadow(rows[0], RUN_BUTTON)?.click() + ) + + expect(received).toHaveLength(1) + expect(received[0]?.detail.uid).toBe(mixedStateRun.passing.uid) + }) + + it('carries a run request out of a nested group as well', async () => { + const { scenario, tests } = nestedTree() + const group = await mountGroup([scenario]) + + const received = capture<TestRunDetail>(group, 'app-test-run', () => + shadow(tests[0], RUN_BUTTON)?.click() + ) + + expect(received).toHaveLength(1) + expect(received[0]?.detail.uid).toBe(nestedRun.signsIn.uid) + }) + + it("carries a row's collapse change up to the group", async () => { + const { scenario } = nestedTree() + const group = await mountGroup([scenario]) + + const received = capture<{ isCollapsed: boolean; entry: Element }>( + group, + 'entry-collapse-change', + () => shadow(scenario, CHEVRON_BUTTON)?.click() + ) + + expect(received).toHaveLength(1) + expect(received[0]?.detail.isCollapsed).toBe(true) + expect(received[0]?.detail.entry).toBe(scenario) + }) + }) +}) diff --git a/packages/app/test-ui/sidebar/filter.test.ts b/packages/app/test-ui/sidebar/filter.test.ts new file mode 100644 index 00000000..082e9b20 --- /dev/null +++ b/packages/app/test-ui/sidebar/filter.test.ts @@ -0,0 +1,151 @@ +import '@components/sidebar/filter.js' +import type { DevtoolsSidebarFilter } from '@components/sidebar/filter.js' +import { KBD } from '@/controller/keyboard.js' + +import { mount } from '../support/mount.js' +import { shadow, shadowAll } from '../support/queries.js' + +const TAG = 'wdio-devtools-sidebar-filter' +const FIELD = 'input[name="filter"]' +const ICON = 'icon-mdi-magnify' + +function field(el: DevtoolsSidebarFilter): HTMLInputElement { + const input = shadow<HTMLInputElement>(el, FIELD) + if (!input) { + throw new Error('no filter field rendered') + } + return input +} + +/** The component reads the field on `keyup`, so a query has to arrive the way a + * keystroke delivers it: value first, then the key event. */ +function type(el: DevtoolsSidebarFilter, query: string): void { + const input = field(el) + input.value = query + input.dispatchEvent(new KeyboardEvent('keyup', { key: query.slice(-1) })) +} + +/** The filter broadcasts on `window`, the way the explorer receives it. */ +function captureFilter(act: () => void): CustomEvent<DevtoolsSidebarFilter>[] { + const received: CustomEvent<DevtoolsSidebarFilter>[] = [] + const listener = (event: Event) => + received.push(event as CustomEvent<DevtoolsSidebarFilter>) + window.addEventListener('app-test-filter', listener) + try { + act() + } finally { + window.removeEventListener('app-test-filter', listener) + } + return received +} + +/** The detail is the component itself rather than a snapshot, so the query has + * to be read as the event lands โ€” by assertion time the field has moved on. */ +function captureQueries(act: () => void): string[] { + const queries: string[] = [] + const listener = (event: Event) => + queries.push( + (event as CustomEvent<DevtoolsSidebarFilter>).detail.filterQuery + ) + window.addEventListener('app-test-filter', listener) + try { + act() + } finally { + window.removeEventListener('app-test-filter', listener) + } + return queries +} + +describe('wdio-devtools-sidebar-filter', () => { + describe('the field', () => { + it('renders a search field naming both query forms it accepts', async () => { + const el = await mount<DevtoolsSidebarFilter>(TAG) + + expect(field(el).placeholder).toBe('Filter (e.g. text, @tag)') + expect(shadowAll(el, ICON)).toHaveLength(1) + }) + + it('starts with an empty query', async () => { + const el = await mount<DevtoolsSidebarFilter>(TAG) + + expect(el.filterQuery).toBe('') + expect(field(el).value).toBe('') + }) + }) + + describe('announcing the query', () => { + it('broadcasts itself as the filter when a query is typed', async () => { + const el = await mount<DevtoolsSidebarFilter>(TAG) + + const received = captureFilter(() => type(el, 'discount')) + + expect(received).toHaveLength(1) + expect(received[0]?.detail).toBe(el) + expect(el.filterQuery).toBe('discount') + }) + + it('broadcasts a filter that crosses shadow boundaries', async () => { + const el = await mount<DevtoolsSidebarFilter>(TAG) + + const received = captureFilter(() => type(el, 'discount')) + + expect(received[0]?.bubbles).toBe(true) + expect(received[0]?.composed).toBe(true) + }) + + it('broadcasts the growing query on every keystroke', async () => { + const el = await mount<DevtoolsSidebarFilter>(TAG) + + const queries = captureQueries(() => { + type(el, 'dis') + type(el, 'discount') + }) + + expect(queries).toEqual(['dis', 'discount']) + }) + + it('broadcasts an empty query once the field is cleared', async () => { + const el = await mount<DevtoolsSidebarFilter>(TAG) + type(el, 'discount') + + const queries = captureQueries(() => type(el, '')) + + expect(queries).toEqual(['']) + expect(el.filterQuery).toBe('') + }) + + // Tag matching and case folding belong to the tree filter, so the query is + // carried verbatim rather than parsed here. + it('carries a tag query through with its sigil intact', async () => { + const el = await mount<DevtoolsSidebarFilter>(TAG) + + expect(captureQueries(() => type(el, '@smoke'))).toEqual(['@smoke']) + }) + + it('carries the query through in the case it was typed', async () => { + const el = await mount<DevtoolsSidebarFilter>(TAG) + + expect(captureQueries(() => type(el, 'DISCOUNT'))).toEqual(['DISCOUNT']) + }) + }) + + describe('the focus shortcut', () => { + it('focuses the field when the app asks for it', async () => { + const el = await mount<DevtoolsSidebarFilter>(TAG) + + window.dispatchEvent(new CustomEvent(KBD.focusFilter)) + + expect(el.shadowRoot?.activeElement).toBe(field(el)) + }) + + it('stops answering the shortcut once it leaves the page', async () => { + const el = await mount<DevtoolsSidebarFilter>(TAG) + const input = field(el) + el.remove() + + window.dispatchEvent(new CustomEvent(KBD.focusFilter)) + + expect(el.shadowRoot?.activeElement).not.toBe(input) + }) + }) +}) diff --git a/packages/app/test-ui/sidebar/fixtures.ts b/packages/app/test-ui/sidebar/fixtures.ts new file mode 100644 index 00000000..dcad5dc3 --- /dev/null +++ b/packages/app/test-ui/sidebar/fixtures.ts @@ -0,0 +1,343 @@ +// Suite trees for the sidebar specs. No two test titles share a distinctive +// word, and no suite title shares a word with its own tests โ€” so a filter that +// keeps a suite whose children were all filtered out has exactly one +// explanation, and a status assertion has exactly one right answer. + +import { TraceType } from '@wdio/devtools-shared' +import type { Metadata, TestStatus } from '@wdio/devtools-shared' + +import type { + SuiteStatsFragment, + TestStatsFragment +} from '../../src/controller/types.js' +import { + computeEntryState, + getTestEntry +} from '@/components/sidebar/test-entry-state.js' +import type { ExplorerTestEntry } from '@/components/sidebar/test-suite.js' +import type { RunnerOptions } from '@/components/sidebar/types.js' + +/** The sidebar only checks *whether* a test carries an `end` stamp, never its + * value, so one fixed stamp serves every fixture. */ +export const FINISHED_AT = new Date(1_700_000_000_000) + +export const SPEC_FILE = '/repo/test/checkout.e2e.ts' +export const CALL_SOURCE = 'checkout.e2e.ts:12:5' + +/** Fragments whose title is guaranteed present, so specs build their expected + * row labels from the fixture instead of restating strings. */ +export type NamedTest = TestStatsFragment & { title: string } +export type NamedSuite = SuiteStatsFragment & { title: string } + +export function testFragment( + uid: string, + title: string, + overrides: Omit<Partial<TestStatsFragment>, 'uid' | 'title'> = {} +): NamedTest { + return { uid, title, fullTitle: title, file: SPEC_FILE, ...overrides } +} + +/** `tests` is defaulted, not optional-by-omission: the sidebar tells a suite + * from a test with `'tests' in entry`, so a suite fragment missing the key is + * read as a leaf โ€” no children, no `suiteType`. Real `SuiteStats` always + * carries the array. */ +export function suiteFragment( + uid: string, + title: string, + overrides: Omit<Partial<SuiteStatsFragment>, 'uid' | 'title'> = {} +): NamedSuite { + return { + uid, + title, + fullTitle: title, + file: SPEC_FILE, + tests: [], + ...overrides + } +} + +/** The `suiteContext` value: the registry reaches the app as an array of + * uid-keyed chunks, and only a suite without a `parent` starts a tree. */ +export function suiteRegistry( + ...suites: SuiteStatsFragment[] +): Record<string, SuiteStatsFragment>[] { + return [Object.fromEntries(suites.map((suite) => [suite.uid, suite]))] +} + +const CHECKOUT_TITLE = 'Checkout flow' + +const passing = testFragment('checkout-cart', 'adds an item to the cart', { + fullTitle: `${CHECKOUT_TITLE} adds an item to the cart`, + state: 'passed', + callSource: CALL_SOURCE, + end: FINISHED_AT +}) + +const failing = testFragment('checkout-discount', 'applies a discount code', { + fullTitle: `${CHECKOUT_TITLE} applies a discount code`, + state: 'failed', + callSource: 'checkout.e2e.ts:24:5', + end: FINISHED_AT +}) + +const running = testFragment('checkout-order', 'submits the order', { + fullTitle: `${CHECKOUT_TITLE} submits the order`, + state: 'running' +}) + +const skipped = testFragment('checkout-receipt', 'prints the receipt', { + fullTitle: `${CHECKOUT_TITLE} prints the receipt`, + state: 'skipped' +}) + +const checkoutSuite = suiteFragment('checkout-suite', CHECKOUT_TITLE, { + tests: [passing, failing, running, skipped] +}) + +export interface MixedStateRun { + registry: Record<string, SuiteStatsFragment>[] + suite: NamedSuite + passing: NamedTest + failing: NamedTest + running: NamedTest + skipped: NamedTest + /** Row labels top to bottom: the root suite, then its tests in registry + * order โ€” the explorer renders tests before nested suites. */ + rowLabels: string[] +} + +/** One root suite carrying every per-test state the sidebar can render. */ +export const mixedStateRun: MixedStateRun = { + registry: suiteRegistry(checkoutSuite), + suite: checkoutSuite, + passing, + failing, + running, + skipped, + rowLabels: [ + CHECKOUT_TITLE, + passing.title, + failing.title, + running.title, + skipped.title + ] +} + +/** + * One root suite whose tests carry exactly the states listed, `undefined` for a + * test that never started. The summary tallies leaf states and nothing else, so + * a run it renders is fully described by its list of states โ€” the titles are + * generated because no summary assertion reads them. + */ +export function summaryRun( + ...states: (TestStatus | undefined)[] +): Record<string, SuiteStatsFragment>[] { + return suiteRegistry( + suiteFragment('summary-suite', 'Checkout flow', { + tests: states.map((state, index) => + testFragment(`summary-step-${index}`, `step ${index + 1}`, { + ...(state ? { state } : {}), + ...(state && state !== 'running' ? { end: FINISHED_AT } : {}) + }) + ) + }) + ) +} + +/** A test the run never reached: no state, no `end`. The only fragment shape + * that derives to `'pending'` โ€” a leaf *reported* pending is a run in + * progress and derives to `'running'`. Kept out of `mixedStateRun` so the + * explorer's row counts stay as they are. */ +export const neverRanTest = testFragment( + 'checkout-invoice', + 'emails the invoice' +) + +/** A second root with nothing failing and nothing running โ€” the sibling a + * status or query filter is expected to drop entirely. */ +export const profileSuite = suiteFragment('profile-suite', 'Profile page', { + tests: [ + testFragment('profile-avatar', 'uploads an avatar', { + state: 'passed', + end: FINISHED_AT + }), + testFragment('profile-email', 'changes the email address', { + state: 'passed', + end: FINISHED_AT + }) + ] +}) + +export interface NestedRun { + registry: Record<string, SuiteStatsFragment>[] + /** Root whose children are suites, which the tree tags as a feature. */ + feature: NamedSuite + scenario: NamedSuite + signsIn: NamedTest + rejectsBadPassword: NamedTest + rowLabels: string[] +} + +const signsIn = testFragment('login-valid', 'signs in with valid credentials', { + state: 'passed', + end: FINISHED_AT +}) + +const rejectsBadPassword = testFragment( + 'login-invalid', + 'rejects a bad password', + { state: 'passed', end: FINISHED_AT } +) + +const scenarioSuite = suiteFragment('login-scenario', 'Sign in', { + type: 'scenario', + parent: 'Login feature', + tests: [signsIn, rejectsBadPassword] +}) + +const featureSuite = suiteFragment('login-feature', 'Login feature', { + suites: [scenarioSuite] +}) + +/** A two-level tree, registered flat the way the backend sends it: the nested + * scenario is its own registry entry and carries a `parent`. */ +export const nestedRun: NestedRun = { + registry: suiteRegistry(featureSuite, scenarioSuite), + feature: featureSuite, + scenario: scenarioSuite, + signsIn, + rejectsBadPassword, + rowLabels: [ + featureSuite.title, + scenarioSuite.title, + signsIn.title, + rejectsBadPassword.title + ] +} + +export const FEATURE_FILE = '/repo/test/refund.feature' + +const refundStepFails = testFragment('refund-gateway', 'calls the gateway', { + state: 'failed', + file: FEATURE_FILE, + featureFile: FEATURE_FILE, + featureLine: 10, + callSource: 'refund.feature:10', + end: FINISHED_AT +}) + +/** A step of a scenario that is still executing, so the row it derives is + * running *and* carries feature coordinates โ€” which is what makes it the + * input for "the stop request leaves the feature fields out". */ +const refundStepRuns = testFragment('refund-email', 'emails the customer', { + state: 'running', + file: FEATURE_FILE, + featureFile: FEATURE_FILE, + featureLine: 12 +}) + +/** Cucumber stops a scenario at its first failing step, so the scenario holds + * only that step and derives to `failed` โ€” the state its run controls need. */ +const refundScenario = suiteFragment( + 'refund-scenario', + 'Refunds a paid order', + { + type: 'scenario', + file: FEATURE_FILE, + featureFile: FEATURE_FILE, + featureLine: 8, + callSource: 'refund.feature:8', + tests: [refundStepFails] + } +) + +/** The only fixture carrying feature coordinates: the run/stop/rerun details a + * Gherkin row emits are the ones that forward them. */ +export const gherkinRun = { + scenario: refundScenario, + failingStep: refundStepFails, + runningStep: refundStepRuns +} + +export const mochaRunnerOptions: RunnerOptions = { + framework: 'mocha', + configFilePath: '/repo/wdio.conf.ts', + rerunCommand: 'npx wdio run wdio.conf.ts --spec', + launchCommand: 'npm run test:e2e' +} + +const runnerMetadata = (options: RunnerOptions): Metadata => ({ + type: TraceType.Testrunner, + options +}) + +export const mochaMetadata = runnerMetadata(mochaRunnerOptions) + +/** Cucumber drives its own steps, so a single test cannot be run alone. */ +export const cucumberMetadata = runnerMetadata({ framework: 'cucumber' }) + +/** Nightwatch exposes no run-everything entry point. */ +export const nightwatchMetadata = runnerMetadata({ framework: 'nightwatch' }) + +export type TestEntryProps = Partial< + Pick< + ExplorerTestEntry, + | 'uid' + | 'state' + | 'labelText' + | 'entryType' + | 'callSource' + | 'specFile' + | 'fullTitle' + | 'featureFile' + | 'featureLine' + | 'suiteType' + | 'hasChildren' + | 'selected' + | 'root' + | 'runDisabled' + | 'runDisabledReason' + | 'isCollapsed' + > +> + +/** + * Row props for one fragment, derived the way the explorer derives them: the + * real `getTestEntry` over the fragment, then the same `TestEntry` โ†’ row + * mapping `explorer.ts`'s `#renderEntry` performs. A spec that wants a row in + * some state names a fragment that *produces* that state, so the assertion is + * sourced from `test-entry-state.ts` instead of restating its conclusion. + * + * `overrides` carry the props no fragment can produce โ€” a tree position + * (`root`, `selected`), a runner capability (`runDisabled`) or an off-contract + * value. Each spec that passes one says why. + */ +export function rowProps( + fragment: TestStatsFragment | SuiteStatsFragment, + overrides: TestEntryProps = {} +): TestEntryProps { + const entry = getTestEntry(fragment, () => true) + return { + uid: entry.uid, + labelText: entry.label, + entryType: entry.type, + // `TestEntry.state` is the widened `string`, the row property the narrow + // `TestStatus`, so the state is read from the same helper the tree derives + // it with rather than cast across that gap. + state: computeEntryState(fragment), + specFile: entry.specFile, + fullTitle: entry.fullTitle, + callSource: entry.callSource, + featureFile: entry.featureFile, + featureLine: entry.featureLine, + suiteType: entry.suiteType, + hasChildren: entry.children.length > 0, + ...overrides + } +} + +/** Property bag for one `wdio-test-entry`. Defaulted to `mixedStateRun`'s + * passing row so the row specs and the explorer spec name the same test. */ +export function entryProps(overrides: TestEntryProps = {}): TestEntryProps { + return rowProps(passing, overrides) +} diff --git a/packages/app/test-ui/sidebar/summary.test.ts b/packages/app/test-ui/sidebar/summary.test.ts new file mode 100644 index 00000000..8daf7934 --- /dev/null +++ b/packages/app/test-ui/sidebar/summary.test.ts @@ -0,0 +1,279 @@ +import '@components/sidebar/summary.js' +import type { DevtoolsSidebarSummary } from '@components/sidebar/summary.js' +import type { + StatusFilterDetail, + TestStatus +} from '@components/sidebar/types.js' +import { suiteContext } from '@/controller/context.js' +import type { SuiteStatsFragment } from '@/controller/types.js' + +import { mount, mountWithContext, settle } from '../support/mount.js' +import { shadow, shadowAll, text, texts } from '../support/queries.js' +import { + mixedStateRun, + nestedRun, + profileSuite, + suiteFragment, + suiteRegistry, + summaryRun +} from './fixtures.js' + +const TAG = 'wdio-devtools-sidebar-summary' +const CARD = '.card' +const PILL = '.pill' +const COUNT = '.count' +const SEGMENT = '.progress > span' +const CHIP = '.legend button' + +const mountSummary = (registry: Record<string, SuiteStatsFragment>[]) => + mountWithContext<DevtoolsSidebarSummary>(TAG, [ + { context: suiteContext, value: registry } + ]) + +const segmentWidths = (el: Element) => + shadowAll<HTMLElement>(el, SEGMENT).map((segment) => segment.style.width) + +const pressed = (el: Element) => + shadowAll(el, CHIP).map((chip) => chip.getAttribute('aria-pressed')) + +function chip(el: Element, status: string): HTMLElement { + const button = shadow<HTMLElement>(el, `${CHIP}.${status}`) + if (!button) { + throw new Error(`no legend chip rendered for "${status}"`) + } + return button +} + +/** The chips broadcast on `window`, the way the explorer receives them. */ +function captureStatusFilter(act: () => void): (TestStatus | null)[] { + const received: (TestStatus | null)[] = [] + const listener = (event: Event) => + received.push((event as CustomEvent<StatusFilterDetail>).detail.status) + window.addEventListener('app-status-filter', listener) + try { + act() + } finally { + window.removeEventListener('app-status-filter', listener) + } + return received +} + +describe('wdio-devtools-sidebar-summary', () => { + describe('counts', () => { + it("reports how many of the run's tests have passed", async () => { + const el = await mountSummary(mixedStateRun.registry) + + expect(text(shadow(el, COUNT))).toBe('1/4 passed') + }) + + it('counts skipped tests in the total but not in the passed tally', async () => { + const el = await mountSummary(summaryRun('passed', 'skipped')) + + expect(text(shadow(el, COUNT))).toBe('1/2 passed') + }) + + it('counts the tests of a nested suite once', async () => { + const el = await mountSummary(nestedRun.registry) + + expect(text(shadow(el, COUNT))).toBe('2/2 passed') + }) + + it('counts a suite delivered in two registry chunks once', async () => { + const el = await mountSummary([ + ...mixedStateRun.registry, + ...mixedStateRun.registry + ]) + + expect(text(shadow(el, COUNT))).toBe('1/4 passed') + }) + }) + + describe('run status', () => { + it('reports a run with a test still executing as running', async () => { + const el = await mountSummary(mixedStateRun.registry) + + expect(text(shadow(el, PILL))).toBe('Running') + expect(el.getAttribute('data-status')).toBe('running') + }) + + it('reports a queued test after a finished one as a run still in progress', async () => { + const el = await mountSummary(summaryRun('passed', undefined)) + + expect(text(shadow(el, PILL))).toBe('Running') + expect(el.getAttribute('data-status')).toBe('running') + }) + + it('reports a finished run carrying a failure as failed', async () => { + const el = await mountSummary(summaryRun('passed', 'failed')) + + expect(text(shadow(el, PILL))).toBe('Failed') + expect(el.getAttribute('data-status')).toBe('failed') + }) + + it('reports an all-passing run as passed', async () => { + const el = await mountSummary(suiteRegistry(profileSuite)) + + expect(text(shadow(el, PILL))).toBe('Passed') + expect(text(shadow(el, COUNT))).toBe('2/2 passed') + }) + + it('reports a run whose tests have not started yet as idle', async () => { + const el = await mountSummary(summaryRun(undefined, undefined)) + + expect(text(shadow(el, PILL))).toBe('Idle') + expect(text(shadow(el, COUNT))).toBe('0/2 passed') + }) + + // A run that verified nothing is not a passing run: reporting green off a + // passed tally of zero was a false green. + it('reports an all-skipped run as skipped', async () => { + const el = await mountSummary(summaryRun('skipped', 'skipped')) + + expect(text(shadow(el, PILL))).toBe('Skipped') + expect(text(shadow(el, COUNT))).toBe('0/2 passed') + }) + + it('tints the card yellow when the run only skipped', async () => { + const el = await mountSummary(summaryRun('skipped', 'skipped')) + + expect(el.style.getPropertyValue('--status')).toBe( + 'var(--vscode-charts-yellow)' + ) + }) + + it('tints the card blue while the run is in progress', async () => { + const el = await mountSummary(mixedStateRun.registry) + + expect(el.style.getPropertyValue('--status')).toBe( + 'var(--vscode-charts-blue)' + ) + }) + + it('tints the card red once the run has failed', async () => { + const el = await mountSummary(summaryRun('failed')) + + expect(el.style.getPropertyValue('--status')).toBe( + 'var(--vscode-charts-red)' + ) + }) + }) + + describe('progress bar', () => { + it('sizes each segment by its share of the run', async () => { + const el = await mountSummary(mixedStateRun.registry) + + expect(segmentWidths(el)).toEqual(['25%', '25%', '25%']) + }) + + it('fills the bar for an all-passing run', async () => { + const el = await mountSummary(suiteRegistry(profileSuite)) + + expect(segmentWidths(el)).toEqual(['100%', '0%', '0%']) + }) + + it('leaves the bar empty for a run that only skipped', async () => { + const el = await mountSummary(summaryRun('skipped', 'skipped')) + + expect(segmentWidths(el)).toEqual(['0%', '0%', '0%']) + }) + + it('leaves the bar empty for a run that has not started', async () => { + const el = await mountSummary(summaryRun(undefined, undefined)) + + expect(segmentWidths(el)).toEqual(['0%', '0%', '0%']) + }) + }) + + describe('status chips', () => { + it('offers one chip per test status', async () => { + const el = await mountSummary(mixedStateRun.registry) + + expect(texts(el, CHIP)).toEqual([ + 'Passed', + 'Failed', + 'Running', + 'Skipped' + ]) + }) + + it('leaves every chip unpressed until one is picked', async () => { + const el = await mountSummary(mixedStateRun.registry) + + expect(pressed(el)).toEqual(['false', 'false', 'false', 'false']) + }) + + it('narrows the tree to the status of the chip picked', async () => { + const el = await mountSummary(mixedStateRun.registry) + + const received = captureStatusFilter(() => chip(el, 'failed').click()) + + expect(received).toEqual(['failed']) + }) + + it('marks only the picked chip as pressed', async () => { + const el = await mountSummary(mixedStateRun.registry) + + chip(el, 'failed').click() + await settle(el) + + expect(pressed(el)).toEqual(['false', 'true', 'false', 'false']) + }) + + it('clears the filter when the pressed chip is picked again', async () => { + const el = await mountSummary(mixedStateRun.registry) + chip(el, 'failed').click() + await settle(el) + + const received = captureStatusFilter(() => chip(el, 'failed').click()) + await settle(el) + + expect(received).toEqual([null]) + expect(pressed(el)).toEqual(['false', 'false', 'false', 'false']) + }) + + it('moves the filter to the next chip picked', async () => { + const el = await mountSummary(mixedStateRun.registry) + chip(el, 'failed').click() + await settle(el) + + const received = captureStatusFilter(() => chip(el, 'running').click()) + await settle(el) + + expect(received).toEqual(['running']) + expect(pressed(el)).toEqual(['false', 'false', 'true', 'false']) + }) + + it('filters on a status no test is in', async () => { + const el = await mountSummary(suiteRegistry(profileSuite)) + + const received = captureStatusFilter(() => chip(el, 'failed').click()) + + expect(received).toEqual(['failed']) + }) + }) + + describe('nothing to summarise', () => { + it('renders nothing until the suite registry arrives', async () => { + const el = await mount<DevtoolsSidebarSummary>(TAG) + + expect(shadowAll(el, CARD)).toHaveLength(0) + expect(el.hasAttribute('data-status')).toBe(false) + }) + + it('renders nothing for an empty registry', async () => { + const el = await mountSummary([]) + + expect(shadowAll(el, CARD)).toHaveLength(0) + expect(el.hasAttribute('data-status')).toBe(false) + }) + + it('renders nothing for a suite that holds no tests', async () => { + const el = await mountSummary( + suiteRegistry(suiteFragment('empty-suite', 'Reporting', { tests: [] })) + ) + + expect(shadowAll(el, CARD)).toHaveLength(0) + expect(shadowAll(el, CHIP)).toHaveLength(0) + }) + }) +}) diff --git a/packages/app/test-ui/support/builders.ts b/packages/app/test-ui/support/builders.ts new file mode 100644 index 00000000..248da85a --- /dev/null +++ b/packages/app/test-ui/support/builders.ts @@ -0,0 +1,53 @@ +// `TraceMutation` here is the browser-side global (packages/script/types.d.ts), +// not shared's Node-safe twin: its `addedNodes: (string | SimplifiedVNode)[]` +// narrows shared's `unknown[]`, so a value typed as the global satisfies both +// the components that use the global and those importing from shared โ€” the +// reverse assignment does not compile. + +import type { CommandLog } from '@wdio/devtools-shared' +import type { SimplifiedVNode } from '../../../script/types' + +/** Fixed base so builder output is stable across runs and across builders. */ +const BASE_TIMESTAMP = 1000 + +export function commandLog(overrides: Partial<CommandLog> = {}): CommandLog { + return { + command: 'click', + args: ['#submit'], + timestamp: BASE_TIMESTAMP, + ...overrides + } +} + +export function mutation( + overrides: Partial<TraceMutation> = {} +): TraceMutation { + return { + type: 'attributes', + target: 'wdio-ref-1', + attributeName: 'class', + attributeValue: 'active', + addedNodes: [], + removedNodes: [], + timestamp: BASE_TIMESTAMP, + ...overrides + } +} + +/** `target` is deliberately absent: the app reads a childList mutation as a + * document load from exactly one added node plus a `url`, and the snapshot + * player additionally requires no `target` to treat it as the new root. */ +export function documentLoaded( + url: string, + overrides: Partial<TraceMutation> = {} +): TraceMutation { + const root: SimplifiedVNode = { type: 'html', props: {} } + return { + type: 'childList', + addedNodes: [root], + removedNodes: [], + url, + timestamp: BASE_TIMESTAMP, + ...overrides + } +} diff --git a/packages/app/test-ui/support/mount.ts b/packages/app/test-ui/support/mount.ts new file mode 100644 index 00000000..a4994cc5 --- /dev/null +++ b/packages/app/test-ui/support/mount.ts @@ -0,0 +1,76 @@ +// Specs import the component module they exercise โ€” that side effect is what +// registers the custom element; these helpers only create and connect the tag. + +import { ContextProvider, type Context } from '@lit/context' +import type { LitElement } from 'lit' + +export interface ContextValue { + context: unknown + value: unknown +} + +/** Mocha's root hook โ€” declared locally because `@types/mocha` sits under + * `@wdio/mocha-framework` and isn't in the app's type roots. */ +declare const afterEach: (teardown: () => void) => void + +const mountedRoots: Element[] = [] + +// Importing this module registers the teardown, so specs never clean up mounts. +afterEach(() => { + for (const root of mountedRoots.splice(0)) { + root.remove() + } +}) + +export async function mount<T extends LitElement = LitElement>( + tag: string, + props?: Record<string, unknown> +): Promise<T> { + const el = create<T>(tag, props) + document.body.append(el) + mountedRoots.push(el) + await el.updateComplete + return el +} + +export async function mountWithContext<T extends LitElement = LitElement>( + tag: string, + contexts: ContextValue[], + props?: Record<string, unknown> +): Promise<T> { + const host = document.createElement('div') + document.body.append(host) + mountedRoots.push(host) + + for (const { context, value } of contexts) { + const provider = new ContextProvider(host, { + context: context as Context<unknown, unknown>, + initialValue: value + }) + // A plain div is no ReactiveElement, so @lit/context can't auto-wire the + // controller โ€” its connect signal has to be fired by hand. + provider.hostConnected() + } + + // Appended after the providers exist so the consumer's `context-request` + // (dispatched on connect) already has a listening ancestor. + const el = create<T>(tag, props) + host.append(el) + await el.updateComplete + return el +} + +export async function settle(el: LitElement): Promise<void> { + await el.updateComplete +} + +function create<T extends LitElement>( + tag: string, + props?: Record<string, unknown> +): T { + const el = document.createElement(tag) as T + if (props) { + Object.assign(el, props) + } + return el +} diff --git a/packages/app/test-ui/support/queries.ts b/packages/app/test-ui/support/queries.ts new file mode 100644 index 00000000..b50f8a84 --- /dev/null +++ b/packages/app/test-ui/support/queries.ts @@ -0,0 +1,35 @@ +// Shadow root first, light DOM as fallback โ€” components that render into their +// host's children (`createRenderRoot` overrides, slotted markup) resolve the +// same way as shadow-rendering ones. + +export function shadow<E extends Element = HTMLElement>( + host: Element, + selector: string +): E | null { + return ( + host.shadowRoot?.querySelector<E>(selector) ?? + host.querySelector<E>(selector) + ) +} + +export function shadowAll<E extends Element = HTMLElement>( + host: Element, + selector: string +): E[] { + const inShadow = host.shadowRoot + ? Array.from(host.shadowRoot.querySelectorAll<E>(selector)) + : [] + return inShadow.length > 0 + ? inShadow + : Array.from(host.querySelectorAll<E>(selector)) +} + +/** Trimmed, whitespace-collapsed textContent. */ +export function text(el: Element | null | undefined): string { + return (el?.textContent ?? '').replace(/\s+/g, ' ').trim() +} + +/** text() of every match of `selector` inside host's shadow root. */ +export function texts(host: Element, selector: string): string[] { + return shadowAll(host, selector).map((el) => text(el)) +} diff --git a/packages/app/test-ui/tsconfig.json b/packages/app/test-ui/tsconfig.json new file mode 100644 index 00000000..637ae32c --- /dev/null +++ b/packages/app/test-ui/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "types": ["@wdio/globals/types", "@wdio/mocha-framework"], + "noEmit": true + }, + // Both the app config and the repo root exclude this folder โ€” the specs run + // under global types (browser-runner mocha + expect-webdriverio) that no other + // program may see, so they are checked here instead. Those inherited excludes + // resolve against the config they came from, so they must be cleared. + "exclude": [], + // vite-env.d.ts carries `vite/client` (the `?inline` CSS module shapes the + // components import) and the app's custom event map. + "include": ["**/*.ts", "../src/vite-env.d.ts"] +} diff --git a/packages/app/test-ui/workbench/actions/actions.test.ts b/packages/app/test-ui/workbench/actions/actions.test.ts new file mode 100644 index 00000000..8601231f --- /dev/null +++ b/packages/app/test-ui/workbench/actions/actions.test.ts @@ -0,0 +1,399 @@ +import type { CommandLog, TraceActionChild } from '@wdio/devtools-shared' + +import { + actionGroupsContext, + commandContext, + mutationContext +} from '@/controller/context.js' +import { activeSpanAt } from '@components/workbench/active-entry.js' +import { + entryDuration, + stepDurations +} from '@components/workbench/actionItems/duration.js' +import '@components/workbench/actions.js' +import type { DevtoolsActions } from '@components/workbench/actions.js' + +import { commandLog, documentLoaded } from '../../support/builders.js' +import { + type ContextValue, + mountWithContext, + settle +} from '../../support/mount.js' +import { shadow, shadowAll } from '../../support/queries.js' +import { + loginActionTree, + loginTimeline, + SET_VALUE_PLAYBACK_TIME +} from '../fixtures.js' + +const PANEL = 'wdio-devtools-actions' +const COMMAND_ROW = 'wdio-devtools-command-item' +const MUTATION_ROW = 'wdio-devtools-mutation-item' +const GROUP_ROW = 'wdio-devtools-group-item' +const PLACEHOLDER = 'wdio-devtools-placeholder' +const ROWS = '.timeline > *' +const ACTIVE_ROW = '[active]' + +type CommandRowElement = HTMLElementTagNameMap[typeof COMMAND_ROW] +type MutationRowElement = HTMLElementTagNameMap[typeof MUTATION_ROW] +type GroupRowElement = HTMLElementTagNameMap[typeof GROUP_ROW] +type TimelineEntry = CommandLog | TraceMutation + +const { commands, mutations } = loginTimeline + +/** The demo target and wall clock of the one scenario built inside this spec: a + * document load BEFORE the first command, which the shared fixture has no case + * for and which is what separates the two baselines. */ +const LOGIN_URL = 'https://the-internet.herokuapp.com/login' +const RUN_START = 1_700_000_000_000 + +/** + * The row order the panel derives: `#sortedEntries` keeps the childList + * mutations that carry a url, drops the rest, and merges them into the command + * stream by timestamp. Asserted against the rendered rows first, then reused as + * the basis for every elapsed/duration expectation below โ€” so those follow the + * panel's own list rather than a second hand-maintained copy of it. + */ +const mergedOrder: TimelineEntry[] = [ + loginTimeline.url, + loginTimeline.documentLoad, + loginTimeline.findInput, + loginTimeline.setValue, + loginTimeline.click +] + +/** Per-row duration as the panel computes it: the entry's own span when it has + * one, else the gap to the next row. */ +function durationsFor(entries: TimelineEntry[]): Array<number | undefined> { + const gaps = stepDurations(entries.map((entry) => entry.timestamp)) + return entries.map((entry, index) => entryDuration(entry, gaps[index])) +} + +const elapsedFor = (entries: TimelineEntry[]) => + entries.map((entry) => entry.timestamp - entries[0].timestamp) + +interface PanelInputs { + commands?: CommandLog[] + mutations?: TraceMutation[] + groups?: TraceActionChild[] +} + +async function mountPanel(inputs: PanelInputs = {}): Promise<DevtoolsActions> { + const contexts: ContextValue[] = [ + { context: commandContext, value: inputs.commands ?? commands }, + { context: mutationContext, value: inputs.mutations ?? mutations } + ] + if (inputs.groups) { + contexts.push({ context: actionGroupsContext, value: inputs.groups }) + } + const panel = await mountWithContext<DevtoolsActions>(PANEL, contexts) + await settle(panel) + return panel +} + +const tagsOf = (rows: Element[]) => rows.map((row) => row.tagName.toLowerCase()) + +const entryOf = (row: Element) => + (row as CommandRowElement | MutationRowElement).entry + +const durationOf = (row: Element) => + (row as CommandRowElement | MutationRowElement).duration + +const elapsedOf = (row: Element) => + (row as CommandRowElement | MutationRowElement).elapsedTime + +const clickRow = (row: Element) => shadow(row, 'button')?.click() + +describe('wdio-devtools-actions', () => { + describe('merged timeline', () => { + it('orders commands and document loads into a single list by timestamp', async () => { + const panel = await mountPanel() + + expect(shadowAll(panel, ROWS).map(entryOf)).toEqual(mergedOrder) + }) + + it('orders by timestamp whatever order the two streams arrived in', async () => { + // Capture order is not timeline order: a spec re-run, a replayed slice or + // a reversed reader would still have to render the same rows. + const panel = await mountPanel({ + commands: [...commands].reverse(), + mutations: [...mutations].reverse() + }) + + expect(shadowAll(panel, ROWS).map(entryOf)).toEqual(mergedOrder) + }) + + it('renders a command row per command and a mutation row per document load', async () => { + const panel = await mountPanel() + + expect(tagsOf(shadowAll(panel, ROWS))).toEqual([ + COMMAND_ROW, + MUTATION_ROW, + COMMAND_ROW, + COMMAND_ROW, + COMMAND_ROW + ]) + }) + + it('leaves out mutations that are not document loads', async () => { + const panel = await mountPanel() + + const mutationRows = shadowAll(panel, MUTATION_ROW) + expect(mutationRows).toHaveLength(1) + expect(entryOf(mutationRows[0])).toEqual(loginTimeline.documentLoad) + }) + + it('measures elapsed time from the first entry of the merged list', async () => { + const panel = await mountPanel() + + // Derived from the merged order, so a baseline taken from the run start or + // from the first COMMAND (rather than the first row) fails here. + expect(shadowAll(panel, ROWS).map(elapsedOf)).toEqual( + elapsedFor(mergedOrder) + ) + expect(shadowAll(panel, ROWS).map(elapsedOf)).toEqual([ + 0, 30, 220, 360, 880 + ]) + }) + + it('times its rows from a document load that precedes the first command', async () => { + // Routine rather than a corner case: a command's timestamp is when it + // ENDED, and a navigation's DOM is captured before `url` returns. + const load = documentLoaded(LOGIN_URL, { timestamp: RUN_START }) + const navigate = commandLog({ + command: 'url', + args: [LOGIN_URL], + timestamp: RUN_START + 300 + }) + const click = commandLog({ command: 'click', timestamp: RUN_START + 800 }) + + const panel = await mountPanel({ + commands: [navigate, click], + mutations: [load] + }) + + // The column stays monotonic from the top row. Timing these rows against + // the commands alone would badge the document load at -300ms. + expect(shadowAll(panel, ROWS).map(elapsedOf)).toEqual([0, 300, 800]) + }) + + it('announces the offset it displays when a row is clicked', async () => { + const load = documentLoaded(LOGIN_URL, { timestamp: RUN_START }) + const navigate = commandLog({ + command: 'url', + args: [LOGIN_URL], + timestamp: RUN_START + 300 + }) + const panel = await mountPanel({ + commands: [navigate], + mutations: [load] + }) + const shown = new Promise<CommandEventProps>((resolve) => { + window.addEventListener( + 'show-command', + (event) => resolve(event.detail), + { once: true } + ) + }) + + clickRow(shadowAll(panel, ROWS)[1]) + + // The Log tab's chip shows the number the user clicked. It is measured from + // this list, so it can differ from what the keyboard and the player's strip + // announce for the same command โ€” neither of those panes has document-load + // rows, so they measure the commands they navigate. + expect(await shown).toEqual({ command: navigate, elapsedTime: 300 }) + }) + + it('renders the placeholder while no commands or mutations have arrived', async () => { + const panel = await mountPanel({ commands: [], mutations: [] }) + + expect(shadowAll(panel, PLACEHOLDER)).toHaveLength(1) + expect(shadowAll(panel, ROWS)).toHaveLength(0) + }) + }) + + describe('row duration', () => { + it('gives every row the duration the merged timeline derives for it', async () => { + const panel = await mountPanel() + + // Pins the pairing as well as the values: a row handed its neighbour's gap + // still renders a plausible badge, and only the whole list catches that. + expect(shadowAll(panel, ROWS).map(durationOf)).toEqual( + durationsFor(mergedOrder) + ) + expect(shadowAll(panel, ROWS).map(durationOf)).toEqual([ + 420, 190, 40, 80, 520 + ]) + }) + + it("prefers a command's own execution span over the gap to the next entry", async () => { + const panel = await mountPanel() + + const rows = shadowAll(panel, ROWS) + // url spans 420ms but is followed by a 30ms gap; setValue spans 80ms and is + // followed by a 520ms gap. + expect(durationOf(rows[0])).toBe(420) + expect(durationOf(rows[3])).toBe(80) + }) + + it('falls back to the gap between entries for rows without a span', async () => { + const panel = await mountPanel() + + const rows = shadowAll(panel, ROWS) + expect(durationOf(rows[1])).toBe(190) + // The last row has no next entry, so it borrows the preceding gap. + expect(durationOf(rows[4])).toBe(520) + }) + }) + + describe('active row', () => { + it('marks the clicked command as the only active row', async () => { + const panel = await mountPanel() + + clickRow(shadowAll(panel, ROWS)[3]) + await settle(panel) + + const active = shadowAll(panel, ACTIVE_ROW) + expect(active).toHaveLength(1) + expect(entryOf(active[0])).toEqual(loginTimeline.setValue) + }) + + it('marks the clicked document load as the active row', async () => { + const panel = await mountPanel() + + clickRow(shadowAll(panel, ROWS)[1]) + await settle(panel) + + const active = shadowAll(panel, ACTIVE_ROW) + expect(active).toHaveLength(1) + expect(tagsOf(active)).toEqual([MUTATION_ROW]) + expect(entryOf(active[0])).toEqual(loginTimeline.documentLoad) + }) + + it("announces the clicked command's call source for the source editor", async () => { + const panel = await mountPanel() + + const tracked = new Promise<string | undefined>((resolve) => { + window.addEventListener( + 'app-source-track', + (event) => + resolve( + (event as CustomEvent<{ callSource?: string }>).detail.callSource + ), + { once: true } + ) + }) + clickRow(shadowAll(panel, ROWS)[3]) + + expect(await tracked).toBe(loginTimeline.setValue.callSource) + }) + + it('follows screencast playback to the action running at that time', async () => { + const panel = await mountPanel() + + // The playback position is inside setValue's span and inside no other's โ€” + // `activeSpanAt` is what the panel resolves it with, so the fixture's claim + // about that time is asserted rather than assumed. + expect(activeSpanAt(mergedOrder, SET_VALUE_PLAYBACK_TIME)).toBe( + loginTimeline.setValue + ) + + window.dispatchEvent( + new CustomEvent('app-screencast-progress', { + detail: { time: SET_VALUE_PLAYBACK_TIME } + }) + ) + await settle(panel) + + const active = shadowAll(panel, ACTIVE_ROW) + expect(active).toHaveLength(1) + expect(entryOf(active[0])).toEqual(loginTimeline.setValue) + }) + }) + + describe('tree mode', () => { + const { groups } = loginActionTree + + it('renders the group tree instead of the merged list when groups are present', async () => { + const panel = await mountPanel({ groups }) + + expect(shadowAll(panel, GROUP_ROW)).toHaveLength(3) + expect(shadowAll(panel, MUTATION_ROW)).toHaveLength(0) + }) + + it('starts a failed group expanded and a passing group collapsed', async () => { + const panel = await mountPanel({ groups }) + + const rows = shadowAll(panel, ROWS) + expect(tagsOf(rows)).toEqual([ + GROUP_ROW, + GROUP_ROW, + COMMAND_ROW, + COMMAND_ROW, + GROUP_ROW + ]) + expect((rows[0] as GroupRowElement).group).toEqual( + loginActionTree.passingStep + ) + expect(rows[0].hasAttribute('expanded')).toBe(false) + expect((rows[1] as GroupRowElement).group).toEqual( + loginActionTree.failedStep + ) + expect(rows[1].hasAttribute('expanded')).toBe(true) + // The nested group is a row of the expanded parent but stays closed itself. + expect((rows[4] as GroupRowElement).group).toEqual( + loginActionTree.nestedStep + ) + expect(rows[4].hasAttribute('expanded')).toBe(false) + }) + + it('renders only the commands of expanded groups', async () => { + const panel = await mountPanel({ groups }) + + expect(shadowAll(panel, COMMAND_ROW).map(entryOf)).toEqual([ + loginTimeline.findInput, + loginTimeline.setValue + ]) + }) + + it('times a tree row against the commands alone, not the merged list', async () => { + const panel = await mountPanel({ groups }) + + // Tree mode has no mutation rows, so its gaps and its baseline come from + // the command stream โ€” a row still handed the merged-list values would + // report a duration its own group never contained. + const commandDurations = durationsFor(commands) + const commandElapsed = elapsedFor(commands) + const rows = shadowAll(panel, COMMAND_ROW) + + expect(rows.map(durationOf)).toEqual([ + commandDurations[1], + commandDurations[2] + ]) + expect(rows.map(elapsedOf)).toEqual([ + commandElapsed[1], + commandElapsed[2] + ]) + }) + + it("reveals a collapsed group's commands when the group row is clicked", async () => { + const panel = await mountPanel({ groups }) + + clickRow(shadowAll(panel, ROWS)[0]) + await settle(panel) + + const rows = shadowAll(panel, ROWS) + expect(rows[0].hasAttribute('expanded')).toBe(true) + expect(tagsOf(rows)).toEqual([ + GROUP_ROW, + COMMAND_ROW, + GROUP_ROW, + COMMAND_ROW, + COMMAND_ROW, + GROUP_ROW + ]) + expect(entryOf(rows[1])).toEqual(loginTimeline.url) + }) + }) +}) diff --git a/packages/app/test-ui/workbench/actions/command-item.test.ts b/packages/app/test-ui/workbench/actions/command-item.test.ts new file mode 100644 index 00000000..95e22f6f --- /dev/null +++ b/packages/app/test-ui/workbench/actions/command-item.test.ts @@ -0,0 +1,393 @@ +import type { CommandLog } from '@wdio/devtools-shared' + +import '@components/workbench/actionItems/command.js' +import type { CommandItem } from '@components/workbench/actionItems/command.js' +import { entryDuration } from '@components/workbench/actionItems/duration.js' + +import { mount, settle } from '../../support/mount.js' +import { shadow, shadowAll, text } from '../../support/queries.js' +import { commandLog } from '../../support/builders.js' + +const TAG = 'wdio-devtools-command-item' +const LABEL = 'code' +const BADGE = '.ml-auto' + +describe('wdio-devtools-command-item', () => { + describe('label', () => { + it('renders no row without an entry', async () => { + const el = await mount<CommandItem>(TAG, {}) + + expect(shadowAll(el, 'button').length).toBe(0) + }) + + it('renders the raw command name when the entry has no title', async () => { + const el = await mount<CommandItem>(TAG, { + entry: commandLog({ command: 'click' }) + }) + + expect(text(shadow(el, LABEL))).toBe('click') + }) + + it('prefers the entry title so the label keeps its arguments', async () => { + const el = await mount<CommandItem>(TAG, { + entry: commandLog({ + command: 'setValue', + args: ['#user', 'admin'], + title: 'Element.setValue("#user", "admin")' + }) + }) + + expect(text(shadow(el, LABEL))).toBe('Element.setValue("#user", "admin")') + }) + + it('renders the command name when the entry has no arguments', async () => { + const el = await mount<CommandItem>(TAG, { + entry: commandLog({ command: 'getUrl', args: [] }) + }) + + expect(text(shadow(el, LABEL))).toBe('getUrl') + }) + + it('capitalizes a leading assert prefix in the label', async () => { + const el = await mount<CommandItem>(TAG, { + entry: commandLog({ command: 'assert.equal' }) + }) + + expect(text(shadow(el, LABEL))).toBe('Assert.equal') + }) + + it('capitalizes a leading expect prefix in the label', async () => { + const el = await mount<CommandItem>(TAG, { + entry: commandLog({ command: 'expect', title: 'expect.toHaveText' }) + }) + + expect(text(shadow(el, LABEL))).toBe('Expect.toHaveText') + }) + + it('capitalizes a leading verify prefix in the label', async () => { + const el = await mount<CommandItem>(TAG, { + entry: commandLog({ command: 'verify.containsText' }) + }) + + expect(text(shadow(el, LABEL))).toBe('Verify.containsText') + }) + + it('leaves an assertion prefix untouched when it is not at the start', async () => { + const el = await mount<CommandItem>(TAG, { + entry: commandLog({ command: 'run', title: 'chained expect.toBe' }) + }) + + expect(text(shadow(el, LABEL))).toBe('chained expect.toBe') + }) + }) + + describe('icon', () => { + it('renders the navigation icon and colour for a url command', async () => { + const el = await mount<CommandItem>(TAG, { + entry: commandLog({ command: 'url' }) + }) + + const icon = shadow(el, 'icon-mdi-arrow-top-right') + expect(icon).toBeTruthy() + expect(icon?.classList.contains('text-chartsBlue')).toBe(true) + }) + + it('renders the reload icon rather than the navigation icon for refresh', async () => { + const el = await mount<CommandItem>(TAG, { + entry: commandLog({ command: 'refresh' }) + }) + + expect(shadow(el, 'icon-mdi-refresh')).toBeTruthy() + expect(shadowAll(el, 'icon-mdi-arrow-top-right').length).toBe(0) + }) + + it('renders the input icon and colour for a click command', async () => { + const el = await mount<CommandItem>(TAG, { + entry: commandLog({ command: 'click' }) + }) + + const icon = shadow(el, 'icon-mdi-cursor-default-click-outline') + expect(icon).toBeTruthy() + expect(icon?.classList.contains('text-chartsPurple')).toBe(true) + }) + + it('renders the keyboard icon for a setValue command', async () => { + const el = await mount<CommandItem>(TAG, { + entry: commandLog({ command: 'setValue' }) + }) + + const icon = shadow(el, 'icon-mdi-keyboard-outline') + expect(icon).toBeTruthy() + expect(icon?.classList.contains('text-chartsPurple')).toBe(true) + }) + + it('renders the select icon and query colour for an element lookup', async () => { + const el = await mount<CommandItem>(TAG, { + entry: commandLog({ command: '$' }) + }) + + const icon = shadow(el, 'icon-mdi-target') + expect(icon).toBeTruthy() + expect(icon?.classList.contains('text-chartsYellow')).toBe(true) + }) + + it('renders the read icon and query colour for a getText command', async () => { + const el = await mount<CommandItem>(TAG, { + entry: commandLog({ command: 'getText' }) + }) + + const icon = shadow(el, 'icon-mdi-text') + expect(icon).toBeTruthy() + expect(icon?.classList.contains('text-chartsYellow')).toBe(true) + }) + + it('renders the check icon and assertion colour for an expect command', async () => { + const el = await mount<CommandItem>(TAG, { + entry: commandLog({ command: 'expect.toHaveText' }) + }) + + const icon = shadow(el, 'icon-mdi-check-circle-outline') + expect(icon).toBeTruthy() + expect(icon?.classList.contains('text-chartsGreen')).toBe(true) + }) + + it('falls back to the generic icon with no category colour', async () => { + const el = await mount<CommandItem>(TAG, { + entry: commandLog({ command: 'executeScript' }) + }) + + const icon = shadow(el, 'icon-mdi-code-tags') + expect(icon).toBeTruthy() + expect(icon?.getAttribute('class')).not.toContain('text-charts') + }) + }) + + describe('state', () => { + it('leaves the row unmarked when the entry has no error', async () => { + const el = await mount<CommandItem>(TAG, { + entry: commandLog({ command: 'click' }) + }) + + expect(el.hasAttribute('failed')).toBe(false) + expect(shadow(el, LABEL)?.classList.contains('text-chartsRed')).toBe( + false + ) + }) + + it('marks the row failed and reddens the label when the entry errored', async () => { + const el = await mount<CommandItem>(TAG, { + entry: commandLog({ + command: 'click', + error: new Error('element not interactable') + }) + }) + + expect(el.hasAttribute('failed')).toBe(true) + expect(shadow(el, LABEL)?.classList.contains('text-chartsRed')).toBe(true) + expect( + shadow(el, 'icon-mdi-cursor-default-click-outline')?.classList.contains( + 'text-chartsRed' + ) + ).toBe(true) + }) + + it("swaps a failed assertion's check icon for a cross", async () => { + const el = await mount<CommandItem>(TAG, { + entry: commandLog({ + command: 'expect.toHaveText', + error: new Error('expected "a" to equal "b"') + }) + }) + + expect(shadow(el, 'icon-mdi-close-circle-outline')).toBeTruthy() + expect(shadowAll(el, 'icon-mdi-check-circle-outline').length).toBe(0) + }) + + it('clears the failed state when the entry is replaced by a passing one', async () => { + const el = await mount<CommandItem>(TAG, { + entry: commandLog({ command: 'click', error: new Error('boom') }) + }) + expect(el.hasAttribute('failed')).toBe(true) + + el.entry = commandLog({ command: 'click' }) + await settle(el) + + expect(el.hasAttribute('failed')).toBe(false) + expect(shadow(el, LABEL)?.classList.contains('text-chartsRed')).toBe( + false + ) + }) + + it('reflects the active state as an attribute', async () => { + const el = await mount<CommandItem>(TAG, { + entry: commandLog({ command: 'click' }), + active: true + }) + + expect(el.hasAttribute('active')).toBe(true) + }) + + it('is inactive by default', async () => { + const el = await mount<CommandItem>(TAG, { + entry: commandLog({ command: 'click' }) + }) + + expect(el.hasAttribute('active')).toBe(false) + }) + }) + + describe('duration', () => { + it('omits the duration badge when no duration is known', async () => { + const el = await mount<CommandItem>(TAG, { + entry: commandLog({ command: 'click' }) + }) + + expect(shadowAll(el, BADGE).length).toBe(0) + }) + + it('renders a zero-length step as 0ms in the fast bucket', async () => { + const el = await mount<CommandItem>(TAG, { + entry: commandLog({ command: 'click' }), + duration: 0 + }) + + expect(text(shadow(el, BADGE))).toBe('0ms') + expect(shadow(el, BADGE)?.classList.contains('text-chartsGreen')).toBe( + true + ) + }) + + it('buckets a step just under 500ms as fast', async () => { + const el = await mount<CommandItem>(TAG, { + entry: commandLog({ command: 'click' }), + duration: 499 + }) + + expect(text(shadow(el, BADGE))).toBe('499ms') + expect(shadow(el, BADGE)?.classList.contains('text-chartsGreen')).toBe( + true + ) + }) + + it('buckets a step at exactly 500ms as mid', async () => { + const el = await mount<CommandItem>(TAG, { + entry: commandLog({ command: 'click' }), + duration: 500 + }) + + expect(text(shadow(el, BADGE))).toBe('500ms') + expect(shadow(el, BADGE)?.classList.contains('text-chartsYellow')).toBe( + true + ) + }) + + it('keeps a step just under 2s in the mid bucket', async () => { + const el = await mount<CommandItem>(TAG, { + entry: commandLog({ command: 'click' }), + duration: 1999 + }) + + // Reads below the boundary it is below: rounding to `2.00s` put the same + // label as the slow bucket's first value on a yellow badge. + expect(text(shadow(el, BADGE))).toBe('1.99s') + expect(shadow(el, BADGE)?.classList.contains('text-chartsYellow')).toBe( + true + ) + }) + + it('buckets a step at exactly 2s as slow', async () => { + const el = await mount<CommandItem>(TAG, { + entry: commandLog({ command: 'click' }), + duration: 2000 + }) + + expect(text(shadow(el, BADGE))).toBe('2.00s') + expect(shadow(el, BADGE)?.classList.contains('text-chartsRed')).toBe(true) + }) + + it('renders a step over a minute in minutes and seconds', async () => { + const el = await mount<CommandItem>(TAG, { + entry: commandLog({ command: 'waitUntil' }), + duration: 65_000 + }) + + expect(text(shadow(el, BADGE))).toBe('1m 5s') + expect(shadow(el, BADGE)?.classList.contains('text-chartsRed')).toBe(true) + }) + + it("shows the command's own span rather than the gap to the next action", async () => { + const entry = commandLog({ + command: 'expect.toHaveText', + startTime: 1000, + timestamp: 1120 + }) + // The panel hands the row `entryDuration`'s answer, so the row is fed the + // real derivation over a 4s gap fallback โ€” not a number chosen to match. + const duration = entryDuration(entry, 4000) + const el = await mount<CommandItem>(TAG, { entry, duration }) + + expect(duration).toBe(120) + expect(text(shadow(el, BADGE))).toBe('120ms') + expect(shadow(el, BADGE)?.classList.contains('text-chartsGreen')).toBe( + true + ) + }) + + it('falls back to the inter-action gap when the entry has no start time', async () => { + const entry = commandLog({ command: 'click', timestamp: 1120 }) + const duration = entryDuration(entry, 750) + const el = await mount<CommandItem>(TAG, { entry, duration }) + + expect(duration).toBe(750) + expect(text(shadow(el, BADGE))).toBe('750ms') + expect(shadow(el, BADGE)?.classList.contains('text-chartsYellow')).toBe( + true + ) + }) + }) + + describe('events', () => { + it('dispatches show-command on window with the entry and elapsed time', async () => { + const entry = commandLog({ command: 'click' }) + const el = await mount<CommandItem>(TAG, { entry, elapsedTime: 1250 }) + const received: CustomEvent<{ + command: CommandLog + elapsedTime?: number + }>[] = [] + const listener = (event: Event) => received.push(event as CustomEvent) + + window.addEventListener('show-command', listener) + try { + shadow(el, 'button')?.dispatchEvent(new MouseEvent('click')) + } finally { + window.removeEventListener('show-command', listener) + } + + expect(received.length).toBe(1) + expect(received[0]?.detail.command).toBe(entry) + expect(received[0]?.detail.elapsedTime).toBe(1250) + }) + + it('announces a zero elapsed time when it was handed none', async () => { + const el = await mount<CommandItem>(TAG, { + entry: commandLog({ command: 'click' }) + }) + const received: CommandEventProps[] = [] + const listener = (event: Event) => + received.push((event as CustomEvent<CommandEventProps>).detail) + + window.addEventListener('show-command', listener) + try { + shadow(el, 'button')?.dispatchEvent(new MouseEvent('click')) + } finally { + window.removeEventListener('show-command', listener) + } + + // The contract declares a number, and the Log tab drops its duration chip + // for `undefined` โ€” so a row with no offset of its own announces the start + // of its list, not "no answer". + expect(received.length).toBe(1) + expect(received[0].elapsedTime).toBe(0) + }) + }) +}) diff --git a/packages/app/test-ui/workbench/actions/group-item.test.ts b/packages/app/test-ui/workbench/actions/group-item.test.ts new file mode 100644 index 00000000..8b1eb081 --- /dev/null +++ b/packages/app/test-ui/workbench/actions/group-item.test.ts @@ -0,0 +1,165 @@ +import '@components/workbench/actionItems/group.js' +import type { GroupItem } from '@components/workbench/actionItems/group.js' + +import { mount, settle } from '../../support/mount.js' +import { shadow, shadowAll, text } from '../../support/queries.js' + +const TAG = 'wdio-devtools-group-item' +const LABEL = 'span.break-all' +const BADGE = '.ml-auto' +const CHEVRON = 'icon-mdi-chevron-right' + +const STEP = { + callId: 'step-7', + title: 'Given I open the login page', + startTime: 1000, + endTime: 1600, + children: [] +} + +describe('wdio-devtools-group-item', () => { + it('renders no row without a group', async () => { + const el = await mount<GroupItem>(TAG, {}) + + expect(shadowAll(el, 'button').length).toBe(0) + }) + + it('renders the group title', async () => { + const el = await mount<GroupItem>(TAG, { group: { ...STEP } }) + + expect(text(shadow(el, LABEL))).toBe('Given I open the login page') + }) + + it("shows the group's own span as its duration", async () => { + const el = await mount<GroupItem>(TAG, { group: { ...STEP } }) + + // Derived from the step, so a row measuring it the wrong way round โ€” or from + // the wrong end โ€” fails here rather than only in the rendered text. + expect(el.duration).toBe(STEP.endTime - STEP.startTime) + expect(text(shadow(el, BADGE))).toBe('600ms') + expect(shadow(el, BADGE)?.classList.contains('text-chartsYellow')).toBe( + true + ) + }) + + it('renders a zero-length group as 0ms', async () => { + const el = await mount<GroupItem>(TAG, { + group: { ...STEP, endTime: STEP.startTime } + }) + + expect(text(shadow(el, BADGE))).toBe('0ms') + expect(shadow(el, BADGE)?.classList.contains('text-chartsGreen')).toBe(true) + }) + + it('derives the duration from the group even when a duration prop is supplied', async () => { + const el = await mount<GroupItem>(TAG, { + group: { ...STEP }, + duration: 9999 + }) + + expect(text(shadow(el, BADGE))).toBe('600ms') + }) + + it('leaves the chevron unrotated when collapsed', async () => { + const el = await mount<GroupItem>(TAG, { group: { ...STEP } }) + + expect(el.hasAttribute('expanded')).toBe(false) + expect(shadow(el, CHEVRON)?.classList.contains('rotate-90')).toBe(false) + }) + + it('rotates the chevron when expanded', async () => { + const el = await mount<GroupItem>(TAG, { + group: { ...STEP }, + expanded: true + }) + + expect(el.hasAttribute('expanded')).toBe(true) + expect(shadow(el, CHEVRON)?.classList.contains('rotate-90')).toBe(true) + }) + + it('rotates the chevron after the expanded state flips', async () => { + const el = await mount<GroupItem>(TAG, { group: { ...STEP } }) + + el.expanded = true + await settle(el) + + expect(shadow(el, CHEVRON)?.classList.contains('rotate-90')).toBe(true) + }) + + it('leaves a passing group unmarked', async () => { + const el = await mount<GroupItem>(TAG, { group: { ...STEP } }) + + expect(el.hasAttribute('failed')).toBe(false) + expect(shadow(el, LABEL)?.classList.contains('text-chartsRed')).toBe(false) + }) + + it('marks the row failed and reddens the title for a failed group', async () => { + const el = await mount<GroupItem>(TAG, { + group: { ...STEP, title: 'Then I see the dashboard', failed: true } + }) + + expect(el.hasAttribute('failed')).toBe(true) + expect(shadow(el, LABEL)?.classList.contains('text-chartsRed')).toBe(true) + }) + + it("emits group-toggle with the group's callId when clicked", async () => { + const el = await mount<GroupItem>(TAG, { group: { ...STEP } }) + const received: CustomEvent<{ callId?: string; expanded: boolean }>[] = [] + const listener = (event: Event) => received.push(event as CustomEvent) + + el.addEventListener('group-toggle', listener) + try { + shadow(el, 'button')?.dispatchEvent(new MouseEvent('click')) + } finally { + el.removeEventListener('group-toggle', listener) + } + + expect(received.length).toBe(1) + expect(received[0]?.detail.callId).toBe('step-7') + expect(received[0]?.bubbles).toBe(true) + expect(received[0]?.composed).toBe(true) + }) + + it('reports the collapsed state in the toggle event of a collapsed group', async () => { + const el = await mount<GroupItem>(TAG, { group: { ...STEP } }) + const received: CustomEvent<{ expanded: boolean }>[] = [] + const listener = (event: Event) => received.push(event as CustomEvent) + + el.addEventListener('group-toggle', listener) + try { + shadow(el, 'button')?.dispatchEvent(new MouseEvent('click')) + } finally { + el.removeEventListener('group-toggle', listener) + } + + expect(received[0]?.detail.expanded).toBe(false) + }) + + it('reports the expanded state in the toggle event of an expanded group', async () => { + const el = await mount<GroupItem>(TAG, { + group: { ...STEP }, + expanded: true + }) + const received: CustomEvent<{ expanded: boolean }>[] = [] + const listener = (event: Event) => received.push(event as CustomEvent) + + el.addEventListener('group-toggle', listener) + try { + shadow(el, 'button')?.dispatchEvent(new MouseEvent('click')) + } finally { + el.removeEventListener('group-toggle', listener) + } + + expect(received[0]?.detail.expanded).toBe(true) + }) + + it('does not toggle its own expanded state when clicked', async () => { + const el = await mount<GroupItem>(TAG, { group: { ...STEP } }) + + shadow(el, 'button')?.dispatchEvent(new MouseEvent('click')) + await settle(el) + + expect(el.expanded).toBe(false) + expect(el.hasAttribute('expanded')).toBe(false) + }) +}) diff --git a/packages/app/test-ui/workbench/actions/mutation-item.test.ts b/packages/app/test-ui/workbench/actions/mutation-item.test.ts new file mode 100644 index 00000000..f422018c --- /dev/null +++ b/packages/app/test-ui/workbench/actions/mutation-item.test.ts @@ -0,0 +1,202 @@ +import '@components/workbench/actionItems/mutation.js' +import type { MutationItem } from '@components/workbench/actionItems/mutation.js' + +import type { SimplifiedVNode } from '../../../../script/types' +import { mount } from '../../support/mount.js' +import { shadow, shadowAll, text } from '../../support/queries.js' +import { mutation, documentLoaded } from '../../support/builders.js' + +const TAG = 'wdio-devtools-mutation-item' +const LABEL = 'span.flex-grow' +const BADGE = '.ml-auto' +const PAGE_URL = 'https://the-internet.herokuapp.com/login' + +/** + * One added node as `serializeMutation` puts it on the wire: `parseFragment` + * output โ€” a typeless documentFragment wrapper around the serialized element. + * The row only counts these, but `addedNodes` never carries markup or a bare + * string, so a fixture that looked like `'<div></div>'` would suggest the row + * has a shape to read that it never gets. + */ +const addedNode = (type: string): SimplifiedVNode => + ({ props: { children: { type, props: {} } } }) as unknown as SimplifiedVNode + +describe('wdio-devtools-mutation-item', () => { + it('renders no row without an entry', async () => { + const el = await mount<MutationItem>(TAG, {}) + + expect(shadowAll(el, 'button').length).toBe(0) + }) + + it("renders 'Document loaded' for a navigation anchor", async () => { + const el = await mount<MutationItem>(TAG, { + entry: documentLoaded(PAGE_URL) + }) + + expect(text(shadow(el, LABEL))).toBe('Document loaded') + expect(shadow(el, 'icon-mdi-document')).toBeTruthy() + expect(shadowAll(el, 'icon-mdi-family-tree').length).toBe(0) + }) + + it('renders the node count for a single added node with no url', async () => { + const el = await mount<MutationItem>(TAG, { + entry: mutation({ type: 'childList', addedNodes: [addedNode('div')] }) + }) + + expect(text(shadow(el, LABEL))).toBe('1 node added') + expect(shadow(el, 'icon-mdi-family-tree')).toBeTruthy() + expect(shadowAll(el, 'icon-mdi-document').length).toBe(0) + }) + + it('renders the node count for a url mutation that added more than one node', async () => { + const el = await mount<MutationItem>(TAG, { + entry: documentLoaded(PAGE_URL, { + addedNodes: [addedNode('div'), addedNode('span')] + }) + }) + + expect(text(shadow(el, LABEL))).toBe('2 nodes added') + expect(shadowAll(el, 'icon-mdi-document').length).toBe(0) + }) + + it('pluralises the added-node label for multiple nodes', async () => { + const el = await mount<MutationItem>(TAG, { + entry: mutation({ + type: 'childList', + addedNodes: [addedNode('a'), addedNode('b'), addedNode('i')] + }) + }) + + expect(text(shadow(el, LABEL))).toBe('3 nodes added') + }) + + it('renders a singular removed-node label when nothing was added', async () => { + const el = await mount<MutationItem>(TAG, { + entry: mutation({ type: 'childList', removedNodes: ['div-ref'] }) + }) + + expect(text(shadow(el, LABEL))).toBe('1 node removed') + }) + + it("joins the added and removed counts with 'and'", async () => { + const el = await mount<MutationItem>(TAG, { + entry: mutation({ + type: 'childList', + addedNodes: [addedNode('a'), addedNode('b')], + removedNodes: ['i-ref'] + }) + }) + + expect(text(shadow(el, LABEL))).toBe('2 nodes added and 1 node removed') + }) + + it('renders an empty childList label when nothing was added or removed', async () => { + const el = await mount<MutationItem>(TAG, { + entry: mutation({ type: 'childList' }) + }) + + expect(text(shadow(el, LABEL))).toBe('') + expect(shadow(el, 'icon-mdi-family-tree')).toBeTruthy() + }) + + it('renders the attribute name for an attributes mutation', async () => { + const el = await mount<MutationItem>(TAG, { + entry: mutation({ type: 'attributes', attributeName: 'aria-hidden' }) + }) + + expect(text(shadow(el, LABEL))).toBe( + 'element attribute "aria-hidden" changed' + ) + expect(shadow(el, 'icon-mdi-pencil')).toBeTruthy() + }) + + it('renders an empty attribute name when the mutation carries none', async () => { + const el = await mount<MutationItem>(TAG, { + entry: mutation({ type: 'attributes', attributeName: undefined }) + }) + + expect(text(shadow(el, LABEL))).toBe('element attribute "" changed') + }) + + // `characterData` is the only third member of `MutationRecordType`, and no + // recorded stream contains one โ€” the collector's observer is configured + // `{ attributes, childList, subtree }`, so characterData is never watched. This + // row is therefore the defensive default rather than something a user sees. + it('labels an unsupported mutation type as unknown', async () => { + const el = await mount<MutationItem>(TAG, { + entry: mutation({ type: 'characterData' }) + }) + + expect(text(shadow(el, 'button'))).toBe('Unknown mutation') + expect(shadowAll(el, LABEL).length).toBe(0) + expect(shadowAll(el, 'span.ic').length).toBe(0) + }) + + it('renders the duration badge for the row', async () => { + const el = await mount<MutationItem>(TAG, { + entry: documentLoaded(PAGE_URL), + duration: 250 + }) + + expect(text(shadow(el, BADGE))).toBe('250ms') + }) + + it('omits the duration badge when no duration is known', async () => { + const el = await mount<MutationItem>(TAG, { + entry: documentLoaded(PAGE_URL) + }) + + expect(shadowAll(el, BADGE).length).toBe(0) + }) + + it('dispatches app-mutation-select on window when clicked', async () => { + const entry = documentLoaded(PAGE_URL) + const el = await mount<MutationItem>(TAG, { entry }) + const received: CustomEvent<unknown>[] = [] + const listener = (event: Event) => received.push(event as CustomEvent) + + window.addEventListener('app-mutation-select', listener) + try { + shadow(el, 'button')?.dispatchEvent(new MouseEvent('click')) + } finally { + window.removeEventListener('app-mutation-select', listener) + } + + expect(received.length).toBe(1) + expect(received[0]?.detail).toBe(entry) + }) + + it('dispatches app-mutation-highlight on window on mouseenter', async () => { + const entry = mutation({ type: 'attributes', attributeName: 'class' }) + const el = await mount<MutationItem>(TAG, { entry }) + const received: CustomEvent<unknown>[] = [] + const listener = (event: Event) => received.push(event as CustomEvent) + + window.addEventListener('app-mutation-highlight', listener) + try { + shadow(el, 'button')?.dispatchEvent(new MouseEvent('mouseenter')) + } finally { + window.removeEventListener('app-mutation-highlight', listener) + } + + expect(received.length).toBe(1) + expect(received[0]?.detail).toBe(entry) + }) + + it('does not dispatch a select event on mouseenter alone', async () => { + const el = await mount<MutationItem>(TAG, { + entry: documentLoaded(PAGE_URL) + }) + const received: CustomEvent<unknown>[] = [] + const listener = (event: Event) => received.push(event as CustomEvent) + + window.addEventListener('app-mutation-select', listener) + try { + shadow(el, 'button')?.dispatchEvent(new MouseEvent('mouseenter')) + } finally { + window.removeEventListener('app-mutation-select', listener) + } + + expect(received.length).toBe(0) + }) +}) diff --git a/packages/app/test-ui/workbench/fixtures.ts b/packages/app/test-ui/workbench/fixtures.ts new file mode 100644 index 00000000..476306b0 --- /dev/null +++ b/packages/app/test-ui/workbench/fixtures.ts @@ -0,0 +1,149 @@ +// Timeline scenarios shared by the workbench specs. Timestamps are chosen so +// every ordering and duration assertion has exactly one right answer: each +// command's own span differs from the gap that follows it, so a row showing the +// gap where the span was expected (or the reverse) fails. + +import type { + CommandLog, + TraceActionChild, + TraceActionGroupNode +} from '@wdio/devtools-shared' + +import { commandLog, documentLoaded, mutation } from '../support/builders.js' + +/** Wall-clock origin of the fixture run โ€” the offsets below read as ms into it. */ +const RUN_START = 1_700_000_000_000 + +/** `setValue`'s own execution span. Named because the screencast playback + * position is taken from it rather than restated as a number. */ +const SET_VALUE_START = RUN_START + 700 +const SET_VALUE_END = RUN_START + 780 + +export interface LoginTimeline { + /** Capture order, which the action tree's `commandIndex` slots refer to. */ + commands: CommandLog[] + mutations: TraceMutation[] + /** Spanned navigation: 420ms of its own against a 30ms gap to the next entry. */ + url: CommandLog + /** Spanned element lookup. */ + findInput: CommandLog + /** Spanned, and the only command carrying a call source. */ + setValue: CommandLog + /** Unspanned and last, so its duration can only come from the entry gap. */ + click: CommandLog + /** The one mutation the panel renders โ€” a childList carrying a url. */ + documentLoad: TraceMutation + /** An attribute mutation, which is not a document load. */ + attributeChange: TraceMutation + /** A childList mutation without a url, which is not a document load either. */ + toastInserted: TraceMutation +} + +const url = commandLog({ + command: 'url', + args: ['https://the-internet.herokuapp.com/login'], + startTime: RUN_START, + timestamp: RUN_START + 420 +}) + +const findInput = commandLog({ + command: '$', + args: ['#username'], + startTime: RUN_START + 600, + timestamp: RUN_START + 640 +}) + +const setValue = commandLog({ + command: 'setValue', + args: ['#username', 'tomsmith'], + callSource: 'login.e2e.ts:12:5', + startTime: SET_VALUE_START, + timestamp: SET_VALUE_END +}) + +/** Playback position used to drive the screencast highlight: the midpoint of + * `setValue`'s span, so it falls inside that span and no other. Derived rather + * than restated, so retiming the command moves the position with it โ€” the + * actions spec asserts through `activeSpanAt` that it still resolves there. */ +export const SET_VALUE_PLAYBACK_TIME = (SET_VALUE_START + SET_VALUE_END) / 2 + +const click = commandLog({ + command: 'click', + args: ['#submit'], + timestamp: RUN_START + 1300 +}) + +const documentLoad = documentLoaded( + 'https://the-internet.herokuapp.com/login', + { + timestamp: RUN_START + 450 + } +) + +const attributeChange = mutation({ + type: 'attributes', + attributeName: 'aria-invalid', + attributeValue: 'true', + timestamp: RUN_START + 900 +}) + +const toastInserted = mutation({ + type: 'childList', + addedNodes: ['<div class="toast">Saved</div>'], + timestamp: RUN_START + 1000 +}) + +export const loginTimeline: LoginTimeline = { + commands: [url, findInput, setValue, click], + mutations: [attributeChange, documentLoad, toastInserted], + url, + findInput, + setValue, + click, + documentLoad, + attributeChange, + toastInserted +} + +export interface LoginActionTree { + /** Root children of the tree, referencing `loginTimeline.commands` by index. */ + groups: TraceActionChild[] + /** Passing step, so it starts collapsed. */ + passingStep: TraceActionGroupNode + /** Failed step, so it starts expanded. */ + failedStep: TraceActionGroupNode + /** Passing step nested under the failed one. */ + nestedStep: TraceActionGroupNode +} + +const nestedStep: TraceActionGroupNode = { + callId: 'step-3', + title: 'Then I see an error message', + startTime: RUN_START + 800, + endTime: RUN_START + 1300, + children: [{ commandIndex: 3 }] +} + +const failedStep: TraceActionGroupNode = { + callId: 'step-2', + title: 'When I submit invalid credentials', + startTime: RUN_START + 600, + endTime: RUN_START + 1300, + failed: true, + children: [{ commandIndex: 1 }, { commandIndex: 2 }, { group: nestedStep }] +} + +const passingStep: TraceActionGroupNode = { + callId: 'step-1', + title: 'Given I open the login page', + startTime: RUN_START, + endTime: RUN_START + 450, + children: [{ commandIndex: 0 }] +} + +export const loginActionTree: LoginActionTree = { + groups: [{ group: passingStep }, { group: failedStep }], + passingStep, + failedStep, + nestedStep +} diff --git a/packages/app/test-ui/workbench/panels/a11y-fixtures.ts b/packages/app/test-ui/workbench/panels/a11y-fixtures.ts new file mode 100644 index 00000000..cf912f3c --- /dev/null +++ b/packages/app/test-ui/workbench/panels/a11y-fixtures.ts @@ -0,0 +1,185 @@ +// Accessibility-snapshot text for the A11y tree panel. The panel takes no +// properties and no context โ€” it follows the `show-command` event โ€” so a spec +// hands these strings to a `CommandLog.snapshotText`, the field the trace reader +// fills from the per-action `-snapshot.txt` resource. +// +// The lines are composed from shared's snapshot-format tokens rather than typed +// out: the producers (core's `serializeWebSnapshot` and `serializeMobileSnapshot`) +// and the panel's parser both reference those constants, so a fixture built from +// them can't drift from the grammar the panel is written against. + +import { + SNAPSHOT_INDENT_UNIT, + SNAPSHOT_LOCATOR_DELIM, + SNAPSHOT_PAGE_HEADER, + SNAPSHOT_PURPOSE_TOKEN, + snapshotNativeHeader +} from '@wdio/devtools-shared' +import type { CommandLog } from '@wdio/devtools-shared' + +import { commandLog } from '../../support/builders.js' + +export const LOGIN_URL = 'https://the-internet.herokuapp.com/login' +export const PAGE_TITLE = 'The Internet' + +/** `[Page: <title> โ€” <url>]`, as the serializer writes the first line. */ +export const PAGE_HEADER = `${SNAPSHOT_PAGE_HEADER}: ${PAGE_TITLE} โ€” ${LOGIN_URL}]` + +export const USERNAME_LOCATOR = '#username' +export const PASSWORD_LOCATOR = '#password' +export const LOGIN_LOCATOR = 'button[type=submit]' + +/** The locator the spec writes in place of the captured one โ€” same element, + * different syntax, which is what the reveal fallback to accessible name is + * for. */ +export const LOGIN_LOCATOR_ALIAS = 'button*=Login' + +export interface NodeLine { + depth: number + role: string + name?: string + /** Inferred ancestor context โ€” the panel drops it from the rendered row. */ + purpose?: string + selector?: string +} + +/** One serialized node line. Nodes sit one indent unit below the header, hence + * `depth + 1` โ€” the same offset the panel's parser subtracts back off. */ +export function a11yLine({ + depth, + role, + name, + purpose, + selector +}: NodeLine): string { + const parts = [`${SNAPSHOT_INDENT_UNIT.repeat(depth + 1)}${role}`] + if (name !== undefined) { + parts.push(`"${name}"`) + } + if (purpose) { + parts.push(`${SNAPSHOT_PURPOSE_TOKEN} "${purpose}"`) + } + const line = parts.join(' ') + return selector ? `${line} ${SNAPSHOT_LOCATOR_DELIM} ${selector}` : line +} + +/** Login page: two nameless containers, three locator-bearing controls, one + * heading with a level suffix, and depths 0โ€“2. The single source of truth for + * the fixture โ€” the snapshot text below is serialized from it, and the + * per-column expectations are projected off it, so no spec restates a role, + * name, depth or locator the snapshot doesn't actually carry. */ +export const LOGIN_NODES: NodeLine[] = [ + { depth: 0, role: 'document', name: PAGE_TITLE }, + { depth: 1, role: 'heading[2]', name: 'Login Page' }, + { depth: 1, role: 'form' }, + { + depth: 2, + role: 'textbox', + name: 'Username', + purpose: 'Login form', + selector: USERNAME_LOCATOR + }, + { + depth: 2, + role: 'textbox', + name: 'Password', + purpose: 'Login form', + selector: PASSWORD_LOCATOR + }, + { + depth: 2, + role: 'button', + name: 'Login', + purpose: 'Login form', + selector: LOGIN_LOCATOR + }, + { depth: 1, role: 'contentinfo' } +] + +export const loginSnapshot = [PAGE_HEADER, ...LOGIN_NODES.map(a11yLine)].join( + '\n' +) + +/** Roles in render order. */ +export const LOGIN_ROLES = LOGIN_NODES.map((node) => node.role) + +/** Quoted accessible names in render order โ€” the two containers have none. */ +export const LOGIN_NAMES = LOGIN_NODES.filter( + (node) => node.name !== undefined +).map((node) => `"${node.name}"`) + +/** Captured locators in render order โ€” only the three controls carry one. */ +export const LOGIN_LOCATORS = LOGIN_NODES.filter( + (node) => node.selector !== undefined +).map((node) => node.selector!) + +/** Node depths in render order, the input the row indent is computed from. */ +export const LOGIN_DEPTHS = LOGIN_NODES.map((node) => node.depth) + +/** A page the capture reached before any element was on it. */ +export const headerOnlySnapshot = PAGE_HEADER + +// A native-mobile capture. `serializeMobileSnapshot` always opens with a header +// of its own โ€” `[<platform> โ€ฆ]`, never a `[Page โ€ฆ]` line โ€” and its locators are +// Appium's rather than CSS. + +export const MOBILE_DEVICE = 'Pixel 7' + +/** `[android โ€” <device> (<w>ร—<h>)]`: the form a capture that knew both writes. */ +export const MOBILE_HEADER = `${snapshotNativeHeader('android')} โ€” ${MOBILE_DEVICE} (412ร—915)]` + +/** The header the PER-ACTION capture writes: it passes neither device nor + * viewport, so the serializer emits the bare platform form. On iOS here, so the + * two fixtures between them cover both platforms the serializer heads with. */ +export const BARE_MOBILE_HEADER = `${snapshotNativeHeader('ios')}]` + +export const MOBILE_NODES: NodeLine[] = [ + { depth: 0, role: 'FrameLayout' }, + { depth: 1, role: 'button', name: 'Skip', selector: '~Skip' }, + { + depth: 1, + role: 'textbox', + name: 'search', + selector: 'id:com.example:id/search' + } +] + +export const MOBILE_ROLES = MOBILE_NODES.map((node) => node.role) + +export const mobileSnapshot = [ + MOBILE_HEADER, + ...MOBILE_NODES.map(a11yLine) +].join('\n') + +export const bareMobileSnapshot = [ + BARE_MOBILE_HEADER, + ...MOBILE_NODES.map(a11yLine) +].join('\n') + +/** 80 characters โ€” past the panel's 64-character name budget. */ +export const LONG_NAME = + 'Login and continue to the secure area with the credentials from the demo page' + +export const longNameSnapshot = [ + PAGE_HEADER, + a11yLine({ + depth: 0, + role: 'button', + name: LONG_NAME, + selector: LOGIN_LOCATOR + }) +].join('\n') + +export const loginCommand: CommandLog = commandLog({ + command: 'click', + args: [LOGIN_LOCATOR], + snapshotText: loginSnapshot +}) + +/** What a Selenium or Nightwatch trace carries: a real command, no snapshot โ€” + * per-command DOM/a11y capture is WDIO-only. */ +export const snapshotlessCommand: CommandLog = commandLog({ + command: 'setValue', + args: [USERNAME_LOCATOR, 'tomsmith'], + result: null +}) diff --git a/packages/app/test-ui/workbench/panels/a11y-tree.test.ts b/packages/app/test-ui/workbench/panels/a11y-tree.test.ts new file mode 100644 index 00000000..aa32a59e --- /dev/null +++ b/packages/app/test-ui/workbench/panels/a11y-tree.test.ts @@ -0,0 +1,561 @@ +import type { CommandLog } from '@wdio/devtools-shared' + +import '@components/workbench/a11y-tree.js' +import type { DevtoolsA11yTree } from '@components/workbench/a11y-tree.js' + +import { commandLog } from '../../support/builders.js' +import { mount, settle } from '../../support/mount.js' +import { shadow, shadowAll, text, texts } from '../../support/queries.js' +import { + BARE_MOBILE_HEADER, + bareMobileSnapshot, + headerOnlySnapshot, + LOGIN_DEPTHS, + LOGIN_LOCATOR, + LOGIN_LOCATOR_ALIAS, + LOGIN_LOCATORS, + LOGIN_NAMES, + LOGIN_ROLES, + loginCommand, + loginSnapshot, + LONG_NAME, + longNameSnapshot, + MOBILE_HEADER, + MOBILE_ROLES, + mobileSnapshot, + PAGE_HEADER, + PASSWORD_LOCATOR, + snapshotlessCommand, + USERNAME_LOCATOR +} from './a11y-fixtures.js' + +const PANEL = 'wdio-devtools-a11y' +const NODE = '.node' +const PICKABLE_NODE = '.node.pick' +const HOT_NODE = '.node.hot' +const ROLE = '.role' +const NAME = '.nm' +const LOCATOR = '.sel' +const HEADER = '.hdr' +const TREE = '.tree' +const COPYBAR = '.copybar' +const HINT_LEAD = '.copybar .lead' +const HINT_LOCATOR = '.copybar .loc' +const HINT_IDLE = '.copybar .idle' +const HINT_CONFIRMATION = '.copybar .ok' +const PLACEHOLDER = 'wdio-devtools-placeholder' +const EMPTY_ICON = '.empty-state-icon' +const EMPTY_HEADING = '.empty-state-text' +const EMPTY_DETAIL = '.empty-state-detail' +const SKELETON = '.ph-item' + +/** Glyph the panel hands its empty-state placeholder. */ +const TREE_GLYPH = '๐ŸŒณ' + +/** Why a trace can reach this panel with commands that carry no snapshot โ€” + * per-command accessibility capture is WDIO-only. */ +const CAPTURE_UNSUPPORTED = + 'Per-command accessibility capture is WebdriverIO-only โ€” Selenium and Nightwatch traces do not include it.' + +/** The copy shown before anything is selected, which is not a capture gap. */ +const NOTHING_SELECTED = + 'Select a command in the Actions tab to see the accessibility tree captured for it.' + +/** Rendered row of the first textbox โ€” the tree drops the `โˆˆ "purpose"` suffix + * the serializer wrote before the locator. */ +const USERNAME_ROW_TEXT = `โ€ข textbox "Username" ${USERNAME_LOCATOR}` + +/** Row index per fixture node, in capture order. */ +const HEADING_ROW = 1 +const FORM_ROW = 2 +const USERNAME_ROW = 3 + +interface RevealDetail { + selector?: string + label?: string + pin?: boolean +} + +interface HighlightDetail { + selector?: string +} + +const rows = (panel: DevtoolsA11yTree) => shadowAll<HTMLElement>(panel, NODE) + +const rowAt = (panel: DevtoolsA11yTree, index: number): HTMLElement => { + const row = rows(panel)[index] + if (!row) { + throw new Error(`no a11y node row at index ${index}`) + } + return row +} + +const indents = (panel: DevtoolsA11yTree) => + rows(panel).map((row) => row.style.paddingLeft) + +const attrOf = (el: Element, name: string) => el.getAttribute(name) + +/** The panel takes no properties: it follows the same `show-command` event the + * snapshot pane listens to. */ +async function showCommand( + panel: DevtoolsA11yTree, + command: CommandLog +): Promise<void> { + window.dispatchEvent(new CustomEvent('show-command', { detail: { command } })) + await settle(panel) +} + +async function mountTree(snapshotText?: string): Promise<DevtoolsA11yTree> { + const panel = await mount<DevtoolsA11yTree>(PANEL) + if (snapshotText !== undefined) { + await showCommand(panel, commandLog({ snapshotText })) + } + return panel +} + +/** The snapshot pane pointing back at a row: transient on overlay-box hover, + * pinned on click. */ +async function reveal( + panel: DevtoolsA11yTree, + detail: RevealDetail | null +): Promise<void> { + window.dispatchEvent(new CustomEvent('a11y-reveal', { detail })) + await settle(panel) +} + +async function hover(panel: DevtoolsA11yTree, index: number): Promise<void> { + rowAt(panel, index).dispatchEvent(new MouseEvent('mouseenter')) + await settle(panel) +} + +async function unhover(panel: DevtoolsA11yTree, index: number): Promise<void> { + rowAt(panel, index).dispatchEvent(new MouseEvent('mouseleave')) + await settle(panel) +} + +/** Lets the awaited clipboard write inside the click handler settle. */ +const flush = () => new Promise<void>((resolve) => setTimeout(resolve, 0)) + +async function clickRow(panel: DevtoolsA11yTree, index: number): Promise<void> { + rowAt(panel, index).click() + await flush() + await settle(panel) +} + +/** The panel copies through `navigator.clipboard`, which a headless run has no + * user gesture or permission for; the writes are recorded instead. */ +function recordClipboard(options: { failing?: boolean } = {}): string[] { + const writes: string[] = [] + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { + writeText: (data: string): Promise<void> => { + if (options.failing) { + return Promise.reject(new Error('clipboard write blocked')) + } + writes.push(data) + return Promise.resolve() + } + } + }) + return writes +} + +describe('wdio-devtools-a11y', () => { + /** Outbound outline requests to the snapshot pane, newest last. */ + const highlights: (HighlightDetail | null)[] = [] + const recordHighlight = (event: Event) => { + highlights.push((event as CustomEvent<HighlightDetail | null>).detail) + } + const lastHighlight = () => highlights[highlights.length - 1] + + beforeEach(() => { + highlights.length = 0 + window.addEventListener('a11y-highlight', recordHighlight) + }) + + afterEach(() => { + window.removeEventListener('a11y-highlight', recordHighlight) + Reflect.deleteProperty(navigator, 'clipboard') + }) + + describe('tree', () => { + it('renders one row per captured node', async () => { + const panel = await mountTree(loginSnapshot) + + expect(rows(panel)).toHaveLength(LOGIN_ROLES.length) + }) + + it('renders the role of every node in capture order', async () => { + const panel = await mountTree(loginSnapshot) + + expect(texts(panel, ROLE)).toEqual(LOGIN_ROLES) + }) + + it('quotes the accessible name of the nodes that have one', async () => { + const panel = await mountTree(loginSnapshot) + + expect(texts(panel, NAME)).toEqual(LOGIN_NAMES) + }) + + it('renders no name for a container captured without one', async () => { + const panel = await mountTree(loginSnapshot) + + expect(shadowAll(rowAt(panel, FORM_ROW), NAME)).toHaveLength(0) + expect(text(shadow(rowAt(panel, FORM_ROW), ROLE))).toBe('form') + }) + + it('indents each row by the depth of its node', async () => { + const panel = await mountTree(loginSnapshot) + + // Derived from the captured depths, so a row indented by its render + // position rather than its nesting fails: rows 2 and 7 are both depth 1 + // but four apart in the list. + expect(indents(panel)).toEqual( + LOGIN_DEPTHS.map((depth) => `${8 + depth * 16}px`) + ) + expect(indents(panel)).toEqual([ + '8px', + '24px', + '24px', + '40px', + '40px', + '40px', + '24px' + ]) + }) + + it("keeps a heading's level in its role", async () => { + const panel = await mountTree(loginSnapshot) + + expect(text(shadow(rowAt(panel, HEADING_ROW), ROLE))).toBe('heading[2]') + }) + + it('drops the inferred purpose from a row that carried one', async () => { + const panel = await mountTree(loginSnapshot) + + expect(text(rowAt(panel, USERNAME_ROW))).toBe(USERNAME_ROW_TEXT) + }) + + it('renders the page title and URL above the tree', async () => { + const panel = await mountTree(loginSnapshot) + + expect(text(shadow(panel, HEADER))).toBe(PAGE_HEADER) + }) + + it('renders the platform and device of a native capture above the tree', async () => { + const panel = await mountTree(mobileSnapshot) + + // A native snapshot heads `[android โ€” โ€ฆ]`, not `[Page โ€ฆ]`; parsed as a node + // it would render as a first row whose role is the header. + expect(text(shadow(panel, HEADER))).toBe(MOBILE_HEADER) + expect(texts(panel, ROLE)).toEqual(MOBILE_ROLES) + }) + + it('renders the bare platform header the per-action capture writes', async () => { + const panel = await mountTree(bareMobileSnapshot) + + expect(text(shadow(panel, HEADER))).toBe(BARE_MOBILE_HEADER) + expect(texts(panel, ROLE)).toEqual(MOBILE_ROLES) + }) + + it('renders an empty tree for a page captured with no nodes', async () => { + const panel = await mountTree(headerOnlySnapshot) + + expect(shadowAll(panel, TREE)).toHaveLength(1) + expect(shadowAll(panel, PLACEHOLDER)).toHaveLength(0) + expect(rows(panel)).toHaveLength(0) + }) + + it('ignores the trailing blank line of a captured snapshot', async () => { + const panel = await mountTree(`${loginSnapshot}\n`) + + expect(rows(panel)).toHaveLength(LOGIN_ROLES.length) + }) + + it('shortens an accessible name past the row budget', async () => { + const panel = await mountTree(longNameSnapshot) + + // 64 characters including the ellipsis that replaces the rest. + expect(text(shadow(panel, NAME))).toBe(`"${LONG_NAME.slice(0, 63)}โ€ฆ"`) + }) + + it('keeps the locator of a row whose name was shortened', async () => { + const panel = await mountTree(longNameSnapshot) + + expect(text(shadow(panel, LOCATOR))).toBe(LOGIN_LOCATOR) + }) + }) + + describe('locators', () => { + it('marks only the nodes that carry a locator as pickable', async () => { + const panel = await mountTree(loginSnapshot) + + expect(shadowAll(panel, PICKABLE_NODE)).toHaveLength( + LOGIN_LOCATORS.length + ) + expect(shadowAll(panel, PICKABLE_NODE)).toHaveLength(3) + }) + + it('renders the captured locator of every pickable row', async () => { + const panel = await mountTree(loginSnapshot) + + expect(texts(panel, LOCATOR)).toEqual(LOGIN_LOCATORS) + expect(texts(panel, LOCATOR)).toEqual([ + USERNAME_LOCATOR, + PASSWORD_LOCATOR, + LOGIN_LOCATOR + ]) + }) + + it('names the copy affordance in the title of a pickable row', async () => { + const panel = await mountTree(loginSnapshot) + + expect(attrOf(rowAt(panel, USERNAME_ROW), 'title')).toBe( + `Click to copy locator: ${USERNAME_LOCATOR}` + ) + }) + + it('leaves a row without a locator untitled and unpickable', async () => { + const panel = await mountTree(loginSnapshot) + + const row = rowAt(panel, FORM_ROW) + expect(attrOf(row, 'title')).toBe(null) + expect(row.classList.contains('pick')).toBe(false) + }) + }) + + describe('hovering a row', () => { + it('prompts for a hover until one happens', async () => { + const panel = await mountTree(loginSnapshot) + + expect(text(shadow(panel, HINT_IDLE))).toBe( + 'Hover a node to preview its locator โ€” click to copy' + ) + }) + + it('asks the snapshot pane to outline the hovered element', async () => { + const panel = await mountTree(loginSnapshot) + await hover(panel, USERNAME_ROW) + + expect(lastHighlight()).toEqual({ selector: USERNAME_LOCATOR }) + }) + + it('echoes the hovered locator in the copy hint', async () => { + const panel = await mountTree(loginSnapshot) + await hover(panel, USERNAME_ROW) + + expect(text(shadow(panel, HINT_LEAD))).toBe('Click to copy locator:') + expect(text(shadow(panel, HINT_LOCATOR))).toBe(USERNAME_LOCATOR) + }) + + it('clears the outline when the cursor leaves the row', async () => { + const panel = await mountTree(loginSnapshot) + await hover(panel, USERNAME_ROW) + await unhover(panel, USERNAME_ROW) + + expect(lastHighlight()).toBe(null) + expect(shadowAll(panel, HINT_IDLE)).toHaveLength(1) + }) + + it('clears the outline when a row with no locator is hovered', async () => { + const panel = await mountTree(loginSnapshot) + await hover(panel, FORM_ROW) + + expect(lastHighlight()).toBe(null) + }) + }) + + describe('copying a locator', () => { + it('copies the locator of the clicked row', async () => { + const writes = recordClipboard() + const panel = await mountTree(loginSnapshot) + await clickRow(panel, USERNAME_ROW) + + expect(writes).toEqual([USERNAME_LOCATOR]) + }) + + it('confirms the copy on the row and in the hint', async () => { + recordClipboard() + const panel = await mountTree(loginSnapshot) + await clickRow(panel, USERNAME_ROW) + + expect(text(shadow(rowAt(panel, USERNAME_ROW), LOCATOR))).toBe('copied โœ“') + expect(text(shadow(panel, HINT_CONFIRMATION))).toBe('โœ“ Copied') + expect(text(shadow(panel, HINT_LOCATOR))).toBe(USERNAME_LOCATOR) + }) + + it('confirms the copy on the clicked row only', async () => { + recordClipboard() + const panel = await mountTree(loginSnapshot) + await clickRow(panel, USERNAME_ROW) + + expect(texts(panel, LOCATOR)).toEqual([ + 'copied โœ“', + ...LOGIN_LOCATORS.slice(1) + ]) + expect(texts(panel, LOCATOR)).toEqual([ + 'copied โœ“', + PASSWORD_LOCATOR, + LOGIN_LOCATOR + ]) + }) + + it('copies nothing when a row with no locator is clicked', async () => { + const writes = recordClipboard() + const panel = await mountTree(loginSnapshot) + await clickRow(panel, FORM_ROW) + + expect(writes).toEqual([]) + expect(shadowAll(panel, HINT_CONFIRMATION)).toHaveLength(0) + }) + + it('shows no confirmation when the clipboard rejects the write', async () => { + recordClipboard({ failing: true }) + const panel = await mountTree(loginSnapshot) + await clickRow(panel, USERNAME_ROW) + + expect(text(shadow(rowAt(panel, USERNAME_ROW), LOCATOR))).toBe( + USERNAME_LOCATOR + ) + expect(shadowAll(panel, HINT_CONFIRMATION)).toHaveLength(0) + }) + }) + + describe('reveal from the snapshot pane', () => { + it('highlights the row whose locator the pane points at', async () => { + const panel = await mountTree(loginSnapshot) + await reveal(panel, { selector: PASSWORD_LOCATOR }) + + const hot = shadowAll(panel, HOT_NODE) + expect(hot).toHaveLength(1) + expect(text(shadow(hot[0], NAME))).toBe('"Password"') + }) + + it("falls back to the element's name when the pane's locator differs", async () => { + const panel = await mountTree(loginSnapshot) + await reveal(panel, { selector: LOGIN_LOCATOR_ALIAS, label: 'Login' }) + + const hot = shadowAll(panel, HOT_NODE) + expect(hot).toHaveLength(1) + expect(text(shadow(hot[0], ROLE))).toBe('button') + }) + + it('names the revealed locator in the copy hint', async () => { + const panel = await mountTree(loginSnapshot) + await reveal(panel, { selector: PASSWORD_LOCATOR }) + + expect(text(shadow(panel, HINT_LOCATOR))).toBe(PASSWORD_LOCATOR) + }) + + it('highlights nothing when the pane points outside the captured tree', async () => { + const panel = await mountTree(loginSnapshot) + await reveal(panel, { selector: '#flash', label: 'You logged in' }) + + expect(shadowAll(panel, HOT_NODE)).toHaveLength(0) + }) + + it('clears the highlight when the pane clears its own', async () => { + const panel = await mountTree(loginSnapshot) + await reveal(panel, { selector: PASSWORD_LOCATOR }) + await reveal(panel, null) + + expect(shadowAll(panel, HOT_NODE)).toHaveLength(0) + }) + + it('keeps a pinned row highlighted while another row is revealed', async () => { + const panel = await mountTree(loginSnapshot) + await reveal(panel, { selector: LOGIN_LOCATOR, pin: true }) + await reveal(panel, { selector: USERNAME_LOCATOR }) + + expect(shadowAll(panel, HOT_NODE)).toHaveLength(2) + }) + + it('drops the pin when another command is selected', async () => { + const panel = await mountTree(loginSnapshot) + await reveal(panel, { selector: LOGIN_LOCATOR, pin: true }) + await showCommand( + panel, + commandLog({ command: 'getText', snapshotText: loginSnapshot }) + ) + + expect(rows(panel)).toHaveLength(LOGIN_ROLES.length) + expect(shadowAll(panel, HOT_NODE)).toHaveLength(0) + }) + }) + + describe('unavailable snapshot', () => { + it('renders the placeholder before a command is selected', async () => { + const panel = await mountTree() + + expect(shadowAll(panel, PLACEHOLDER)).toHaveLength(1) + expect(shadowAll(panel, COPYBAR)).toHaveLength(0) + expect(rows(panel)).toHaveLength(0) + }) + + it('renders the placeholder for a command captured without a snapshot', async () => { + // Per-command a11y capture is WDIO-only, so Selenium and Nightwatch + // traces reach this panel with commands that carry no `snapshotText`. + const panel = await mountTree() + await showCommand(panel, snapshotlessCommand) + + expect(shadowAll(panel, PLACEHOLDER)).toHaveLength(1) + expect(shadowAll(panel, COPYBAR)).toHaveLength(0) + expect(rows(panel)).toHaveLength(0) + }) + + it('names the missing capture as the reason a selected command has no tree', async () => { + const panel = await mountTree() + await showCommand(panel, snapshotlessCommand) + const placeholder = shadow(panel, PLACEHOLDER)! + + expect(text(shadow(placeholder, EMPTY_HEADING))).toBe( + 'No accessibility snapshot for this command' + ) + expect(text(shadow(placeholder, EMPTY_DETAIL))).toBe(CAPTURE_UNSUPPORTED) + expect(text(shadow(placeholder, EMPTY_ICON))).toBe(TREE_GLYPH) + }) + + it('prompts for a selection rather than blaming capture when nothing is selected', async () => { + const panel = await mountTree() + const placeholder = shadow(panel, PLACEHOLDER)! + + expect(text(shadow(placeholder, EMPTY_HEADING))).toBe( + 'No command selected' + ) + expect(text(shadow(placeholder, EMPTY_DETAIL))).toBe(NOTHING_SELECTED) + }) + + it('explains the empty panel instead of drawing a loading skeleton', async () => { + const panel = await mountTree() + await showCommand(panel, snapshotlessCommand) + + expect(shadowAll(shadow(panel, PLACEHOLDER)!, SKELETON)).toHaveLength(0) + }) + + it('falls back to the placeholder when a snapshotless command follows one with a snapshot', async () => { + const panel = await mountTree() + await showCommand(panel, loginCommand) + await showCommand(panel, snapshotlessCommand) + + expect(shadowAll(panel, PLACEHOLDER)).toHaveLength(1) + expect(rows(panel)).toHaveLength(0) + }) + + it('renders the placeholder for a command whose snapshot came back empty', async () => { + const panel = await mountTree('') + + expect(shadowAll(panel, PLACEHOLDER)).toHaveLength(1) + expect(rows(panel)).toHaveLength(0) + }) + + it('renders the placeholder when the selection is cleared', async () => { + const panel = await mountTree(loginSnapshot) + window.dispatchEvent(new CustomEvent('show-command', { detail: {} })) + await settle(panel) + + expect(shadowAll(panel, PLACEHOLDER)).toHaveLength(1) + expect(rows(panel)).toHaveLength(0) + }) + }) +}) diff --git a/packages/app/test-ui/workbench/panels/compare-fixtures.ts b/packages/app/test-ui/workbench/panels/compare-fixtures.ts new file mode 100644 index 00000000..6d67d89e --- /dev/null +++ b/packages/app/test-ui/workbench/panels/compare-fixtures.ts @@ -0,0 +1,287 @@ +// One preserve-and-rerun comparison, as the compare panel receives it: a login +// test that failed on a wrong password, preserved as a `PreservedAttempt`, and +// rerun successfully. The rerun reaches the panel as the *global* live command +// stream plus the live suite tree the panel windows that stream against โ€” not +// as a second attempt โ€” so the fixtures below model both halves separately. +// +// Builds on `./fixtures.js` (the shared login run) for the run origin, the demo +// URL, the spec path and the suite/test fragment builders. + +import type { + CommandLog, + PreservedAttempt, + PreservedStep +} from '@wdio/devtools-shared' + +import type { + SuiteStatsFragment, + TestStatsFragment +} from '@/controller/types.js' + +import { commandLog } from '../../support/builders.js' +import { + LOGIN_URL, + RUN_START, + SPEC_FILE, + suiteFragment, + suiteRegistry, + testFragment +} from './fixtures.js' + +/** The uid the baseline was preserved under โ€” a **suite** uid, so its live steps + * are the whole suite's tests. `LIVE_TEST_UID` covers the other shape: a test + * uid, which is what preserving from a test row records. */ +export const SELECTED_UID = 'login-suite' +export const LIVE_TEST_UID = 'login-valid' +export const LIVE_TEST_TITLE = 'login page logs in with valid credentials' + +/** The rerun starts a minute after the preserved attempt, so a fixture can tell + * a baseline timestamp from a live one at a glance. */ +export const RERUN_START = RUN_START + 60_000 +export const RERUN_END = RERUN_START + 2400 + +export const USERNAME = 'tomsmith' +export const VALID_PASSWORD = 'SuperSecretPassword!' +export const WRONG_PASSWORD = 'wrong-password' +export const SUBMIT_SELECTOR = 'button[type=submit]' +export const FLASH_SELECTOR = '#flash' + +export const EXPECTED_FLASH = 'Secure Area' +export const BASELINE_FLASH = 'Your username is invalid!' +export const LATEST_FLASH = 'You logged into a secure area!' + +/** The failed step's own error โ€” one line, so it reads whole in a marker title + * and in the detail block's `assertion:` row. */ +export const FLASH_ASSERTION_MESSAGE = + 'Expect $(`#flash`) to have text "Secure Area"' + +// The `\u001b` prefixes are load-bearing: a real terminal colour code is +// `ESC[<n>m`, and a cleaner that strips the `[<n>m` alone leaves the invisible +// ESC in the rendered banner. +export const BASELINE_ERROR_MESSAGE = [ + 'Expect $(`#flash`) to have text', + '', + '', + '', + `Expected: \u001b[32m"${EXPECTED_FLASH}"\u001b[39m`, + `Received: \u001b[31m"${BASELINE_FLASH}"\u001b[39m` +].join('\n') + +/** `BASELINE_ERROR_MESSAGE` as the banner renders it: colour codes gone, the + * run of blank lines collapsed to one. */ +export const BASELINE_ERROR_LINES = [ + 'Expect $(`#flash`) to have text', + '', + `Expected: "${EXPECTED_FLASH}"`, + `Received: "${BASELINE_FLASH}"` +] + +/** Passed, and holds the three form commands. */ +export const FILL_STEP: PreservedStep = { + uid: 'login-fill', + title: 'fills the login form', + fullTitle: 'login page fills the login form', + start: RUN_START, + end: RUN_START + 1400, + state: 'passed' +} + +/** Failed, and holds the submit + flash-read commands. The flash read is its + * last command, which is what makes it the step's failure site. */ +export const ASSERT_STEP: PreservedStep = { + uid: 'login-assert', + title: 'shows the secure-area flash', + fullTitle: 'login page shows the secure-area flash', + start: RUN_START + 1500, + end: RUN_START + 2600, + state: 'failed', + error: { + message: FLASH_ASSERTION_MESSAGE, + expected: EXPECTED_FLASH, + actual: BASELINE_FLASH + } +} + +export interface RunShape { + /** Wall clock of the run's first command. */ + start: number + password: string + /** What the flash read returned โ€” `result` never takes part in pairing. */ + flash: string +} + +/** The five commands both runs share the shape of. Same commands and args + * except the password, so index 2 is where the two runs fork. */ +export function runCommands({ + start, + password, + flash +}: RunShape): CommandLog[] { + return [ + commandLog({ command: 'url', args: [LOGIN_URL], timestamp: start }), + commandLog({ + command: 'setValue', + args: ['#username', USERNAME], + timestamp: start + 500 + }), + commandLog({ + command: 'setValue', + args: ['#password', password], + timestamp: start + 1000 + }), + commandLog({ + command: 'click', + args: [SUBMIT_SELECTOR], + timestamp: start + 1600 + }), + commandLog({ + command: 'getText', + args: [FLASH_SELECTOR], + result: flash, + timestamp: start + 2100 + }) + ] +} + +/** A sixth command, past the failed step's window โ€” the baseline-only row, and + * the one command that resolves to no step at all. */ +const baselineOnlyCommand = commandLog({ + command: 'getTitle', + args: [], + result: 'The Internet', + timestamp: RUN_START + 2700 +}) + +const baselineCommands: CommandLog[] = [ + ...runCommands({ + start: RUN_START, + password: WRONG_PASSWORD, + flash: BASELINE_FLASH + }), + baselineOnlyCommand +] + +const scopedLiveCommands = runCommands({ + start: RERUN_START, + password: VALID_PASSWORD, + flash: LATEST_FLASH +}) + +/** Ran before the rerun's window, in the test the runner executed first. */ +const beforeWindow = commandLog({ + command: 'deleteAllCookies', + args: [], + timestamp: RERUN_START - 5000 +}) + +/** Ran after the rerun's window, in the test that followed it. */ +const afterWindow = commandLog({ + command: 'url', + args: [LOGIN_URL], + timestamp: RERUN_START + 9000 +}) + +export function preservedAttempt( + overrides: Partial<PreservedAttempt> = {} +): PreservedAttempt { + return { + testUid: SELECTED_UID, + scope: 'suite', + capturedAt: RUN_START + 3000, + window: { start: RUN_START, end: RUN_START + 2800 }, + test: { + title: 'login page', + fullTitle: 'login page', + file: SPEC_FILE, + state: 'failed', + error: { message: BASELINE_ERROR_MESSAGE } + }, + steps: [FILL_STEP, ASSERT_STEP], + commands: baselineCommands, + consoleLogs: [], + networkRequests: [], + mutations: [], + sources: {}, + ...overrides + } +} + +/** The `baselineContext` value โ€” baselines reach the app keyed by test uid. */ +export function baselineMap( + attempt: PreservedAttempt, + uid: string = SELECTED_UID +): Map<string, PreservedAttempt> { + return new Map([[uid, attempt]]) +} + +/** A live test entry. Defaults to `passed` โ€” the rerun's outcome โ€” where + * `testFragment` defaults to `failed` for the errors panel. */ +export function liveTest( + window: { start?: number; end?: number }, + overrides: Omit<Partial<TestStatsFragment>, 'uid'> & { uid?: string } = {} +): TestStatsFragment { + const { uid = LIVE_TEST_UID, ...rest } = overrides + return testFragment(uid, { + title: uid, + fullTitle: `login page ${uid}`, + state: 'passed', + start: window.start === undefined ? undefined : new Date(window.start), + end: window.end === undefined ? undefined : new Date(window.end), + ...rest + }) +} + +/** The `suiteContext` value, with the selected uid as the suite the tests + * hang off. */ +export function liveSuitesWith( + ...tests: TestStatsFragment[] +): Record<string, SuiteStatsFragment>[] { + return suiteRegistry( + suiteFragment(SELECTED_UID, { title: 'login page', tests }) + ) +} + +const rerunTest = liveTest( + { start: RERUN_START, end: RERUN_END }, + { title: 'logs in with valid credentials', fullTitle: LIVE_TEST_TITLE } +) + +export interface LoginCompare { + /** The whole live stream, including the commands of the tests either side of + * the rerun โ€” the panel is expected to window those out. */ + liveCommands: CommandLog[] + /** The five live commands inside the rerun's window. */ + scopedLiveCommands: CommandLog[] + liveSuites: Record<string, SuiteStatsFragment>[] + /** The rerun's live test entry, for specs that rebuild the registry. */ + rerunTest: TestStatsFragment + /** Same suite, reached through two levels of parent suite. */ + nestedLiveSuites: Record<string, SuiteStatsFragment>[] + /** The baseline's commands, in capture order. */ + baselineCommands: CommandLog[] + /** Commands whose shape matches the rerun's exactly, on the baseline's clock โ€” + * a preserved attempt that diverges nowhere. */ + identicalCommands: CommandLog[] +} + +export const loginCompare: LoginCompare = { + liveCommands: [beforeWindow, ...scopedLiveCommands, afterWindow], + scopedLiveCommands, + liveSuites: liveSuitesWith(rerunTest), + rerunTest, + nestedLiveSuites: suiteRegistry( + suiteFragment('login-feature', { + suites: [ + suiteFragment(SELECTED_UID, { + suites: [suiteFragment('login-scenario', { tests: [rerunTest] })] + }) + ] + }) + ), + baselineCommands, + identicalCommands: runCommands({ + start: RUN_START, + password: VALID_PASSWORD, + flash: BASELINE_FLASH + }) +} diff --git a/packages/app/test-ui/workbench/panels/compare.test.ts b/packages/app/test-ui/workbench/panels/compare.test.ts new file mode 100644 index 00000000..8796fd05 --- /dev/null +++ b/packages/app/test-ui/workbench/panels/compare.test.ts @@ -0,0 +1,1375 @@ +import { BASELINE_API } from '@wdio/devtools-shared' +import type { + CommandLog, + PreservedAttempt, + PreservedStep +} from '@wdio/devtools-shared' + +import { + baselineContext, + commandContext, + selectedTestUidContext, + suiteContext +} from '@/controller/context.js' +import type { SuiteStatsFragment } from '@/controller/types.js' +import '@components/workbench/compare.js' +import type { DevtoolsCompare } from '@components/workbench/compare.js' +import { + firstDivergentIndex, + pairSteps, + type ComparePairedStep +} from '@components/workbench/compare/compareUtils.js' + +import { commandLog } from '../../support/builders.js' +import { mount, mountWithContext, settle } from '../../support/mount.js' +import type { ContextValue } from '../../support/mount.js' +import { shadow, shadowAll, text, texts } from '../../support/queries.js' +import { + RUN_START, + capturedError, + suiteFragment, + suiteRegistry +} from './fixtures.js' +import { + ASSERT_STEP, + BASELINE_ERROR_LINES, + BASELINE_FLASH, + EXPECTED_FLASH, + FLASH_ASSERTION_MESSAGE, + FLASH_SELECTOR, + LATEST_FLASH, + LIVE_TEST_TITLE, + LIVE_TEST_UID, + RERUN_END, + RERUN_START, + SELECTED_UID, + SUBMIT_SELECTOR, + baselineMap, + liveSuitesWith, + liveTest, + loginCompare, + preservedAttempt +} from './compare-fixtures.js' + +const PANEL = 'wdio-devtools-compare' +const EMPTY_STATE = '.empty-state' +const EMPTY_LINE = '.empty-state p' +const TOPBAR = '.topbar' +const PILL = '.pill' +const SCOPE = '.scope' +const COL_HEADER = '.col-header' +const DIFFERENCES_ONLY = '.toggle-label input' +const SWAP_BUTTON = 'button[title="Swap sides"]' +const CLEAR_BUTTON = 'button[title="Drop this baseline"]' +const POPOUT_BUTTON = 'button[aria-label="Open in a separate window"]' +const ERROR_BANNER = '.error-banner' +const ERROR_BANNER_TITLE = '.error-banner-title' +const ERROR_BANNER_MESSAGE = '.error-banner-message' +const STEP_ROW = '.step-row' +const STEP_CELL = '.step-cell' +const FIRST_DIVERGENT_CELL = '.step-cell.divergent.first' +const MISSING_CELL = '.step-cell.missing' +const MARKER = '.marker' +const COMMAND_MARKER = '.marker.command' +const DETAIL_PANEL = '.detail-panel' +const DETAIL_BLOCK = '.detail-block' +const DETAIL_HEADING = '.detail-block h4' + +/** WebDriver-level failure used by the focused marker fixtures. */ +const CLICK_ERROR = 'element click intercepted' + +// --- Derived expectations --------------------------------------------------- +// The canonical comparison's pairing is computed with the same `pairSteps` the +// panel calls, over the two command lists the panel is expected to feed it (the +// preserved commands and the *windowed* live ones). A panel that pairs by +// timestamp, drops the fork bit or windows the wrong stream fails these even +// though the literal command lists below still read right. + +const LOGIN_PAIRS: ComparePairedStep[] = pairSteps( + loginCompare.baselineCommands, + loginCompare.scopedLiveCommands +) + +/** Command name per row for one logical side; `''` where that side ran out. */ +const pairedCommands = (side: 'baseline' | 'latest') => + LOGIN_PAIRS.map((pair) => pair[side]?.command ?? '') + +/** The `N.` prefix of every row, 1-based on the pair index. */ +const pairNumbers = (pairs: ComparePairedStep[] = LOGIN_PAIRS) => + pairs.map((pair) => String(pair.index + 1)) + +/** The rows the differences-only toggle leaves: divergent or truncated. */ +const divergentOrTruncated = LOGIN_PAIRS.filter( + (pair) => pair.divergent || !pair.baseline || !pair.latest +) + +interface CompareInput { + baselines?: Map<string, PreservedAttempt> + selectedTestUid?: string + liveCommands?: CommandLog[] + liveSuites?: Record<string, SuiteStatsFragment>[] +} + +interface RecordedRequest { + url: string + method: string + body: Record<string, unknown> +} + +interface OpenedWindow { + url: string + name: string + features: string +} + +const nativeFetch = globalThis.fetch +const nativeOpen = window.open +const nativeScrollIntoView = Element.prototype.scrollIntoView + +afterEach(() => { + globalThis.fetch = nativeFetch + window.open = nativeOpen + Element.prototype.scrollIntoView = nativeScrollIntoView +}) + +/** Clearing a baseline is a POST; a component test starts no backend, so the + * request is recorded rather than sent. */ +function recordBackend( + options: { rejecting?: boolean } = {} +): RecordedRequest[] { + const requests: RecordedRequest[] = [] + globalThis.fetch = (input: RequestInfo | URL, init?: RequestInit) => { + requests.push({ + url: String(input), + method: String(init?.method), + body: init?.body + ? (JSON.parse(String(init.body)) as Record<string, unknown>) + : {} + }) + return options.rejecting + ? Promise.reject(new Error('backend unreachable')) + : Promise.resolve(new Response('{}', { status: 200 })) + } + return requests +} + +function recordWindowOpen(): OpenedWindow[] { + const opened: OpenedWindow[] = [] + window.open = (url?: string | URL, name?: string, features?: string) => { + opened.push({ + url: String(url), + name: String(name), + features: String(features) + }) + return null + } + return opened +} + +function recordScrolls(): Element[] { + const targets: Element[] = [] + Element.prototype.scrollIntoView = function record(this: Element) { + targets.push(this) + } + return targets +} + +/** Let the awaited fetch inside the clear handler settle. */ +const flush = () => new Promise<void>((resolve) => setTimeout(resolve, 0)) + +async function mountCompare(input: CompareInput): Promise<DevtoolsCompare> { + const contexts: ContextValue[] = [] + if (input.baselines) { + contexts.push({ context: baselineContext, value: input.baselines }) + } + if (input.selectedTestUid) { + contexts.push({ + context: selectedTestUidContext, + value: input.selectedTestUid + }) + } + if (input.liveCommands) { + contexts.push({ context: commandContext, value: input.liveCommands }) + } + if (input.liveSuites) { + contexts.push({ context: suiteContext, value: input.liveSuites }) + } + const panel = await mountWithContext<DevtoolsCompare>(PANEL, contexts) + await settle(panel) + return panel +} + +/** The canonical scenario: the failing baseline against its passing rerun. */ +function mountLogin(overrides: CompareInput = {}): Promise<DevtoolsCompare> { + return mountCompare({ + baselines: baselineMap(preservedAttempt()), + selectedTestUid: SELECTED_UID, + liveCommands: loginCompare.liveCommands, + liveSuites: loginCompare.liveSuites, + ...overrides + }) +} + +/** Two hand-built runs, compared without a live suite tree โ€” so the latest side + * is exactly `latest`, unwindowed. */ +function mountPair( + baseline: CommandLog[], + latest: CommandLog[], + steps: PreservedStep[] = [] +): Promise<DevtoolsCompare> { + return mountCompare({ + baselines: baselineMap( + preservedAttempt({ commands: baseline, steps, test: { state: 'failed' } }) + ), + selectedTestUid: SELECTED_UID, + liveCommands: latest + }) +} + +const rows = (panel: DevtoolsCompare) => shadowAll(panel, STEP_ROW) + +/** The cells of one physical column, one per rendered row. */ +const column = (panel: DevtoolsCompare, index: 0 | 1) => + rows(panel).map((row) => shadowAll(row, STEP_CELL)[index]) + +const commandsIn = (panel: DevtoolsCompare, index: 0 | 1) => + column(panel, index).map((cell) => text(shadow(cell, 'code'))) + +const markersIn = (panel: DevtoolsCompare, index: 0 | 1) => + column(panel, index).map((cell) => texts(cell, MARKER)) + +const divergentIn = (panel: DevtoolsCompare, index: 0 | 1) => + column(panel, index).map((cell) => cell.classList.contains('divergent')) + +/** The `N.` prefix each cell is numbered with โ€” `null` on the dashed side. */ +const numbersIn = (panel: DevtoolsCompare, index: 0 | 1) => + column(panel, index).map( + (cell) => (cell.textContent ?? '').trim().match(/^(\d+)\./)?.[1] ?? null + ) + +const markerTitle = (cell: Element) => + shadow(cell, MARKER)?.getAttribute('title') ?? null + +const blocks = (panel: DevtoolsCompare) => shadowAll(panel, DETAIL_BLOCK) + +const blockLines = (block: Element) => texts(block, 'pre') + +/** Raw lines of an element's text โ€” `text()` collapses the newlines a cleaned + * multi-line failure message is made of. */ +const lines = (el: Element | null): string[] => + (el?.textContent ?? '') + .trim() + .split('\n') + .map((line) => line.trim()) + +/** Control characters left in rendered text, line breaks excluded: a cleaned + * message keeps its line breaks but must carry no escape bytes. */ +const controlChars = (el: Element | null): string[] => + [...(el?.textContent ?? '')].filter((char) => { + const code = char.charCodeAt(0) + return code !== 0x0a && (code < 0x20 || code === 0x7f) + }) + +async function clickCell( + panel: DevtoolsCompare, + row: number, + index: 0 | 1 +): Promise<void> { + const cell = column(panel, index)[row] + if (!cell) { + throw new Error(`no step cell at row ${row}, column ${index}`) + } + cell.click() + await settle(panel) +} + +async function clickAction( + panel: DevtoolsCompare, + selector: string +): Promise<void> { + const button = shadow<HTMLButtonElement>(panel, selector) + if (!button) { + throw new Error(`the compare toolbar rendered no ${selector}`) + } + button.click() + await settle(panel) +} + +async function showDifferencesOnly( + panel: DevtoolsCompare, + on = true +): Promise<void> { + const toggle = shadow<HTMLInputElement>(panel, DIFFERENCES_ONLY) + if (!toggle) { + throw new Error('the compare toolbar rendered no differences-only toggle') + } + toggle.checked = on + toggle.dispatchEvent(new Event('change')) + await settle(panel) +} + +/** Mount with the popout query on the page URL. `#isPopout` is read once, at + * construction, so the URL has to carry it before the element exists. The + * runner's own params are preserved โ€” it resolves a spec's session from `cid`. */ +async function mountInPopoutWindow(): Promise<DevtoolsCompare> { + const search = window.location.search + const params = new URLSearchParams(search) + params.set('view', 'compare') + history.replaceState( + null, + '', + `${window.location.pathname}?${params.toString()}` + ) + try { + return await mountLogin() + } finally { + history.replaceState(null, '', `${window.location.pathname}${search}`) + } +} + +describe('wdio-devtools-compare', () => { + describe('no baseline', () => { + it('prompts for a preserve-and-rerun when no baseline was preserved', async () => { + const panel = await mount<DevtoolsCompare>(PANEL) + + expect(texts(panel, EMPTY_LINE)).toEqual([ + 'No baseline preserved.', + 'Click the ๐Ÿ“Œ Preserve & Rerun button on a failed test to compare the failing run against the rerun.' + ]) + }) + + it('prompts for a preserve-and-rerun while no test is selected', async () => { + const panel = await mountCompare({ + baselines: baselineMap(preservedAttempt()), + liveCommands: loginCompare.liveCommands + }) + + expect(shadowAll(panel, EMPTY_STATE)).toHaveLength(1) + }) + + it('prompts for a preserve-and-rerun when the baseline belongs to another test', async () => { + const panel = await mountLogin({ + baselines: baselineMap(preservedAttempt(), 'checkout-suite') + }) + + expect(shadowAll(panel, EMPTY_STATE)).toHaveLength(1) + }) + + it('renders no toolbar and no step row while the prompt is showing', async () => { + const panel = await mount<DevtoolsCompare>(PANEL) + + expect(shadowAll(panel, TOPBAR)).toHaveLength(0) + expect(shadowAll(panel, COL_HEADER)).toHaveLength(0) + expect(rows(panel)).toHaveLength(0) + }) + }) + + describe('topbar', () => { + it('labels each side with its own command count', async () => { + const panel = await mountLogin() + + expect(texts(panel, PILL)).toEqual([ + 'Baseline ยท failed ยท 6 commands', + 'Latest ยท 5 commands' + ]) + }) + + it("carries the baseline test's state as a class on its pill", async () => { + const panel = await mountLogin() + + expect([...shadowAll(panel, PILL)[0].classList]).toEqual([ + 'pill', + 'failed' + ]) + }) + + it('reports an unknown state for a baseline preserved without one', async () => { + const panel = await mountLogin({ + baselines: baselineMap(preservedAttempt({ test: {} })) + }) + + expect(text(shadow(panel, PILL))).toBe('Baseline ยท unknown ยท 6 commands') + expect([...shadowAll(panel, PILL)[0].classList]).toEqual(['pill']) + }) + + it('names the scope the baseline was preserved at', async () => { + const panel = await mountLogin() + + expect(text(shadow(panel, SCOPE))).toBe('suite scope') + }) + + it('names the test scope of a baseline preserved from a single test', async () => { + const panel = await mountLogin({ + baselines: baselineMap(preservedAttempt({ scope: 'test' })) + }) + + expect(text(shadow(panel, SCOPE))).toBe('test scope') + }) + + it('heads the left column with the baseline before a swap', async () => { + const panel = await mountLogin() + + expect(texts(panel, COL_HEADER)).toEqual(['Baseline', 'Latest']) + }) + }) + + describe('error banner', () => { + it("renders the baseline failure's message with its colour codes and blank-line runs cleaned", async () => { + const panel = await mountLogin() + + expect(text(shadow(panel, ERROR_BANNER_TITLE))).toBe( + 'Why the baseline failed' + ) + expect(lines(shadow(panel, ERROR_BANNER_MESSAGE))).toEqual( + BASELINE_ERROR_LINES + ) + }) + + it('leaves no invisible control character in the rendered banner', async () => { + const panel = await mountLogin() + const banner = shadow(panel, ERROR_BANNER_MESSAGE) + + expect(text(banner)).toContain(`Expected: "${EXPECTED_FLASH}"`) + expect(controlChars(banner)).toEqual([]) + expect(text(banner)).not.toMatch(/\[\d+m/) + }) + + it('renders no banner for a baseline that carried no error', async () => { + const panel = await mountLogin({ + baselines: baselineMap(preservedAttempt({ test: { state: 'failed' } })) + }) + + expect(shadowAll(panel, ERROR_BANNER)).toHaveLength(0) + expect(rows(panel)).toHaveLength(6) + }) + }) + + describe('step pairing', () => { + it('renders one row per index of the longer run', async () => { + const panel = await mountLogin() + + expect(rows(panel)).toHaveLength(LOGIN_PAIRS.length) + expect(rows(panel)).toHaveLength(6) + }) + + it('pairs the two runs by index', async () => { + const panel = await mountLogin() + + expect(commandsIn(panel, 0)).toEqual(pairedCommands('baseline')) + expect(commandsIn(panel, 1)).toEqual(pairedCommands('latest')) + expect(commandsIn(panel, 0)).toEqual([ + 'url', + 'setValue', + 'setValue', + 'click', + 'getText', + 'getTitle' + ]) + expect(commandsIn(panel, 1)).toEqual([ + 'url', + 'setValue', + 'setValue', + 'click', + 'getText', + '' + ]) + }) + + it('numbers every row by its index in the run', async () => { + const panel = await mountLogin() + + expect(numbersIn(panel, 0)).toEqual(pairNumbers()) + expect(numbersIn(panel, 0)).toEqual(['1', '2', '3', '4', '5', '6']) + }) + + it('dashes the side that ran out of commands', async () => { + const panel = await mountLogin() + + expect(shadowAll(panel, MISSING_CELL)).toHaveLength(1) + expect(text(column(panel, 1)[5])).toBe('โ€”') + }) + + it('marks the step where the two runs first differ', async () => { + const panel = await mountLogin() + + const forkIndex = firstDivergentIndex(LOGIN_PAIRS) + const first = shadowAll(panel, FIRST_DIVERGENT_CELL) + expect(first).toHaveLength(2) + expect(first.map((cell) => text(shadow(cell, 'code')))).toEqual([ + pairedCommands('baseline')[forkIndex], + pairedCommands('latest')[forkIndex] + ]) + expect(numbersIn(panel, 0)[forkIndex]).toBe(String(forkIndex + 1)) + expect(first.map((cell) => text(shadow(cell, 'code')))).toEqual([ + 'setValue', + 'setValue' + ]) + expect(numbersIn(panel, 0)[2]).toBe('3') + }) + + it('highlights only the cells that carry a difference, not every row after the fork', async () => { + const panel = await mountLogin() + + // Rows 4-6 inherit the fork bit but agree command-for-command; only the + // baseline's failure site (row 5) is flagged on its own account. + expect(divergentIn(panel, 0)).toEqual([ + false, + false, + true, + false, + true, + false + ]) + expect(divergentIn(panel, 1)).toEqual([ + false, + false, + true, + false, + false, + false + ]) + }) + + it('flags no cell as divergent when the two runs ran the same commands', async () => { + const panel = await mountLogin({ + baselines: baselineMap( + preservedAttempt({ + commands: loginCompare.identicalCommands, + steps: [], + test: { state: 'passed' } + }) + ) + }) + + expect(rows(panel)).toHaveLength(5) + expect(divergentIn(panel, 0)).toEqual([false, false, false, false, false]) + expect(shadowAll(panel, FIRST_DIVERGENT_CELL)).toHaveLength(0) + }) + + it('renders a single row for a baseline of one command', async () => { + const panel = await mountPair( + [commandLog({ command: 'getTitle', args: [] })], + [commandLog({ command: 'getTitle', args: [] })] + ) + + expect(rows(panel)).toHaveLength(1) + expect(numbersIn(panel, 0)).toEqual(['1']) + }) + }) + + describe('markers', () => { + it('marks every side of the canonical comparison with its own status', async () => { + const panel = await mountLogin() + + expect(markersIn(panel, 0)).toEqual([ + ['โœ“'], + ['โœ“'], + ['args differ'], + ['โœ“'], + ['โœ— in failed step'], + ['only here', 'โœ“'] + ]) + expect(markersIn(panel, 1)).toEqual([ + ['โœ“'], + ['โœ“'], + ['args differ'], + ['โœ“'], + ['โœ“'], + [] + ]) + }) + + it('labels both sides of a step whose arguments differ', async () => { + const panel = await mountLogin() + + expect(texts(panel, COMMAND_MARKER)).toEqual([ + 'args differ', + 'args differ' + ]) + expect(shadow(column(panel, 0)[2], MARKER)?.getAttribute('title')).toBe( + 'Same command, different arguments (compare args in the expanded view)' + ) + }) + + it('labels both sides of a step that ran a different command', async () => { + const panel = await mountPair( + [commandLog({ command: 'getText', args: [FLASH_SELECTOR] })], + [commandLog({ command: 'getTitle', args: [] })] + ) + + expect(texts(panel, COMMAND_MARKER)).toEqual([ + 'different command', + 'different command' + ]) + expect(divergentIn(panel, 0)).toEqual([true]) + expect(divergentIn(panel, 1)).toEqual([true]) + }) + + it('marks only the side that errored when the two runs disagree on the error', async () => { + const panel = await mountPair( + [ + commandLog({ + command: 'click', + args: [SUBMIT_SELECTOR], + error: capturedError(CLICK_ERROR) + }) + ], + [commandLog({ command: 'click', args: [SUBMIT_SELECTOR] })] + ) + + expect(markersIn(panel, 0)).toEqual([['โš  error']]) + expect(markersIn(panel, 1)).toEqual([['โœ“']]) + expect(divergentIn(panel, 0)).toEqual([true]) + expect(divergentIn(panel, 1)).toEqual([false]) + expect(markerTitle(column(panel, 0)[0])).toBe( + `WebDriver error: ${CLICK_ERROR}` + ) + }) + + it("marks the last command of a failed step as the step's failure site", async () => { + const panel = await mountLogin() + + expect(markerTitle(column(panel, 0)[4])).toBe( + `Failed step: ${ASSERT_STEP.fullTitle}\n${FLASH_ASSERTION_MESSAGE}` + ) + }) + + it('ticks an earlier command of the same failed step as identical', async () => { + const panel = await mountLogin() + + expect(markersIn(panel, 0)[3]).toEqual(['โœ“']) + expect(markerTitle(column(panel, 0)[3])).toBe('Identical') + }) + + it("flags a failed step's erroring command and its last command, not the ones between", async () => { + const failedStep: PreservedStep = { + uid: 'submit-step', + title: 'submits the form', + start: RUN_START, + end: RUN_START + 2000, + state: 'failed' + } + const commands = [ + commandLog({ + command: 'click', + args: [SUBMIT_SELECTOR], + error: capturedError(CLICK_ERROR), + timestamp: RUN_START + 100 + }), + commandLog({ command: 'getUrl', args: [], timestamp: RUN_START + 900 }), + commandLog({ + command: 'getText', + args: [FLASH_SELECTOR], + timestamp: RUN_START + 1500 + }) + ] + const panel = await mountPair(commands, commands, [failedStep]) + + expect(markersIn(panel, 0)).toEqual([ + ['โœ— in failed step'], + ['โœ“'], + ['โœ— in failed step'] + ]) + // The two runs agree, so nothing is highlighted as divergent โ€” the + // failure-site marker is independent of the diff. + expect(divergentIn(panel, 0)).toEqual([false, false, false]) + }) + + it('marks a single failure site when two commands share a millisecond', async () => { + const failedStep: PreservedStep = { + uid: 'assert-step', + title: 'shows the secure-area flash', + start: RUN_START, + end: RUN_START + 2000, + state: 'failed' + } + // Commands are stamped in wall-clock ms, so two fast reads inside one + // step share a timestamp; only the later one is the failure site. + const commands = [ + commandLog({ + command: 'getText', + args: [FLASH_SELECTOR], + timestamp: RUN_START + 1500 + }), + commandLog({ + command: 'getAttribute', + args: [FLASH_SELECTOR, 'class'], + timestamp: RUN_START + 1500 + }) + ] + const panel = await mountPair(commands, commands, [failedStep]) + + expect(markersIn(panel, 0)).toEqual([['โœ“'], ['โœ— in failed step']]) + }) + + it('marks a command that errored as failed when no step state resolved', async () => { + const errored = commandLog({ + command: 'click', + args: [SUBMIT_SELECTOR], + error: capturedError(CLICK_ERROR) + }) + const panel = await mountPair([errored], [errored]) + + expect(markersIn(panel, 0)).toEqual([['โœ— failed']]) + expect(markerTitle(column(panel, 0)[0])).toBe(`Failed: ${CLICK_ERROR}`) + }) + + it("titles a passing step's tick with the step the command ran in", async () => { + const panel = await mountLogin() + + expect(markerTitle(column(panel, 0)[0])).toBe( + 'Step passed: login page fills the login form' + ) + expect(markerTitle(column(panel, 1)[0])).toBe( + `Step passed: ${LIVE_TEST_TITLE}` + ) + }) + + it('tags the populated side of a truncated row as only here', async () => { + const panel = await mountLogin() + + expect(markerTitle(column(panel, 0)[5])).toBe( + 'Only present on this side โ€” the other run ended before this step' + ) + }) + + it('suppresses the only-here tag when the rerun produced no commands at all', async () => { + const panel = await mountLogin({ liveCommands: [] }) + + expect(text(shadow(panel, PILL))).toBe('Baseline ยท failed ยท 6 commands') + expect(texts(panel, PILL)[1]).toBe('Latest ยท 0 commands') + expect(markersIn(panel, 0)).toEqual([ + ['โœ“'], + ['โœ“'], + ['โœ“'], + ['โœ“'], + ['โœ— in failed step'], + ['โœ“'] + ]) + }) + + it('flags no cell as divergent while the rerun has produced no commands', async () => { + const panel = await mountLogin({ liveCommands: [] }) + + expect(shadowAll(panel, MISSING_CELL)).toHaveLength(6) + expect(divergentIn(panel, 0)).toEqual([ + false, + false, + false, + false, + false, + false + ]) + }) + }) + + describe('latest scoping', () => { + it("windows the live stream to the selected suite's live tests", async () => { + const panel = await mountLogin() + + expect(texts(panel, PILL)[1]).toBe('Latest ยท 5 commands') + expect(commandsIn(panel, 1)[0]).toBe('url') + }) + + it('spans the window from the first live test to the last', async () => { + const panel = await mountLogin({ + liveSuites: liveSuitesWith( + liveTest( + { start: RERUN_START, end: RERUN_START + 1200 }, + { uid: 'first-half' } + ), + liveTest( + { start: RERUN_START + 1500, end: RERUN_END }, + { uid: 'second-half' } + ) + ) + }) + + expect(texts(panel, PILL)[1]).toBe('Latest ยท 5 commands') + }) + + it('drops the live commands that ran before the selected suite started', async () => { + const panel = await mountLogin({ + liveSuites: liveSuitesWith( + liveTest({ start: RERUN_START + 1500, end: RERUN_END }) + ) + }) + + expect(texts(panel, PILL)[1]).toBe('Latest ยท 2 commands') + // Pairing is by index, so the surviving commands line up against the + // baseline's first two rather than their own. + expect(commandsIn(panel, 1)).toEqual(['click', 'getText', '', '', '', '']) + }) + + it('walks nested suites to find the selected suite', async () => { + const panel = await mountLogin({ + liveSuites: loginCompare.nestedLiveSuites + }) + + expect(texts(panel, PILL)[1]).toBe('Latest ยท 5 commands') + }) + + // Preserving from a test row records the TEST's uid, which is the common + // case; it must window to that test, not fall open to the whole run. + it('windows the live stream to the selected test when the uid names a test', async () => { + const panel = await mountLogin({ + baselines: baselineMap(preservedAttempt(), LIVE_TEST_UID), + selectedTestUid: LIVE_TEST_UID + }) + + expect(texts(panel, PILL)[1]).toBe('Latest ยท 5 commands') + expect(commandsIn(panel, 1)[0]).toBe('url') + }) + + it('compares the whole live stream when the selected uid is not in the live tree', async () => { + const panel = await mountLogin({ + liveSuites: suiteRegistry(suiteFragment('checkout-suite')) + }) + + expect(texts(panel, PILL)[1]).toBe('Latest ยท 7 commands') + }) + + // A start the runner hasn't reported yet is unknown, not unbounded: the end + // it did report still keeps the *next* test's commands out. Falling open to + // the whole stream here re-admits them. + it('windows the live stream with the end alone when no live test recorded a start time', async () => { + const panel = await mountLogin({ + liveSuites: liveSuitesWith(liveTest({ end: RERUN_END })) + }) + + expect(texts(panel, PILL)[1]).toBe('Latest ยท 6 commands') + // Everything up to the rerun's end โ€” the preceding test's command is + // admitted (no start to exclude it by), the following test's is not. + expect(commandsIn(panel, 1)).toEqual([ + 'deleteAllCookies', + 'url', + 'setValue', + 'setValue', + 'click', + 'getText' + ]) + }) + + it('takes the window start from the live tests that recorded one', async () => { + const panel = await mountLogin({ + liveSuites: liveSuitesWith( + liveTest({ end: RERUN_START + 1200 }, { uid: 'no-start' }), + liveTest( + { start: RERUN_START + 1500, end: RERUN_END }, + { uid: 'second-half' } + ) + ) + }) + + // The step without a start contributes only its end, so the window still + // opens at the started step rather than at the beginning of the run. + expect(texts(panel, PILL)[1]).toBe('Latest ยท 2 commands') + expect(commandsIn(panel, 1)).toEqual(['click', 'getText', '', '', '', '']) + }) + + it('compares the whole live stream when the live test recorded neither bound', async () => { + const panel = await mountLogin({ + liveSuites: liveSuitesWith(liveTest({})) + }) + + expect(texts(panel, PILL)[1]).toBe('Latest ยท 7 commands') + expect(commandsIn(panel, 1)[6]).toBe('url') + }) + + it('ignores a live test with neither bound while another one is windowed', async () => { + const panel = await mountLogin({ + liveSuites: liveSuitesWith( + liveTest({ start: RERUN_START, end: RERUN_END }, { uid: 'windowed' }), + liveTest({}, { uid: 'never-started' }) + ) + }) + + // The timeless step carries no window information, so it must not widen + // the windowed one's end to "now" and re-admit the next test's command. + expect(texts(panel, PILL)[1]).toBe('Latest ยท 5 commands') + expect(commandsIn(panel, 1)).toEqual([ + 'url', + 'setValue', + 'setValue', + 'click', + 'getText', + '' + ]) + }) + + it('leaves the window open-ended for a live test that has not finished', async () => { + const panel = await mountLogin({ + liveSuites: liveSuitesWith(liveTest({ start: RERUN_START })) + }) + + // Everything from the rerun's first command onwards, so the command of the + // test that followed is included too. + expect(texts(panel, PILL)[1]).toBe('Latest ยท 6 commands') + }) + + it('resolves no live step for a test that has not finished', async () => { + const panel = await mountLogin({ + liveSuites: liveSuitesWith(liveTest({ start: RERUN_START })) + }) + + expect(markerTitle(column(panel, 1)[0])).toBe('Identical') + }) + }) + + describe('differences only', () => { + it('hides the rows where the two runs agree', async () => { + const panel = await mountLogin() + await showDifferencesOnly(panel) + + // Every row the fork bit or a truncation keeps, and only those โ€” the fork + // bit sticks, so rows 4-6 stay even though they agree command-for-command. + expect(commandsIn(panel, 0)).toEqual( + divergentOrTruncated.map((pair) => pair.baseline?.command ?? '') + ) + expect(commandsIn(panel, 0)).toEqual([ + 'setValue', + 'click', + 'getText', + 'getTitle' + ]) + }) + + it('keeps the original step numbers on the rows it leaves', async () => { + const panel = await mountLogin() + await showDifferencesOnly(panel) + + expect(numbersIn(panel, 0)).toEqual(pairNumbers(divergentOrTruncated)) + expect(numbersIn(panel, 0)).toEqual(['3', '4', '5', '6']) + }) + + it('keeps the row where the two runs first differ marked as the first', async () => { + const panel = await mountLogin() + await showDifferencesOnly(panel) + + expect(shadowAll(panel, FIRST_DIVERGENT_CELL)).toHaveLength(2) + }) + + it('keeps a truncated row visible', async () => { + const panel = await mountLogin() + await showDifferencesOnly(panel) + + expect(shadowAll(panel, MISSING_CELL)).toHaveLength(1) + }) + + it('restores the hidden rows when unchecked', async () => { + const panel = await mountLogin() + await showDifferencesOnly(panel) + await showDifferencesOnly(panel, false) + + expect(rows(panel)).toHaveLength(6) + }) + + it('hides every row of a comparison with no differences', async () => { + const panel = await mountLogin({ + baselines: baselineMap( + preservedAttempt({ + commands: loginCompare.identicalCommands, + steps: [], + test: { state: 'passed' } + }) + ) + }) + await showDifferencesOnly(panel) + + expect(rows(panel)).toHaveLength(0) + }) + }) + + describe('swap', () => { + it('swaps the column headers', async () => { + const panel = await mountLogin() + await clickAction(panel, SWAP_BUTTON) + + expect(texts(panel, COL_HEADER)).toEqual(['Latest', 'Baseline']) + }) + + it('swaps the two runs between the columns', async () => { + const panel = await mountLogin() + await clickAction(panel, SWAP_BUTTON) + + expect(commandsIn(panel, 0)).toEqual([ + 'url', + 'setValue', + 'setValue', + 'click', + 'getText', + '' + ]) + expect(commandsIn(panel, 1)[5]).toBe('getTitle') + }) + + it('keeps the failure-site highlight on the baseline after a swap', async () => { + const panel = await mountLogin() + await clickAction(panel, SWAP_BUTTON) + + expect(divergentIn(panel, 1)).toEqual([ + false, + false, + true, + false, + true, + false + ]) + expect(markersIn(panel, 1)[4]).toEqual(['โœ— in failed step']) + }) + + it('swaps the detail-block labels', async () => { + const panel = await mountLogin() + await clickAction(panel, SWAP_BUTTON) + await clickCell(panel, 4, 0) + + expect(texts(panel, DETAIL_HEADING)).toEqual([ + 'Latest ยท getText', + 'Baseline ยท getText' + ]) + }) + }) + + describe('detail block', () => { + it('renders no detail panel until a step is clicked', async () => { + const panel = await mountLogin() + + expect(shadowAll(panel, DETAIL_PANEL)).toHaveLength(0) + }) + + it('expands the clicked step into one block per side', async () => { + const panel = await mountLogin() + await clickCell(panel, 4, 0) + + expect(shadowAll(panel, DETAIL_PANEL)).toHaveLength(1) + expect(texts(panel, DETAIL_HEADING)).toEqual([ + 'Baseline ยท getText', + 'Latest ยท getText' + ]) + }) + + it('collapses the panel when the same step is clicked again', async () => { + const panel = await mountLogin() + await clickCell(panel, 4, 0) + await clickCell(panel, 4, 1) + + expect(shadowAll(panel, DETAIL_PANEL)).toHaveLength(0) + }) + + it('moves the panel to another step when that step is clicked', async () => { + const panel = await mountLogin() + await clickCell(panel, 4, 0) + await clickCell(panel, 0, 0) + + expect(shadowAll(panel, DETAIL_PANEL)).toHaveLength(1) + expect(texts(panel, DETAIL_HEADING)).toEqual([ + 'Baseline ยท url', + 'Latest ยท url' + ]) + }) + + it('expands from the dashed side of a truncated row', async () => { + const panel = await mountLogin() + await clickCell(panel, 5, 1) + + expect(texts(panel, DETAIL_HEADING)).toEqual([ + 'Baseline ยท getTitle', + 'Latest' + ]) + expect(text(shadow(blocks(panel)[1], 'em'))).toBe( + 'No command at this step' + ) + }) + + it('names the step each command ran in', async () => { + const panel = await mountLogin() + await clickCell(panel, 0, 0) + + expect(blockLines(blocks(panel)[0])[0]).toBe( + 'step: login page fills the login form' + ) + expect(blockLines(blocks(panel)[1])[0]).toBe(`step: ${LIVE_TEST_TITLE}`) + }) + + it("renders the failed step's expected and actual values on its failure site", async () => { + const panel = await mountLogin() + await clickCell(panel, 4, 0) + + expect(blockLines(blocks(panel)[0])).toEqual([ + `step: ${ASSERT_STEP.fullTitle}`, + `args: ["${FLASH_SELECTOR}"]`, + `result: "${BASELINE_FLASH}"`, + `expected: "${EXPECTED_FLASH}"`, + `actual: "${BASELINE_FLASH}"`, + `assertion: ${FLASH_ASSERTION_MESSAGE}` + ]) + }) + + it('renders no expected or actual for a side whose step passed', async () => { + const panel = await mountLogin() + await clickCell(panel, 4, 0) + + expect(blockLines(blocks(panel)[1])).toEqual([ + `step: ${LIVE_TEST_TITLE}`, + `args: ["${FLASH_SELECTOR}"]`, + `result: "${LATEST_FLASH}"` + ]) + }) + + it('renders no expected or actual on a command that is not the failure site', async () => { + const panel = await mountLogin() + await clickCell(panel, 3, 0) + + expect( + blockLines(blocks(panel)[0]).some((line) => line.startsWith('expected')) + ).toBe(false) + }) + + it("renders a command's error in place of its result", async () => { + const panel = await mountPair( + [ + commandLog({ + command: 'click', + args: [SUBMIT_SELECTOR], + error: capturedError(CLICK_ERROR) + }) + ], + [commandLog({ command: 'click', args: [SUBMIT_SELECTOR] })] + ) + await clickCell(panel, 0, 0) + + expect(blockLines(blocks(panel)[0])).toEqual([ + `args: ["${SUBMIT_SELECTOR}"]`, + `error: ${CLICK_ERROR}` + ]) + }) + + it('renders the collapsed values of an assertion command instead of its error', async () => { + const panel = await mountPair( + [ + commandLog({ + command: 'assert.strictEqual', + args: [BASELINE_FLASH, EXPECTED_FLASH], + result: { + passed: false, + actual: BASELINE_FLASH, + expected: EXPECTED_FLASH + }, + error: capturedError('AssertionError: strictEqual failed') + }) + ], + [] + ) + await clickCell(panel, 0, 0) + + expect(blockLines(blocks(panel)[0])).toEqual([ + `args: ["${BASELINE_FLASH}","${EXPECTED_FLASH}"]`, + `expected: "${EXPECTED_FLASH}"`, + `actual: "${BASELINE_FLASH}"` + ]) + }) + + it('reads the positional args of an assertion command that carries no collapsed result', async () => { + const panel = await mountPair( + [ + commandLog({ + command: 'verify.equal', + args: [BASELINE_FLASH, EXPECTED_FLASH], + error: capturedError('verify.equal failed') + }) + ], + [] + ) + await clickCell(panel, 0, 0) + + expect(blockLines(blocks(panel)[0])).toEqual([ + `args: ["${BASELINE_FLASH}","${EXPECTED_FLASH}"]`, + `expected: "${EXPECTED_FLASH}"`, + `actual: "${BASELINE_FLASH}"` + ]) + }) + + it('falls back to the error of an assertion command with a single argument', async () => { + const panel = await mountPair( + [ + commandLog({ + command: 'assert.ok', + args: [false], + error: capturedError('assert.ok failed') + }) + ], + [] + ) + await clickCell(panel, 0, 0) + + expect(blockLines(blocks(panel)[0])).toEqual([ + 'args: [false]', + 'error: assert.ok failed' + ]) + }) + + it('derives the expected value from the step text when the assertion surfaced none', async () => { + const cucumberStep: PreservedStep = { + uid: 'flash-step', + title: 'Then I should see a flash message saying "Secure Area"', + start: RUN_START, + end: RUN_START + 1000, + state: 'failed' + } + const panel = await mountPair( + [ + commandLog({ + command: 'getText', + args: [FLASH_SELECTOR], + result: BASELINE_FLASH, + timestamp: RUN_START + }) + ], + [], + [cucumberStep] + ) + await clickCell(panel, 0, 0) + + expect(blockLines(blocks(panel)[0])).toEqual([ + `step: ${cucumberStep.title}`, + `args: ["${FLASH_SELECTOR}"]`, + `result: "${BASELINE_FLASH}"`, + `expected (from step): "${EXPECTED_FLASH}"` + ]) + expect( + shadowAll(blocks(panel)[0], 'pre')[3].getAttribute('title') + ).toContain('Derived from the step text') + }) + + it('renders no step banner for a baseline preserved without steps', async () => { + const panel = await mountPair( + [commandLog({ command: 'getTitle', args: [] })], + [] + ) + await clickCell(panel, 0, 0) + + expect(blockLines(blocks(panel)[0])[0]).toBe('args: []') + }) + + it('renders a captured screenshot as an inline image', async () => { + const panel = await mountPair( + [ + commandLog({ + command: 'getTitle', + args: [], + screenshot: 'iVBORw0KGg' + }) + ], + [] + ) + await clickCell(panel, 0, 0) + + expect(shadow(blocks(panel)[0], 'img')?.getAttribute('src')).toBe( + 'data:image/png;base64,iVBORw0KGg' + ) + }) + + it('leaves an already-encoded screenshot data URL alone', async () => { + const screenshot = 'data:image/png;base64,iVBORw0KGg' + const panel = await mountPair( + [commandLog({ command: 'getTitle', args: [], screenshot })], + [] + ) + await clickCell(panel, 0, 0) + + expect(shadow(blocks(panel)[0], 'img')?.getAttribute('src')).toBe( + screenshot + ) + }) + }) + + describe('clear', () => { + it('posts the selected test uid to the baseline clear endpoint', async () => { + const panel = await mountLogin() + const requests = recordBackend() + + await clickAction(panel, CLEAR_BUTTON) + await flush() + + expect(requests).toEqual([ + { + url: BASELINE_API.clear, + method: 'POST', + body: { testUid: SELECTED_UID } + } + ]) + }) + + it('keeps rendering the comparison when the clear request fails', async () => { + const panel = await mountLogin() + const requests = recordBackend({ rejecting: true }) + + await clickAction(panel, CLEAR_BUTTON) + await flush() + + // The server broadcast is what drops the baseline; the panel does not + // clear itself optimistically. + expect(requests).toHaveLength(1) + expect(rows(panel)).toHaveLength(6) + }) + }) + + describe('pop out', () => { + it('opens the comparison in a window named after the selected test', async () => { + const panel = await mountLogin() + const opened = recordWindowOpen() + + await clickAction(panel, POPOUT_BUTTON) + + expect(opened).toEqual([ + { + url: `${window.location.pathname}?view=compare&uid=${SELECTED_UID}`, + name: `compare-${SELECTED_UID}`, + features: 'width=1400,height=900,resizable=yes,scrollbars=yes' + } + ]) + }) + + it('renders no pop-out button inside a popout window', async () => { + const panel = await mountInPopoutWindow() + + expect(shadowAll(panel, POPOUT_BUTTON)).toHaveLength(0) + expect(shadowAll(panel, SWAP_BUTTON)).toHaveLength(1) + expect(shadowAll(panel, CLEAR_BUTTON)).toHaveLength(1) + }) + }) + + describe('autoscroll', () => { + it('scrolls the step where the runs first differ into view', async () => { + const scrolled = recordScrolls() + await mountLogin() + + expect(scrolled).toHaveLength(1) + expect(scrolled[0].getAttribute('data-first-divergent')).toBe('true') + expect(text(shadow(scrolled[0], 'code'))).toBe('setValue') + }) + + it('scrolls only once for the selected test', async () => { + const scrolled = recordScrolls() + const panel = await mountLogin() + await clickAction(panel, SWAP_BUTTON) + + expect(scrolled).toHaveLength(1) + }) + + it('does not scroll when the two runs agree', async () => { + const scrolled = recordScrolls() + await mountLogin({ + baselines: baselineMap( + preservedAttempt({ + commands: loginCompare.identicalCommands, + steps: [], + test: { state: 'passed' } + }) + ) + }) + + expect(scrolled).toHaveLength(0) + }) + }) +}) diff --git a/packages/app/test-ui/workbench/panels/console.test.ts b/packages/app/test-ui/workbench/panels/console.test.ts new file mode 100644 index 00000000..12ac0f66 --- /dev/null +++ b/packages/app/test-ui/workbench/panels/console.test.ts @@ -0,0 +1,296 @@ +import type { ConsoleLog } from '@wdio/devtools-shared' + +import { consoleLogContext } from '@/controller/context.js' +import '@components/workbench/console.js' +import type { DevtoolsConsoleLogs } from '@components/workbench/console.js' +import { formatConsoleArgs } from '@components/workbench/console-filter.js' + +import { mount, mountWithContext, settle } from '../../support/mount.js' +import { shadow, shadowAll, text, texts } from '../../support/queries.js' +import { + LONG_CONSOLE_MESSAGE, + RUNNER_WARN_TEXT, + RUN_START, + loginConsole, + consoleLog +} from './fixtures.js' + +const PANEL = 'wdio-devtools-console-logs' +const ENTRY = '.log-entry' +const MESSAGE = '.log-message' +const ICON = '.log-icon' +const BADGE = '.log-badge' +const TIME = '.log-time' +const LEVEL_TAB = '.filter-tab' +const ACTIVE_LEVEL_TAB = '.filter-tab.active' +const SEARCH = '.search-input' +const TOOLBAR = '.console-header' +const EMPTY_STATE = '.empty-state' +const FILTER_EMPTY = '.console-container .empty-state-text' + +/** The four `loginConsole` messages as the panel renders them โ€” derived through + * the same formatter the panel calls, so a row wired to the wrong entry (or to + * the raw args) fails. The literals below pin the strings a user reads. */ +const MESSAGES = loginConsole.logs.map((log) => formatConsoleArgs(log.args)) + +const MESSAGE_LITERALS = [ + '[TEST] logging in with valid credentials', + 'navigating to /secure', + RUNNER_WARN_TEXT, + "TypeError: Cannot read properties of undefined (reading 'flash')" +] + +/** Elapsed time as the panel measures it: seconds since the *first captured* + * log, to one decimal. Written out because `#formatElapsedTime` is private. */ +const elapsed = (timestamp: number) => + `${((timestamp - loginConsole.logs[0].timestamp) / 1000).toFixed(1)}s` + +async function mountConsole(logs: ConsoleLog[]): Promise<DevtoolsConsoleLogs> { + const panel = await mountWithContext<DevtoolsConsoleLogs>(PANEL, [ + { context: consoleLogContext, value: logs } + ]) + await settle(panel) + return panel +} + +const levelClassOf = (row: Element) => + [...row.classList].find((name) => name.startsWith('log-type-')) + +const badgeClassOf = (badge: Element) => + [...badge.classList].find((name) => name !== 'log-badge') + +async function clickLevelTab(panel: DevtoolsConsoleLogs, label: string) { + const tab = shadowAll(panel, LEVEL_TAB).find( + (button) => text(button) === label + ) + if (!tab) { + throw new Error(`no level filter tab labelled "${label}"`) + } + tab.click() + await settle(panel) +} + +async function search(panel: DevtoolsConsoleLogs, query: string) { + const input = shadow<HTMLInputElement>(panel, SEARCH) + if (!input) { + throw new Error('the console toolbar rendered no search input') + } + input.value = query + input.dispatchEvent(new Event('input')) + await settle(panel) +} + +describe('wdio-devtools-console-logs', () => { + describe('log list', () => { + it('renders one row per captured log entry', async () => { + const panel = await mountConsole(loginConsole.logs) + + expect(shadowAll(panel, ENTRY)).toHaveLength(4) + }) + + it('renders the messages in the order they were captured', async () => { + const panel = await mountConsole(loginConsole.logs) + + expect(texts(panel, MESSAGE)).toEqual(MESSAGES) + expect(texts(panel, MESSAGE)).toEqual(MESSAGE_LITERALS) + }) + + it('keeps capture order rather than sorting rows by timestamp', async () => { + const panel = await mountConsole([ + consoleLog({ args: ['logged first'], timestamp: RUN_START + 900 }), + consoleLog({ args: ['logged second'], timestamp: RUN_START + 100 }) + ]) + + expect(texts(panel, MESSAGE)).toEqual(['logged first', 'logged second']) + }) + + it('joins string args and pretty-prints object args into one message', async () => { + const panel = await mountConsole([ + consoleLog({ args: ['secure area', { attempts: 2 }] }) + ]) + + expect(text(shadow(panel, MESSAGE))).toBe('secure area { "attempts": 2 }') + }) + + it('renders a long message in full instead of truncating it', async () => { + const panel = await mountConsole([ + consoleLog({ args: [LONG_CONSOLE_MESSAGE] }) + ]) + + expect(text(shadow(panel, MESSAGE))).toBe(LONG_CONSOLE_MESSAGE.trim()) + }) + }) + + describe('log level', () => { + it('tags each row with the level it was logged at', async () => { + const panel = await mountConsole(loginConsole.logs) + + expect(shadowAll(panel, ENTRY).map(levelClassOf)).toEqual([ + 'log-type-log', + 'log-type-info', + 'log-type-warn', + 'log-type-error' + ]) + }) + + it('renders the icon that belongs to each level', async () => { + const panel = await mountConsole(loginConsole.logs) + + expect(texts(panel, ICON)).toEqual(['โ€บ', 'โ“˜', 'โš ', 'โœ•']) + }) + + it('falls back to the plain log icon for a level with no icon of its own', async () => { + const panel = await mountConsole([consoleLog({ type: 'debug' })]) + + expect(text(shadow(panel, ICON))).toBe('โ€บ') + expect(levelClassOf(shadowAll(panel, ENTRY)[0])).toBe('log-type-debug') + }) + }) + + describe('source badge', () => { + it('labels browser logs PAGE, spec logs TEST and runner output RUNNER', async () => { + const panel = await mountConsole(loginConsole.logs) + + expect(texts(panel, BADGE)).toEqual(['PAGE', 'TEST', 'RUNNER', 'PAGE']) + }) + + it('styles each source badge with its own class', async () => { + const panel = await mountConsole(loginConsole.logs) + + expect(shadowAll(panel, BADGE).map(badgeClassOf)).toEqual([ + 'b-browser', + 'b-test', + 'b-runner', + 'b-browser' + ]) + }) + + it('renders no badge for an entry captured without a source', async () => { + const panel = await mountConsole([consoleLog({ source: undefined })]) + + expect(shadowAll(panel, BADGE)).toHaveLength(0) + expect(shadowAll(panel, ENTRY)).toHaveLength(1) + }) + }) + + describe('timestamp', () => { + it("renders each row's time elapsed from the first captured log", async () => { + const panel = await mountConsole(loginConsole.logs) + + expect(texts(panel, TIME)).toEqual( + loginConsole.logs.map((log) => elapsed(log.timestamp)) + ) + expect(texts(panel, TIME)).toEqual(['0.0s', '0.4s', '1.2s', '2.5s']) + }) + + it('renders an empty time cell for an entry without a timestamp', async () => { + const panel = await mountConsole([consoleLog({ timestamp: 0 })]) + + expect(text(shadow(panel, TIME))).toBe('') + }) + }) + + describe('filtering', () => { + it('shows every level with the All tab active before anything is filtered', async () => { + const panel = await mountConsole(loginConsole.logs) + + expect(text(shadow(panel, ACTIVE_LEVEL_TAB))).toBe('All') + expect(shadowAll(panel, ENTRY)).toHaveLength(4) + }) + + it('narrows the list to error rows when the Errors tab is clicked', async () => { + const panel = await mountConsole(loginConsole.logs) + await clickLevelTab(panel, 'Errors') + + expect(text(shadow(panel, ACTIVE_LEVEL_TAB))).toBe('Errors') + expect(texts(panel, MESSAGE)).toEqual([MESSAGES[3]]) + }) + + it('keeps elapsed time measured from the first captured log while filtered', async () => { + const panel = await mountConsole(loginConsole.logs) + await clickLevelTab(panel, 'Errors') + + // Measured from the first *captured* log, not the first *visible* one โ€” + // re-basing on the filtered list would read 0.0s here. + expect(texts(panel, TIME)).toEqual([ + elapsed(loginConsole.pageError.timestamp) + ]) + expect(texts(panel, TIME)).toEqual(['2.5s']) + }) + + it('shows only log-level rows under the Logs tab, dropping a debug entry', async () => { + const panel = await mountConsole([ + consoleLog({ args: ['plain log'] }), + consoleLog({ type: 'debug', args: ['ws frame received'] }) + ]) + await clickLevelTab(panel, 'Logs') + + expect(texts(panel, MESSAGE)).toEqual(['plain log']) + }) + + it('narrows the list to messages containing the search text, ignoring case', async () => { + const panel = await mountConsole(loginConsole.logs) + await search(panel, 'SECURE') + + expect(texts(panel, MESSAGE)).toEqual([MESSAGES[1]]) + }) + + it('requires both the level tab and the search text to match', async () => { + const panel = await mountConsole(loginConsole.logs) + await clickLevelTab(panel, 'Warnings') + // "flash" also appears in the error row, which the level tab excludes. + await search(panel, 'flash') + + expect(texts(panel, MESSAGE)).toEqual([RUNNER_WARN_TEXT]) + }) + + it('reports that nothing matches when the search excludes every row', async () => { + const panel = await mountConsole(loginConsole.logs) + await search(panel, 'no such log line') + + expect(shadowAll(panel, ENTRY)).toHaveLength(0) + expect(text(shadow(panel, FILTER_EMPTY))).toBe( + 'No logs match the current filter.' + ) + }) + }) + + describe('terminal colour codes', () => { + it('renders a coloured runner line without its SGR codes', async () => { + const panel = await mountConsole([loginConsole.runnerWarn]) + + expect(text(shadow(panel, MESSAGE))).toBe(RUNNER_WARN_TEXT) + }) + + it('searches the stripped message rather than the colour codes', async () => { + const panel = await mountConsole([loginConsole.runnerWarn]) + await search(panel, '33m') + + expect(shadowAll(panel, ENTRY)).toHaveLength(0) + }) + }) + + describe('empty state', () => { + it('renders the empty state when no logs have been captured', async () => { + const panel = await mountConsole([]) + + expect(text(shadow(panel, EMPTY_STATE))).toBe( + '๐Ÿ“‹ No console logs captured yet' + ) + }) + + it('renders the empty state before a provider supplies any logs', async () => { + const panel = await mount<DevtoolsConsoleLogs>(PANEL) + + expect(shadowAll(panel, EMPTY_STATE)).toHaveLength(1) + expect(shadowAll(panel, ENTRY)).toHaveLength(0) + }) + + it('renders no filter toolbar while the empty state is showing', async () => { + const panel = await mountConsole([]) + + expect(shadowAll(panel, TOOLBAR)).toHaveLength(0) + expect(shadowAll(panel, SEARCH)).toHaveLength(0) + }) + }) +}) diff --git a/packages/app/test-ui/workbench/panels/errors.test.ts b/packages/app/test-ui/workbench/panels/errors.test.ts new file mode 100644 index 00000000..8dcffb60 --- /dev/null +++ b/packages/app/test-ui/workbench/panels/errors.test.ts @@ -0,0 +1,674 @@ +import type { CommandLog } from '@wdio/devtools-shared' + +import { commandContext, suiteContext } from '@/controller/context.js' +import type { SuiteStatsFragment } from '@/controller/types.js' +import '@components/workbench/errors.js' +import type { DevtoolsErrors } from '@components/workbench/errors.js' +import { collectErrors } from '@components/workbench/errors/collect.js' + +import { mount, mountWithContext, settle } from '../../support/mount.js' +import { commandLog } from '../../support/builders.js' +import { shadow, shadowAll, text, texts } from '../../support/queries.js' +import { + ASSERT_ACTUAL, + ASSERT_CALL_SOURCE, + ASSERT_EXPECTED, + CLICK_CALL_SOURCE, + CLICK_MESSAGE, + HOOK_MESSAGE, + MATCHER_CALL_SOURCE, + MATCHER_EXPECTED, + MATCHER_HEADLINE, + MATCHER_RECEIVED, + RUN_START, + SPEC_FILE, + STACK_FRAMES, + capturedError, + loginErrors, + suiteFragment, + suiteRegistry, + testError, + testFragment +} from './fixtures.js' + +const PANEL = 'wdio-devtools-errors' +const ENTRY = '.error-entry' +const LOC = '.error-loc' +const TITLE = '.error-title' +const MESSAGE = '.error-message' +const DIFF = '.error-diff' +const DIFF_LABEL = '.error-diff .label' +const RECEIVED = '.error-diff .received' +const EXPECTED = '.error-diff .expected' +const STACK = '.error-stack' +const STACK_BODY = '.error-stack pre' +const STACK_SUMMARY = '.error-stack summary' +const EMPTY_STATE = '.empty-state' +const EMPTY_ICON = '.empty-state-icon' + +/** `@` plus the last three path segments of a call source, which is all the + * anchor shows. Mirrors `errors.ts`'s `shortSource`, which is module-private โ€” + * so the anchors below follow the fixture's own call sources rather than + * restating its file path and line numbers as literals. A change to how many + * segments the panel keeps still fails these. */ +const anchor = (callSource: string) => + `@${callSource.split(/[\\/]/).slice(-3).join('/')}` + +/** Anchor labels of the scenario's failures. */ +const ANCHOR = { + click: anchor(CLICK_CALL_SOURCE), + matcher: anchor(MATCHER_CALL_SOURCE), + nativeAssert: anchor(ASSERT_CALL_SOURCE), + hook: anchor(`${SPEC_FILE}:14:3`) +} + +/** `fullTitle` of the fixture run's failed test, which heads its row. */ +const HOOK_TEST_TITLE = 'login page logs out again' + +async function mountErrors( + commands: CommandLog[], + suites: Record<string, SuiteStatsFragment>[] = [] +): Promise<DevtoolsErrors> { + const panel = await mountWithContext<DevtoolsErrors>(PANEL, [ + { context: commandContext, value: commands }, + { context: suiteContext, value: suites } + ]) + await settle(panel) + return panel +} + +const entries = (panel: DevtoolsErrors) => shadowAll(panel, ENTRY) + +/** Raw lines of an element's text โ€” `text()` collapses the newlines a wrapped + * message and a stack are made of. */ +const lines = (el: Element | null): string[] => + (el?.textContent ?? '') + .trim() + .split('\n') + .map((line) => line.trim()) + +function failingCommand( + message: string, + overrides: Partial<CommandLog> = {} +): CommandLog { + return commandLog({ + command: 'click', + error: capturedError(message), + ...overrides + }) +} + +/** Every window event the panel can announce. `show-command` is listed because + * it is what a clickable row would emit โ€” the panel owns no timeline, so it has + * no baseline to derive that event's required `elapsedTime` from, and rows are + * read-only apart from their source anchor. */ +const OUTPUT_EVENTS = ['app-source-highlight', 'show-command'] as const + +/** What the panel announced on `window` while `act` ran, keyed by event type. */ +function recordOutput(act: () => void): Record<string, CustomEvent[]> { + const seen: Record<string, CustomEvent[]> = Object.fromEntries( + OUTPUT_EVENTS.map((type) => [type, []]) + ) + const listeners = OUTPUT_EVENTS.map((type) => { + const listener = (event: Event) => seen[type].push(event as CustomEvent) + window.addEventListener(type, listener) + return { type, listener } + }) + try { + act() + } finally { + for (const { type, listener } of listeners) { + window.removeEventListener(type, listener) + } + } + return seen +} + +const click = () => new MouseEvent('click', { bubbles: true, composed: true }) + +describe('wdio-devtools-errors', () => { + describe('error list', () => { + it('renders one entry per failing command', async () => { + const panel = await mountErrors(loginErrors.commands) + + expect(entries(panel)).toHaveLength(3) + }) + + it('skips the commands that succeeded', async () => { + const panel = await mountErrors([loginErrors.navigate]) + + expect(entries(panel)).toHaveLength(0) + expect(text(shadow(panel, EMPTY_STATE))).toBe('โœ“ No errors') + }) + + it('orders command failures by the time they were captured', async () => { + const panel = await mountErrors(loginErrors.commands) + + expect(texts(panel, LOC)).toEqual([ + ANCHOR.click, + ANCHOR.matcher, + ANCHOR.nativeAssert + ]) + }) + + it('lists the failures a suite reported after the command failures', async () => { + const panel = await mountErrors(loginErrors.commands, loginErrors.suites) + + expect(entries(panel)).toHaveLength(4) + expect(texts(panel, LOC)).toEqual([ + ANCHOR.click, + ANCHOR.matcher, + ANCHOR.nativeAssert, + ANCHOR.hook + ]) + }) + + it('renders every error the collector produced, in its order', async () => { + const panel = await mountErrors(loginErrors.commands, loginErrors.suites) + const collected = collectErrors( + loginErrors.commands, + loginErrors.suites as never + ) + + // The panel renders `collectErrors` output directly, so a row it drops or + // reorders on its own account shows up here even though the per-field + // assertions above still pass. + expect(entries(panel)).toHaveLength(collected.length) + expect(texts(panel, LOC)).toEqual( + collected.map((error) => anchor(error.callSource!)) + ) + expect(texts(panel, MESSAGE)).toEqual( + collected.map((error) => error.message) + ) + }) + + it('ignores a test that carries an error but did not fail', async () => { + const panel = await mountErrors([], loginErrors.suites) + + // The scenario's passing test carries a retry error; only the failed one + // becomes a row. + expect(entries(panel)).toHaveLength(1) + expect(text(shadow(panel, MESSAGE))).toBe(HOOK_MESSAGE) + }) + + it('walks nested suites for failed tests', async () => { + const panel = await mountErrors( + [], + suiteRegistry( + suiteFragment('feature', { + suites: [ + suiteFragment('scenario', { + tests: [ + testFragment('deep', { error: testError('nested failure') }) + ] + }) + ] + }) + ) + ) + + expect(text(shadow(panel, MESSAGE))).toBe('nested failure') + }) + + it('keeps the freshest fragment when the same test uid failed twice', async () => { + const failed = (message: string) => + suiteFragment('login-suite', { + tests: [testFragment('login-logout', { error: testError(message) })] + }) + const panel = await mountErrors( + [], + [ + ...suiteRegistry(failed('first report')), + ...suiteRegistry(failed('corrected report')) + ] + ) + + expect(texts(panel, MESSAGE)).toEqual(['corrected report']) + }) + + it('renders no entry for a failure carrying neither message nor stack', async () => { + const panel = await mountErrors([ + commandLog({ error: capturedError('', { name: '' }) }) + ]) + + expect(entries(panel)).toHaveLength(0) + }) + }) + + describe('row heading', () => { + it("heads a command failure with the action's display label", async () => { + const panel = await mountErrors([ + failingCommand('element not interactable', { + command: 'click', + title: 'Element.click("#login")' + }) + ]) + + // The label, not the raw command name โ€” a trace-player row is titled + // `Element.click("#login")` while its command is still `click`. + expect(text(shadow(panel, TITLE))).toBe('Element.click("#login")') + }) + + it('heads a command failure with the command name when it has no label', async () => { + const panel = await mountErrors([loginErrors.matcher]) + + expect(text(shadow(panel, TITLE))).toBe(loginErrors.matcher.command) + }) + + it("heads a suite-level failure with the failed test's full title", async () => { + const panel = await mountErrors([], loginErrors.suites) + + expect(text(shadow(panel, TITLE))).toBe(HOOK_TEST_TITLE) + }) + + it('heads every row of a run that failed several ways', async () => { + const panel = await mountErrors(loginErrors.commands, loginErrors.suites) + + expect(texts(panel, TITLE)).toEqual([ + loginErrors.click.command, + loginErrors.matcher.command, + loginErrors.nativeAssert.command, + HOOK_TEST_TITLE + ]) + }) + + it('distinguishes two failures that report the same message', async () => { + const panel = await mountErrors([ + failingCommand('Timeout of 5000ms exceeded', { + command: 'waitForDisplayed', + timestamp: RUN_START + }), + failingCommand('Timeout of 5000ms exceeded', { + command: 'click', + timestamp: RUN_START + 400 + }) + ]) + + expect(texts(panel, MESSAGE)).toEqual([ + 'Timeout of 5000ms exceeded', + 'Timeout of 5000ms exceeded' + ]) + expect(texts(panel, TITLE)).toEqual(['waitForDisplayed', 'click']) + }) + }) + + describe('de-duplication', () => { + it('drops a failed test that only re-reports a command failure', async () => { + const panel = await mountErrors( + [loginErrors.click], + suiteRegistry( + suiteFragment('login-suite', { + tests: [ + testFragment('login-valid', { error: testError(CLICK_MESSAGE) }) + ] + }) + ) + ) + + expect(entries(panel)).toHaveLength(1) + expect(text(shadow(panel, LOC))).toBe(ANCHOR.click) + }) + + it('drops a failed test whose message only adds the framework Error prefix', async () => { + const panel = await mountErrors( + [loginErrors.click], + suiteRegistry( + suiteFragment('login-suite', { + tests: [ + testFragment('login-valid', { + error: testError(`Error: ${CLICK_MESSAGE}`) + }) + ] + }) + ) + ) + + expect(entries(panel)).toHaveLength(1) + }) + + it("drops a failed test that echoes a command's assertion values in its stack", async () => { + const panel = await mountErrors( + [loginErrors.nativeAssert], + suiteRegistry( + suiteFragment('login-suite', { + tests: [ + testFragment('login-valid', { + // Reworded headline, so only the expected+actual echo can match. + error: testError( + 'Error: assertion failed', + `AssertionError: expected '${ASSERT_ACTUAL}' to equal '${ASSERT_EXPECTED}'\n${STACK_FRAMES.join('\n')}` + ) + }) + ] + }) + ) + ) + + expect(entries(panel)).toHaveLength(1) + expect(text(shadow(panel, LOC))).toBe(ANCHOR.nativeAssert) + }) + + it('keeps a failed test whose own failure no command reported', async () => { + const panel = await mountErrors([loginErrors.click], loginErrors.suites) + + expect(texts(panel, MESSAGE)).toEqual([CLICK_MESSAGE, HOOK_MESSAGE]) + }) + + it('renders a repeated failure once per command rather than grouping it', async () => { + const panel = await mountErrors([ + failingCommand('Timeout of 5000ms exceeded', { timestamp: RUN_START }), + failingCommand('Timeout of 5000ms exceeded', { + timestamp: RUN_START + 400 + }) + ]) + + expect(texts(panel, MESSAGE)).toEqual([ + 'Timeout of 5000ms exceeded', + 'Timeout of 5000ms exceeded' + ]) + }) + }) + + describe('assertion diff', () => { + it("renders a matcher failure's Expected and Received values as a labelled diff", async () => { + const panel = await mountErrors([loginErrors.matcher]) + + expect(texts(panel, DIFF_LABEL)).toEqual(['Actual', 'Expected']) + // The matcher already printed these values; the panel renders them as-is + // rather than quoting a second time. + expect(text(shadow(panel, RECEIVED))).toBe(MATCHER_RECEIVED) + expect(text(shadow(panel, EXPECTED))).toBe(MATCHER_EXPECTED) + }) + + it('keeps the matcher headline above the values pulled out of it', async () => { + const panel = await mountErrors([loginErrors.matcher]) + + // The headline is the message minus the Expected/Received lines, so it + // heads the row without repeating what the diff already shows. + expect(text(shadow(panel, MESSAGE))).toBe(MATCHER_HEADLINE) + expect(text(shadow(panel, MESSAGE))).not.toContain('Expected:') + }) + + it('reads the values off a collapsed assert result when the command carries one', async () => { + const panel = await mountErrors([loginErrors.nativeAssert]) + + expect(text(shadow(panel, RECEIVED))).toBe(`'${ASSERT_ACTUAL}'`) + expect(text(shadow(panel, EXPECTED))).toBe(`'${ASSERT_EXPECTED}'`) + }) + + it('falls back to the positional args of an assertion command with no result', async () => { + const panel = await mountErrors([ + commandLog({ + command: 'verify.equal', + args: [ASSERT_ACTUAL, ASSERT_EXPECTED], + error: capturedError('verify.equal failed') + }) + ]) + + expect(text(shadow(panel, RECEIVED))).toBe(`'${ASSERT_ACTUAL}'`) + expect(text(shadow(panel, EXPECTED))).toBe(`'${ASSERT_EXPECTED}'`) + }) + + it('reads the values off the error itself when nothing else carries them', async () => { + const panel = await mountErrors([ + commandLog({ + command: 'getTitle', + args: [], + error: Object.assign(new Error('title mismatch'), { + expected: ASSERT_EXPECTED, + actual: ASSERT_ACTUAL + }) + }) + ]) + + expect(text(shadow(panel, RECEIVED))).toBe(`'${ASSERT_ACTUAL}'`) + expect(text(shadow(panel, EXPECTED))).toBe(`'${ASSERT_EXPECTED}'`) + }) + + it('renders only the row it has a value for', async () => { + const panel = await mountErrors([ + commandLog({ + command: 'assert.ok', + args: [false], + result: { passed: false, actual: ASSERT_ACTUAL }, + error: capturedError('assert.ok failed') + }) + ]) + + expect(texts(panel, DIFF_LABEL)).toEqual(['Actual']) + expect(shadowAll(panel, EXPECTED)).toHaveLength(0) + }) + + it('renders no diff for a failure that carried no values', async () => { + const panel = await mountErrors([loginErrors.click]) + + expect(shadowAll(panel, DIFF)).toHaveLength(0) + expect(text(shadow(panel, MESSAGE))).toBe(CLICK_MESSAGE) + }) + + it('drops the bare-Error headline when the whole message was the diff', async () => { + const panel = await mountErrors([ + failingCommand( + `Expected: ${MATCHER_EXPECTED}\nReceived: ${MATCHER_RECEIVED}`, + { command: 'expect.toHaveText' } + ) + ]) + + // Nothing is left to head the row once the values are pulled out of it, + // and a lone `Error` above a diff says nothing the diff doesn't. The + // counterpart โ€” no diff, so `Error` is all the row has โ€” is asserted under + // "stack". + expect(shadowAll(panel, MESSAGE)).toHaveLength(0) + expect(text(shadow(panel, RECEIVED))).toBe(MATCHER_RECEIVED) + expect(text(shadow(panel, EXPECTED))).toBe(MATCHER_EXPECTED) + expect(entries(panel)).toHaveLength(1) + }) + }) + + describe('stack', () => { + it('renders the captured stack in a collapsed Stack section', async () => { + const panel = await mountErrors([loginErrors.click]) + + const details = shadow<HTMLDetailsElement>(panel, STACK) + expect(text(shadow(panel, STACK_SUMMARY))).toBe('Stack') + expect(details?.open).toBe(false) + expect(lines(shadow(panel, STACK_BODY))).toEqual([ + `Error: ${CLICK_MESSAGE}`, + ...STACK_FRAMES.map((frame) => frame.trim()) + ]) + }) + + it('renders no stack section for a failure captured without one', async () => { + const panel = await mountErrors([loginErrors.nativeAssert]) + + expect(shadowAll(panel, STACK)).toHaveLength(0) + expect(entries(panel)).toHaveLength(1) + }) + + it('splits trailing stack frames off a message that carries its own', async () => { + const panel = await mountErrors([ + failingCommand(`element not interactable\n${STACK_FRAMES.join('\n')}`) + ]) + + expect(text(shadow(panel, MESSAGE))).toBe('element not interactable') + expect(lines(shadow(panel, STACK_BODY))).toEqual( + STACK_FRAMES.map((frame) => frame.trim()) + ) + }) + + it('reports a bare Error when the message was only stack frames', async () => { + const panel = await mountErrors([failingCommand(STACK_FRAMES.join('\n'))]) + + expect(text(shadow(panel, MESSAGE))).toBe('Error') + expect(shadowAll(panel, STACK)).toHaveLength(1) + }) + }) + + describe('message', () => { + // The `\u001b` prefixes are load-bearing: `stripAnsi` matches `ESC[<n>m`, so + // a colour code that reached the app without its ESC stays in the row. + it('strips terminal colour codes from the message', async () => { + const panel = await mountErrors([ + failingCommand('\u001b[31mTimeout\u001b[39m of 5000ms exceeded') + ]) + + expect(text(shadow(panel, MESSAGE))).toBe('Timeout of 5000ms exceeded') + }) + + it('strips terminal colour codes from the stack', async () => { + const panel = await mountErrors([ + commandLog({ + error: capturedError('Timeout of 5000ms exceeded', { + stack: `Error: Timeout\n\u001b[90m${STACK_FRAMES[0]}\u001b[39m` + }) + }) + ]) + + expect(lines(shadow(panel, STACK_BODY))).toEqual([ + 'Error: Timeout', + STACK_FRAMES[0].trim() + ]) + }) + + it('drops the indentation an assertion library adds to a wrapped message', async () => { + const panel = await mountErrors([ + failingCommand( + 'Timed out waiting for element\n while polling #flash\n\n for 5000ms' + ) + ]) + + expect(lines(shadow(panel, MESSAGE))).toEqual([ + 'Timed out waiting for element', + 'while polling #flash', + 'for 5000ms' + ]) + }) + + it('falls back to the error name when the failure carried no message', async () => { + const panel = await mountErrors([ + commandLog({ error: capturedError('', { name: 'AssertionError' }) }) + ]) + + expect(text(shadow(panel, MESSAGE))).toBe('AssertionError') + }) + }) + + describe('source anchor', () => { + it('labels the anchor with the last three segments of the call source', async () => { + const panel = await mountErrors([loginErrors.matcher]) + + expect(text(shadow(panel, LOC))).toBe(ANCHOR.matcher) + expect(shadow(panel, LOC)?.getAttribute('title')).toBe( + 'Open source at this line' + ) + }) + + it('dispatches app-source-highlight with the full call source when clicked', async () => { + const panel = await mountErrors([loginErrors.matcher]) + const received: string[] = [] + const listener = (event: Event) => + received.push((event as CustomEvent<string>).detail) + + window.addEventListener('app-source-highlight', listener) + try { + shadow(panel, LOC)?.dispatchEvent(new MouseEvent('click')) + } finally { + window.removeEventListener('app-source-highlight', listener) + } + + expect(received).toEqual([loginErrors.matcher.callSource]) + }) + + it('renders no anchor for a failure captured without a call source', async () => { + const panel = await mountErrors([ + failingCommand('no call source', { callSource: undefined }) + ]) + + expect(shadowAll(panel, LOC)).toHaveLength(0) + expect(entries(panel)).toHaveLength(1) + }) + + it('dispatches exactly one highlight per click, and nothing else', async () => { + const panel = await mountErrors([loginErrors.matcher]) + + const seen = recordOutput(() => { + shadow(panel, LOC)?.dispatchEvent(click()) + }) + + expect(seen['app-source-highlight']).toHaveLength(1) + expect(seen['app-source-highlight'][0].detail).toBe( + loginErrors.matcher.callSource + ) + // The anchor is the row's only destination: `show-command` would also send + // the dock to the Log tab, so one click would ask for two. + expect(seen['show-command']).toHaveLength(0) + }) + + it("anchors a suite-level failure at the test's own call source", async () => { + const panel = await mountErrors( + [], + suiteRegistry( + suiteFragment('login-suite', { + tests: [ + testFragment('login-logout', { + callSource: `${SPEC_FILE}:14:3`, + error: testError('after hook failed') + }) + ] + }) + ) + ) + + expect(text(shadow(panel, LOC))).toBe(ANCHOR.hook) + }) + }) + + describe('row interaction', () => { + it('announces nothing when the row itself is clicked', async () => { + const panel = await mountErrors([loginErrors.matcher]) + + const seen = recordOutput(() => { + shadow(panel, ENTRY)?.dispatchEvent(click()) + shadow(panel, TITLE)?.dispatchEvent(click()) + }) + + // A command failure is the row that could plausibly select its action, and + // it deliberately doesn't โ€” the anchor is the row's only control. + expect(seen['show-command']).toHaveLength(0) + expect(seen['app-source-highlight']).toHaveLength(0) + }) + + it('presents the row as no kind of control', async () => { + const panel = await mountErrors([loginErrors.matcher]) + const entry = shadow(panel, ENTRY) + + expect(entry?.tagName).toBe('DIV') + expect(entry?.getAttribute('role')).toBeNull() + expect(entry?.getAttribute('tabindex')).toBeNull() + expect(shadowAll(panel, `${ENTRY} button`)).toHaveLength(1) + }) + }) + + describe('empty state', () => { + it('renders the no-errors state when nothing failed', async () => { + const panel = await mountErrors([]) + + expect(text(shadow(panel, EMPTY_ICON))).toBe('โœ“') + expect(text(shadow(panel, EMPTY_STATE))).toBe('โœ“ No errors') + }) + + it('renders the no-errors state before a provider supplies any data', async () => { + const panel = await mount<DevtoolsErrors>(PANEL) + + expect(shadowAll(panel, EMPTY_STATE)).toHaveLength(1) + expect(entries(panel)).toHaveLength(0) + }) + + it('renders no entry while the no-errors state is showing', async () => { + const panel = await mountErrors([loginErrors.navigate], suiteRegistry()) + + expect(entries(panel)).toHaveLength(0) + expect(shadowAll(panel, EMPTY_STATE)).toHaveLength(1) + }) + }) +}) diff --git a/packages/app/test-ui/workbench/panels/fixtures.ts b/packages/app/test-ui/workbench/panels/fixtures.ts new file mode 100644 index 00000000..7d715020 --- /dev/null +++ b/packages/app/test-ui/workbench/panels/fixtures.ts @@ -0,0 +1,544 @@ +// One login run, seen by every workbench panel: console entries, network +// requests, command failures, session metadata and captured spec sources. Each +// panel takes its data through @lit/context, so a spec hands these values to +// `mountWithContext` rather than setting properties. +// +// Typed against shared's `ConsoleLog`/`NetworkRequest` โ€” the contracts the +// adapters produce. The panels annotate the same data with the browser-side +// globals of the same name, which resolve to `any` in this program +// (packages/script/types.d.ts aliases both to themselves), so shared is the +// only shape that actually type-checks a fixture. + +import { TraceType } from '@wdio/devtools-shared' +import type { + CommandLog, + ConsoleLog, + Metadata, + NetworkRequest, + SerializedError +} from '@wdio/devtools-shared' + +import type { + SuiteStatsFragment, + TestStatsFragment +} from '../../../src/controller/types.js' +import { commandLog } from '../../support/builders.js' + +/** Wall-clock origin of the fixture run โ€” the offsets below read as ms into it. */ +export const RUN_START = 1_700_000_000_000 + +/** The page the fixture run drives โ€” the demo app every example project uses. */ +export const LOGIN_URL = 'https://the-internet.herokuapp.com/login' + +export function consoleLog(overrides: Partial<ConsoleLog> = {}): ConsoleLog { + return { + type: 'log', + args: ['log line'], + timestamp: RUN_START, + ...overrides + } +} + +export function networkRequest( + overrides: Partial<NetworkRequest> = {} +): NetworkRequest { + return { + id: 'req-1', + url: LOGIN_URL, + method: 'GET', + type: 'document', + status: 200, + timestamp: RUN_START, + startTime: RUN_START, + ...overrides + } +} + +/** `runnerWarn`'s message once the panel has stripped its SGR codes. */ +export const RUNNER_WARN_TEXT = '[WARN] retrying assertion on #flash' + +/** Wider than the message column by two orders of magnitude โ€” the panel wraps + * it (CSS `pre-wrap`/`break-word`) and never shortens the text. */ +export const LONG_CONSOLE_MESSAGE = `Unhandled rejection: ${'at Object.<anonymous> (login.e2e.ts:42:7) '.repeat( + 40 +)}` + +export interface LoginConsole { + /** Capture order, which is the order the panel renders. */ + logs: ConsoleLog[] + /** Page-side `console.log`, and the run's first entry โ€” elapsed origin. */ + pageLog: ConsoleLog + /** Spec-side log, 0.4s in. */ + testInfo: ConsoleLog + /** Runner stdout, 1.2s in, carrying the SGR residue a coloured logger leaves. */ + runnerWarn: ConsoleLog + /** Page-side error, 2.5s in. */ + pageError: ConsoleLog +} + +const pageLog = consoleLog({ + type: 'log', + args: ['[TEST] logging in with valid credentials'], + source: 'browser', + timestamp: RUN_START +}) + +const testInfo = consoleLog({ + type: 'info', + args: ['navigating to /secure'], + source: 'test', + timestamp: RUN_START + 400 +}) + +// The `\u001b` prefixes are load-bearing: `stripAnsi` matches `ESC[<n>m`, so a +// colour code that reached the app without its ESC stays in the rendered row. +const runnerWarn = consoleLog({ + type: 'warn', + args: ['\u001b[33m[WARN]\u001b[39m retrying assertion on #flash'], + source: 'terminal', + timestamp: RUN_START + 1200 +}) + +const pageError = consoleLog({ + type: 'error', + args: ["TypeError: Cannot read properties of undefined (reading 'flash')"], + source: 'browser', + timestamp: RUN_START + 2500 +}) + +export const loginConsole: LoginConsole = { + logs: [pageLog, testInfo, runnerWarn, pageError], + pageLog, + testInfo, + runnerWarn, + pageError +} + +export interface LoginNetwork { + /** Capture order, which is the order the panel renders. */ + requests: NetworkRequest[] + /** Slowest at 800ms, so its bar fills the track; HTML by content-type. */ + pageHtml: NetworkRequest + /** Half the slowest, so a half-width bar; JS by content-type. */ + script: NetworkRequest + /** POST with headers and JSON bodies on both sides โ€” drives the detail panel. + * At 8ms it is also the request that needs the bar's minimum width. */ + api: NetworkRequest + /** 404 with no response headers: typed by URL extension, size unknown. */ + missingImage: NetworkRequest + /** Transport failure โ€” no status, an `error`, still timed. */ + failedFont: NetworkRequest + /** 3xx, the one request in the redirect status bucket. */ + redirect: NetworkRequest + /** In flight: no status, no timing, no size, no headers. */ + pending: NetworkRequest +} + +const pageHtml = networkRequest({ + id: 'req-html', + url: LOGIN_URL, + type: 'document', + status: 200, + statusText: 'OK', + requestHeaders: { accept: 'text/html' }, + responseHeaders: { 'content-type': 'text/html; charset=utf-8' }, + size: 12_288, + time: 800, + timestamp: RUN_START, + startTime: RUN_START, + endTime: RUN_START + 800 +}) + +const script = networkRequest({ + id: 'req-js', + url: 'https://the-internet.herokuapp.com/js/vendor/jquery-1.11.3.min.js', + type: 'script', + status: 200, + statusText: 'OK', + responseHeaders: { 'content-type': 'application/javascript' }, + size: 240_000, + time: 400, + timestamp: RUN_START + 820, + startTime: RUN_START + 820, + endTime: RUN_START + 1220 +}) + +const api = networkRequest({ + id: 'req-api', + url: 'https://the-internet.herokuapp.com/api/session', + method: 'POST', + type: 'fetch', + status: 200, + statusText: 'OK', + requestHeaders: { 'content-type': 'application/json' }, + responseHeaders: { 'content-type': 'application/json' }, + requestBody: '{"sku":"AB-1","qty":2}', + responseBody: '{"ok":true,"items":2}', + size: 320, + time: 8, + timestamp: RUN_START + 1300, + startTime: RUN_START + 1300, + endTime: RUN_START + 1308 +}) + +const missingImage = networkRequest({ + id: 'req-img', + url: 'https://the-internet.herokuapp.com/img/missing-avatar.png', + type: 'image', + status: 404, + statusText: 'Not Found', + time: 120, + timestamp: RUN_START + 1500, + startTime: RUN_START + 1500, + endTime: RUN_START + 1620 +}) + +const failedFont = networkRequest({ + id: 'req-font', + url: 'https://the-internet.herokuapp.com/fonts/inter.woff2', + type: 'font', + status: undefined, + error: 'net::ERR_CONNECTION_REFUSED', + time: 200, + timestamp: RUN_START + 1700, + startTime: RUN_START + 1700, + endTime: RUN_START + 1900 +}) + +const redirect = networkRequest({ + id: 'req-redirect', + url: 'https://the-internet.herokuapp.com/authenticate', + type: 'document', + status: 302, + statusText: 'Found', + time: 40, + timestamp: RUN_START + 1900, + startTime: RUN_START + 1900, + endTime: RUN_START + 1940 +}) + +const pending = networkRequest({ + id: 'req-pending', + url: 'https://the-internet.herokuapp.com/api/notifications?limit=4', + type: 'fetch', + status: undefined, + timestamp: RUN_START + 2100, + startTime: RUN_START + 2100 +}) + +export const loginNetwork: LoginNetwork = { + requests: [ + pageHtml, + script, + api, + missingImage, + failedFont, + redirect, + pending + ], + pageHtml, + script, + api, + missingImage, + failedFont, + redirect, + pending +} + +// --- The spec under test ---------------------------------------------------- +// One file, shared by the errors and source panels: the errors anchor points +// into it, the source panel renders it, and the line constants below are the +// 1-based indices the call sources name. + +/** Three directories deep, so the Errors anchor's last-three-segments label is + * a real truncation and the Source toolbar's path is really elided. */ +export const SPEC_FILE = '/repo/test/specs/login.e2e.ts' +export const STEPS_FILE = '/repo/test/step-definitions/login.steps.ts' +/** Named by a command's call source but never captured as a source. */ +export const HELPER_FILE = '/repo/test/support/helpers.ts' + +export const SPEC_LINES = [ + "import { $, browser, expect } from '@wdio/globals'", + "import assert from 'node:assert'", + '', + "describe('login page', () => {", + " it('logs in with valid credentials', async () => {", + " await browser.url('https://the-internet.herokuapp.com/login')", + " await $('#username').setValue('tomsmith')", + " await $('#password').setValue('SuperSecretPassword!')", + " await $('button[type=submit]').click()", + " await expect($('#flash')).toHaveText('Secure Area')", + " assert.strictEqual(await browser.getTitle(), 'Secure Area')", + ' })', + '})' +] + +export const NAVIGATE_LINE = 6 +export const SET_VALUE_LINE = 7 +export const CLICK_LINE = 9 +export const MATCHER_LINE = 10 +export const ASSERT_LINE = 11 + +export const STEPS_LINES = [ + "import { When } from '@cucumber/cucumber'", + '', + "When('I log in as {string}', async (user) => {", + " await $('#username').setValue(user)", + '})' +] + +export const STEPS_SET_VALUE_LINE = 4 + +export const NAVIGATE_CALL_SOURCE = `${SPEC_FILE}:${NAVIGATE_LINE}:19` +export const SET_VALUE_CALL_SOURCE = `${SPEC_FILE}:${SET_VALUE_LINE}:26` +export const CLICK_CALL_SOURCE = `${SPEC_FILE}:${CLICK_LINE}:36` +export const MATCHER_CALL_SOURCE = `${SPEC_FILE}:${MATCHER_LINE}:11` +export const ASSERT_CALL_SOURCE = `${SPEC_FILE}:${ASSERT_LINE}:12` +export const STEPS_CALL_SOURCE = `${STEPS_FILE}:${STEPS_SET_VALUE_LINE}:24` +export const HELPER_CALL_SOURCE = `${HELPER_FILE}:12:9` + +// --- Errors panel ----------------------------------------------------------- + +/** Stack as a captured failure carries it: a user frame, then a node internal. */ +export const STACK_FRAMES = [ + ` at Context.<anonymous> (${SPEC_FILE}:${MATCHER_LINE}:11)`, + ' at processTicksAndRejections (node:internal/process/task_queues:95:5)' +] + +/** A failing expect-webdriverio matcher's message: a headline, then + * jest-matcher-utils' Expected/Received block โ€” whose values arrive already + * quoted (`printExpected`). ANSI is stripped before it reaches the app. */ +export const MATCHER_HEADLINE = 'Expect $(`#flash`) to have text' +export const MATCHER_EXPECTED = '"Secure Area"' +export const MATCHER_RECEIVED = '"You logged into a secure area!"' +export const MATCHER_MESSAGE = [ + MATCHER_HEADLINE, + '', + `Expected: ${MATCHER_EXPECTED}`, + `Received: ${MATCHER_RECEIVED}` +].join('\n') + +/** A node:assert failure the way core's `describeAssertFailure` rewrites it โ€” + * node's auto-generated per-character diff replaced by a value-bearing block, + * with the clean values also carried as a collapsed result. */ +export const ASSERT_HEADLINE = 'strictEqual(actual, expected)' +export const ASSERT_EXPECTED = 'Secure Area' +export const ASSERT_ACTUAL = 'Login Page' +export const ASSERT_MESSAGE = [ + ASSERT_HEADLINE, + '', + `Expected: '${ASSERT_EXPECTED}'`, + `Received: '${ASSERT_ACTUAL}'` +].join('\n') + +export const CLICK_MESSAGE = + 'Can not call click on element with selector "#login" because element was not found' + +export const HOOK_MESSAGE = + 'beforeEach hook: browser.setWindowRect is not a function' + +/** A command failure as it crosses the WS bridge โ€” the serialized shape, so a + * fixture can model an error with no stack at all. */ +export function capturedError( + message: string, + overrides: Partial<SerializedError> = {} +): SerializedError { + return { name: 'Error', message, ...overrides } +} + +/** A test-level failure. `@wdio/reporter` types `TestStats.error` as a real + * `Error`, which always synthesizes a stack โ€” assigned unconditionally here so + * a fixture can model a failure that reached the app without one. */ +export function testError(message: string, stack?: string): Error { + const error = new Error(message) + error.stack = stack + return error +} + +/** Defaults to `failed`: the Errors panel reads no other test state. */ +export function testFragment( + uid: string, + overrides: Omit<Partial<TestStatsFragment>, 'uid'> = {} +): TestStatsFragment { + return { + uid, + title: uid, + fullTitle: uid, + file: SPEC_FILE, + state: 'failed', + ...overrides + } +} + +export function suiteFragment( + uid: string, + overrides: Omit<Partial<SuiteStatsFragment>, 'uid'> = {} +): SuiteStatsFragment { + return { + uid, + title: uid, + fullTitle: uid, + file: SPEC_FILE, + tests: [], + suites: [], + ...overrides + } +} + +/** The `suiteContext` value โ€” the registry reaches the app as uid-keyed chunks. */ +export function suiteRegistry( + ...suites: SuiteStatsFragment[] +): Record<string, SuiteStatsFragment>[] { + return [Object.fromEntries(suites.map((suite) => [suite.uid, suite]))] +} + +export interface LoginErrors { + /** Capture order, which is deliberately *not* timestamp order. */ + commands: CommandLog[] + /** Succeeded, so it contributes no row. */ + navigate: CommandLog + /** Earliest failure; carries no assertion values, so it renders message-first. */ + click: CommandLog + /** expect-webdriverio matcher โ€” its diff lives in the message. */ + matcher: CommandLog + /** node:assert โ€” its diff lives in a collapsed result, and it has no stack. */ + nativeAssert: CommandLog + /** One passed and one failed test; only the hook failure adds a row. */ + suites: Record<string, SuiteStatsFragment>[] +} + +const navigate = commandLog({ + command: 'url', + args: [LOGIN_URL], + callSource: NAVIGATE_CALL_SOURCE, + timestamp: RUN_START +}) + +const clickFailure = commandLog({ + command: 'click', + args: ['#login'], + callSource: CLICK_CALL_SOURCE, + timestamp: RUN_START + 1000, + error: capturedError(CLICK_MESSAGE, { + stack: `Error: ${CLICK_MESSAGE}\n${STACK_FRAMES.join('\n')}` + }) +}) + +const matcherFailure = commandLog({ + command: 'expect.toHaveText', + args: [ASSERT_EXPECTED], + callSource: MATCHER_CALL_SOURCE, + timestamp: RUN_START + 3000, + error: capturedError(MATCHER_MESSAGE, { + stack: `Error: ${MATCHER_MESSAGE}\n${STACK_FRAMES.join('\n')}` + }) +}) + +const nativeAssertFailure = commandLog({ + command: 'assert.strictEqual', + args: [ASSERT_ACTUAL, ASSERT_EXPECTED], + result: { passed: false, actual: ASSERT_ACTUAL, expected: ASSERT_EXPECTED }, + callSource: ASSERT_CALL_SOURCE, + timestamp: RUN_START + 4000, + error: capturedError(ASSERT_MESSAGE, { name: 'AssertionError' }) +}) + +export const loginErrors: LoginErrors = { + commands: [navigate, matcherFailure, nativeAssertFailure, clickFailure], + navigate, + click: clickFailure, + matcher: matcherFailure, + nativeAssert: nativeAssertFailure, + suites: suiteRegistry( + suiteFragment('login-suite', { + title: 'login page', + tests: [ + testFragment('login-valid', { + title: 'logs in with valid credentials', + fullTitle: 'login page logs in with valid credentials', + state: 'passed', + error: testError('stale element reference on the first attempt') + }), + testFragment('login-logout', { + title: 'logs out again', + fullTitle: 'login page logs out again', + callSource: `${SPEC_FILE}:14:3`, + error: testError( + HOOK_MESSAGE, + `Error: ${HOOK_MESSAGE}\n${STACK_FRAMES.join('\n')}` + ) + }) + ] + }) + ) +} + +// --- Metadata panel --------------------------------------------------------- + +export const SECURE_URL = 'https://the-internet.herokuapp.com/secure' + +export function metadata(overrides: Partial<Metadata> = {}): Metadata { + return { type: TraceType.Testrunner, ...overrides } +} + +/** Every field the Session section knows, plus a boolean, an object and a + * string capability โ€” one fixture covering each value renderer. */ +export const loginMetadata = metadata({ + sessionId: '3a7f19c4e2b8', + testEnv: 'local', + host: 'http://localhost:4444', + modulePath: SPEC_FILE, + url: LOGIN_URL, + viewport: { width: 1600, height: 900, offsetLeft: 0, offsetTop: 0, scale: 1 }, + capabilities: { + browserName: 'chrome', + browserVersion: '149.0.7204.15', + 'goog:chromeOptions': { args: ['--headless=new'] }, + setWindowRect: true + }, + desiredCapabilities: { browserName: 'chrome', acceptInsecureCerts: false }, + options: { waitforTimeout: 5000, logLevel: 'error' } +}) + +/** A second session, on the page the login redirects to. */ +export const secureMetadata = metadata({ + sessionId: 'b52d08fa17c6', + url: SECURE_URL, + capabilities: { browserName: 'firefox' } +}) + +// --- Source panel ----------------------------------------------------------- + +export const loginSources: Record<string, string> = { + [SPEC_FILE]: SPEC_LINES.join('\n'), + [STEPS_FILE]: STEPS_LINES.join('\n') +} + +/** Commands whose call sources cover all three files: two lines of the captured + * spec, one of the captured step definitions, and one file never captured. */ +export const loginSourceCommands: CommandLog[] = [ + commandLog({ + command: 'url', + args: [LOGIN_URL], + callSource: NAVIGATE_CALL_SOURCE, + timestamp: RUN_START + }), + commandLog({ + command: 'setValue', + args: ['#username', 'tomsmith'], + callSource: SET_VALUE_CALL_SOURCE, + timestamp: RUN_START + 500 + }), + commandLog({ + command: 'setValue', + args: ['#username', 'tomsmith'], + callSource: STEPS_CALL_SOURCE, + timestamp: RUN_START + 900 + }), + commandLog({ + command: 'getTitle', + args: [], + callSource: HELPER_CALL_SOURCE, + timestamp: RUN_START + 1200 + }) +] diff --git a/packages/app/test-ui/workbench/panels/logs.test.ts b/packages/app/test-ui/workbench/panels/logs.test.ts new file mode 100644 index 00000000..b8eda9df --- /dev/null +++ b/packages/app/test-ui/workbench/panels/logs.test.ts @@ -0,0 +1,428 @@ +import type { CommandLog } from '@wdio/devtools-shared' + +import { commandCategory } from '@components/workbench/actionItems/category.js' +import { formatDuration } from '@components/workbench/actionItems/duration.js' +import '@components/workbench/logs.js' +import type { DevtoolsCommandLogs } from '@components/workbench/logs.js' + +import { commandLog } from '../../support/builders.js' +import { mount, settle } from '../../support/mount.js' +import { shadow, shadowAll, text, texts } from '../../support/queries.js' + +const PANEL = 'wdio-devtools-logs' +const EMPTY_STATE = '.cmd-empty' +const HEAD = '.cmd-head' +const CATEGORY_DOT = '.cat-dot' +const NAME = '.cmd-name' +const DURATION = '.cmd-dur' +const REFERENCE = '.cmd-ref' +const SECTION = '.dsec' +const DESCRIPTION = '.cmd-desc' +const ROW = '.kv' +const VALUE = '.kv .v' +const EMPTY_VALUE = '.kv .v.empty' + +const LOGIN_URL = 'https://the-internet.herokuapp.com/login' + +/** Reference target of `navigateTo` in the pinned `@wdio/protocols`. */ +const NAVIGATE_REF = 'https://w3c.github.io/webdriver/#dfn-navigate-to' + +/** Wider than the value column by two orders of magnitude, and whitespace-free + * so the collapsed assertion compares the whole string. The panel wraps it + * (CSS `word-break: break-all`) and never shortens the text. */ +const LONG_ARG = `data:image/png;base64,${'iVBORw0KGgoAAAANSUhEUg'.repeat(40)}` + +interface Section { + title: string + keys: string[] + values: string[] +} + +const sections = (panel: DevtoolsCommandLogs): Section[] => + shadowAll(panel, SECTION).map((section) => ({ + title: text(shadow(section, 'h4')), + keys: texts(section, '.k'), + values: texts(section, '.v') + })) + +const sectionTitles = (panel: DevtoolsCommandLogs) => + sections(panel).map((section) => section.title) + +const sectionNamed = (panel: DevtoolsCommandLogs, title: string): Section => { + const section = sections(panel).find((entry) => entry.title === title) + if (!section) { + throw new Error(`the panel rendered no "${title}" section`) + } + return section +} + +const categoryOf = (panel: DevtoolsCommandLogs) => + [...(shadow(panel, CATEGORY_DOT)?.classList ?? [])].find( + (name) => name !== 'cat-dot' + ) + +const attrOf = (el: Element | null, name: string) => + el?.getAttribute(name) ?? null + +/** A non-string value as the panel prints it, then whitespace-collapsed the way + * `text()` collapses the rendered cell. */ +const prettyValue = (value: unknown) => + JSON.stringify(value, null, 2).replace(/\s+/g, ' ') + +/** Property path: what the workbench does when it renders the panel directly. + * No protocol definition is resolved, so no description or reference exists. */ +async function mountLogs( + command?: CommandLog, + elapsedTime?: number +): Promise<DevtoolsCommandLogs> { + const panel = await mount<DevtoolsCommandLogs>(PANEL, { + command, + elapsedTime + }) + await settle(panel) + return panel +} + +async function waitFor(cond: () => boolean, what: string): Promise<void> { + const deadline = Date.now() + 4000 + while (Date.now() < deadline) { + if (cond()) { + return + } + await new Promise<void>((resolve) => setTimeout(resolve, 0)) + } + throw new Error(`Timed out waiting for ${what}`) +} + +/** Event path: the Actions panel's row click. The handler resolves the command's + * protocol definition through a dynamic import before it assigns `command`, + * which is why this waits rather than awaiting one render. */ +async function showCommand( + panel: DevtoolsCommandLogs, + command: CommandLog, + elapsedTime?: number +): Promise<void> { + window.dispatchEvent( + new CustomEvent('show-command', { detail: { command, elapsedTime } }) + ) + await waitFor( + () => panel.command === command, + `the panel to pick up ${command.command}` + ) + await settle(panel) +} + +describe('wdio-devtools-logs', () => { + describe('empty state', () => { + it('asks for a selection while no command has been chosen', async () => { + const panel = await mountLogs() + + expect(text(shadow(panel, EMPTY_STATE))).toBe( + 'Select a command to view its details' + ) + }) + + it('renders no header or detail sections without a command', async () => { + const panel = await mountLogs() + + expect(shadowAll(panel, HEAD)).toHaveLength(0) + expect(shadowAll(panel, SECTION)).toHaveLength(0) + }) + + it('replaces the empty state once a command is selected', async () => { + const panel = await mountLogs() + await showCommand(panel, commandLog({ command: 'getTitle', args: [] })) + + expect(shadowAll(panel, EMPTY_STATE)).toHaveLength(0) + expect(text(shadow(panel, NAME))).toBe('getTitle') + }) + }) + + describe('header', () => { + it('names the selected command', async () => { + const panel = await mountLogs(commandLog({ command: 'elementClick' })) + + expect(text(shadow(panel, NAME))).toBe('elementClick') + }) + + it('marks the command with the dot of the category it belongs to', async () => { + const commands = ['click', 'navigateTo', 'expect.toHaveText', 'getUrl'] + const panels = [] + for (const command of commands) { + panels.push(await mountLogs(commandLog({ command }))) + } + + // Derived: the dot must follow the classifier's verdict for that command. + expect(panels.map(categoryOf)).toEqual( + commands.map((command) => `cat-${commandCategory(command)}`) + ) + expect(panels.map(categoryOf)).toEqual([ + 'cat-input', + 'cat-navigation', + 'cat-assertion', + 'cat-query' + ]) + }) + + it('falls back to the other category for a command it cannot classify', async () => { + const panel = await mountLogs(commandLog({ command: 'takeScreenshot' })) + + expect(commandCategory('takeScreenshot')).toBe('other') + expect(categoryOf(panel)).toBe('cat-other') + }) + + it("renders the command's elapsed time in human units", async () => { + const milliseconds = await mountLogs(commandLog(), 320) + const seconds = await mountLogs(commandLog(), 1500) + + expect(text(shadow(milliseconds, DURATION))).toBe(formatDuration(320)) + expect(text(shadow(seconds, DURATION))).toBe(formatDuration(1500)) + expect(text(shadow(milliseconds, DURATION))).toBe('320ms') + expect(text(shadow(seconds, DURATION))).toBe('1.50s') + }) + + it('renders a zero elapsed time rather than dropping it', async () => { + const panel = await mountLogs(commandLog(), 0) + + expect(text(shadow(panel, DURATION))).toBe(formatDuration(0)) + expect(text(shadow(panel, DURATION))).toBe('0ms') + }) + + it('renders no duration for a command selected without one', async () => { + const panel = await mountLogs(commandLog()) + + expect(shadowAll(panel, DURATION)).toHaveLength(0) + }) + + it('links to the protocol reference of a command it can resolve', async () => { + const panel = await mountLogs() + await showCommand(panel, commandLog({ command: 'navigateTo' })) + + const link = shadow(panel, REFERENCE) + expect(text(link)).toBe('Reference โ†—') + expect(attrOf(link, 'href')).toBe(NAVIGATE_REF) + expect(attrOf(link, 'target')).toBe('_blank') + }) + + it('renders no reference link for a command outside the protocols', async () => { + const panel = await mountLogs() + await showCommand(panel, commandLog({ command: 'click' })) + + expect(shadowAll(panel, REFERENCE)).toHaveLength(0) + }) + + it('renders no reference link when the command arrives as a property', async () => { + // The protocol lookup lives in the `show-command` handler, so a command + // assigned directly carries no definition. + const panel = await mountLogs(commandLog({ command: 'navigateTo' })) + + expect(shadowAll(panel, REFERENCE)).toHaveLength(0) + }) + }) + + describe('description', () => { + it("renders the protocol's description of the selected command", async () => { + const panel = await mountLogs() + await showCommand(panel, commandLog({ command: 'navigateTo' })) + + expect(sectionTitles(panel)[0]).toBe('Description') + expect(text(shadow(panel, DESCRIPTION))).toMatch( + /^The navigateTo \(go\) command/ + ) + }) + + it('renders no description section for a command outside the protocols', async () => { + const panel = await mountLogs() + await showCommand(panel, commandLog({ command: 'click' })) + + expect(sectionTitles(panel)).not.toContain('Description') + expect(shadowAll(panel, DESCRIPTION)).toHaveLength(0) + }) + }) + + describe('parameters', () => { + it("names each argument after the protocol's parameter", async () => { + const panel = await mountLogs() + await showCommand( + panel, + commandLog({ command: 'navigateTo', args: [LOGIN_URL] }) + ) + + expect(sectionNamed(panel, 'Parameters')).toEqual({ + title: 'Parameters', + keys: ['url'], + values: [LOGIN_URL] + }) + }) + + it('names every argument of a multi-parameter command', async () => { + const panel = await mountLogs() + await showCommand( + panel, + commandLog({ + command: 'executeScript', + args: ['return document.title', []] + }) + ) + + const parameters = sectionNamed(panel, 'Parameters') + expect(parameters.keys).toEqual(['script', 'args']) + expect(parameters.values).toEqual(['return document.title', '[]']) + }) + + it('falls back to the argument position when no parameter name is known', async () => { + const panel = await mountLogs( + commandLog({ command: 'setValue', args: ['#username', 'tomsmith'] }) + ) + + expect(sectionNamed(panel, 'Parameters').keys).toEqual(['0', '1']) + }) + + it('pretty-prints an object argument', async () => { + const size = { width: 1600, height: 900 } + const panel = await mountLogs( + commandLog({ command: 'setWindowSize', args: [size] }) + ) + + expect(sectionNamed(panel, 'Parameters').values).toEqual([ + prettyValue(size) + ]) + expect(sectionNamed(panel, 'Parameters').values).toEqual([ + '{ "width": 1600, "height": 900 }' + ]) + }) + + it('renders a null argument as null and flags the value as empty', async () => { + const panel = await mountLogs( + commandLog({ command: 'deleteCookies', args: [null] }) + ) + + expect(sectionNamed(panel, 'Parameters').values).toEqual(['null']) + expect(shadowAll(panel, EMPTY_VALUE)).toHaveLength(1) + }) + + it('renders an oversized argument in full rather than truncating it', async () => { + const panel = await mountLogs( + commandLog({ command: 'execute', args: [LONG_ARG] }) + ) + + expect(text(shadow(panel, VALUE))).toBe(LONG_ARG) + }) + + it('renders no parameters section for a command called without arguments', async () => { + const panel = await mountLogs( + commandLog({ command: 'getTitle', args: [] }) + ) + + expect(sectionTitles(panel)).not.toContain('Parameters') + expect(shadowAll(panel, ROW)).toHaveLength(0) + }) + }) + + describe('result', () => { + it('renders one row per entry of an object result', async () => { + const rect = { x: 8, y: 240, width: 176, height: 32 } + const panel = await mountLogs( + commandLog({ command: 'getElementRect', args: [], result: rect }) + ) + + // One row per own entry of the result, keyed and valued by it. + expect(sectionNamed(panel, 'Result')).toEqual({ + title: 'Result', + keys: Object.keys(rect), + values: Object.values(rect).map(prettyValue) + }) + expect(sectionNamed(panel, 'Result')).toEqual({ + title: 'Result', + keys: ['x', 'y', 'width', 'height'], + values: ['8', '240', '176', '32'] + }) + }) + + it('renders a string result as a single value row', async () => { + const panel = await mountLogs( + commandLog({ command: 'getTitle', args: [], result: 'The Internet' }) + ) + + expect(sectionNamed(panel, 'Result')).toEqual({ + title: 'Result', + keys: ['value'], + values: ['The Internet'] + }) + }) + + it('keys an array result by position', async () => { + const panel = await mountLogs( + commandLog({ + command: 'findElements', + args: ['css selector', 'a'], + result: ['element-1', 'element-2'] + }) + ) + + expect(sectionNamed(panel, 'Result').keys).toEqual(['0', '1']) + }) + + it('renders a false result rather than treating it as absent', async () => { + const panel = await mountLogs( + commandLog({ command: 'isElementSelected', args: [], result: false }) + ) + + expect(sectionNamed(panel, 'Result').values).toEqual(['false']) + }) + + it('renders an empty-string result as a row rather than dropping it', async () => { + const panel = await mountLogs( + commandLog({ command: 'getText', args: [], result: '' }) + ) + + expect(sectionNamed(panel, 'Result').keys).toEqual(['value']) + expect(shadowAll(panel, EMPTY_VALUE)).toHaveLength(0) + }) + + it('renders no result section for a command that returned nothing', async () => { + const panel = await mountLogs( + commandLog({ command: 'elementClick', args: [], result: null }) + ) + + expect(sectionTitles(panel)).not.toContain('Result') + }) + }) + + describe('section order', () => { + it('renders the description, then the parameters, then the result', async () => { + const panel = await mountLogs() + await showCommand( + panel, + commandLog({ + command: 'navigateTo', + args: [LOGIN_URL], + result: 'ok' + }), + 640 + ) + + expect(sectionTitles(panel)).toEqual([ + 'Description', + 'Parameters', + 'Result' + ]) + expect(text(shadow(panel, DURATION))).toBe('640ms') + }) + + it('replaces the rendered detail when another command is selected', async () => { + const panel = await mountLogs() + await showCommand( + panel, + commandLog({ command: 'navigateTo', args: [LOGIN_URL] }) + ) + await showCommand( + panel, + commandLog({ command: 'getTitle', args: [], result: 'The Internet' }) + ) + + expect(text(shadow(panel, NAME))).toBe('getTitle') + expect(sectionTitles(panel)).toEqual(['Description', 'Result']) + }) + }) +}) diff --git a/packages/app/test-ui/workbench/panels/metadata.test.ts b/packages/app/test-ui/workbench/panels/metadata.test.ts new file mode 100644 index 00000000..9c8ac6a5 --- /dev/null +++ b/packages/app/test-ui/workbench/panels/metadata.test.ts @@ -0,0 +1,628 @@ +import type { Metadata, MetadataBySession } from '@wdio/devtools-shared' + +import { + metadataBySessionContext, + metadataContext +} from '@/controller/context.js' +import { PENDING_SESSION_KEY } from '@/controller/contextUpdates.js' +import '@components/workbench/metadata.js' +import type { DevtoolsMetadata } from '@components/workbench/metadata.js' + +import { mount, mountWithContext, settle } from '../../support/mount.js' +import { shadow, shadowAll, text, texts } from '../../support/queries.js' +import { + LOGIN_URL, + SECURE_URL, + SPEC_FILE, + loginMetadata, + metadata, + secureMetadata +} from './fixtures.js' + +const PANEL = 'wdio-devtools-metadata' +const SECTION = '.meta-sec' +const HEADING = '.meta-sec h4' +const CHEVRON = '.chev' +const OPEN_CHEVRON = '.chev.open' +const CARD = '.meta-card' +const ROW = '.mrow' +const JSON_BLOCK = '.mrow.json pre' +const LINK = '.mrow .v a' +const BOOL_TRUE = '.bool-true' +const BOOL_FALSE = '.bool-false' +const SELECT = '.session-select' +const OPTION = '.session-select option' +const PLACEHOLDER = 'wdio-devtools-placeholder' +const EMPTY_ICON = '.empty-state-icon' +const EMPTY_HEADING = '.empty-state-text' +const EMPTY_DETAIL = '.empty-state-detail' +const SKELETON = '.ph-item' + +/** Copy the panel hands its placeholder โ€” shown both when no session metadata + * arrived at all and when what arrived carries no renderable field. */ +const EMPTY_GLYPH = '๐Ÿงพ' +const EMPTY_HEADING_TEXT = 'No session metadata captured' +const EMPTY_DETAIL_TEXT = + 'Capabilities, options and session details are recorded when the driver session starts โ€” this run carries none.' + +interface MetaSection { + label: string + keys: string[] + /** Row values, reading the JSON block of an object row and the plain value + * cell of every other. */ + values: string[] +} + +async function mountMetadata( + active: Metadata | undefined, + bySession: MetadataBySession = {} +): Promise<DevtoolsMetadata> { + const panel = await mountWithContext<DevtoolsMetadata>(PANEL, [ + { context: metadataContext, value: active }, + { context: metadataBySessionContext, value: bySession } + ]) + await settle(panel) + return panel +} + +const sections = (panel: DevtoolsMetadata): MetaSection[] => + shadowAll(panel, SECTION).map((section) => ({ + label: text(shadow(section, 'h4')), + keys: texts(section, `${ROW} .k`), + values: shadowAll(section, ROW).map((row) => + text(shadow(row, '.v') ?? shadow(row, 'pre')) + ) + })) + +const sectionLabels = (panel: DevtoolsMetadata) => + sections(panel).map((section) => section.label) + +function sectionNamed(panel: DevtoolsMetadata, label: string): MetaSection { + const found = sections(panel).find((section) => section.label === label) + if (!found) { + throw new Error(`no metadata section headed "${label}"`) + } + return found +} + +/** Raw lines of a JSON block โ€” `text()` collapses the indentation that makes it + * pretty-printed in the first place. */ +const jsonLines = (el: Element | null): string[] => + (el?.textContent ?? '').trim().split('\n') + +/** A captured value as the panel prints it in a JSON block. */ +const prettyJson = (value: unknown) => JSON.stringify(value, null, 2) + +/** A session option's label โ€” `Session <n>` plus the host of the page it was + * last on, which is what makes several options distinguishable. */ +const sessionLabel = (index: number, url?: string) => { + const host = url ? tryHost(url) : undefined + return host ? `Session ${index + 1} ยท ${host}` : `Session ${index + 1}` +} + +const tryHost = (url: string): string | undefined => { + try { + return new URL(url).host + } catch { + return undefined + } +} + +async function toggleSection(panel: DevtoolsMetadata, label: string) { + const heading = shadowAll(panel, HEADING).find( + (candidate) => text(candidate) === label + ) + if (!heading) { + throw new Error(`no metadata section headed "${label}"`) + } + heading.click() + await settle(panel) +} + +async function pickSession(panel: DevtoolsMetadata, sessionKey: string) { + const select = shadow<HTMLSelectElement>(panel, SELECT) + if (!select) { + throw new Error('the metadata panel rendered no session picker') + } + select.value = sessionKey + select.dispatchEvent(new Event('change')) + await settle(panel) +} + +describe('wdio-devtools-metadata', () => { + describe('session section', () => { + it('renders a labelled row per session field the capture carried', async () => { + const panel = await mountMetadata(loginMetadata) + + expect(sectionNamed(panel, 'Session')).toEqual({ + label: 'Session', + keys: [ + 'Session ID', + 'Environment', + 'WebDriver Host', + 'Test File', + 'URL', + 'Viewport' + ], + // Derived, so a row wired to the wrong metadata field fails. + values: [ + loginMetadata.sessionId, + loginMetadata.testEnv, + loginMetadata.host, + loginMetadata.modulePath, + loginMetadata.url, + `${loginMetadata.viewport?.width} ร— ${loginMetadata.viewport?.height} px` + ] + }) + expect(sectionNamed(panel, 'Session').values).toEqual([ + '3a7f19c4e2b8', + 'local', + 'http://localhost:4444', + SPEC_FILE, + LOGIN_URL, + '1600 ร— 900 px' + ]) + }) + + it('renders the captured viewport as one row of dimensions', async () => { + const viewport = { + width: 1024, + height: 768, + offsetLeft: 40, + offsetTop: 90, + scale: 2 + } + const panel = await mountMetadata(metadata({ viewport })) + + const session = sectionNamed(panel, 'Session') + expect(session.keys).toEqual(['Viewport']) + // Derived from the fixture, so a row that drops a dimension, swaps the two + // or reads the scroll offsets / pinch scale instead fails. + expect(session.values).toEqual([ + `${viewport.width} ร— ${viewport.height} px` + ]) + expect(session.values).toEqual(['1024 ร— 768 px']) + }) + + it('leaves out the session fields the capture did not carry', async () => { + const panel = await mountMetadata(metadata({ sessionId: 'only-an-id' })) + + expect(sectionNamed(panel, 'Session').keys).toEqual(['Session ID']) + }) + + it('renders no Session section when none of its fields were captured', async () => { + const panel = await mountMetadata( + metadata({ capabilities: { browserName: 'chrome' } }) + ) + + expect(sectionLabels(panel)).toEqual(['Capabilities']) + }) + + it('links every http value out to the page it points at', async () => { + const panel = await mountMetadata(loginMetadata) + + const links = shadowAll<HTMLAnchorElement>(panel, LINK) + expect(links.map((link) => link.getAttribute('href'))).toEqual([ + 'http://localhost:4444', + LOGIN_URL + ]) + expect(links[1].getAttribute('target')).toBe('_blank') + expect(links[1].getAttribute('rel')).toBe('noreferrer') + }) + }) + + describe('capability and option rows', () => { + it('renders each captured section in a fixed order', async () => { + const panel = await mountMetadata(loginMetadata) + + expect(sectionLabels(panel)).toEqual([ + 'Session', + 'Capabilities', + 'Desired Capabilities', + 'Options' + ]) + }) + + it('renders a row per capability', async () => { + const panel = await mountMetadata(loginMetadata) + + const captured = loginMetadata.capabilities as Record<string, unknown> + const capabilities = sectionNamed(panel, 'Capabilities') + // One row per captured capability, in capture order. + expect(capabilities.keys).toEqual(Object.keys(captured)) + expect(capabilities.keys).toEqual([ + 'browserName', + 'browserVersion', + 'goog:chromeOptions', + 'setWindowRect' + ]) + // Object values go through a JSON block, scalars through the value cell. + expect(capabilities.values).toEqual([ + String(captured.browserName), + String(captured.browserVersion), + prettyJson(captured['goog:chromeOptions']).replace(/\s+/g, ' '), + String(captured.setWindowRect) + ]) + expect(capabilities.values).toEqual([ + 'chrome', + '149.0.7204.15', + '{ "args": [ "--headless=new" ] }', + 'true' + ]) + }) + + it('renders a row per runner option', async () => { + const panel = await mountMetadata(loginMetadata) + + const captured = loginMetadata.options as Record<string, unknown> + expect(sectionNamed(panel, 'Options')).toEqual({ + label: 'Options', + keys: Object.keys(captured), + values: Object.values(captured).map(String) + }) + expect(sectionNamed(panel, 'Options')).toEqual({ + label: 'Options', + keys: ['waitforTimeout', 'logLevel'], + values: ['5000', 'error'] + }) + }) + + it('renders a row per requested capability', async () => { + const panel = await mountMetadata(loginMetadata) + + const captured = loginMetadata.desiredCapabilities as Record< + string, + unknown + > + expect(sectionNamed(panel, 'Desired Capabilities')).toEqual({ + label: 'Desired Capabilities', + keys: Object.keys(captured), + values: Object.values(captured).map(String) + }) + expect(sectionNamed(panel, 'Desired Capabilities')).toEqual({ + label: 'Desired Capabilities', + keys: ['browserName', 'acceptInsecureCerts'], + values: ['chrome', 'false'] + }) + }) + + it('omits a section whose bag was captured empty', async () => { + const panel = await mountMetadata( + metadata({ sessionId: 'no-caps', capabilities: {}, options: {} }) + ) + + expect(sectionLabels(panel)).toEqual(['Session']) + }) + + it('omits a section the capture left out entirely', async () => { + const panel = await mountMetadata( + metadata({ + sessionId: 'caps-only', + capabilities: { platformName: 'mac' } + }) + ) + + expect(sectionLabels(panel)).toEqual(['Session', 'Capabilities']) + }) + + it('colours a boolean capability by its value', async () => { + const panel = await mountMetadata(loginMetadata) + + expect(text(shadow(panel, BOOL_TRUE))).toBe('true') + expect(text(shadow(panel, BOOL_FALSE))).toBe('false') + }) + + it('pretty-prints an object capability into a JSON block', async () => { + const panel = await mountMetadata(loginMetadata) + + expect(jsonLines(shadow(panel, JSON_BLOCK))).toEqual( + prettyJson( + (loginMetadata.capabilities as Record<string, unknown>)[ + 'goog:chromeOptions' + ] + ).split('\n') + ) + expect(jsonLines(shadow(panel, JSON_BLOCK))).toEqual([ + '{', + ' "args": [', + ' "--headless=new"', + ' ]', + '}' + ]) + }) + + it('renders an array option as a JSON block too', async () => { + const specs = ['login.e2e.ts'] + const panel = await mountMetadata(metadata({ options: { specs } })) + + expect(shadowAll(panel, JSON_BLOCK)).toHaveLength(1) + expect(jsonLines(shadow(panel, JSON_BLOCK))).toEqual( + prettyJson(specs).split('\n') + ) + expect(jsonLines(shadow(panel, JSON_BLOCK))).toEqual([ + '[', + ' "login.e2e.ts"', + ']' + ]) + }) + + it('renders a null option as text rather than a JSON block', async () => { + const panel = await mountMetadata( + metadata({ options: { outputDir: null } }) + ) + + expect(shadowAll(panel, JSON_BLOCK)).toHaveLength(0) + expect(sectionNamed(panel, 'Options').values).toEqual(['null']) + }) + }) + + describe('collapsing', () => { + it('starts with every section expanded', async () => { + const panel = await mountMetadata(loginMetadata) + + expect(shadowAll(panel, OPEN_CHEVRON)).toHaveLength(4) + expect(shadowAll(panel, CARD)).toHaveLength(4) + }) + + it('hides a section body when its heading is clicked', async () => { + const panel = await mountMetadata(loginMetadata) + await toggleSection(panel, 'Capabilities') + + expect(shadowAll(panel, CARD)).toHaveLength(3) + expect(sectionNamed(panel, 'Capabilities').keys).toEqual([]) + }) + + it('turns the chevron of a collapsed section closed', async () => { + const panel = await mountMetadata(loginMetadata) + await toggleSection(panel, 'Capabilities') + + expect(shadowAll(panel, CHEVRON)).toHaveLength(4) + expect(shadowAll(panel, OPEN_CHEVRON)).toHaveLength(3) + }) + + it('collapses only the section whose heading was clicked', async () => { + const panel = await mountMetadata(loginMetadata) + await toggleSection(panel, 'Session') + + expect(sectionNamed(panel, 'Session').keys).toEqual([]) + expect(sectionNamed(panel, 'Options').keys).toEqual([ + 'waitforTimeout', + 'logLevel' + ]) + }) + + it('expands a collapsed section when its heading is clicked again', async () => { + const panel = await mountMetadata(loginMetadata) + await toggleSection(panel, 'Options') + await toggleSection(panel, 'Options') + + expect(shadowAll(panel, CARD)).toHaveLength(4) + expect(sectionNamed(panel, 'Options').values).toEqual(['5000', 'error']) + }) + }) + + describe('several sessions', () => { + it('renders no session picker for a single captured session', async () => { + const panel = await mountMetadata(undefined, { + 'session-1': loginMetadata + }) + + expect(shadowAll(panel, SELECT)).toHaveLength(0) + expect(sectionNamed(panel, 'Session').values).toContain( + loginMetadata.sessionId + ) + }) + + it('renders one picker option per captured session', async () => { + const panel = await mountMetadata(undefined, { + 'session-1': loginMetadata, + 'session-2': secureMetadata + }) + + expect(shadowAll(panel, OPTION)).toHaveLength(2) + }) + + it('labels each option by its position and the host it opened', async () => { + const panel = await mountMetadata(undefined, { + 'session-1': loginMetadata, + 'session-2': secureMetadata + }) + + expect(texts(panel, OPTION)).toEqual([ + sessionLabel(0, loginMetadata.url), + sessionLabel(1, secureMetadata.url) + ]) + expect(texts(panel, OPTION)).toEqual([ + 'Session 1 ยท the-internet.herokuapp.com', + 'Session 2 ยท the-internet.herokuapp.com' + ]) + }) + + it('labels a session that never navigated by its position alone', async () => { + const never = metadata({ sessionId: 'never-navigated' }) + const panel = await mountMetadata(undefined, { + 'session-1': loginMetadata, + 'session-2': never + }) + + expect(texts(panel, OPTION)).toEqual([ + sessionLabel(0, loginMetadata.url), + sessionLabel(1, never.url) + ]) + expect(texts(panel, OPTION)).toEqual([ + 'Session 1 ยท the-internet.herokuapp.com', + 'Session 2' + ]) + }) + + it('labels a session whose url is not a url by its position alone', async () => { + // `about:blank` parses as a URL but has no host, so the suffix drops. + const blank = metadata({ url: 'about:blank' }) + const panel = await mountMetadata(undefined, { + 'session-1': loginMetadata, + 'session-2': blank + }) + + expect(texts(panel, OPTION)).toEqual([ + sessionLabel(0, loginMetadata.url), + sessionLabel(1, blank.url) + ]) + expect(texts(panel, OPTION)).toEqual([ + 'Session 1 ยท the-internet.herokuapp.com', + 'Session 2' + ]) + }) + + it('shows the newest session before one is picked', async () => { + const panel = await mountMetadata(undefined, { + 'session-1': loginMetadata, + 'session-2': secureMetadata + }) + + expect(sectionNamed(panel, 'Session').values).toEqual([ + secureMetadata.sessionId, + secureMetadata.url + ]) + expect(sectionNamed(panel, 'Session').values).toEqual([ + 'b52d08fa17c6', + SECURE_URL + ]) + expect(shadow<HTMLSelectElement>(panel, SELECT)?.value).toBe('session-2') + }) + + it('shows the session the picker selects', async () => { + const panel = await mountMetadata(undefined, { + 'session-1': loginMetadata, + 'session-2': secureMetadata + }) + await pickSession(panel, 'session-1') + + expect(sectionNamed(panel, 'Session').values).toContain( + loginMetadata.sessionId + ) + expect(sectionNamed(panel, 'Capabilities').values).toContain('chrome') + }) + + it('keeps the pending-session buffer out of the picker', async () => { + const panel = await mountMetadata(undefined, { + [PENDING_SESSION_KEY]: metadata({ testEnv: 'buffered' }), + 'session-1': loginMetadata, + 'session-2': secureMetadata + }) + + expect(shadowAll(panel, OPTION)).toHaveLength(2) + // Numbered over the *filtered* list, so the buffer doesn't shift the + // positions by one. + expect(texts(panel, OPTION)).toEqual([ + sessionLabel(0, loginMetadata.url), + sessionLabel(1, secureMetadata.url) + ]) + expect(texts(panel, OPTION)).toEqual([ + 'Session 1 ยท the-internet.herokuapp.com', + 'Session 2 ยท the-internet.herokuapp.com' + ]) + }) + + it('falls back to the active metadata when no session map was captured', async () => { + const panel = await mountMetadata(loginMetadata, {}) + + expect(sectionNamed(panel, 'Session').values).toContain( + loginMetadata.sessionId + ) + expect(shadowAll(panel, SELECT)).toHaveLength(0) + }) + + it('prefers the captured session over the active metadata', async () => { + const panel = await mountMetadata(loginMetadata, { + 'session-2': secureMetadata + }) + + expect(sectionNamed(panel, 'Session').values).toEqual([ + secureMetadata.sessionId, + secureMetadata.url + ]) + expect(sectionNamed(panel, 'Session').values).toEqual([ + 'b52d08fa17c6', + SECURE_URL + ]) + }) + }) + + describe('missing data', () => { + it('renders the placeholder when no metadata was captured', async () => { + const panel = await mountMetadata(undefined, {}) + + expect(shadowAll(panel, PLACEHOLDER)).toHaveLength(1) + expect(shadowAll(panel, SECTION)).toHaveLength(0) + }) + + // Read inside the placeholder's own shadow root: the panel's `textContent` + // stops at the placeholder's host, so it reads empty whether or not the + // words render โ€” which is how inert copy went unnoticed. + it('says why there is no metadata', async () => { + const panel = await mountMetadata(undefined, {}) + const placeholder = shadow(panel, PLACEHOLDER)! + + expect(text(shadow(placeholder, EMPTY_HEADING))).toBe(EMPTY_HEADING_TEXT) + expect(text(shadow(placeholder, EMPTY_DETAIL))).toBe(EMPTY_DETAIL_TEXT) + expect(text(shadow(placeholder, EMPTY_ICON))).toBe(EMPTY_GLYPH) + }) + + it('explains the empty panel instead of drawing a loading skeleton', async () => { + const panel = await mountMetadata(undefined, {}) + const placeholder = shadow(panel, PLACEHOLDER)! + + expect(shadowAll(placeholder, SKELETON)).toHaveLength(0) + }) + + it('renders the placeholder before a provider supplies anything', async () => { + const panel = await mount<DevtoolsMetadata>(PANEL) + const placeholder = shadow(panel, PLACEHOLDER)! + + expect(shadowAll(panel, PLACEHOLDER)).toHaveLength(1) + expect(text(shadow(placeholder, EMPTY_HEADING))).toBe(EMPTY_HEADING_TEXT) + }) + + it('renders the placeholder when only the pending buffer was captured', async () => { + const panel = await mountMetadata(undefined, { + [PENDING_SESSION_KEY]: loginMetadata + }) + + expect(shadowAll(panel, PLACEHOLDER)).toHaveLength(1) + }) + + // A metadata object exists here, so the panel takes its rendering path and + // then finds every bag empty โ€” without the fallback the user gets a blank + // pane rather than an explanation. + it('falls back to the placeholder for metadata carrying nothing but its trace type', async () => { + const panel = await mountMetadata(metadata()) + const placeholder = shadow(panel, PLACEHOLDER)! + + expect(shadowAll(panel, PLACEHOLDER)).toHaveLength(1) + expect(shadowAll(panel, SECTION)).toHaveLength(0) + expect(text(shadow(placeholder, EMPTY_HEADING))).toBe(EMPTY_HEADING_TEXT) + }) + + it('surfaces no viewport row for a capture that carried no dimensions', async () => { + // The viewport can reach the panel before it is serialized; a `0 ร— 0 px` + // row would read as a captured size rather than a missing one. + const panel = await mountMetadata( + metadata({ + viewport: { + width: 0, + height: 0, + offsetLeft: 0, + offsetTop: 0, + scale: 1 + } + }) + ) + + expect(shadowAll(panel, SECTION)).toHaveLength(0) + expect(shadowAll(panel, ROW)).toHaveLength(0) + // Nothing renderable was left, so the panel explains itself rather than + // showing an empty Session card. + expect(shadowAll(panel, PLACEHOLDER)).toHaveLength(1) + }) + }) +}) diff --git a/packages/app/test-ui/workbench/panels/network.test.ts b/packages/app/test-ui/workbench/panels/network.test.ts new file mode 100644 index 00000000..35068f9f --- /dev/null +++ b/packages/app/test-ui/workbench/panels/network.test.ts @@ -0,0 +1,665 @@ +import type { NetworkRequest } from '@wdio/devtools-shared' + +import { networkRequestContext } from '@/controller/context.js' +import { + contentType, + formatBytes, + formatTime, + getFileName, + statusKind +} from '@/utils/network-helpers.js' +import '@components/workbench/network.js' +import type { DevtoolsNetwork } from '@components/workbench/network.js' +import { + networkWindow, + waterfallBar +} from '@components/workbench/network/waterfall.js' + +import { mount, mountWithContext, settle } from '../../support/mount.js' +import { shadow, shadowAll, text, texts } from '../../support/queries.js' +import { loginNetwork, networkRequest } from './fixtures.js' + +const PANEL = 'wdio-devtools-network' +const ROW = '.request-row' +const COLUMN_HEADERS = '.requests-header > div' +const NAME = '.req-name' +const METHOD = '.req-method' +const STATUS = '.req-status' +const TYPE = '.req-type' +const DURATION = '.req-dur' +const SIZE = '.req-size' +const TYPE_DOT = '.type-dot' +const BAR = '.wf-bar' +const DETAIL = '.request-detail' +const DETAIL_SECTION = '.request-detail .detail-section' +const ERROR_VALUE = '.request-detail .v.kind-error' +const TYPE_TAB = '.filter-tab' +const ACTIVE_TYPE_TAB = '.filter-tab.active' +const SEARCH = '.search-input' +const TOOLBAR = '.network-header' +const FILTER_EMPTY = '.filter-empty' +const PLACEHOLDER = 'wdio-devtools-placeholder' +const EMPTY_ICON = '.empty-state-icon' +const EMPTY_HEADING = '.empty-state-text' +const EMPTY_DETAIL = '.empty-state-detail' +const SKELETON = '.ph-item' + +/** Glyph the panel hands its empty-state placeholder. */ +const NETWORK_GLYPH = '๐ŸŒ' + +/** The panel's own "no value" glyph, distinct from the `-` `formatBytes` + * returns โ€” an em dash, written out so the two can't be confused. */ +const NO_VALUE = 'โ€”' + +interface DetailSection { + title: string + keys: string[] + values: string[] +} + +// --- Derived expectations --------------------------------------------------- +// Each column's expected content is computed from the fixture through the same +// exported helper the panel calls, so a column wired to the wrong field (or to +// the wrong formatter) fails even though the literal list still reads right. +// The literals stay alongside as the pinned user-visible strings. + +const expectedNames = (requests: NetworkRequest[]) => + requests.map((request) => getFileName(request.url)) + +const expectedTypes = (requests: NetworkRequest[]) => requests.map(contentType) + +const expectedDurations = (requests: NetworkRequest[]) => + requests.map((request) => + typeof request.time === 'number' && request.time > 0 + ? formatTime(request.time) + : NO_VALUE + ) + +const expectedSizes = (requests: NetworkRequest[]) => + requests.map((request) => formatBytes(request.size)) + +const expectedKindClasses = (requests: NetworkRequest[]) => + requests.map( + (request) => `kind-${statusKind(request.status, Boolean(request.error))}` + ) + +/** A captured body as the detail panel re-indents it, then whitespace-collapsed + * the way `text()` collapses the rendered `<pre>`. */ +const prettyJson = (body: string | undefined) => + JSON.stringify(JSON.parse(body ?? ''), null, 2).replace(/\s+/g, ' ') + +/** Bar width per timed request, scaled against the set actually in view โ€” the + * panel scales against the *filtered* list, which is what makes this derived + * form worth asserting. */ +const expectedBarWidths = (inView: NetworkRequest[]) => { + const range = networkWindow(inView) + return inView + .filter((request) => typeof request.time === 'number' && request.time > 0) + .map((request) => `${waterfallBar(request, range).width}%`) +} + +async function mountNetwork( + requests: NetworkRequest[] +): Promise<DevtoolsNetwork> { + const panel = await mountWithContext<DevtoolsNetwork>(PANEL, [ + { context: networkRequestContext, value: requests } + ]) + await settle(panel) + return panel +} + +const rows = (panel: DevtoolsNetwork) => shadowAll(panel, ROW) + +const selectedRows = (panel: DevtoolsNetwork) => + rows(panel).filter((row) => row.classList.contains('selected')) + +const kindClassOf = (el: Element) => + [...el.classList].find((name) => name.startsWith('kind-')) + +const dotClassOf = (dot: Element) => + [...dot.classList].find((name) => name !== 'type-dot') + +const attrOf = (el: Element | null, name: string) => + el?.getAttribute(name) ?? null + +/** Bar width per row โ€” `null` where the row drew no bar at all. */ +const barWidths = (panel: DevtoolsNetwork) => + rows(panel).map((row) => shadow<HTMLElement>(row, BAR)?.style.width ?? null) + +const detailSections = (panel: DevtoolsNetwork): DetailSection[] => + shadowAll(panel, DETAIL_SECTION).map((section) => ({ + title: text(shadow(section, '.detail-title')), + keys: texts(section, '.k'), + values: texts(section, '.v') + })) + +async function clickRow(panel: DevtoolsNetwork, index: number) { + const row = rows(panel)[index] + if (!row) { + throw new Error(`no request row at index ${index}`) + } + row.click() + await settle(panel) +} + +async function clickTypeTab(panel: DevtoolsNetwork, label: string) { + const tab = shadowAll(panel, TYPE_TAB).find( + (button) => text(button) === label + ) + if (!tab) { + throw new Error(`no resource type tab labelled "${label}"`) + } + tab.click() + await settle(panel) +} + +async function search(panel: DevtoolsNetwork, query: string) { + const input = shadow<HTMLInputElement>(panel, SEARCH) + if (!input) { + throw new Error('the network toolbar rendered no search input') + } + input.value = query + input.dispatchEvent(new Event('input')) + await settle(panel) +} + +describe('wdio-devtools-network', () => { + describe('request list', () => { + it('renders one row per captured request', async () => { + const panel = await mountNetwork(loginNetwork.requests) + + expect(rows(panel)).toHaveLength(7) + }) + + it('renders a column header per request field', async () => { + const panel = await mountNetwork(loginNetwork.requests) + + expect(texts(panel, COLUMN_HEADERS)).toEqual([ + 'Name', + 'Method', + 'Status', + 'Type', + 'Waterfall', + 'Duration', + 'Size' + ]) + }) + + it('names each row after the file its URL points at', async () => { + const panel = await mountNetwork(loginNetwork.requests) + + expect(texts(panel, NAME)).toEqual(expectedNames(loginNetwork.requests)) + expect(texts(panel, NAME)).toEqual([ + 'login', + 'jquery-1.11.3.min.js', + 'session', + 'missing-avatar.png', + 'inter.woff2', + 'authenticate', + 'notifications' + ]) + }) + + it('renders the method of each request', async () => { + const panel = await mountNetwork(loginNetwork.requests) + + expect(texts(panel, METHOD)).toEqual([ + 'GET', + 'GET', + 'POST', + 'GET', + 'GET', + 'GET', + 'GET' + ]) + }) + + it('renders the content type of each request', async () => { + const panel = await mountNetwork(loginNetwork.requests) + + expect(texts(panel, TYPE)).toEqual(expectedTypes(loginNetwork.requests)) + expect(texts(panel, TYPE)).toEqual([ + 'text/html', + 'application/javascript', + 'application/json', + 'image', + 'font', + 'document', + 'fetch' + ]) + }) + + it('renders the duration and transferred size of each request', async () => { + const panel = await mountNetwork(loginNetwork.requests) + + expect(texts(panel, DURATION)).toEqual( + expectedDurations(loginNetwork.requests) + ) + expect(texts(panel, SIZE)).toEqual(expectedSizes(loginNetwork.requests)) + // The two "missing" glyphs differ: the panel writes an em dash for an + // absent duration, `formatBytes` a hyphen for an absent size. + expect(texts(panel, DURATION)).toEqual([ + '800ms', + '400ms', + '8ms', + '120ms', + '200ms', + '40ms', + 'โ€”' + ]) + expect(texts(panel, SIZE)).toEqual([ + '12KB', + '234KB', + '320B', + '-', + '-', + '-', + '-' + ]) + }) + + // Literals only, deliberately: routing the expectation through the same + // helper the panel calls made this list agree with itself while it + // contradicted the Type column above โ€” a document row dotted `type-other`. + it('marks each row with the dot of the type its Type column names', async () => { + const panel = await mountNetwork(loginNetwork.requests) + + expect(shadowAll(panel, TYPE_DOT).map(dotClassOf)).toEqual([ + 'type-html', + 'type-js', + 'type-fetch', + 'type-image', + 'type-font', + // A document with no response headers and no `.html` in its URL โ€” its + // dot follows the captured type, as the Type column already did. + 'type-html', + 'type-fetch' + ]) + }) + }) + + describe('status', () => { + it('renders the status code of each finished request', async () => { + const panel = await mountNetwork(loginNetwork.requests) + + expect(texts(panel, STATUS)).toEqual([ + '200', + '200', + '200', + '404', + 'ERR', + '302', + 'โ€”' + ]) + }) + + it('buckets 2xx as ok, 3xx as redirect and 4xx as error', async () => { + const panel = await mountNetwork(loginNetwork.requests) + + expect(shadowAll(panel, STATUS).map(kindClassOf)).toEqual( + expectedKindClasses(loginNetwork.requests) + ) + expect(shadowAll(panel, STATUS).map(kindClassOf)).toEqual([ + 'kind-ok', + 'kind-ok', + 'kind-ok', + 'kind-error', + 'kind-error', + 'kind-redirect', + 'kind-pending' + ]) + }) + + it('renders a request that never got a status as pending', async () => { + const panel = await mountNetwork([loginNetwork.pending]) + + expect(text(shadow(panel, STATUS))).toBe('โ€”') + expect(kindClassOf(shadowAll(panel, STATUS)[0])).toBe('kind-pending') + }) + }) + + describe('waterfall', () => { + it('scales every bar against the slowest request in view', async () => { + const panel = await mountNetwork(loginNetwork.requests) + + expect(barWidths(panel)).toEqual([ + ...expectedBarWidths(loginNetwork.requests), + // The in-flight request draws no bar at all. + null + ]) + // 800ms is the slowest; the 8ms request is held at the visible minimum. + expect(barWidths(panel)).toEqual([ + '100%', + '50%', + '2%', + '15%', + '25%', + '5%', + null + ]) + }) + + it('starts every bar at the left edge of its track', async () => { + const panel = await mountNetwork(loginNetwork.requests) + + expect( + shadowAll<HTMLElement>(panel, BAR).map((bar) => bar.style.left) + ).toEqual(['0%', '0%', '0%', '0%', '0%', '0%']) + }) + + it('draws no bar and dashes the duration of an in-flight request', async () => { + const panel = await mountNetwork([loginNetwork.pending]) + + expect(shadowAll(panel, BAR)).toHaveLength(0) + expect(text(shadow(panel, DURATION))).toBe('โ€”') + expect( + shadowAll(panel, DURATION)[0].classList.contains('req-dur-empty') + ).toBe(true) + }) + + it("colours a failed request's bar as an error", async () => { + const panel = await mountNetwork(loginNetwork.requests) + + expect(shadowAll(panel, BAR).map(kindClassOf)).toEqual([ + 'kind-ok', + 'kind-ok', + 'kind-ok', + 'kind-error', + 'kind-error', + 'kind-redirect' + ]) + }) + + it('rescales the bars against the slowest request left after filtering', async () => { + const panel = await mountNetwork(loginNetwork.requests) + await clickTypeTab(panel, 'JS') + + // Scaled against the filtered set, so the 400ms script now fills the + // track; scaling against the unfiltered 800ms maximum would give 50%. + expect(barWidths(panel)).toEqual(expectedBarWidths([loginNetwork.script])) + expect(barWidths(panel)).toEqual(['100%']) + expect(expectedBarWidths(loginNetwork.requests)[1]).toBe('50%') + }) + }) + + describe('detail panel', () => { + it('renders no detail panel until a row is clicked', async () => { + const panel = await mountNetwork(loginNetwork.requests) + + expect(shadowAll(panel, DETAIL)).toHaveLength(0) + expect(selectedRows(panel)).toHaveLength(0) + }) + + it('expands the clicked row into a detail panel and selects only that row', async () => { + const panel = await mountNetwork(loginNetwork.requests) + await clickRow(panel, 2) + + expect(shadowAll(panel, DETAIL)).toHaveLength(1) + expect(selectedRows(panel)).toHaveLength(1) + expect(text(shadow(selectedRows(panel)[0], NAME))).toBe('session') + }) + + it("summarises the clicked request's URL, method, status, timing and size", async () => { + const panel = await mountNetwork(loginNetwork.requests) + await clickRow(panel, 2) + + const { api } = loginNetwork + const [general] = detailSections(panel) + expect(general.keys).toEqual([ + 'Request URL', + 'Method', + 'Status', + 'Type', + 'Time', + 'Size' + ]) + expect(general.values).toEqual([ + api.url, + api.method, + `${api.status} ${api.statusText}`, + contentType(api), + formatTime(api.time), + formatBytes(api.size) + ]) + expect(general.values).toEqual([ + 'https://the-internet.herokuapp.com/api/session', + 'POST', + '200 OK', + 'application/json', + '8ms', + '320B' + ]) + }) + + it('lists the request and response headers of the clicked request', async () => { + const panel = await mountNetwork(loginNetwork.requests) + await clickRow(panel, 2) + + const sections = detailSections(panel) + expect(sections.map((section) => section.title)).toEqual([ + 'General', + 'Request Headers', + 'Request Body', + 'Response Headers', + 'Response Body' + ]) + expect(sections[1].keys).toEqual(['content-type']) + expect(sections[1].values).toEqual(['application/json']) + expect(sections[3].keys).toEqual(['content-type']) + expect(sections[3].values).toEqual(['application/json']) + }) + + it('pretty-prints a JSON request and response body', async () => { + const panel = await mountNetwork(loginNetwork.requests) + await clickRow(panel, 2) + + const sections = detailSections(panel) + // Re-indented from the captured wire body โ€” `text()` then collapses the + // indentation, so the assertion is on the reflow, not the whitespace. + expect(sections[2].values).toEqual([ + prettyJson(loginNetwork.api.requestBody) + ]) + expect(sections[4].values).toEqual([ + prettyJson(loginNetwork.api.responseBody) + ]) + expect(sections[2].values).toEqual(['{ "sku": "AB-1", "qty": 2 }']) + expect(sections[4].values).toEqual(['{ "ok": true, "items": 2 }']) + }) + + it('shows only the General section for a request with no headers or bodies', async () => { + const panel = await mountNetwork([loginNetwork.pending]) + await clickRow(panel, 0) + + expect(detailSections(panel).map((section) => section.title)).toEqual([ + 'General' + ]) + }) + + it('leaves out the timing and size of a request that has neither', async () => { + const panel = await mountNetwork([loginNetwork.pending]) + await clickRow(panel, 0) + + const { pending } = loginNetwork + const [general] = detailSections(panel) + expect(general.keys).toEqual(['Request URL', 'Method', 'Status', 'Type']) + expect(general.values).toEqual([ + pending.url, + pending.method, + NO_VALUE, + contentType(pending) + ]) + expect(general.values).toEqual([ + 'https://the-internet.herokuapp.com/api/notifications?limit=4', + 'GET', + 'โ€”', + 'fetch' + ]) + }) + + it('reports the transport error of a request that never got a status', async () => { + const panel = await mountNetwork([loginNetwork.failedFont]) + await clickRow(panel, 0) + + const [general] = detailSections(panel) + expect(general.keys).toEqual([ + 'Request URL', + 'Method', + 'Status', + 'Type', + 'Time', + 'Error' + ]) + // The missing status and the error message are both flagged as errors. + expect(texts(panel, ERROR_VALUE)).toEqual([ + 'โ€”', + 'net::ERR_CONNECTION_REFUSED' + ]) + }) + + it('closes the detail panel when the selected row is clicked again', async () => { + const panel = await mountNetwork(loginNetwork.requests) + await clickRow(panel, 2) + await clickRow(panel, 2) + + expect(shadowAll(panel, DETAIL)).toHaveLength(0) + expect(selectedRows(panel)).toHaveLength(0) + }) + + it('moves the detail panel to another row when that row is clicked', async () => { + const panel = await mountNetwork(loginNetwork.requests) + await clickRow(panel, 2) + await clickRow(panel, 0) + + expect(selectedRows(panel)).toHaveLength(1) + expect(detailSections(panel)[0].values[0]).toBe( + 'https://the-internet.herokuapp.com/login' + ) + }) + }) + + describe('filtering', () => { + it('narrows the list to one resource type when its tab is clicked', async () => { + const panel = await mountNetwork(loginNetwork.requests) + await clickTypeTab(panel, 'JS') + + expect(text(shadow(panel, ACTIVE_TYPE_TAB))).toBe('JS') + expect(texts(panel, NAME)).toEqual(['jquery-1.11.3.min.js']) + }) + + // The HTML tab used to match nothing: a document was only dotted HTML when + // its response headers said `text/html`, and the tab filtered on the same + // narrower rule. Both documents here must reach it, headers or not. + it('narrows the list to every document when the HTML tab is clicked', async () => { + const panel = await mountNetwork(loginNetwork.requests) + await clickTypeTab(panel, 'HTML') + + expect(texts(panel, NAME)).toEqual(['login', 'authenticate']) + }) + + it('starts with the All type tab active', async () => { + const panel = await mountNetwork(loginNetwork.requests) + + expect(text(shadow(panel, ACTIVE_TYPE_TAB))).toBe('All') + }) + + it('narrows the list to requests whose file name matches the search', async () => { + const panel = await mountNetwork(loginNetwork.requests) + await search(panel, 'jquery-1.11.3.min.js') + + expect(texts(panel, NAME)).toEqual(['jquery-1.11.3.min.js']) + }) + + // Matches inside the URL but outside the name the row shows, so only the + // filter's URL clause can find it. + it('narrows the list by a path segment that is not the file name', async () => { + const panel = await mountNetwork(loginNetwork.requests) + await search(panel, 'js/vendor') + + expect(texts(panel, NAME)).toEqual(['jquery-1.11.3.min.js']) + }) + + // The name a row shows isn't always a slice of the URL it was captured + // with โ€” this host reaches the list punycoded. Searching it exercises the + // name clause of the filter on its own; the URL clause can't match `xn--`. + it('narrows the list by the name a row displays, not only by its URL', async () => { + const internationalHost = networkRequest({ + id: 'req-idn', + url: 'https://mรผnchen.example/?q=1' + }) + const panel = await mountNetwork([internationalHost, loginNetwork.script]) + await search(panel, 'xn--') + + expect(texts(panel, NAME)).toEqual(['xn--mnchen-3ya.example']) + }) + + it('narrows the list by status code', async () => { + const panel = await mountNetwork(loginNetwork.requests) + await search(panel, '404') + + expect(texts(panel, NAME)).toEqual(['missing-avatar.png']) + }) + + it('reports that nothing matches when a resource type has no requests', async () => { + const panel = await mountNetwork(loginNetwork.requests) + await clickTypeTab(panel, 'CSS') + + expect(rows(panel)).toHaveLength(0) + expect(text(shadow(panel, FILTER_EMPTY))).toBe( + 'No requests match your filter' + ) + }) + }) + + describe('empty state', () => { + it('renders the network placeholder when no requests have been captured', async () => { + const panel = await mountNetwork([]) + + const placeholder = shadow(panel, PLACEHOLDER) + expect(shadowAll(panel, PLACEHOLDER)).toHaveLength(1) + expect(attrOf(placeholder, 'icon')).toBe(NETWORK_GLYPH) + expect(attrOf(placeholder, 'heading')).toBe( + 'No network requests captured' + ) + expect(attrOf(placeholder, 'description')).toBe( + 'Network requests will appear here as your tests run' + ) + }) + + // The copy is asserted inside the placeholder's own shadow root, not through + // the panel's `textContent` โ€” that stops at the placeholder's host and so + // reads empty whether or not the words render. + it('renders the copy it hands the placeholder as visible text', async () => { + const panel = await mountNetwork([]) + const placeholder = shadow(panel, PLACEHOLDER)! + + expect(text(shadow(placeholder, EMPTY_HEADING))).toBe( + 'No network requests captured' + ) + expect(text(shadow(placeholder, EMPTY_DETAIL))).toBe( + 'Network requests will appear here as your tests run' + ) + expect(text(shadow(placeholder, EMPTY_ICON))).toBe(NETWORK_GLYPH) + }) + + it('explains the empty panel instead of drawing a loading skeleton', async () => { + const panel = await mountNetwork([]) + const placeholder = shadow(panel, PLACEHOLDER)! + + expect(shadowAll(placeholder, SKELETON)).toHaveLength(0) + }) + + it('renders the placeholder before a provider supplies any requests', async () => { + const panel = await mount<DevtoolsNetwork>(PANEL) + + expect(shadowAll(panel, PLACEHOLDER)).toHaveLength(1) + expect(rows(panel)).toHaveLength(0) + }) + + it('renders no toolbar or column headers while the placeholder is showing', async () => { + const panel = await mountNetwork([]) + + expect(shadowAll(panel, TOOLBAR)).toHaveLength(0) + expect(shadowAll(panel, COLUMN_HEADERS)).toHaveLength(0) + }) + }) +}) diff --git a/packages/app/test-ui/workbench/panels/source.test.ts b/packages/app/test-ui/workbench/panels/source.test.ts new file mode 100644 index 00000000..d2140bf3 --- /dev/null +++ b/packages/app/test-ui/workbench/panels/source.test.ts @@ -0,0 +1,392 @@ +import type { CommandLog } from '@wdio/devtools-shared' + +import { commandContext, sourceContext } from '@/controller/context.js' +import { commandCategory } from '@components/workbench/actionItems/category.js' +import '@components/workbench/source.js' +import type { DevtoolsSource } from '@components/workbench/source.js' + +import { commandLog } from '../../support/builders.js' +import { mount, mountWithContext, settle } from '../../support/mount.js' +import { shadow, shadowAll, text, texts } from '../../support/queries.js' +import { + HELPER_CALL_SOURCE, + NAVIGATE_CALL_SOURCE, + SET_VALUE_CALL_SOURCE, + SPEC_FILE, + SPEC_LINES, + SET_VALUE_LINE, + STEPS_CALL_SOURCE, + STEPS_LINES, + STEPS_SET_VALUE_LINE, + loginSourceCommands, + loginSources +} from './fixtures.js' + +const PANEL = 'wdio-devtools-source' +const FILE_TAB = '.src-file' +const ACTIVE_FILE_TAB = '.src-file.active' +const PATH = '.src-path' +const BASE = '.src-path .base' +const ELISION = '.src-path .sep' +const ACTION = '.src-act' +const EDITOR_LINK = 'a.src-act' +const CHIP = '.cs-chip' +const CHIP_COMMAND = '.cs-chip .cmd' +const CHIP_LINE = '.cs-chip .ln' +const EDITOR = '.cm-editor' +const LINE = '.cm-line' +const CALL_SITE = '.cm-line.cm-callsite' +const NOT_CAPTURED = '.src-empty' +const PLACEHOLDER = 'wdio-devtools-placeholder' +const EMPTY_ICON = '.empty-state-icon' +const EMPTY_HEADING = '.empty-state-text' +const EMPTY_DETAIL = '.empty-state-detail' +const SKELETON = '.ph-item' + +/** Copy the panel hands its placeholder โ€” a terminal state whenever the run + * captured no source and no command reported a call site. */ +const EMPTY_GLYPH = '๐Ÿ“„' +const EMPTY_HEADING_TEXT = 'No source to show' +const EMPTY_DETAIL_TEXT = + "A file appears here once the run captures a spec's source or a command reports the line it ran from โ€” this run carries neither." + +/** Theme token the panel exposes as `--cs`, per `ActionCategory`. Mirrors + * `source.ts`'s `CATEGORY_VAR`, which is module-private; `none` is the value + * the panel writes when there is no call site at all. */ +const CATEGORY_COLOUR = { + navigation: 'var(--vscode-charts-blue)', + input: 'var(--vscode-charts-purple)', + assertion: 'var(--vscode-charts-green)', + query: 'var(--vscode-charts-yellow)', + other: 'var(--vscode-descriptionForeground)', + none: 'var(--vscode-descriptionForeground)' +} + +/** The tint a command's call site should get โ€” looked up by the classifier's + * verdict for that command, so a panel tinting by anything else fails. */ +const tintFor = (command: string) => CATEGORY_COLOUR[commandCategory(command)] + +/** Last three segments of a path, elided, as the toolbar shows it. */ +const elidedPath = (path: string) => { + const segments = path.split('/').filter(Boolean) + const shown = segments.slice(-3) + return `${segments.length > shown.length ? 'โ€ฆ/' : ''}${shown.join('/')}` +} + +/** `text()` collapses whitespace, so a rendered editor line is compared against + * its source line collapsed the same way. */ +const collapsed = (line: string) => line.replace(/\s+/g, ' ').trim() + +/** CodeMirror measures its viewport in a rAF โ€” give it one before reading the + * lines it rendered. */ +const nextFrame = () => + new Promise<void>((resolve) => requestAnimationFrame(() => resolve())) + +async function mountSource( + sources: Record<string, string>, + commands: CommandLog[] = [] +): Promise<DevtoolsSource> { + const panel = await mountWithContext<DevtoolsSource>(PANEL, [ + { context: sourceContext, value: sources }, + { context: commandContext, value: commands } + ]) + await settle(panel) + await nextFrame() + return panel +} + +async function highlight(panel: DevtoolsSource, callSource: string) { + window.dispatchEvent( + new CustomEvent('app-source-highlight', { detail: callSource }) + ) + await settle(panel) + await nextFrame() +} + +async function track(panel: DevtoolsSource, callSource: string) { + window.dispatchEvent( + new CustomEvent('app-source-track', { detail: { callSource } }) + ) + await settle(panel) + await nextFrame() +} + +async function openFile(panel: DevtoolsSource, basename: string) { + const tab = shadowAll(panel, FILE_TAB).find( + (candidate) => text(candidate) === basename + ) + if (!tab) { + throw new Error(`no file tab labelled "${basename}"`) + } + tab.click() + await settle(panel) + await nextFrame() +} + +const callSiteColour = (panel: DevtoolsSource) => + panel.style.getPropertyValue('--cs') + +describe('wdio-devtools-source', () => { + describe('file tabs', () => { + it('renders a tab per captured source file, labelled by file name', async () => { + const panel = await mountSource(loginSources) + + expect(texts(panel, FILE_TAB)).toEqual(['login.e2e.ts', 'login.steps.ts']) + }) + + it('adds a file that only a command call source names', async () => { + const panel = await mountSource(loginSources, loginSourceCommands) + + expect(texts(panel, FILE_TAB)).toEqual([ + 'login.e2e.ts', + 'login.steps.ts', + 'helpers.ts' + ]) + }) + + it('marks the first captured file active until another is picked', async () => { + const panel = await mountSource(loginSources) + + expect(texts(panel, ACTIVE_FILE_TAB)).toEqual(['login.e2e.ts']) + }) + + it('moves the active mark to the file that was clicked', async () => { + const panel = await mountSource(loginSources) + await openFile(panel, 'login.steps.ts') + + expect(texts(panel, ACTIVE_FILE_TAB)).toEqual(['login.steps.ts']) + }) + }) + + describe('path', () => { + it('shows the last three segments of the active path, marking the elision', async () => { + const panel = await mountSource(loginSources) + + expect(text(shadow(panel, PATH))).toBe(elidedPath(SPEC_FILE)) + expect(text(shadow(panel, PATH))).toBe('โ€ฆ/test/specs/login.e2e.ts') + expect(text(shadow(panel, BASE))).toBe('login.e2e.ts') + }) + + it('keeps the whole path in the hover title', async () => { + const panel = await mountSource(loginSources) + + expect(shadow(panel, PATH)?.getAttribute('title')).toBe(SPEC_FILE) + }) + + it('shows a path short enough to fit whole, with no elision', async () => { + const panel = await mountSource({ 'login.e2e.ts': SPEC_LINES.join('\n') }) + + expect(text(shadow(panel, PATH))).toBe(elidedPath('login.e2e.ts')) + expect(text(shadow(panel, PATH))).toBe('login.e2e.ts') + expect(shadowAll(panel, ELISION)).toHaveLength(0) + }) + + it('offers copy-path and open-in-editor actions', async () => { + const panel = await mountSource(loginSources) + + expect(texts(panel, ACTION)).toEqual(['Copy path', 'Open in editor']) + }) + }) + + describe('editor', () => { + it('renders the captured source of the active file', async () => { + const panel = await mountSource(loginSources) + + expect(texts(panel, LINE)).toEqual(SPEC_LINES.map(collapsed)) + }) + + it('renders the source of the file the tabs switch to', async () => { + const panel = await mountSource(loginSources) + await openFile(panel, 'login.steps.ts') + + expect(texts(panel, LINE)).toEqual(STEPS_LINES.map(collapsed)) + expect(text(shadow(panel, BASE))).toBe('login.steps.ts') + }) + + it('reports a file the trace never captured instead of an editor', async () => { + const panel = await mountSource(loginSources, loginSourceCommands) + await openFile(panel, 'helpers.ts') + + expect(text(shadow(panel, NOT_CAPTURED))).toBe( + 'Source for helpers.ts was not captured in this trace.' + ) + expect(shadowAll(panel, EDITOR)).toHaveLength(0) + }) + + it('keeps the file tabs while reporting an uncaptured file', async () => { + const panel = await mountSource(loginSources, loginSourceCommands) + await openFile(panel, 'helpers.ts') + + expect(texts(panel, ACTIVE_FILE_TAB)).toEqual(['helpers.ts']) + expect(texts(panel, FILE_TAB)).toHaveLength(3) + }) + + it('reports the uncaptured file a command named when nothing was captured', async () => { + const panel = await mountSource({}, [ + commandLog({ command: 'getTitle', callSource: HELPER_CALL_SOURCE }) + ]) + + expect(shadowAll(panel, PLACEHOLDER)).toHaveLength(0) + expect(text(shadow(panel, NOT_CAPTURED))).toBe( + 'Source for helpers.ts was not captured in this trace.' + ) + }) + }) + + describe('call site', () => { + it('highlights the line a source-highlight event names', async () => { + const panel = await mountSource(loginSources, loginSourceCommands) + await highlight(panel, SET_VALUE_CALL_SOURCE) + + expect(texts(panel, CALL_SITE)).toEqual([ + collapsed(SPEC_LINES[SET_VALUE_LINE - 1]) + ]) + }) + + it('highlights the line a passive source-track event names', async () => { + const panel = await mountSource(loginSources, loginSourceCommands) + await track(panel, SET_VALUE_CALL_SOURCE) + + expect(texts(panel, CALL_SITE)).toEqual([ + collapsed(SPEC_LINES[SET_VALUE_LINE - 1]) + ]) + }) + + it('opens the call source file before highlighting it', async () => { + const panel = await mountSource(loginSources, loginSourceCommands) + await highlight(panel, STEPS_CALL_SOURCE) + + expect(texts(panel, ACTIVE_FILE_TAB)).toEqual(['login.steps.ts']) + expect(texts(panel, CALL_SITE)).toEqual([ + collapsed(STEPS_LINES[STEPS_SET_VALUE_LINE - 1]) + ]) + }) + + it('names the command and line of the call site in a chip', async () => { + const panel = await mountSource(loginSources, loginSourceCommands) + await highlight(panel, SET_VALUE_CALL_SOURCE) + + expect(text(shadow(panel, CHIP_COMMAND))).toBe('setValue') + expect(text(shadow(panel, CHIP_LINE))).toBe(`L${SET_VALUE_LINE}`) + }) + + it('tints the call site with the category of the command that ran there', async () => { + const panel = await mountSource(loginSources, loginSourceCommands) + await highlight(panel, SET_VALUE_CALL_SOURCE) + + expect(callSiteColour(panel)).toBe(tintFor('setValue')) + expect(callSiteColour(panel)).toBe(CATEGORY_COLOUR.input) + }) + + it('tints a navigation call site with its own category colour', async () => { + const panel = await mountSource(loginSources, loginSourceCommands) + await highlight(panel, NAVIGATE_CALL_SOURCE) + + expect(text(shadow(panel, CHIP_COMMAND))).toBe('url') + expect(callSiteColour(panel)).toBe(tintFor('url')) + expect(callSiteColour(panel)).toBe(CATEGORY_COLOUR.navigation) + // The two categories really are distinct tints, so the assertion above + // isn't satisfied by a panel that hard-codes one colour. + expect(tintFor('url')).not.toBe(tintFor('setValue')) + }) + + it('highlights a line no command ran on without naming one', async () => { + const panel = await mountSource(loginSources, loginSourceCommands) + await highlight(panel, `${SPEC_FILE}:4:1`) + + expect(texts(panel, CALL_SITE)).toEqual([collapsed(SPEC_LINES[3])]) + expect(shadowAll(panel, CHIP)).toHaveLength(0) + }) + + it('highlights nothing for a line past the end of the file', async () => { + const panel = await mountSource(loginSources, [ + commandLog({ command: 'click', callSource: `${SPEC_FILE}:99:3` }) + ]) + await highlight(panel, `${SPEC_FILE}:99:3`) + + expect(shadowAll(panel, CALL_SITE)).toHaveLength(0) + expect(text(shadow(panel, CHIP_LINE))).toBe('L99') + }) + + it('ignores a call source that carries no line number', async () => { + const panel = await mountSource(loginSources, loginSourceCommands) + await highlight(panel, SPEC_FILE) + + expect(shadowAll(panel, CALL_SITE)).toHaveLength(0) + expect(shadowAll(panel, CHIP)).toHaveLength(0) + expect(texts(panel, ACTIVE_FILE_TAB)).toEqual(['login.e2e.ts']) + }) + + it('clears the highlight when another file is opened', async () => { + const panel = await mountSource(loginSources, loginSourceCommands) + await highlight(panel, SET_VALUE_CALL_SOURCE) + await openFile(panel, 'login.steps.ts') + + expect(shadowAll(panel, CALL_SITE)).toHaveLength(0) + expect(shadowAll(panel, CHIP)).toHaveLength(0) + expect(callSiteColour(panel)).toBe(CATEGORY_COLOUR.none) + }) + + it('points the editor link at the call-site line', async () => { + const panel = await mountSource(loginSources, loginSourceCommands) + await highlight(panel, SET_VALUE_CALL_SOURCE) + + expect(shadow(panel, EDITOR_LINK)?.getAttribute('href')).toBe( + `vscode://file/${SPEC_FILE}:${SET_VALUE_LINE}` + ) + }) + + it('points the editor link at the file itself before any call site', async () => { + const panel = await mountSource(loginSources) + + expect(shadow(panel, EDITOR_LINK)?.getAttribute('href')).toBe( + `vscode://file/${SPEC_FILE}` + ) + }) + }) + + describe('empty state', () => { + it('renders the placeholder when no source and no call source was captured', async () => { + const panel = await mountSource({}, [ + commandLog({ callSource: undefined }) + ]) + + expect(shadowAll(panel, PLACEHOLDER)).toHaveLength(1) + expect(shadowAll(panel, FILE_TAB)).toHaveLength(0) + }) + + // Read inside the placeholder's own shadow root: the panel's `textContent` + // stops at the placeholder's host, so it reads empty whether or not the + // words render โ€” which is how inert copy went unnoticed. + it('says why there is no file to show', async () => { + const panel = await mountSource({}, [ + commandLog({ callSource: undefined }) + ]) + const placeholder = shadow(panel, PLACEHOLDER)! + + expect(text(shadow(placeholder, EMPTY_HEADING))).toBe(EMPTY_HEADING_TEXT) + expect(text(shadow(placeholder, EMPTY_DETAIL))).toBe(EMPTY_DETAIL_TEXT) + expect(text(shadow(placeholder, EMPTY_ICON))).toBe(EMPTY_GLYPH) + }) + + // Distinct from the uncaptured-file notice above: that one names a file the + // commands pointed at, this one is the no-file-at-all state. + it('explains the empty panel instead of drawing a loading skeleton', async () => { + const panel = await mountSource({}, [ + commandLog({ callSource: undefined }) + ]) + const placeholder = shadow(panel, PLACEHOLDER)! + + expect(shadowAll(placeholder, SKELETON)).toHaveLength(0) + expect(shadowAll(panel, NOT_CAPTURED)).toHaveLength(0) + }) + + it('renders the placeholder before a provider supplies anything', async () => { + const panel = await mount<DevtoolsSource>(PANEL) + const placeholder = shadow(panel, PLACEHOLDER)! + + expect(shadowAll(panel, PLACEHOLDER)).toHaveLength(1) + expect(text(shadow(placeholder, EMPTY_HEADING))).toBe(EMPTY_HEADING_TEXT) + }) + }) +}) diff --git a/packages/app/test-ui/workbench/panels/transcript.test.ts b/packages/app/test-ui/workbench/panels/transcript.test.ts new file mode 100644 index 00000000..bc6adb2f --- /dev/null +++ b/packages/app/test-ui/workbench/panels/transcript.test.ts @@ -0,0 +1,288 @@ +import type { CommandLog } from '@wdio/devtools-shared' + +import { commandContext, transcriptContext } from '@/controller/context.js' +import '@components/workbench/transcript.js' +import type { DevtoolsTranscript } from '@components/workbench/transcript.js' + +import { commandLog } from '../../support/builders.js' +import { mount, mountWithContext, settle } from '../../support/mount.js' +import { shadow, shadowAll, text } from '../../support/queries.js' + +const PANEL = 'wdio-devtools-transcript' +const BODY = 'pre' +const COPY_BUTTON = 'button' +const COPY_ICON = 'icon-mdi-content-copy' +const COPIED_ICON = 'icon-mdi-check' +const PLACEHOLDER = 'wdio-devtools-placeholder' +const EMPTY_ICON = '.empty-state-icon' +const EMPTY_HEADING = '.empty-state-text' +const EMPTY_DETAIL = '.empty-state-detail' +const SKELETON = '.ph-item' + +/** Copy the panel hands its placeholder. The panel mounts in player mode only, + * over an already-loaded trace, so this is a terminal state โ€” a skeleton here + * would never resolve. */ +const EMPTY_GLYPH = '๐Ÿ“' +const EMPTY_HEADING_TEXT = 'No transcript in this trace' +const EMPTY_DETAIL_TEXT = + 'A run writes transcript.md from the steps it captured โ€” this trace carries none, so there is no prompt to copy.' +/** Elements a markdown renderer would produce โ€” the panel renders none. */ +const MARKDOWN_ELEMENTS = 'h1, h2, ul, ol, li, strong, code' + +const LOGIN_URL = 'https://the-internet.herokuapp.com/login' + +/** A run transcript as the exporter writes `transcript.md`. */ +const STEPS = [ + '# Login flow', + '', + '## Step 1 โ€” open the login page', + `- url("${LOGIN_URL}")`, + '', + '## Step 2 โ€” sign in', + '- setValue("#username", "tomsmith")', + '- setValue("#password", "SuperSecretPassword!")', + '- click("button[type=submit]")', + '', + '## Step 3 โ€” assert the flash message', + '- expect("#flash").toHaveText("You logged into a secure area!")' +] + +const TRANSCRIPT = STEPS.join('\n') + +const FLASH_ASSERTION = 'expect("#flash").toHaveText(โ€ฆ)' +const FLASH_ERROR = + 'Expected: "You logged into a secure area!"\nReceived: "Your username is invalid!"' + +const failingAssertion: CommandLog = commandLog({ + command: 'expect.toHaveText', + title: FLASH_ASSERTION, + args: ['#flash'], + error: { name: 'AssertionError', message: FLASH_ERROR } +}) + +const failingClick: CommandLog = commandLog({ + command: 'click', + args: ['button[type=submit]'], + error: { name: 'Error', message: 'element not interactable' } +}) + +const passingNavigation: CommandLog = commandLog({ + command: 'url', + args: [LOGIN_URL] +}) + +async function mountTranscript( + transcript?: string, + commands: CommandLog[] = [] +): Promise<DevtoolsTranscript> { + const panel = await mountWithContext<DevtoolsTranscript>(PANEL, [ + { context: transcriptContext, value: transcript }, + { context: commandContext, value: commands } + ]) + await settle(panel) + return panel +} + +/** Rendered transcript with its line breaks intact โ€” `text()` collapses them, + * and line order is the thing under test. */ +const bodyLines = (panel: DevtoolsTranscript): string[] => + (shadow(panel, BODY)?.textContent ?? '').split('\n') + +/** The panel copies through `navigator.clipboard`, which a headless run has no + * user gesture or permission for; the writes are recorded instead. */ +function recordClipboard(options: { failing?: boolean } = {}): string[] { + const writes: string[] = [] + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { + writeText: (data: string): Promise<void> => { + if (options.failing) { + return Promise.reject(new Error('clipboard write blocked')) + } + writes.push(data) + return Promise.resolve() + } + } + }) + return writes +} + +afterEach(() => { + Reflect.deleteProperty(navigator, 'clipboard') +}) + +/** Lets the awaited clipboard write inside the click handler settle. */ +const flush = () => new Promise<void>((resolve) => setTimeout(resolve, 0)) + +async function clickCopy(panel: DevtoolsTranscript): Promise<void> { + const button = shadow<HTMLButtonElement>(panel, COPY_BUTTON) + if (!button) { + throw new Error('the transcript rendered no copy button') + } + button.click() + await flush() + await settle(panel) +} + +describe('wdio-devtools-transcript', () => { + describe('transcript body', () => { + it('renders every step in the order the transcript records them', async () => { + const panel = await mountTranscript(TRANSCRIPT) + + expect(bodyLines(panel)).toEqual(STEPS) + }) + + it('renders the markdown as text rather than formatting it', async () => { + const panel = await mountTranscript(TRANSCRIPT) + + expect(shadowAll(panel, MARKDOWN_ELEMENTS)).toHaveLength(0) + expect(bodyLines(panel)).toContain('## Step 2 โ€” sign in') + }) + + it('renders a single-step transcript on its own', async () => { + const step = `- url("${LOGIN_URL}")` + const panel = await mountTranscript(step) + + expect(bodyLines(panel)).toEqual([step]) + }) + + it('renders the transcript verbatim, including its surrounding blank lines', async () => { + const padded = `\n${TRANSCRIPT}\n` + const panel = await mountTranscript(padded) + + expect(shadow(panel, BODY)?.textContent).toBe(padded) + }) + }) + + describe('absent transcript', () => { + it('renders the placeholder when the run carried no transcript', async () => { + const panel = await mountTranscript() + + expect(shadowAll(panel, PLACEHOLDER)).toHaveLength(1) + expect(shadowAll(panel, BODY)).toHaveLength(0) + }) + + // Read inside the placeholder's own shadow root: the panel's `textContent` + // stops at the placeholder's host, so it reads empty whether or not the + // words render โ€” which is how inert copy went unnoticed. + it('says why there is no transcript', async () => { + const panel = await mountTranscript() + const placeholder = shadow(panel, PLACEHOLDER)! + + expect(text(shadow(placeholder, EMPTY_HEADING))).toBe(EMPTY_HEADING_TEXT) + expect(text(shadow(placeholder, EMPTY_DETAIL))).toBe(EMPTY_DETAIL_TEXT) + expect(text(shadow(placeholder, EMPTY_ICON))).toBe(EMPTY_GLYPH) + }) + + // The panel only mounts over a loaded trace, so a skeleton would spin for + // the life of the tab. + it('explains the empty panel instead of drawing a loading skeleton', async () => { + const panel = await mountTranscript() + const placeholder = shadow(panel, PLACEHOLDER)! + + expect(shadowAll(placeholder, SKELETON)).toHaveLength(0) + }) + + it('renders the placeholder before a provider supplies one', async () => { + const panel = await mount<DevtoolsTranscript>(PANEL) + const placeholder = shadow(panel, PLACEHOLDER)! + + expect(shadowAll(panel, PLACEHOLDER)).toHaveLength(1) + expect(text(shadow(placeholder, EMPTY_HEADING))).toBe(EMPTY_HEADING_TEXT) + }) + + it('renders the placeholder for an empty transcript', async () => { + const panel = await mountTranscript('') + const placeholder = shadow(panel, PLACEHOLDER)! + + expect(shadowAll(panel, PLACEHOLDER)).toHaveLength(1) + expect(text(shadow(placeholder, EMPTY_HEADING))).toBe(EMPTY_HEADING_TEXT) + }) + + it('offers no copy control while the placeholder is showing', async () => { + const panel = await mountTranscript() + + expect(shadowAll(panel, COPY_BUTTON)).toHaveLength(0) + }) + }) + + describe('copy prompt', () => { + it('offers a copy control describing what it copies', async () => { + const panel = await mountTranscript(TRANSCRIPT) + + expect(shadowAll(panel, COPY_ICON)).toHaveLength(1) + expect(shadow(panel, COPY_BUTTON)?.getAttribute('title')).toBe( + 'Copy the transcript + failures as an LLM prompt' + ) + }) + + it('copies the trimmed transcript when no command failed', async () => { + const writes = recordClipboard() + const panel = await mountTranscript(`\n${TRANSCRIPT}\n`, [ + passingNavigation + ]) + await clickCopy(panel) + + expect(writes).toEqual([TRANSCRIPT]) + }) + + it('appends a failures section built from the commands that errored', async () => { + const writes = recordClipboard() + const panel = await mountTranscript(TRANSCRIPT, [ + passingNavigation, + failingAssertion + ]) + await clickCopy(panel) + + expect(writes).toEqual([ + `${TRANSCRIPT}\n\n## Failures\n- ${FLASH_ASSERTION}: ${FLASH_ERROR}` + ]) + }) + + it('names a failure after its command when it carries no title', async () => { + const writes = recordClipboard() + const panel = await mountTranscript(TRANSCRIPT, [failingClick]) + await clickCopy(panel) + + expect(writes[0]).toContain('- click: element not interactable') + }) + + it('lists one line per failing command', async () => { + const writes = recordClipboard() + const panel = await mountTranscript(TRANSCRIPT, [ + failingClick, + passingNavigation, + failingAssertion + ]) + await clickCopy(panel) + + const failures = writes[0].split('## Failures\n')[1].split('\n') + expect(failures[0]).toBe('- click: element not interactable') + expect(failures[1]).toContain(FLASH_ASSERTION) + }) + + it('confirms the copy on the control itself', async () => { + recordClipboard() + const panel = await mountTranscript(TRANSCRIPT) + await clickCopy(panel) + + expect(shadowAll(panel, COPIED_ICON)).toHaveLength(1) + expect(shadowAll(panel, COPY_ICON)).toHaveLength(0) + expect(shadow(panel, COPY_BUTTON)?.classList.contains('copied')).toBe( + true + ) + }) + + it('shows no confirmation when the clipboard rejects the write', async () => { + recordClipboard({ failing: true }) + const panel = await mountTranscript(TRANSCRIPT) + await clickCopy(panel) + + expect(shadowAll(panel, COPIED_ICON)).toHaveLength(0) + expect(shadowAll(panel, COPY_ICON)).toHaveLength(1) + expect(shadow(panel, COPY_BUTTON)?.classList.contains('copied')).toBe( + false + ) + }) + }) +}) diff --git a/packages/app/test-ui/workbench/player/fixtures.ts b/packages/app/test-ui/workbench/player/fixtures.ts new file mode 100644 index 00000000..51982225 --- /dev/null +++ b/packages/app/test-ui/workbench/player/fixtures.ts @@ -0,0 +1,745 @@ +// Trace-player scenarios shared by the snapshot and trace-timeline specs, built +// on `support/builders.ts`. The captured pages mirror what `packages/script` +// serializes: every element carries the `data-wdio-ref` attribute the replay +// matches mutations against, and form state arrives as a synthetic +// `value` / `checked` attribute mutation the way the collector emits it +// (`String(el.value)` / `String(el.checked)`). +// +// The two producers each shape gets its shape from, so a change on either side +// shows up here: +// `parseDocument` (collector.captureCurrentDom) โ†’ the full-DOM anchor +// `parseFragment` (serializeMutation) โ†’ every added node +// both via `parseNode`, which builds nodes with preact's `h()`. + +import { TraceType } from '@wdio/devtools-shared' +import type { + CommandLog, + Metadata, + MetadataBySession, + TracePlayerFrame +} from '@wdio/devtools-shared' + +import type { SimplifiedVNode } from '../../../../script/types' +import { commandLog, documentLoaded, mutation } from '../../support/builders.js' + +/** Wall-clock origin of the fixture run โ€” every offset below reads as ms into it. */ +const RUN_START = 1_700_000_000_000 + +export const LOGIN_URL = 'https://the-internet.herokuapp.com/login' +export const SECURE_URL = 'https://the-internet.herokuapp.com/secure' +export const METADATA_URL = 'https://the-internet.herokuapp.com/from-metadata' + +/** + * Value the login page was captured with. Preact applies a non-empty `value` as + * a PROPERTY, which raises the input's dirty-value flag โ€” from then on the + * `value` attribute alone no longer moves what the field displays. So a replay + * that set the attribute without mirroring the property would keep showing this + * string after the test typed over it. + */ +export const STALE_USERNAME = 'olduser' +export const TYPED_USERNAME = 'tomsmith' +export const FLASH_TEXT = 'You logged into a secure area!' + +/** Label of the submit button. The a11y tree captures interactive elements as + * WDIO text locators (`button*=Login`), so the reverse highlight has to resolve + * one โ€” which needs an element whose TEXT identifies it. */ +export const LOGIN_LABEL = 'Login' +/** Text `#flash` was captured with โ€” the value every text change replaces, so a + * change that never landed is distinguishable from one that did. */ +export const FLASH_PENDING = 'Signing you inโ€ฆ' + +/** `#notice` holds text, an element, then text again: the mixed-content parent a + * characterData mutation has to address a single child of without disturbing + * the other two. */ +export const NOTICE_LEAD = 'Signed in as ' +export const NOTICE_WHO = 'guest' +export const NOTICE_TAIL = ' โ€” session active' +export const NOTICE_TAIL_UPDATED = ' โ€” session expires in 5 minutes' + +/** Session id of the recording the screencast view plays. */ +export const VIDEO_SESSION_ID = 'session-secure' + +/** Distinct 1x1 PNGs, so a spec can tell which frame is on screen. */ +export const LOGIN_SHOT = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR42mO4o6EBAAMQAS0Qc/Z2AAAAAElFTkSuQmCC' +export const SECURE_SHOT = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR42mPQqLgDAAJIAX2kVPgTAAAAAElFTkSuQmCC' +export const FRAME_SHOT = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR42mP48OEDAAWkAtFkkTHCAAAAAElFTkSuQmCC' + +/** Refs the mutation stream targets. `assignRef` numbers them and stamps one on + * EVERY element of the captured tree (`<html>` and `<head>` included); the + * names here cover only the elements the specs address, and read better in an + * assertion than a serial number. */ +export const REF = { + body: 'body-ref', + form: 'form-ref', + username: 'username-ref', + password: 'password-ref', + remember: 'remember-ref', + planGuest: 'plan-guest-ref', + planMember: 'plan-member-ref', + submit: 'submit-ref', + error: 'error-ref', + secureBody: 'secure-body-ref', + flash: 'flash-ref', + notice: 'notice-ref', + who: 'who-ref', + logout: 'logout-ref' +} as const + +/** A serialized element as the collector emits it โ€” attributes and the child + * list share `props`, and a wrapper fragment carries no `type` at all. */ +interface CapturedNode { + type?: string + props: Record<string, string | CapturedChild | CapturedChild[]> +} + +/** A child of a captured element. A text child is a BARE STRING: `parseNode` + * returns a `#text` node's value, and `parseFragment` an added Text node's + * data, without wrapping either. */ +type CapturedChild = CapturedNode | string + +/** + * One serialized element, shaped as `parseNode` leaves it: attributes spread + * onto `props`, children under `props.children` โ€” a single child BARE, several + * as an array. That split is not cosmetic: `parseNode` builds each node with + * preact's `h()`, and `h(type, props, one)` stores that one child as-is, so a + * real capture of `body > form` hands the replay an object where a capture of + * `body > [div, a]` hands it an array. Always emitting an array would leave the + * single-child path โ€” the common one โ€” untested. + */ +function vnode( + type: string, + props: Record<string, string>, + ...children: CapturedChild[] +): CapturedNode { + if (children.length === 0) { + return { type, props } + } + return { + type, + props: { + ...props, + children: children.length === 1 ? children[0] : children + } + } +} + +/** + * An added node as `serializeMutation` puts it on the wire. `parseFragment` + * serializes a documentFragment, whose nodeName yields no tagName, so every + * added node arrives inside a TYPELESS node holding it as its only child โ€” + * the wrapper `vnode-transform`'s ToDo branch unwraps on replay. + */ +function capturedFragment(node: CapturedNode): CapturedNode { + return { props: { children: node } } +} + +/** + * A captured page as `parseDocument` emits it: `html > [head, body]`, since + * parse5 always materialises both. `head` takes at least two nodes here because + * the replay prepends its `<base>` by spreading `head.props.children` โ€” a real + * page with a single `<title>` therefore hands it a bare object, the rebuild + * throws, and the iframe stays blank (see `vnode-transform.test.ts`). + */ +function capturedDocument( + head: readonly [CapturedNode, CapturedNode, ...CapturedNode[]], + body: CapturedNode +): CapturedNode { + return vnode('html', { lang: 'en' }, vnode('head', {}, ...head), body) +} + +/** Hands a captured tree to the mutation builders. `SimplifiedVNode` types its + * props as `Record<string, string>`, which cannot also hold the child list the + * real payload carries โ€” `packages/script` casts for the same reason โ€” so the + * tree is built with an honest shape and narrowed here. */ +const payload = (node: CapturedNode) => node as unknown as SimplifiedVNode + +const loginPage = payload( + capturedDocument( + [ + vnode('meta', { charset: 'utf-8' }), + // Never runs on replay โ€” the player strips the captured page's scripts. + vnode('script', { src: '/login.js' }) + ], + vnode( + 'body', + { 'data-wdio-ref': REF.body }, + vnode( + 'form', + { id: 'login', 'data-wdio-ref': REF.form }, + vnode('input', { + id: 'username', + type: 'text', + name: 'username', + value: STALE_USERNAME, + 'data-wdio-ref': REF.username + }), + vnode('input', { + id: 'password', + type: 'password', + name: 'password', + 'data-wdio-ref': REF.password + }), + vnode('input', { + id: 'remember', + type: 'checkbox', + 'data-wdio-ref': REF.remember + }), + vnode('input', { + id: 'plan-guest', + type: 'radio', + name: 'plan', + checked: 'checked', + 'data-wdio-ref': REF.planGuest + }), + vnode('input', { + id: 'plan-member', + type: 'radio', + name: 'plan', + 'data-wdio-ref': REF.planMember + }), + vnode( + 'button', + { + id: 'submit', + type: 'submit', + 'data-wdio-ref': REF.submit + }, + LOGIN_LABEL + ) + ) + ) + ) +) + +const securePage = payload( + capturedDocument( + [ + vnode('meta', { charset: 'utf-8' }), + vnode('meta', { name: 'viewport', content: 'width=device-width' }) + ], + vnode( + 'body', + { 'data-wdio-ref': REF.secureBody }, + vnode( + 'div', + { + id: 'flash', + class: 'success', + 'data-wdio-ref': REF.flash + }, + FLASH_PENDING + ), + vnode( + 'p', + { id: 'notice', 'data-wdio-ref': REF.notice }, + NOTICE_LEAD, + vnode('strong', { id: 'who', 'data-wdio-ref': REF.who }, NOTICE_WHO), + NOTICE_TAIL + ), + vnode('a', { id: 'logout', href: '/logout', 'data-wdio-ref': REF.logout }) + ) + ) +) + +/** The two streams one player mount consumes. */ +export interface TraceScenario { + commands: CommandLog[] + mutations: TraceMutation[] +} + +const openLogin = commandLog({ + command: 'url', + args: [LOGIN_URL], + startTime: RUN_START, + timestamp: RUN_START + 400 +}) + +const findUsername = commandLog({ + command: '$', + args: ['#username'], + startTime: RUN_START + 600, + timestamp: RUN_START + 640 +}) + +const typeUsername = commandLog({ + command: 'setValue', + args: ['#username', TYPED_USERNAME], + callSource: 'login.e2e.ts:14:5', + startTime: RUN_START + 700, + timestamp: RUN_START + 780 +}) + +const submit = commandLog({ + command: 'click', + args: ['#submit'], + point: { x: 320, y: 480 }, + startTime: RUN_START + 1200, + timestamp: RUN_START + 1260 +}) + +const readFlash = commandLog({ + command: 'getText', + args: ['#flash'], + title: 'expect(#flash).toHaveText', + startTime: RUN_START + 1500, + timestamp: RUN_START + 1520 +}) + +const loginDocument = documentLoaded(LOGIN_URL, { + addedNodes: [loginPage], + timestamp: RUN_START + 450 +}) + +const usernameCleared = mutation({ + target: REF.username, + attributeName: 'value', + attributeValue: '', + timestamp: RUN_START + 740 +}) + +const usernameTyped = mutation({ + target: REF.username, + attributeName: 'value', + attributeValue: TYPED_USERNAME, + timestamp: RUN_START + 760 +}) + +const rememberChecked = mutation({ + target: REF.remember, + attributeName: 'checked', + attributeValue: 'true', + timestamp: RUN_START + 800 +}) + +const rememberUnchecked = mutation({ + target: REF.remember, + attributeName: 'checked', + attributeValue: 'false', + timestamp: RUN_START + 830 +}) + +const planSelected = mutation({ + target: REF.planMember, + attributeName: 'checked', + attributeValue: 'true', + timestamp: RUN_START + 860 +}) + +const securePageDocument = documentLoaded(SECURE_URL, { + addedNodes: [securePage], + timestamp: RUN_START + 1400 +}) + +/** + * A characterData mutation as `packages/script` records one. The mutated node is + * a Text node, which carries no ref of its own, so the collector addresses it + * as its PARENT element's ref plus its index among that parent's `childNodes` โ€” + * `newTextContent` is that one node's new data, never the parent's whole text. + */ +const flashText = mutation({ + type: 'characterData', + target: REF.flash, + childIndex: 0, + newTextContent: FLASH_TEXT, + timestamp: RUN_START + 1600 +}) + +/** `#notice`'s trailing text โ€” child index 2, AFTER its `<strong>` element child + * and after its own leading text. Both of those staying untouched is what + * proves the patch reached one node rather than the parent's text as a whole. */ +const noticeTailChanged = mutation({ + type: 'characterData', + target: REF.notice, + childIndex: 2, + newTextContent: NOTICE_TAIL_UPDATED, + timestamp: RUN_START + 1560 +}) + +/** + * An index the replayed parent does not have. Real captures produce these: the + * index counts the captured parent's childNodes, and the player strips the page's + * `<script>` children, so anything after one shifts down. `#flash` has a single + * text child, which is what the replay falls back to. + */ +const flashOutOfRange = mutation({ + type: 'characterData', + target: REF.flash, + childIndex: 9, + newTextContent: FLASH_TEXT, + timestamp: RUN_START + 1560 +}) + +/** + * A characterData mutation with no `childIndex` โ€” every trace recorded before the + * collector addressed text nodes, and the shape the replay must refuse to apply: + * with only a parent ref to go on it would have to write the parent's + * `textContent`, deleting its element children. + */ +const noticeUnaddressed = mutation({ + type: 'characterData', + target: REF.notice, + childIndex: undefined, + newTextContent: NOTICE_TAIL_UPDATED, + timestamp: RUN_START + 1580 +}) + +/** `getRef()` on a removed Text node returns null โ€” the only signal the wire + * carries that a childList mutation dropped a non-element child. + * `TraceMutation.removedNodes` is typed `string[]`, so the value the reader + * really hands the player is cast once here. */ +const REMOVED_TEXT_NODE = null as unknown as string + +/** + * `el.textContent = โ€ฆ` as a capture records it: one childList record adding the + * new text (a bare string โ€” `parseFragment` returns an added Text node's data) + * and removing the old Text node, which has no ref to name it by. + */ +const flashTextReplaced = mutation({ + type: 'childList', + target: REF.flash, + addedNodes: [FLASH_TEXT], + removedNodes: [REMOVED_TEXT_NODE], + timestamp: RUN_START + 1600 +}) + +const flashDismissed = mutation({ + target: REF.flash, + attributeName: 'class', + attributeValue: 'success dismissed', + timestamp: RUN_START + 1650 +}) + +export interface LoginTrace extends TraceScenario { + /** Navigation, spanning 0โ€“400ms โ€” the first action on the timeline. */ + openLogin: CommandLog + /** Element lookup, and the bound that ends `openLogin`'s DOM window. */ + findUsername: CommandLog + /** Fills `#username`; its result state is the whole login-form field batch. */ + typeUsername: CommandLog + /** Navigating click: the DOM it produced is captured 140ms AFTER it ended. */ + submit: CommandLog + /** Last command, so its DOM window is unbounded, and the only one carrying a + * display title. */ + readFlash: CommandLog + /** Full-DOM anchor of the login page. */ + loginDocument: TraceMutation + /** Prefilled email wiped before typing (`clearValue` half of `setValue`). */ + usernameCleared: TraceMutation + /** The typed email โ€” the field state a replay must show. */ + usernameTyped: TraceMutation + rememberChecked: TraceMutation + /** Same checkbox unticked again, so the two states are separable. */ + rememberUnchecked: TraceMutation + /** Radio move: picks `#plan-member` away from the captured `#plan-guest`. */ + planSelected: TraceMutation + /** Full-DOM anchor of the page the click navigated to. */ + securePageDocument: TraceMutation + /** Flash text arriving on the secure page as a characterData record. */ + flashText: TraceMutation +} + +export const loginTrace: LoginTrace = { + commands: [openLogin, findUsername, typeUsername, submit, readFlash], + mutations: [ + loginDocument, + usernameCleared, + usernameTyped, + rememberChecked, + rememberUnchecked, + planSelected, + securePageDocument, + flashText + ], + openLogin, + findUsername, + typeUsername, + submit, + readFlash, + loginDocument, + usernameCleared, + usernameTyped, + rememberChecked, + rememberUnchecked, + planSelected, + securePageDocument, + flashText +} + +/** A text-change scenario. Only the command needs a handle โ€” every assertion is + * about the replayed DOM, and `flashDismissed` closing each stream gives the + * spec one signal that is uniquely true once the whole window has replayed + * (without it a text change that never landed and an unfinished replay look the + * same). */ +export interface TextTrace extends TraceScenario { + /** Last command, so its DOM window runs to the end of the mutation stream. */ + readFlash: CommandLog +} + +/** Text changes as `packages/script` records them: a characterData patch of one + * text child of `#notice`, and `#flash`'s text replaced wholesale the way a + * `textContent` write arrives. The two targets are different elements, so + * neither case can mask the other. */ +export const textNodeTrace: TextTrace = { + commands: [readFlash], + mutations: [ + securePageDocument, + noticeTailChanged, + flashTextReplaced, + flashDismissed + ], + readFlash +} + +/** Text mutations the replay cannot resolve exactly: `#flash`'s is addressed past + * the end of the replayed parent, `#notice`'s carries no index at all. */ +export const looseTextTrace: TextTrace = { + commands: [readFlash], + mutations: [ + securePageDocument, + flashOutOfRange, + noticeUnaddressed, + flashDismissed + ], + readFlash +} + +const launchSession = commandLog({ + command: 'url', + args: [LOGIN_URL], + startTime: RUN_START, + timestamp: RUN_START + 100 +}) + +const waitForForm = commandLog({ + command: 'waitForExist', + args: ['#login'], + startTime: RUN_START + 150, + timestamp: RUN_START + 320 +}) + +export interface PreCaptureTrace extends TraceScenario { + /** Ran and ended before the first DOM was captured, and `waitForForm` starts + * before it too โ€” so no mutation falls inside its window. */ + launchSession: CommandLog + /** Last command, so it resolves to the final captured DOM. */ + waitForForm: CommandLog +} + +/** A slice whose first commands precede its initial full-DOM capture. */ +export const preCaptureTrace: PreCaptureTrace = { + commands: [launchSession, waitForForm], + mutations: [loginDocument, securePageDocument], + launchSession, + waitForForm +} + +const errorInserted = mutation({ + type: 'childList', + target: REF.form, + addedNodes: [ + payload( + capturedFragment(vnode('p', { id: 'error', 'data-wdio-ref': REF.error })) + ) + ], + nextSibling: REF.submit, + timestamp: RUN_START + 900 +}) + +const rememberRemoved = mutation({ + type: 'childList', + target: REF.form, + addedNodes: [], + removedNodes: [REF.remember], + timestamp: RUN_START + 950 +}) + +const submitInvalid = commandLog({ + command: 'click', + args: ['#submit'], + startTime: RUN_START + 800, + timestamp: RUN_START + 860 +}) + +const readError = commandLog({ + command: 'getText', + args: ['#error'], + startTime: RUN_START + 1000, + timestamp: RUN_START + 1020 +}) + +export interface ValidationTrace extends TraceScenario { + /** Its DOM window covers both childList mutations below. */ + submitInvalid: CommandLog + readError: CommandLog + /** Adds `#error` before `#submit` rather than at the end of the form. */ + errorInserted: TraceMutation + /** Drops the remember-me checkbox from the form. */ + rememberRemoved: TraceMutation +} + +/** A failed submit: the page grows an error node and drops another. */ +export const validationTrace: ValidationTrace = { + commands: [submitInvalid, readError], + mutations: [loginDocument, errorInserted, rememberRemoved], + submitInvalid, + readError, + errorInserted, + rememberRemoved +} + +const orphanedValue = mutation({ + target: 'detached-ref', + attributeName: 'value', + attributeValue: 'never applied', + timestamp: RUN_START + 700 +}) + +export interface OrphanTrace extends TraceScenario { + /** Targets a ref that is not in the captured page. */ + orphanedValue: TraceMutation + /** Replayed after the orphan, so it proves the replay carried on. */ + usernameTyped: TraceMutation +} + +export const orphanTrace: OrphanTrace = { + commands: [typeUsername], + mutations: [loginDocument, orphanedValue, usernameTyped], + orphanedValue, + usernameTyped +} + +const navigate = commandLog({ + command: 'url', + args: [LOGIN_URL], + screenshot: LOGIN_SHOT, + startTime: RUN_START, + timestamp: RUN_START + 400 +}) + +const clickSubmit = commandLog({ + command: 'click', + args: ['#submit'], + screenshot: SECURE_SHOT, + startTime: RUN_START + 1200, + timestamp: RUN_START + 1260 +}) + +const assertFlash = commandLog({ + command: 'isDisplayed', + args: ['#flash'], + startTime: RUN_START + 1500, + timestamp: RUN_START + 1520 +}) + +export interface DomlessTrace extends TraceScenario { + navigate: CommandLog + clickSubmit: CommandLog + /** Assertions capture no frame of their own. */ + assertFlash: CommandLog +} + +/** A foreign / DOM-less trace: screenshots only, no mutation stream. */ +export const domlessTrace: DomlessTrace = { + commands: [navigate, clickSubmit, assertFlash], + mutations: [], + navigate, + clickSubmit, + assertFlash +} + +/** + * A document anchor is a childList of one added node with no target; this one + * carries no url, which is what makes the player fall back for the address bar. + * + * SYNTHETIC in that respect: `captureCurrentDom` always stamps + * `window.location.href` on the anchor it emits, so a recorded anchor has a url. + * The fallback is reached from the other direction instead โ€” a slice whose first + * mutation is not an anchor at all leaves `#renderBrowserState` on `mutations[0]` + * with no url. Driving it from a url-less anchor is the only way to exercise the + * fallback and still have a document to replay. + */ +const urllessDocument = mutation({ + type: 'childList', + target: undefined, + addedNodes: [loginPage], + timestamp: RUN_START + 450 +}) + +export const urllessTrace: TraceScenario = { + commands: [], + mutations: [urllessDocument] +} + +const viewport = { + width: 1280, + height: 800, + offsetLeft: 0, + offsetTop: 0, + scale: 1 +} + +/** Default metadata: viewport only, so no assertion can pass through its url. */ +export const viewportMetadata: Metadata = { + type: TraceType.Testrunner, + viewport +} + +/** Metadata that does carry a url, for the address-bar fallback. */ +export const urlMetadata: Metadata = { + type: TraceType.Testrunner, + url: METADATA_URL, + viewport +} + +/** Per-session metadata for the recording the screencast view plays. */ +export const recordedSessionMetadata: MetadataBySession = { + [VIDEO_SESSION_ID]: { + type: TraceType.Testrunner, + url: SECURE_URL, + viewport + } +} + +const firstFrame: TracePlayerFrame = { + timestamp: RUN_START, + screenshot: FRAME_SHOT +} + +const typingFrame: TracePlayerFrame = { + timestamp: RUN_START + 750, + screenshot: LOGIN_SHOT +} + +const lastFrame: TracePlayerFrame = { + timestamp: RUN_START + 2000, + screenshot: SECURE_SHOT +} + +export interface Filmstrip { + frames: TracePlayerFrame[] + /** Recording start โ€” active while the playhead sits at zero, and the origin + * every timeline position is measured from. */ + first: TracePlayerFrame + /** Falls inside `typeUsername`'s span, so clicking it selects that command. */ + typing: TracePlayerFrame + /** End of the window: with it the strip spans exactly 2000ms. */ + last: TracePlayerFrame +} + +/** Frames at 0 / 500 / 750 / 1250 / 2000ms into `loginTrace`, so every position + * the strip renders is an exact percentage. */ +export const filmstrip: Filmstrip = { + frames: [ + firstFrame, + { timestamp: RUN_START + 500, screenshot: SECURE_SHOT }, + typingFrame, + { timestamp: RUN_START + 1250, screenshot: FRAME_SHOT }, + lastFrame + ], + first: firstFrame, + typing: typingFrame, + last: lastFrame +} diff --git a/packages/app/test-ui/workbench/player/snapshot.test.ts b/packages/app/test-ui/workbench/player/snapshot.test.ts new file mode 100644 index 00000000..af57963f --- /dev/null +++ b/packages/app/test-ui/workbench/player/snapshot.test.ts @@ -0,0 +1,843 @@ +import type { + CommandLog, + Metadata, + MetadataBySession +} from '@wdio/devtools-shared' + +import { + commandContext, + metadataBySessionContext, + metadataContext, + mutationContext +} from '@/controller/context.js' +import { mutationForCommand } from '@components/browser/mutation-at-command.js' +import '@components/browser/snapshot.js' + +import { mountWithContext, settle } from '../../support/mount.js' +import { shadow, shadowAll, text, texts } from '../../support/queries.js' +import { + domlessTrace, + FLASH_TEXT, + LOGIN_LABEL, + LOGIN_SHOT, + LOGIN_URL, + loginTrace, + looseTextTrace, + METADATA_URL, + NOTICE_LEAD, + NOTICE_TAIL, + NOTICE_TAIL_UPDATED, + NOTICE_WHO, + orphanTrace, + preCaptureTrace, + recordedSessionMetadata, + REF, + SECURE_SHOT, + SECURE_URL, + STALE_USERNAME, + textNodeTrace, + type TextTrace, + type TraceScenario, + TYPED_USERNAME, + urllessTrace, + urlMetadata, + validationTrace, + VIDEO_SESSION_ID, + viewportMetadata +} from './fixtures.js' + +const TAG = 'wdio-devtools-browser' +const ADDRESS_BAR = 'header .truncate' +const SCREENSHOT = '.screenshot-overlay img' +const PLACEHOLDER = 'wdio-devtools-placeholder' +const SCREENCAST = 'wdio-devtools-screencast-player' +const VIEW_BUTTON = '.view-toggle button' +/** One option per recording the player holds โ€” rendered from the second on. */ +const RECORDING_OPTION = '.video-select option' +const OVERLAY_TOGGLE = 'button[title^="Element overlay"]' +/** Boxes `element-overlay` draws inside the replayed page. */ +const OVERLAY_BOX = '.__wdio-el-overlay__' +/** Single outline the player draws for a hovered mutation or a11y row. */ +const HIGHLIGHT_BOX = '.__mutation-highlight__' + +type Browser = HTMLElementTagNameMap[typeof TAG] + +interface Inputs { + commands?: CommandLog[] + mutations?: TraceMutation[] + metadata?: Metadata + metadataBySession?: MetadataBySession +} + +function mountBrowser(inputs: Inputs = {}): Promise<Browser> { + return mountWithContext<Browser>(TAG, [ + { context: commandContext, value: inputs.commands ?? [] }, + { context: mutationContext, value: inputs.mutations ?? [] }, + { context: metadataContext, value: inputs.metadata ?? viewportMetadata }, + { context: metadataBySessionContext, value: inputs.metadataBySession ?? {} } + ]) +} + +const replayDoc = (el: Browser): Document | null => + el.iframe?.contentDocument ?? null + +/** rAF-paced poll. A replay resolves across several microtasks plus a frame, so + * awaiting one render cannot observe its result. */ +async function waitFor<T>( + probe: () => T | null | undefined, + what: string +): Promise<T> { + const deadline = Date.now() + 4000 + while (Date.now() < deadline) { + const value = probe() + if (value) { + return value + } + await new Promise<number>((resolve) => requestAnimationFrame(resolve)) + } + throw new Error(`Timed out waiting for ${what}`) +} + +/** The replayed document, once the mount's own first rebuild has landed. */ +function replayedPage(el: Browser): Promise<Document> { + return waitFor(() => { + const doc = replayDoc(el) + return doc?.body?.firstElementChild ? doc : null + }, 'the initial DOM replay') +} + +/** Runs `act`, then resolves with the rebuilt document. Every replay re-renders + * from the document anchor it resolves to, so a fresh `documentElement` is the + * signal that the selection's anchor has been applied โ€” the mutations that + * follow it in the window land later, which is what `waitUntil` is for. */ +async function replayAfter(el: Browser, act: () => void): Promise<Document> { + const previous = replayDoc(el)?.documentElement + act() + return waitFor(() => { + const doc = replayDoc(el) + return doc && doc.documentElement !== previous ? doc : null + }, 'the replayed document to be rebuilt') +} + +/** + * rAF-paced poll for a condition โ€” state a truthy probe cannot express + * (`value === ''`). The first check is deferred by one frame deliberately: a + * replay walks the mutations after its anchor across microtasks, so a + * synchronous read lands on a half-applied page. One frame is enough because + * that walk never yields to the task queue. + */ +async function waitUntil(cond: () => boolean, what: string): Promise<void> { + const deadline = Date.now() + 4000 + while (Date.now() < deadline) { + await new Promise<number>((resolve) => requestAnimationFrame(resolve)) + if (cond()) { + return + } + } + throw new Error(`Timed out waiting for ${what}`) +} + +const selectCommand = (command: CommandLog) => + window.dispatchEvent(new CustomEvent('show-command', { detail: { command } })) + +const selectMutation = (entry: TraceMutation) => + window.dispatchEvent( + new CustomEvent('app-mutation-select', { detail: entry }) + ) + +const hoverMutation = (entry: TraceMutation | null) => + window.dispatchEvent( + new CustomEvent('app-mutation-highlight', { detail: entry }) + ) + +const revealFromA11y = (selector: string) => + window.dispatchEvent( + new CustomEvent('a11y-highlight', { detail: { selector } }) + ) + +const recordingArrives = () => + window.dispatchEvent( + new CustomEvent('screencast-ready', { + detail: { + sessionId: VIDEO_SESSION_ID, + startTime: loginTrace.openLogin.startTime, + duration: 2000 + } + }) + ) + +function input(doc: Document, selector: string): HTMLInputElement { + const el = doc.querySelector<HTMLInputElement>(selector) + if (!el) { + throw new Error(`No ${selector} in the replayed page`) + } + return el +} + +const boxesIn = (el: Browser, selector: string) => + Array.from(replayDoc(el)?.querySelectorAll<HTMLElement>(selector) ?? []) + +describe('wdio-devtools-browser', () => { + describe('document replay', () => { + it('rebuilds the iframe document from the captured document anchor', async () => { + const el = await mountBrowser(loginTrace) + const doc = await replayedPage(el) + + expect(doc.querySelector('form#login')).toBeTruthy() + expect(doc.querySelectorAll('input')).toHaveLength(5) + expect(doc.querySelector('button#submit')).toBeTruthy() + expect(doc.body.getAttribute('data-wdio-ref')).toBe(REF.body) + }) + + it('never replays the captured page scripts', async () => { + const el = await mountBrowser(loginTrace) + const doc = await replayedPage(el) + + expect(doc.querySelectorAll('script')).toHaveLength(0) + }) + + it('resolves the replayed page against the captured page URL', async () => { + const el = await mountBrowser(loginTrace) + const doc = await replayedPage(el) + + expect(doc.querySelector('base')?.getAttribute('href')).toBe(LOGIN_URL) + }) + + it('shows the captured page URL in the address bar', async () => { + const el = await mountBrowser(loginTrace) + await replayedPage(el) + await settle(el) + + expect(text(shadow(el, ADDRESS_BAR))).toBe(LOGIN_URL) + }) + + it('shows the DOM of the first capture before any command is selected', async () => { + const el = await mountBrowser(loginTrace) + const doc = await replayedPage(el) + + // The later anchor is the secure page, so this proves it is not replaying + // the newest capture it has. + expect(doc.querySelector('#flash')).toBeNull() + expect(input(doc, '#username').value).toBe(STALE_USERNAME) + }) + }) + + describe('form state', () => { + it('replays the text the test typed into the input', async () => { + const el = await mountBrowser(loginTrace) + await replayedPage(el) + + const doc = await replayAfter(el, () => + selectCommand(loginTrace.typeUsername) + ) + await waitUntil( + () => input(doc, '#username').value === TYPED_USERNAME, + 'the typed value to replay' + ) + + const username = input(doc, '#username') + expect(username.value).toBe(TYPED_USERNAME) + expect(username.getAttribute('value')).toBe(TYPED_USERNAME) + }) + + it('replays a field cleared back to empty', async () => { + const el = await mountBrowser(loginTrace) + await replayedPage(el) + + const doc = await replayAfter(el, () => + selectMutation(loginTrace.usernameCleared) + ) + // Only the clear can produce this: the page was captured prefilled. + await waitUntil( + () => input(doc, '#username').value === '', + 'the cleared field to replay' + ) + + expect(input(doc, '#username').value).toBe('') + }) + + it('ticks the checkbox the test checked', async () => { + const el = await mountBrowser(loginTrace) + await replayedPage(el) + + const doc = await replayAfter(el, () => + selectMutation(loginTrace.rememberChecked) + ) + await waitUntil( + () => input(doc, '#remember').checked, + 'the checkbox to be ticked by the replay' + ) + + expect(input(doc, '#remember').checked).toBe(true) + }) + + it('unticks a checkbox whose captured state is false', async () => { + const el = await mountBrowser(loginTrace) + await replayedPage(el) + + const doc = await replayAfter(el, () => + selectMutation(loginTrace.rememberUnchecked) + ) + // An unticked box looks like the captured page, so wait on the attribute + // only this mutation writes โ€” otherwise the assertion could read the + // anchor state and pass without the untick ever having been applied. + await waitUntil( + () => input(doc, '#remember').getAttribute('checked') === 'false', + 'the untick to replay' + ) + + const remember = input(doc, '#remember') + expect(remember.checked).toBe(false) + // The attribute is present and 'false': only the property mirror keeps + // the replayed box unticked. + expect(remember.getAttribute('checked')).toBe('false') + }) + + it('moves the radio selection to the option the test picked', async () => { + const el = await mountBrowser(loginTrace) + await replayedPage(el) + + const doc = await replayAfter(el, () => + selectMutation(loginTrace.planSelected) + ) + await waitUntil( + () => input(doc, '#plan-member').checked, + 'the radio move to replay' + ) + + expect(input(doc, '#plan-member').checked).toBe(true) + expect(input(doc, '#plan-guest').checked).toBe(false) + }) + + it('carries on replaying when a mutation targets an element that is not on the page', async () => { + const el = await mountBrowser(orphanTrace) + await replayedPage(el) + + const doc = await replayAfter(el, () => + selectMutation(orphanTrace.usernameTyped) + ) + await waitUntil( + () => input(doc, '#username').value === TYPED_USERNAME, + 'the mutation after the orphan to replay' + ) + + expect(input(doc, '#username').value).toBe(TYPED_USERNAME) + }) + }) + + describe('command selection', () => { + it('shows the page a navigating click produced, not the one it left', async () => { + const el = await mountBrowser(loginTrace) + await replayedPage(el) + + const doc = await replayAfter(el, () => selectCommand(loginTrace.submit)) + + expect(doc.querySelector('#flash')).toBeTruthy() + expect(doc.querySelector('form#login')).toBeNull() + }) + + it("follows the selected command's page in the address bar", async () => { + const el = await mountBrowser(loginTrace) + await replayedPage(el) + await replayAfter(el, () => selectCommand(loginTrace.submit)) + // The re-render that repaints the address bar is requested at the END of + // the replay, so awaiting one render can still read the previous page. + await waitUntil( + () => text(shadow(el, ADDRESS_BAR)) === SECURE_URL, + 'the address bar to follow the selection' + ) + + expect(text(shadow(el, ADDRESS_BAR))).toBe(SECURE_URL) + }) + + it('rebuilds the earlier page when an earlier command is selected again', async () => { + const el = await mountBrowser(loginTrace) + await replayedPage(el) + await replayAfter(el, () => selectCommand(loginTrace.submit)) + + const doc = await replayAfter(el, () => + selectCommand(loginTrace.openLogin) + ) + + expect(doc.querySelector('form#login')).toBeTruthy() + // The field mutations belong to a later command, so the input is back to + // the value the page was captured with. + expect(input(doc, '#username').value).toBe(STALE_USERNAME) + }) + + it('falls back to the first capture for a command that ran before any DOM was captured', async () => { + const el = await mountBrowser(preCaptureTrace) + await replayedPage(el) + await replayAfter(el, () => selectCommand(preCaptureTrace.waitForForm)) + + const doc = await replayAfter(el, () => + selectCommand(preCaptureTrace.launchSession) + ) + + expect(doc.querySelector('form#login')).toBeTruthy() + }) + + it('replays a text change recorded as a character-data mutation', async () => { + const el = await mountBrowser(loginTrace) + await replayedPage(el) + + const doc = await replayAfter(el, () => + selectCommand(loginTrace.readFlash) + ) + // The text arrives after the secure-page anchor, so the anchor landing is + // not enough to read it. + await waitUntil( + () => doc.querySelector('#flash')?.textContent === FLASH_TEXT, + 'the flash text to replay' + ) + + expect(doc.querySelector('#flash')?.textContent).toBe(FLASH_TEXT) + }) + }) + + /** + * A characterData record's target is the mutated Text NODE, which carries no + * `data-wdio-ref` of its own, so the collector addresses it as its parent + * element's ref plus the node's index among that parent's childNodes. These + * cases pin both halves: that the addressed node is the one patched, and that + * a mutation the address does not resolve leaves the parent alone rather than + * writing over its children. + */ + describe('text changes', () => { + /** The class change closing every text stream โ€” uniquely true once the whole + * walk has run, so a text change that never landed cannot read as an + * unfinished replay. */ + const wholeWindowApplied = (doc: Document) => + waitUntil( + () => doc.querySelector('#flash')?.className === 'success dismissed', + 'the whole replay window to be applied' + ) + + const replayTextTrace = async (scenario: TextTrace) => { + const el = await mountBrowser(scenario) + await replayedPage(el) + const doc = await replayAfter(el, () => selectCommand(scenario.readFlash)) + await wholeWindowApplied(doc) + return doc + } + + it('patches only the addressed text node of a mixed-content parent', async () => { + const doc = await replayTextTrace(textNodeTrace) + + const notice = doc.querySelector('#notice')! + // Text, element, text โ€” the child list the capture recorded, still intact. + expect( + Array.from(notice.childNodes).map((node) => node.nodeName) + ).toEqual(['#text', 'STRONG', '#text']) + expect(notice.childNodes[0].textContent).toBe(NOTICE_LEAD) + expect(notice.childNodes[2].textContent).toBe(NOTICE_TAIL_UPDATED) + // The element child and its own text survive: a parent-level `textContent` + // write would have deleted both. + expect(doc.querySelector('#who')?.textContent).toBe(NOTICE_WHO) + }) + + it('replaces the text of a node the page rewrote through its parent', async () => { + const doc = await replayTextTrace(textNodeTrace) + + // Exactly the new text: the removed Text node reaches the replay as a null + // ref, and an entry it fails to drop leaves the old text alongside. + expect(doc.querySelector('#flash')?.textContent).toBe(FLASH_TEXT) + }) + + it('falls back to the only text child when the captured index has shifted', async () => { + const doc = await replayTextTrace(looseTextTrace) + + expect(doc.querySelector('#flash')?.textContent).toBe(FLASH_TEXT) + }) + + it('leaves the parent alone for a text mutation carrying no address', async () => { + const doc = await replayTextTrace(looseTextTrace) + + // Nothing to patch, so nothing changes โ€” the parent keeps its element + // child and both of its text children. + const notice = doc.querySelector('#notice')! + expect(notice.childNodes[0].textContent).toBe(NOTICE_LEAD) + expect(notice.childNodes[2].textContent).toBe(NOTICE_TAIL) + expect(doc.querySelector('#who')?.textContent).toBe(NOTICE_WHO) + }) + }) + + // The fixture documents a DOM window per command in prose; `mutationForCommand` + // is what actually decides them. Asserting the windows keeps those claims + // honest โ€” a fixture timestamp that stopped meaning what it says would + // otherwise silently move which page every replay case above observes. + describe('the DOM window a command resolves to', () => { + const windowFor = (command: CommandLog, scenario: TraceScenario) => + mutationForCommand(command, scenario.commands, scenario.mutations) + + it('ends a command at the last mutation before the next one starts', () => { + expect(windowFor(loginTrace.openLogin, loginTrace)).toBe( + loginTrace.loginDocument + ) + }) + + it('gives the fill command the whole field batch that followed it', () => { + expect(windowFor(loginTrace.typeUsername, loginTrace)).toBe( + loginTrace.planSelected + ) + }) + + it('reaches past a navigating click to the page it produced', () => { + expect(windowFor(loginTrace.submit, loginTrace)).toBe( + loginTrace.securePageDocument + ) + }) + + it('leaves the last command unbounded', () => { + expect(windowFor(loginTrace.readFlash, loginTrace)).toBe( + loginTrace.flashText + ) + }) + + it('falls back to the first capture for a command that predates every mutation', () => { + expect(windowFor(preCaptureTrace.launchSession, preCaptureTrace)).toBe( + preCaptureTrace.mutations[0] + ) + }) + }) + + describe('added and removed nodes', () => { + it('inserts an added node at its captured position', async () => { + const el = await mountBrowser(validationTrace) + await replayedPage(el) + + const doc = await replayAfter(el, () => + selectCommand(validationTrace.submitInvalid) + ) + await waitUntil( + () => doc.querySelector('#error') !== null, + 'the added node to replay' + ) + + const error = doc.querySelector('#error') + expect(error).toBeTruthy() + expect(error?.parentElement?.id).toBe('login') + expect(error?.nextElementSibling?.id).toBe('submit') + }) + + it('removes a node the page dropped', async () => { + const el = await mountBrowser(validationTrace) + await replayedPage(el) + + const doc = await replayAfter(el, () => + selectCommand(validationTrace.submitInvalid) + ) + // The captured page has the checkbox, so its absence is the removal. + await waitUntil( + () => doc.querySelector('#remember') === null, + 'the removed node to disappear from the replay' + ) + + expect(doc.querySelector('#remember')).toBeNull() + expect(doc.querySelectorAll('input')).toHaveLength(4) + }) + }) + + describe('screenshot fallback for a trace without DOM', () => { + it("shows the selected command's own screenshot", async () => { + const el = await mountBrowser(domlessTrace) + selectCommand(domlessTrace.navigate) + await settle(el) + + expect(shadow(el, SCREENSHOT)?.getAttribute('src')).toBe( + `data:image/png;base64,${LOGIN_SHOT}` + ) + }) + + it("falls back to the preceding command's frame for a command that captured none", async () => { + const el = await mountBrowser(domlessTrace) + selectCommand(domlessTrace.assertFlash) + await settle(el) + + expect(shadow(el, SCREENSHOT)?.getAttribute('src')).toBe( + `data:image/png;base64,${SECURE_SHOT}` + ) + }) + + it('shows the latest captured frame before any command is selected', async () => { + const el = await mountBrowser(domlessTrace) + await settle(el) + + expect(shadow(el, SCREENSHOT)?.getAttribute('src')).toBe( + `data:image/png;base64,${SECURE_SHOT}` + ) + }) + + it('resolves the address bar from the last navigation command', async () => { + const el = await mountBrowser(domlessTrace) + selectCommand(domlessTrace.clickSubmit) + await settle(el) + + expect(text(shadow(el, ADDRESS_BAR))).toBe(LOGIN_URL) + }) + + it('renders the placeholder when neither DOM nor screenshot was captured', async () => { + const el = await mountBrowser() + await settle(el) + + expect(shadowAll(el, PLACEHOLDER)).toHaveLength(1) + expect(shadowAll(el, SCREENSHOT)).toHaveLength(0) + expect(el.iframe).toBeFalsy() + }) + + it('renders the placeholder for commands that captured no frames', async () => { + const el = await mountBrowser({ commands: loginTrace.commands }) + selectCommand(loginTrace.submit) + await settle(el) + + expect(shadowAll(el, PLACEHOLDER)).toHaveLength(1) + expect(shadowAll(el, SCREENSHOT)).toHaveLength(0) + }) + }) + + describe('element overlay', () => { + it('boxes only the command locators that resolve on the replayed page', async () => { + const el = await mountBrowser(loginTrace) + await replayedPage(el) + + shadow(el, OVERLAY_TOGGLE)?.click() + + // `#flash` is on the secure page and the url command's argument is no + // locator at all, so neither draws a box. + expect(boxesIn(el, OVERLAY_BOX).map((box) => text(box))).toEqual([ + '#username', + '#submit' + ]) + }) + + it('clears the boxes when the overlay is switched off again', async () => { + const el = await mountBrowser(loginTrace) + await replayedPage(el) + const toggle = shadow(el, OVERLAY_TOGGLE) + + toggle?.click() + toggle?.click() + + expect(boxesIn(el, OVERLAY_BOX)).toHaveLength(0) + }) + + it('asks the A11y tab to reveal the locator of the box clicked', async () => { + const el = await mountBrowser(loginTrace) + await replayedPage(el) + shadow(el, OVERLAY_TOGGLE)?.click() + + const opened = new Promise<string | undefined>((resolve) => { + window.addEventListener( + 'open-dock-tab', + (event) => + resolve((event as CustomEvent<{ label?: string }>).detail?.label), + { once: true } + ) + }) + const revealed = new Promise<unknown>((resolve) => { + window.addEventListener( + 'a11y-reveal', + (event) => resolve((event as CustomEvent<unknown>).detail), + { once: true } + ) + }) + boxesIn(el, OVERLAY_BOX)[0].click() + + expect(await opened).toBe('A11y') + expect(await revealed).toEqual({ + selector: '#username', + label: '', + pin: true + }) + }) + + it('offers no overlay toggle for a trace without DOM', async () => { + const el = await mountBrowser(domlessTrace) + await settle(el) + + expect(shadowAll(el, OVERLAY_TOGGLE)).toHaveLength(0) + }) + }) + + describe('highlighting', () => { + it('outlines the element of the mutation being hovered', async () => { + const el = await mountBrowser(loginTrace) + await replayedPage(el) + + hoverMutation(loginTrace.usernameTyped) + + expect(boxesIn(el, HIGHLIGHT_BOX)).toHaveLength(1) + }) + + it('drops the outline when the hover ends', async () => { + const el = await mountBrowser(loginTrace) + await replayedPage(el) + + hoverMutation(loginTrace.usernameTyped) + hoverMutation(null) + + expect(boxesIn(el, HIGHLIGHT_BOX)).toHaveLength(0) + }) + + it('outlines the element an a11y row points at', async () => { + const el = await mountBrowser(loginTrace) + await replayedPage(el) + + revealFromA11y('#submit') + + expect(boxesIn(el, HIGHLIGHT_BOX)).toHaveLength(1) + }) + + it('outlines the element an a11y text locator points at', async () => { + const el = await mountBrowser(loginTrace) + await replayedPage(el) + + // The form the a11y tree captures its own locators in โ€” querySelector + // cannot parse it, so only the shared resolver finds the element. + revealFromA11y(`button*=${LOGIN_LABEL}`) + + expect(boxesIn(el, HIGHLIGHT_BOX)).toHaveLength(1) + }) + + it('outlines nothing for an a11y locator absent from the replayed page', async () => { + const el = await mountBrowser(loginTrace) + await replayedPage(el) + + // Same locator syntax as above, so this is about the page not carrying + // the text โ€” not about the syntax being unparseable. + revealFromA11y('button*=Log out') + + expect(boxesIn(el, HIGHLIGHT_BOX)).toHaveLength(0) + }) + }) + + describe('screencast view', () => { + const withRecording = () => + mountBrowser({ + commands: loginTrace.commands, + mutations: loginTrace.mutations, + metadataBySession: recordedSessionMetadata + }) + + it("plays an arriving recording and shows that session's page URL", async () => { + const el = await withRecording() + await replayedPage(el) + + recordingArrives() + await settle(el) + + expect(shadow(el, SCREENCAST)?.getAttribute('src')).toBe( + `/api/video/${VIDEO_SESSION_ID}` + ) + expect(text(shadow(el, ADDRESS_BAR))).toBe(SECURE_URL) + expect(texts(el, VIEW_BUTTON)).toEqual(['Snapshot', 'Screencast']) + }) + + it('returns to the DOM replay when Snapshot is selected', async () => { + const el = await withRecording() + await replayedPage(el) + recordingArrives() + await settle(el) + + shadowAll(el, VIEW_BUTTON)[0].click() + await settle(el) + + expect(shadowAll(el, SCREENCAST)).toHaveLength(0) + expect(el.iframe).toBeTruthy() + expect(text(shadow(el, ADDRESS_BAR))).toBe(LOGIN_URL) + }) + + it('offers no view toggle before a recording arrives', async () => { + const el = await mountBrowser(loginTrace) + await settle(el) + + expect(shadowAll(el, VIEW_BUTTON)).toHaveLength(0) + }) + + it('offers one picker option per recording it holds', async () => { + const el = await withRecording() + await replayedPage(el) + + recordingArrives() + recordingArrives() + await settle(el) + + // The signal the re-connect case below reads: a player that took one + // recording twice shows a second option here. + expect(shadowAll(el, RECORDING_OPTION)).toHaveLength(2) + }) + }) + + /** + * Re-connecting is Lit's contract, not an edge case โ€” the workbench swaps the + * player between its player-mode host and the plain pane, and every window + * listener it registers on connect outlives the element unless connect and + * disconnect stay symmetric. + */ + describe('re-connecting', () => { + /** Removes the player from its mount, handing back the host to re-attach to. */ + function detach(el: Browser): HTMLElement { + const host = el.parentElement + if (!host) { + throw new Error('the mounted player has no host to re-attach it to') + } + el.remove() + return host + } + + it('takes an arriving recording once after being re-connected', async () => { + const el = await mountBrowser(loginTrace) + await replayedPage(el) + detach(el).append(el) + await settle(el) + + recordingArrives() + await settle(el) + + // Exactly once: a registration that only runs on the first connect leaves + // the re-connected player deaf, and one that stacks takes the recording + // twice โ€” which shows up as a picker for two. + expect(shadowAll(el, SCREENCAST)).toHaveLength(1) + expect(shadowAll(el, RECORDING_OPTION)).toHaveLength(0) + }) + + it('stops taking recordings while it is detached', async () => { + const el = await mountBrowser(loginTrace) + await replayedPage(el) + const host = detach(el) + + recordingArrives() + + host.append(el) + // Renders whatever it collected while detached, without depending on a + // detached element having rendered on its own. + el.requestUpdate() + await settle(el) + + expect(shadowAll(el, SCREENCAST)).toHaveLength(0) + expect(shadowAll(el, VIEW_BUTTON)).toHaveLength(0) + }) + }) + + describe('address-bar fallbacks', () => { + it('falls back to the metadata URL when the capture carries none', async () => { + const el = await mountBrowser({ + mutations: urllessTrace.mutations, + metadata: urlMetadata + }) + await replayedPage(el) + await settle(el) + + expect(text(shadow(el, ADDRESS_BAR))).toBe(METADATA_URL) + }) + + it("shows 'unknown' when neither the capture nor the metadata has a URL", async () => { + // The default metadata carries a viewport only. + const el = await mountBrowser({ mutations: urllessTrace.mutations }) + await replayedPage(el) + await settle(el) + + expect(text(shadow(el, ADDRESS_BAR))).toBe('unknown') + }) + }) +}) diff --git a/packages/app/test-ui/workbench/player/trace-player-controls.test.ts b/packages/app/test-ui/workbench/player/trace-player-controls.test.ts new file mode 100644 index 00000000..aa0012b4 --- /dev/null +++ b/packages/app/test-ui/workbench/player/trace-player-controls.test.ts @@ -0,0 +1,256 @@ +import { KBD } from '@/controller/keyboard.js' +import '@components/browser/trace-player-controls.js' +import type { TracePlayerControls } from '@components/browser/trace-player-controls.js' +import { + PLAYER_RESTART_EVENT, + PLAYER_SPEED_EVENT, + PLAYER_STATE_EVENT, + SPEEDS, + type PlayerState +} from '@components/browser/trace-timeline-constants.js' +import { formatTimecode } from '@components/browser/trace-timeline-utils.js' + +import { mount, settle } from '../../support/mount.js' +import { shadow, shadowAll, text, texts } from '../../support/queries.js' + +const TAG = 'wdio-devtools-trace-player-controls' +const TIMECODE = 'code' +const CONTROL = 'button[title]' +const SPEED_SELECT = 'select' +const SPEED_OPTION = 'select option' + +/** The clock the timeline strip broadcasts; the bar only mirrors it. */ +function broadcast(state: Partial<PlayerState> = {}): void { + window.dispatchEvent( + new CustomEvent<PlayerState>(PLAYER_STATE_EVENT, { + detail: { currentMs: 0, duration: 0, playing: false, speed: 1, ...state } + }) + ) +} + +async function mountControls( + state?: Partial<PlayerState> +): Promise<TracePlayerControls> { + const el = await mount<TracePlayerControls>(TAG) + if (state) { + broadcast(state) + await settle(el) + } + return el +} + +function control(el: TracePlayerControls, title: string): HTMLButtonElement { + const button = shadow<HTMLButtonElement>(el, `button[title="${title}"]`) + if (!button) { + throw new Error(`no control rendered for "${title}"`) + } + return button +} + +function speedSelect(el: TracePlayerControls): HTMLSelectElement { + const select = shadow<HTMLSelectElement>(el, SPEED_SELECT) + if (!select) { + throw new Error('no speed control rendered') + } + return select +} + +const titles = (el: TracePlayerControls) => + shadowAll(el, CONTROL).map((button) => button.getAttribute('title')) + +/** What the bar asks the timeline to do, off `window`. */ +function capture<T>(type_: string, act: () => void): CustomEvent<T>[] { + const received: CustomEvent<T>[] = [] + const listener = (event: Event) => received.push(event as CustomEvent<T>) + window.addEventListener(type_, listener) + try { + act() + } finally { + window.removeEventListener(type_, listener) + } + return received +} + +describe('wdio-devtools-trace-player-controls', () => { + describe('the clock', () => { + it('starts at zero with the recording length still unknown', async () => { + const el = await mountControls() + + expect(texts(el, TIMECODE)).toEqual(['0:00.00', '0:00.00']) + }) + + it('mirrors the clock the timeline broadcasts', async () => { + const position = 32_270 + const length = 61_000 + const el = await mountControls({ + currentMs: position, + duration: length + }) + + // Derived, so a bar that swapped the two codes or read either straight off + // the state without the shared formatter fails here... + expect(texts(el, TIMECODE)).toEqual([ + formatTimecode(position), + formatTimecode(length) + ]) + // ...and pinned, so the derivation cannot drift with a broken formatter. + expect(texts(el, TIMECODE)).toEqual(['0:32.27', '1:01.00']) + }) + + it('stops mirroring the clock once it leaves the page', async () => { + const el = await mountControls() + el.remove() + + broadcast({ currentMs: 5_000, duration: 9_000 }) + await settle(el) + + expect(el.playerState.currentMs).toBe(0) + expect(texts(el, TIMECODE)).toEqual(['0:00.00', '0:00.00']) + }) + }) + + describe('the controls', () => { + it('offers restart, both steps and playback in playback order', async () => { + const el = await mountControls() + + expect(titles(el)).toEqual([ + 'Restart', + 'Previous action', + 'Play', + 'Next action' + ]) + }) + + it('offers to play while the recording is paused', async () => { + const el = await mountControls({ playing: false }) + + expect(shadowAll(el, 'icon-mdi-play')).toHaveLength(1) + expect(shadowAll(el, 'icon-mdi-pause')).toHaveLength(0) + }) + + it('offers to pause while the recording plays', async () => { + const el = await mountControls({ playing: true }) + + expect(titles(el)).toContain('Pause') + expect(shadowAll(el, 'icon-mdi-pause')).toHaveLength(1) + expect(shadowAll(el, 'icon-mdi-play')).toHaveLength(0) + }) + + it('asks the timeline to start playing', async () => { + const el = await mountControls() + + const received = capture(KBD.togglePlay, () => + control(el, 'Play').click() + ) + + expect(received).toHaveLength(1) + }) + + it('asks the timeline to stop again from the same control', async () => { + const el = await mountControls({ playing: true }) + + const received = capture(KBD.togglePlay, () => + control(el, 'Pause').click() + ) + + expect(received).toHaveLength(1) + }) + + it('steps the timeline back one action', async () => { + const el = await mountControls({ currentMs: 4_000, duration: 9_000 }) + + const received = capture<{ dir: number }>(KBD.step, () => + control(el, 'Previous action').click() + ) + + expect(received.map((event) => event.detail.dir)).toEqual([-1]) + }) + + it('steps the timeline forward one action', async () => { + const el = await mountControls({ currentMs: 4_000, duration: 9_000 }) + + const received = capture<{ dir: number }>(KBD.step, () => + control(el, 'Next action').click() + ) + + expect(received.map((event) => event.detail.dir)).toEqual([1]) + }) + + it('asks the timeline to restart the recording', async () => { + const el = await mountControls({ currentMs: 9_000, duration: 9_000 }) + + const received = capture(PLAYER_RESTART_EVENT, () => + control(el, 'Restart').click() + ) + + expect(received).toHaveLength(1) + }) + + // The bar has no bounds state: stepping past either end is the timeline's + // call, so nothing here is ever disabled. + it('keeps every control live at the start of the recording', async () => { + const el = await mountControls({ currentMs: 0, duration: 9_000 }) + + expect( + shadowAll<HTMLButtonElement>(el, CONTROL).map( + (button) => button.disabled + ) + ).toEqual([false, false, false, false]) + }) + + it('keeps every control live at the end of the recording', async () => { + const el = await mountControls({ currentMs: 9_000, duration: 9_000 }) + + expect( + shadowAll<HTMLButtonElement>(el, CONTROL).map( + (button) => button.disabled + ) + ).toEqual([false, false, false, false]) + }) + + it('keeps every control live for a recording of no length', async () => { + const el = await mountControls() + + expect( + shadowAll<HTMLButtonElement>(el, CONTROL).map( + (button) => button.disabled + ) + ).toEqual([false, false, false, false]) + }) + }) + + describe('playback speed', () => { + it('offers every speed the player supports', async () => { + const el = await mountControls() + + expect(texts(el, SPEED_OPTION)).toEqual( + SPEEDS.map((speed) => `${speed}ร—`) + ) + }) + + it('plays at single speed until told otherwise', async () => { + const el = await mountControls() + + expect(speedSelect(el).value).toBe('1') + }) + + it('shows the speed the timeline broadcasts', async () => { + const el = await mountControls({ speed: 2 }) + + expect(speedSelect(el).value).toBe('2') + expect(text(shadow(el, 'select option[selected]'))).toBe('2ร—') + }) + + it('asks the timeline for the speed picked', async () => { + const el = await mountControls() + const select = speedSelect(el) + + const received = capture<{ value: number }>(PLAYER_SPEED_EVENT, () => { + select.value = '3' + select.dispatchEvent(new Event('change')) + }) + + expect(received.map((event) => event.detail.value)).toEqual([3]) + }) + }) +}) diff --git a/packages/app/test-ui/workbench/player/trace-timeline.test.ts b/packages/app/test-ui/workbench/player/trace-timeline.test.ts new file mode 100644 index 00000000..c21f0021 --- /dev/null +++ b/packages/app/test-ui/workbench/player/trace-timeline.test.ts @@ -0,0 +1,414 @@ +import type { CommandLog, TracePlayerFrame } from '@wdio/devtools-shared' + +import { commandContext, framesContext } from '@/controller/context.js' +import { KBD } from '@/controller/keyboard.js' +import { + PLAYER_RESTART_EVENT, + PLAYER_SPEED_EVENT, + PLAYER_STATE_EVENT, + type PlayerState +} from '@components/browser/trace-timeline-constants.js' +import { + formatTickLabel, + formatTimecode, + tickStep +} from '@components/browser/trace-timeline-utils.js' +import '@components/browser/trace-timeline.js' + +import { mountWithContext, settle } from '../../support/mount.js' +import { shadow, shadowAll, text, texts } from '../../support/queries.js' +import { filmstrip, FRAME_SHOT, loginTrace } from './fixtures.js' + +const TAG = 'wdio-devtools-trace-timeline' +const STRIP = '[data-scrub]' +const THUMB = '[data-scrub] button' +const GRIDLINE = 'div.w-px' +const TICK_LABEL = 'span.absolute' +/** Action marks are the only titled divs; the thumbnails are buttons. */ +const MARK = 'div[title]' +/** The playhead knob is the only div rounded by a class. */ +const PLAYHEAD = 'div.rounded-full' + +type Timeline = HTMLElementTagNameMap[typeof TAG] + +const { commands, openLogin, typeUsername, readFlash } = loginTrace +const { frames, first, typing, last } = filmstrip +/** Origin and span of the strip's window, as the component derives them. */ +const START = first.timestamp +const DURATION = last.timestamp - START + +function mountTimeline( + timelineCommands: CommandLog[], + timelineFrames: TracePlayerFrame[] +): Promise<Timeline> { + return mountWithContext<Timeline>(TAG, [ + { context: commandContext, value: timelineCommands }, + { context: framesContext, value: timelineFrames } + ]) +} + +/** The clock the strip mirrors to the controls bar on its next render. */ +function nextPlayerState(): Promise<PlayerState> { + return new Promise((resolve) => { + window.addEventListener( + PLAYER_STATE_EVENT, + (event) => resolve((event as CustomEvent<PlayerState>).detail), + { once: true } + ) + }) +} + +/** The next `show-command` the strip announces, whole: the workbench reads the + * command, the Logs panel badges the elapsed offset that comes with it. */ +function nextShowCommand(): Promise<CommandEventProps> { + return new Promise((resolve) => { + window.addEventListener('show-command', (event) => resolve(event.detail), { + once: true + }) + }) +} + +/** The action the strip asks the workbench to show next. */ +const nextShownCommand = (): Promise<CommandLog> => + nextShowCommand().then((detail) => detail.command) + +const attrs = (els: Element[], name: string) => + els.map((el) => el.getAttribute(name)) + +const lefts = (els: HTMLElement[]) => els.map((el) => el.style.left) + +/** Where the strip puts a wall-clock timestamp: its share of the window, from + * the same origin and span the component derives. */ +const at = (timestamp: number) => `${((timestamp - START) / DURATION) * 100}%` + +/** Ruler ticks as the strip lays them out โ€” one every `tickStep(duration)`, up + * to but not including the end of the window. */ +function rulerTicks(duration: number): number[] { + const step = tickStep(duration) + const ticks: number[] = [] + for (let tick = step; tick < duration; tick += step) { + ticks.push(tick) + } + return ticks +} + +const byTimestamp = (entries: CommandLog[]) => + [...entries].sort((a, b) => a.timestamp - b.timestamp) + +const isActive = (thumb: HTMLElement) => thumb.classList.contains('ring-1') + +describe('wdio-devtools-trace-timeline', () => { + describe('filmstrip', () => { + it('renders one thumbnail per captured frame, timecoded from the start', async () => { + const el = await mountTimeline(commands, frames) + + // Derived, so a strip that timecoded from the wrong origin โ€” or stopped + // running its labels through the shared formatter โ€” fails here. + expect(attrs(shadowAll(el, THUMB), 'title')).toEqual( + frames.map((frame) => formatTimecode(frame.timestamp - START)) + ) + // ...and pinned, so the derivation can't drift along with a broken + // formatter. These are the fixture's designed offsets: 0/500/750/1250/2000. + expect(attrs(shadowAll(el, THUMB), 'title')).toEqual([ + '0:00.00', + '0:00.50', + '0:00.75', + '0:01.25', + '0:02.00' + ]) + }) + + it('positions each thumbnail at its share of the recording', async () => { + const el = await mountTimeline(commands, frames) + + expect(lefts(shadowAll(el, THUMB))).toEqual( + frames.map((frame) => at(frame.timestamp)) + ) + expect(lefts(shadowAll(el, THUMB))).toEqual([ + '0%', + '25%', + '37.5%', + '62.5%', + '100%' + ]) + }) + + it("renders each frame's screenshot as its thumbnail", async () => { + const el = await mountTimeline(commands, frames) + + expect(shadow(el, `${THUMB} img`)?.getAttribute('src')).toBe( + `data:image/png;base64,${FRAME_SHOT}` + ) + }) + + it('marks the frame nearest the playhead as the active one', async () => { + const el = await mountTimeline(commands, frames) + const thumbs = shadowAll(el, THUMB) + + expect(isActive(thumbs[0])).toBe(true) + expect(thumbs.filter(isActive)).toHaveLength(1) + }) + + it('moves the active frame to the thumbnail clicked', async () => { + const el = await mountTimeline(commands, frames) + + shadowAll(el, THUMB)[3].click() + await settle(el) + + const thumbs = shadowAll(el, THUMB) + expect(isActive(thumbs[3])).toBe(true) + expect(isActive(thumbs[0])).toBe(false) + }) + + it('says so when the trace captured no frames', async () => { + const el = await mountTimeline(commands, []) + + expect(shadowAll(el, THUMB)).toHaveLength(0) + expect(text(shadow(el, STRIP))).toContain('No frames captured') + }) + + it('renders a single captured frame at the start of the strip', async () => { + const el = await mountTimeline([], [first]) + const thumbs = shadowAll(el, THUMB) + + expect(thumbs).toHaveLength(1) + expect(thumbs[0].style.left).toBe('0%') + expect(isActive(thumbs[0])).toBe(true) + }) + }) + + describe('action marks', () => { + it('orders the marks by timestamp, whatever order the commands arrived in', async () => { + const el = await mountTimeline([...commands].reverse(), frames) + + expect(attrs(shadowAll(el, MARK), 'title')).toEqual([ + 'url', + '$', + 'setValue', + 'click', + // The last command carries a display title, which wins over its name. + readFlash.title + ]) + }) + + it('positions each mark at its share of the recording', async () => { + const el = await mountTimeline(commands, frames) + + // A mark sits at its command's END, not its start โ€” the two differ for + // every spanned command in the fixture, so a mark drawn from `startTime` + // moves every position here. + expect(lefts(shadowAll(el, MARK))).toEqual( + byTimestamp(commands).map((command) => at(command.timestamp)) + ) + expect(lefts(shadowAll(el, MARK))).toEqual([ + '20%', + '32%', + '39%', + '63%', + '76%' + ]) + }) + + it('gives a typing command the keyboard glyph', async () => { + const el = await mountTimeline(commands, frames) + + expect(shadowAll(el, MARK)[2].style.backgroundColor).toBe( + 'rgb(70, 201, 106)' + ) + }) + + it('gives a command with a hit point the pointer dot', async () => { + const el = await mountTimeline(commands, frames) + + expect(shadowAll(el, MARK)[3].style.borderRadius).toBe('50%') + }) + + it('gives every other command a plain tick', async () => { + const el = await mountTimeline(commands, frames) + + expect(shadowAll(el, MARK)[0].style.width).toBe('1px') + }) + + it('renders no marks for a trace without commands', async () => { + const el = await mountTimeline([], frames) + + expect(shadowAll(el, MARK)).toHaveLength(0) + }) + }) + + describe('ruler', () => { + it('labels the ruler at the interval that fits the recording', async () => { + const el = await mountTimeline(commands, frames) + + // 2000ms over the 14 divisions the ruler aims for lands on the 250ms step. + expect(tickStep(DURATION)).toBe(250) + expect(texts(el, TICK_LABEL)).toEqual( + rulerTicks(DURATION).map(formatTickLabel) + ) + expect(texts(el, TICK_LABEL)).toEqual([ + '250ms', + '500ms', + '750ms', + '1.0s', + '1.3s', + '1.5s', + '1.8s' + ]) + }) + + it('positions each ruler label at its tick', async () => { + const el = await mountTimeline(commands, frames) + + expect(lefts(shadowAll(el, TICK_LABEL))).toEqual( + rulerTicks(DURATION).map((tick) => `${(tick / DURATION) * 100}%`) + ) + }) + + it('draws one gridline per ruler tick', async () => { + const el = await mountTimeline(commands, frames) + + expect(shadowAll(el, GRIDLINE)).toHaveLength(rulerTicks(DURATION).length) + expect(shadowAll(el, GRIDLINE)).toHaveLength(7) + }) + + it('draws no ruler at all for an empty trace', async () => { + const el = await mountTimeline([], []) + + expect(texts(el, TICK_LABEL)).toEqual([]) + expect(shadowAll(el, GRIDLINE)).toHaveLength(0) + expect(text(shadow(el, STRIP))).toContain('No frames captured') + }) + }) + + describe('selection', () => { + it('announces the first action as soon as commands arrive', async () => { + const shown = nextShownCommand() + + await mountTimeline(commands, frames) + + expect(await shown).toEqual(openLogin) + }) + + it('announces the action running at the frame clicked', async () => { + const el = await mountTimeline(commands, frames) + const shown = nextShownCommand() + + shadowAll(el, THUMB)[frames.indexOf(typing)].click() + + expect(await shown).toEqual(typeUsername) + }) + + it('times the first action from itself, so its badge reads zero', async () => { + const shown = nextShowCommand() + + await mountTimeline(commands, frames) + + expect(await shown).toEqual({ command: openLogin, elapsedTime: 0 }) + }) + + it('times the announced action from the first command, as the actions list does', async () => { + const el = await mountTimeline(commands, frames) + const shown = nextShowCommand() + + shadowAll(el, THUMB)[frames.indexOf(typing)].click() + + // The offset the Logs panel badges, derived the way actions.ts derives the + // one it sends on a row click: from the first COMMAND. The strip's own + // window starts 400ms earlier (at the first captured frame), so measuring + // from that origin would badge 780ms for this action instead of 380ms and + // the same action would read differently in the two panes. + expect(await shown).toEqual({ + command: typeUsername, + elapsedTime: typeUsername.timestamp - openLogin.timestamp + }) + expect((await shown).elapsedTime).toBe(380) + }) + + it('does not re-announce the action already selected', async () => { + const el = await mountTimeline(commands, frames) + let announced = 0 + const count = () => { + announced += 1 + } + window.addEventListener('show-command', count) + + // The frame at 500ms still falls after `url` and before `$`. + shadowAll(el, THUMB)[1].click() + await settle(el) + window.removeEventListener('show-command', count) + + expect(announced).toBe(0) + }) + }) + + describe('playback clock', () => { + it('steps the clock to the next action', async () => { + const el = await mountTimeline(commands, frames) + const state = nextPlayerState() + + window.dispatchEvent(new CustomEvent(KBD.step, { detail: { dir: 1 } })) + await settle(el) + + expect((await state).currentMs).toBe(openLogin.timestamp - START) + }) + + it('jumps the clock to the end of the recording', async () => { + const el = await mountTimeline(commands, frames) + const state = nextPlayerState() + + window.dispatchEvent(new CustomEvent(KBD.jump, { detail: { to: 'end' } })) + await settle(el) + + expect(await state).toMatchObject({ + currentMs: DURATION, + duration: DURATION + }) + }) + + it('parks the playhead at the clock position', async () => { + const el = await mountTimeline(commands, frames) + + window.dispatchEvent(new CustomEvent(KBD.jump, { detail: { to: 'end' } })) + await settle(el) + + expect(shadow(el, PLAYHEAD)?.style.left).toBe('calc(100% - 6px)') + }) + + it('returns the clock to the start on restart', async () => { + const el = await mountTimeline(commands, frames) + const jumped = nextPlayerState() + window.dispatchEvent(new CustomEvent(KBD.jump, { detail: { to: 'end' } })) + await jumped + + const restarted = nextPlayerState() + window.dispatchEvent(new CustomEvent(PLAYER_RESTART_EVENT)) + await settle(el) + + expect(await restarted).toMatchObject({ currentMs: 0, playing: false }) + }) + + it('takes its playback speed from the controls bar', async () => { + const el = await mountTimeline(commands, frames) + const state = nextPlayerState() + + window.dispatchEvent( + new CustomEvent(PLAYER_SPEED_EVENT, { detail: { value: 2 } }) + ) + await settle(el) + + expect((await state).speed).toBe(2) + }) + + it('reports playing while the clock runs and stops on the second toggle', async () => { + await mountTimeline(commands, frames) + + const started = nextPlayerState() + window.dispatchEvent(new CustomEvent(KBD.togglePlay)) + expect((await started).playing).toBe(true) + + const stopped = nextPlayerState() + window.dispatchEvent(new CustomEvent(KBD.togglePlay)) + expect((await stopped).playing).toBe(false) + }) + }) +}) diff --git a/packages/app/test-ui/workbench/tabs/tab.test.ts b/packages/app/test-ui/workbench/tabs/tab.test.ts new file mode 100644 index 00000000..95bb931f --- /dev/null +++ b/packages/app/test-ui/workbench/tabs/tab.test.ts @@ -0,0 +1,121 @@ +import '@components/tabs.js' +import type { DevtoolsTab } from '@components/tabs.js' + +import { mount, settle } from '../../support/mount.js' +import { shadowAll } from '../../support/queries.js' + +const TAG = 'wdio-devtools-tab' + +const slotted = (el: DevtoolsTab) => + el.shadowRoot?.querySelector<HTMLSlotElement>('slot')?.assignedElements() ?? + [] + +const display = (el: DevtoolsTab) => getComputedStyle(el).display + +describe('wdio-devtools-tab', () => { + describe('its panel', () => { + it('projects the panel it wraps', async () => { + const el = await mount<DevtoolsTab>(TAG, { + innerHTML: '<p id="panel">Console panel</p>' + }) + + expect(slotted(el).map((child) => child.id)).toEqual(['panel']) + }) + + it('renders nothing of its own but the slot', async () => { + const el = await mount<DevtoolsTab>(TAG, { + innerHTML: '<p>Console panel</p>' + }) + + expect(shadowAll(el, '*').map((child) => child.tagName)).toEqual(['SLOT']) + }) + + it('stays hidden while another tab is open', async () => { + const el = await mount<DevtoolsTab>(TAG, { + innerHTML: '<p>Console panel</p>' + }) + + expect(el.hasAttribute('active')).toBe(false) + expect(display(el)).toBe('none') + }) + + it('shows its panel once the tabs bar marks it active', async () => { + const el = await mount<DevtoolsTab>(TAG, { + innerHTML: '<p>Console panel</p>' + }) + + el.setAttribute('active', '') + await settle(el) + + expect(display(el)).toBe('flex') + }) + + it('hides its panel again when the bar moves on', async () => { + const el = await mount<DevtoolsTab>(TAG, { active: '' }) + + el.removeAttribute('active') + await settle(el) + + expect(display(el)).toBe('none') + }) + + it('projects nothing when it wraps no panel', async () => { + const el = await mount<DevtoolsTab>(TAG) + + expect(slotted(el)).toEqual([]) + }) + }) + + describe('the count it reports to the bar', () => { + it('carries no count until one is set', async () => { + const el = await mount<DevtoolsTab>(TAG) + + expect(el.badge).toBe(undefined) + expect(el.badgeTone).toBe(undefined) + }) + + // The workbench binds the count as a PROPERTY (`.badge="${โ€ฆ}"`) and the tone + // as an attribute (`badgeTone="danger"`); the bar reads both as properties, + // so the attribute has to reach the typed property to be badged at all. + it('reads a count written as an attribute as a number', async () => { + const el = await mount<DevtoolsTab>(TAG) + + el.setAttribute('badge', '12') + await settle(el) + + expect(el.badge).toBe(12) + }) + + it('reads the danger tone written as an attribute', async () => { + const el = await mount<DevtoolsTab>(TAG) + + el.setAttribute('badgeTone', 'danger') + await settle(el) + + expect(el.badgeTone).toBe('danger') + }) + + it('draws no badge of its own โ€” the bar renders the count', async () => { + const el = await mount<DevtoolsTab>(TAG, { + badge: 12, + badgeTone: 'danger', + innerHTML: '<p>Console panel</p>' + }) + + expect(shadowAll(el, 'span')).toHaveLength(0) + expect(el.shadowRoot?.textContent?.trim()).toBe('') + }) + + it('keeps its label as data for the bar rather than rendering it', async () => { + const el = await mount<DevtoolsTab>(TAG, { + innerHTML: '<p>Console panel</p>' + }) + + el.setAttribute('label', 'Console') + await settle(el) + + expect(el.getAttribute('label')).toBe('Console') + expect(el.shadowRoot?.textContent).not.toContain('Console') + }) + }) +}) diff --git a/packages/app/test-ui/workbench/tabs/tabs.test.ts b/packages/app/test-ui/workbench/tabs/tabs.test.ts new file mode 100644 index 00000000..ce28624f --- /dev/null +++ b/packages/app/test-ui/workbench/tabs/tabs.test.ts @@ -0,0 +1,362 @@ +import '@components/tabs.js' +import type { DevtoolsTab, DevtoolsTabs } from '@components/tabs.js' + +import { mount, settle } from '../../support/mount.js' +import { shadow, shadowAll, text, texts } from '../../support/queries.js' + +const TAG = 'wdio-devtools-tabs' +const NAV = 'nav' +const TAB_BUTTON = 'nav button' +const TAB_LABEL = 'nav button > span:first-child' +const BADGE = '.tab-badge' +const ACTIVE_BUTTON = 'nav button.tab-btn--active' + +const CACHE_ID = 'test-active-dock-tab' + +interface TabSpec { + label: string + badge?: number + badgeTone?: string + active?: boolean +} + +/** + * One tabs child as the workbench declares it: `label`, `badgeTone` and `active` + * are ATTRIBUTES, and the count is left out of the markup โ€” the workbench binds + * it as a property (`.badge="${โ€ฆ}"` in workbench.ts), so `mountTabs` assigns it + * that way instead of routing it through Lit's attribute converter. + */ +function markupFor(tabs: TabSpec[]): string { + return tabs + .map( + ({ label, badgeTone, active }) => + `<wdio-devtools-tab label="${label}"` + + (badgeTone === undefined ? '' : ` badgeTone="${badgeTone}"`) + + (active ? ' active' : '') + + `><p>${label} panel</p></wdio-devtools-tab>` + ) + .join('') +} + +const nextTask = () => new Promise<void>((resolve) => setTimeout(resolve, 0)) + +interface MountOptions { + cacheId?: string + /** Markup appended after the tabs, for the bar's named `actions` slot. */ + extraMarkup?: string +} + +/** The bar builds its tab list in a `setTimeout` from `connectedCallback` โ€” it + * waits for its light DOM to be parsed โ€” so the nav only exists a macrotask + * after the mount resolves. */ +async function mountTabs( + tabs: TabSpec[], + options: MountOptions = {} +): Promise<DevtoolsTabs> { + const el = await mount<DevtoolsTabs>(TAG, { + innerHTML: markupFor(tabs) + (options.extraMarkup ?? ''), + ...(options.cacheId === undefined ? {} : { cacheId: options.cacheId }) + }) + panels(el).forEach((panel, index) => { + const badge = tabs[index]?.badge + if (badge !== undefined) { + panel.badge = badge + } + }) + await nextTask() + await settle(el) + return el +} + +/** The bar polls its children for badge changes every 250ms rather than + * observing them, so a count that changes after mount lands a tick later. */ +async function nextBadgePoll(el: DevtoolsTabs): Promise<void> { + await new Promise<void>((resolve) => setTimeout(resolve, 300)) + await settle(el) +} + +const panels = (el: DevtoolsTabs) => + Array.from(el.querySelectorAll<DevtoolsTab>('wdio-devtools-tab')) + +const activePanels = (el: DevtoolsTabs) => + panels(el) + .filter((panel) => panel.hasAttribute('active')) + .map((panel) => panel.getAttribute('label')) + +const openTab = (label?: string) => + window.dispatchEvent(new CustomEvent('open-dock-tab', { detail: { label } })) + +function tabButton(el: DevtoolsTabs, label: string): HTMLButtonElement { + const found = shadowAll<HTMLButtonElement>(el, TAB_BUTTON).find((button) => + text(button).startsWith(label) + ) + if (!found) { + throw new Error(`no tab button rendered for "${label}"`) + } + return found +} + +/** The workbench's own dock: an unbadged tab, a counted one, and the Errors tab + * which is the only one that asks for the danger tint. */ +const DOCK: TabSpec[] = [ + { label: 'Source' }, + { label: 'Console', badge: 4 }, + { label: 'Errors', badge: 2, badgeTone: 'danger' } +] + +describe('wdio-devtools-tabs', () => { + afterEach(() => localStorage.removeItem(CACHE_ID)) + + describe('the tab strip', () => { + it('renders one button per labelled child, in child order', async () => { + const el = await mountTabs(DOCK) + + expect(texts(el, TAB_LABEL)).toEqual(['Source', 'Console', 'Errors']) + }) + + it('opens the first tab when no child claims to be active', async () => { + const el = await mountTabs(DOCK) + + expect(text(shadow(el, ACTIVE_BUTTON))).toBe('Source') + expect(activePanels(el)).toEqual(['Source']) + }) + + it('opens the child that claims to be active instead of the first', async () => { + const el = await mountTabs([ + { label: 'Source' }, + { label: 'Console', active: true } + ]) + + expect(text(shadow(el, ACTIVE_BUTTON))).toContain('Console') + expect(activePanels(el)).toEqual(['Console']) + }) + + it('renders a single tab as the active one', async () => { + const el = await mountTabs([{ label: 'Source' }]) + + expect(shadowAll(el, TAB_BUTTON)).toHaveLength(1) + expect(activePanels(el)).toEqual(['Source']) + }) + + it('renders no strip at all without any tabs', async () => { + const el = await mountTabs([]) + + expect(shadowAll(el, NAV)).toHaveLength(0) + expect(shadowAll(el, TAB_BUTTON)).toHaveLength(0) + }) + + it('keeps a slotted action out of the tab list', async () => { + const el = await mountTabs([{ label: 'Source' }], { + extraMarkup: '<nav slot="actions"><button>collapse</button></nav>' + }) + + expect(texts(el, TAB_LABEL)).toEqual(['Source']) + }) + + it('adds a button for a tab that mounts after the strip', async () => { + const el = await mountTabs([{ label: 'Source' }]) + + const compare = document.createElement('wdio-devtools-tab') + compare.setAttribute('label', 'Compare') + el.append(compare) + await nextTask() + await settle(el) + + expect(texts(el, TAB_LABEL)).toEqual(['Source', 'Compare']) + expect(activePanels(el)).toEqual(['Source']) + }) + + it('opens the first tab when the open one unmounts', async () => { + // The workbench drops the Compare tab as soon as its baseline goes; the + // strip would otherwise keep pointing at a tab that no longer exists. + const el = await mountTabs(DOCK) + tabButton(el, 'Errors').click() + await settle(el) + + panels(el)[2].remove() + await nextTask() + await settle(el) + + expect(texts(el, TAB_LABEL)).toEqual(['Source', 'Console']) + expect(activePanels(el)).toEqual(['Source']) + expect(text(shadow(el, ACTIVE_BUTTON))).toBe('Source') + }) + + it('keeps the open tab when another one unmounts', async () => { + const el = await mountTabs(DOCK) + tabButton(el, 'Console').click() + await settle(el) + + panels(el)[2].remove() + await nextTask() + await settle(el) + + expect(activePanels(el)).toEqual(['Console']) + }) + }) + + describe('switching tabs', () => { + it('opens the tab whose button is clicked', async () => { + const el = await mountTabs(DOCK) + + tabButton(el, 'Console').click() + await settle(el) + + expect(text(shadow(el, ACTIVE_BUTTON))).toContain('Console') + expect(activePanels(el)).toEqual(['Console']) + }) + + it('marks only one button active at a time', async () => { + const el = await mountTabs(DOCK) + + tabButton(el, 'Errors').click() + await settle(el) + + expect(shadowAll(el, ACTIVE_BUTTON)).toHaveLength(1) + expect(activePanels(el)).toEqual(['Errors']) + }) + + it('opens the tab named by an open-dock-tab request', async () => { + const el = await mountTabs(DOCK) + + openTab('Errors') + await settle(el) + + expect(text(shadow(el, ACTIVE_BUTTON))).toContain('Errors') + expect(activePanels(el)).toEqual(['Errors']) + }) + + it('ignores an open-dock-tab request for a label it does not own', async () => { + const el = await mountTabs(DOCK) + + openTab('A11y') + await settle(el) + + expect(activePanels(el)).toEqual(['Source']) + }) + + it('ignores an open-dock-tab request carrying no label', async () => { + const el = await mountTabs(DOCK) + + openTab() + await settle(el) + + expect(activePanels(el)).toEqual(['Source']) + }) + + it('stops answering open-dock-tab once it leaves the page', async () => { + const el = await mountTabs(DOCK) + el.remove() + + openTab('Errors') + + expect(activePanels(el)).toEqual(['Source']) + }) + }) + + describe('count badges', () => { + it("shows each tab's count next to its label", async () => { + const el = await mountTabs(DOCK) + + // Derived from the same specs the children were built from, so a bar that + // badged the wrong tab โ€” or dropped the filter โ€” fails here. + expect(texts(el, BADGE)).toEqual( + DOCK.filter((tab) => tab.badge).map((tab) => String(tab.badge)) + ) + expect(texts(el, BADGE)).toEqual(['4', '2']) + }) + + it('leaves a tab without a count unbadged', async () => { + // The workbench leaves `.badge` off Source/Log/A11y entirely. + const el = await mountTabs([{ label: 'Source' }]) + + expect(panels(el)[0].badge).toBe(undefined) + expect(shadowAll(el, BADGE)).toHaveLength(0) + }) + + it('leaves a tab counting zero unbadged', async () => { + // `.badge="${this.consoleLogs?.length || 0}"` โ€” an empty panel really does + // report 0 rather than leaving the count off. + const el = await mountTabs([{ label: 'Console', badge: 0 }]) + + expect(shadowAll(el, BADGE)).toHaveLength(0) + }) + + it('badges a count written as an attribute as well', async () => { + // Only `badgeTone` arrives as an attribute today, but the count survives + // Lit's converter either way โ€” see tab.test.ts for that conversion. + const el = await mountTabs([{ label: 'Console' }]) + + panels(el)[0].setAttribute('badge', '9') + await nextBadgePoll(el) + + expect(texts(el, BADGE)).toEqual(['9']) + }) + + it('tints the count of a danger tab', async () => { + const el = await mountTabs(DOCK) + + expect( + shadow(el, `${BADGE}--danger`)?.previousElementSibling?.textContent + ).toBe('Errors') + expect(shadowAll(el, `${BADGE}--danger`)).toHaveLength(1) + }) + + it('picks up a count that changes after the strip rendered', async () => { + const el = await mountTabs([{ label: 'Console', badge: 0 }]) + + panels(el)[0].badge = 7 + await nextBadgePoll(el) + + expect(texts(el, BADGE)).toEqual(['7']) + }) + }) + + describe('remembering the open tab', () => { + it('remembers the tab opened under its cache id', async () => { + const el = await mountTabs(DOCK, { cacheId: CACHE_ID }) + + tabButton(el, 'Console').click() + + expect(localStorage.getItem(CACHE_ID)).toBe('Console') + }) + + it('reopens the remembered tab on the next mount', async () => { + localStorage.setItem(CACHE_ID, 'Errors') + + const el = await mountTabs(DOCK, { cacheId: CACHE_ID }) + + expect(activePanels(el)).toEqual(['Errors']) + }) + + it('opens the first tab when the remembered one is not rendered', async () => { + // A dock that remembered `Compare` and mounts without a baseline: the + // remembered label names no tab, so the first one takes over. + localStorage.setItem(CACHE_ID, 'Compare') + + const el = await mountTabs(DOCK, { cacheId: CACHE_ID }) + + expect(activePanels(el)).toEqual(['Source']) + expect(text(shadow(el, ACTIVE_BUTTON))).toBe('Source') + }) + + it('prefers the remembered tab over the child that claims to be active', async () => { + localStorage.setItem(CACHE_ID, 'Console') + + const el = await mountTabs( + [{ label: 'Source', active: true }, { label: 'Console' }], + { cacheId: CACHE_ID } + ) + + expect(activePanels(el)).toEqual(['Console']) + }) + + it('remembers nothing without a cache id', async () => { + const el = await mountTabs(DOCK) + + tabButton(el, 'Console').click() + + expect(localStorage.getItem(CACHE_ID)).toBe(null) + }) + }) +}) diff --git a/packages/app/test-ui/workbench/workbench-fixtures.ts b/packages/app/test-ui/workbench/workbench-fixtures.ts new file mode 100644 index 00000000..b022c191 --- /dev/null +++ b/packages/app/test-ui/workbench/workbench-fixtures.ts @@ -0,0 +1,428 @@ +// Mount harness + data for the workbench parent spec. +// +// The workbench takes everything by context and nothing by property, so its +// spec needs what `mountWithContext` cannot give it: a handle on the providers, +// so a baseline can be preserved and dropped โ€” and a different test selected โ€” +// *after* the mount; those two contexts joined are where the Compare tab lives. +// The provider set mirrors DataManagerController's constructor one for one, so +// the element sees exactly the context surface the running app gives it (a panel +// whose context is missing altogether renders a different empty state than one +// reading `[]`). +// +// The values published here go through the same `ContextProvider.setValue` the +// controller calls on a WS frame; the frame โ†’ provider hop itself is covered in +// packages/app/tests/data-manager.test.ts. + +import { ContextProvider, type Context } from '@lit/context' +import type { + CommandLog, + ConsoleLog, + NetworkRequest, + PreservedAttempt, + PreservedStep +} from '@wdio/devtools-shared' + +import { + actionGroupsContext, + baselineContext, + commandContext, + consoleLogContext, + framesContext, + hasConnectionContext, + logContext, + metadataBySessionContext, + metadataContext, + mutationContext, + networkRequestContext, + selectedTestUidContext, + sourceContext, + suiteContext, + transcriptContext +} from '@/controller/context.js' +import type { + SuiteStatsFragment, + TestStatsFragment +} from '@/controller/types.js' +import '@components/workbench.js' +import type { DevtoolsWorkbench } from '@components/workbench.js' +import type { DevtoolsTabs } from '@components/tabs.js' +import type { DevtoolsTab } from '@components/tabs.js' + +import { commandLog } from '../support/builders.js' + +export const LOGIN_URL = 'https://the-internet.herokuapp.com/login' + +/** Wall-clock origin of the fixture run. */ +const RUN_START = 1_700_000_000_000 + +export const SELECTED_TEST_UID = 'login-test' +export const OTHER_TEST_UID = 'checkout-test' +/** A test no baseline is ever preserved for โ€” selecting it must offer nothing. */ +export const UNPRESERVED_TEST_UID = 'signup-test' + +const FLASH_SELECTOR = '#flash' +const SUBMIT_SELECTOR = 'button[type="submit"]' +const EXPECTED_FLASH = 'You logged into a secure area!' +const BASELINE_FLASH = 'Your password is invalid!' + +/** The run that was preserved: the flash text read back wrong, so the last + * command is the failure site. */ +const baselineCommands: CommandLog[] = [ + commandLog({ command: 'url', args: [LOGIN_URL], timestamp: RUN_START }), + commandLog({ + command: 'setValue', + args: ['#username', 'tomsmith'], + timestamp: RUN_START + 400 + }), + commandLog({ + command: 'click', + args: [SUBMIT_SELECTOR], + timestamp: RUN_START + 900 + }), + commandLog({ + command: 'getText', + args: [FLASH_SELECTOR], + result: BASELINE_FLASH, + timestamp: RUN_START + 1400 + }) +] + +/** The rerun, which got the password right โ€” same commands, different read. */ +export const liveCommands: CommandLog[] = [ + commandLog({ + command: 'url', + args: [LOGIN_URL], + timestamp: RUN_START + 9000 + }), + commandLog({ + command: 'setValue', + args: ['#username', 'tomsmith'], + timestamp: RUN_START + 9400 + }), + commandLog({ + command: 'click', + args: [SUBMIT_SELECTOR], + timestamp: RUN_START + 9900 + }), + commandLog({ + command: 'getText', + args: [FLASH_SELECTOR], + result: EXPECTED_FLASH, + timestamp: RUN_START + 10_400 + }) +] + +/** A live command that errored โ€” one of the two sources the Errors tab collects. */ +export const erroringCommand: CommandLog = commandLog({ + command: 'click', + args: [SUBMIT_SELECTOR], + timestamp: RUN_START + 11_000, + error: { + name: 'element click intercepted', + message: + 'element click intercepted: another element would receive the click' + } +}) + +const failedStep: PreservedStep = { + uid: 'assert-step', + title: 'sees the success banner', + fullTitle: 'login page sees the success banner', + start: RUN_START + 1000, + end: RUN_START + 1500, + state: 'failed', + error: { + message: `Expected: "${EXPECTED_FLASH}"\nReceived: "${BASELINE_FLASH}"` + } +} + +export function preservedAttempt( + overrides: Partial<PreservedAttempt> = {} +): PreservedAttempt { + return { + testUid: SELECTED_TEST_UID, + scope: 'test', + capturedAt: RUN_START + 2000, + window: { start: RUN_START, end: RUN_START + 1500 }, + test: { + title: 'logs in with valid credentials', + fullTitle: 'login page logs in with valid credentials', + file: '/specs/login.e2e.ts', + state: 'failed', + error: { message: `Expected: "${EXPECTED_FLASH}"` } + }, + steps: [failedStep], + commands: baselineCommands, + consoleLogs: [], + networkRequests: [], + mutations: [], + sources: {}, + ...overrides + } +} + +/** The baseline map as DataManager publishes it: keyed by preserved test uid. */ +export function baselineMap( + ...entries: [string, PreservedAttempt][] +): Map<string, PreservedAttempt> { + return new Map(entries) +} + +export const preservedCommands = (): CommandLog[] => baselineCommands + +export function consoleLogs(): ConsoleLog[] { + return [ + { + type: 'log', + args: ['[TEST] logging in'], + source: 'browser', + timestamp: RUN_START + 100 + }, + { + type: 'error', + args: ['Failed to load resource: /favicon.ico'], + source: 'browser', + timestamp: RUN_START + 700 + }, + { + type: 'info', + args: ['navigating to /secure'], + source: 'test', + timestamp: RUN_START + 1200 + } + ] +} + +export function networkRequests(): NetworkRequest[] { + return [ + { + id: 'req-1', + url: LOGIN_URL, + method: 'GET', + status: 200, + type: 'document', + timestamp: RUN_START + 50, + startTime: RUN_START + 50 + }, + { + id: 'req-2', + url: `${LOGIN_URL}/authenticate`, + method: 'POST', + status: 302, + type: 'xhr', + timestamp: RUN_START + 950, + startTime: RUN_START + 950 + } + ] +} + +function failingTest(uid: string): TestStatsFragment { + return { + uid, + title: 'logs in with valid credentials', + fullTitle: 'login page logs in with valid credentials', + file: '/specs/login.e2e.ts', + state: 'failed', + error: { + name: 'AssertionError', + message: `Expected: "${EXPECTED_FLASH}"\nReceived: "${BASELINE_FLASH}"` + } + } as TestStatsFragment +} + +/** One root suite holding the failed test โ€” the second source the Errors tab + * collects from, and the tree the compare panel windows the live run by. */ +export function failingSuites( + uid = SELECTED_TEST_UID +): Record<string, SuiteStatsFragment>[] { + return [ + { + 'login-suite': { + uid: 'login-suite', + title: 'login page', + fullTitle: 'login page', + file: '/specs/login.e2e.ts', + state: 'failed', + start: new Date(RUN_START), + end: new Date(RUN_START + 1500), + tests: [failingTest(uid)], + suites: [] + } as SuiteStatsFragment + } + ] +} + +export interface WorkbenchContexts { + commands?: CommandLog[] + consoleLogs?: ConsoleLog[] + networkRequests?: NetworkRequest[] + baselines?: Map<string, PreservedAttempt> + selectedTestUid?: string + suites?: Record<string, SuiteStatsFragment>[] +} + +export interface WorkbenchHarness { + workbench: DevtoolsWorkbench + /** The dock tab bar โ€” Source/Log/Console/Network/Errors (+ Compare). */ + dock: DevtoolsTabs + /** The left bar โ€” Actions/Metadata. */ + sidebar: DevtoolsTabs + /** Republish `baselineContext` the way DataManager does on a + * `baseline:saved` / `baseline:cleared` broadcast: always a fresh Map. */ + publishBaselines(next: Map<string, PreservedAttempt>): Promise<void> + /** Republish `selectedTestUidContext` through the one setter DataManager + * exposes for it (`setSelectedTestUid`); `undefined` deselects. */ + publishSelectedTestUid(next: string | undefined): Promise<void> + /** Await a tab-bar rebuild โ€” it refreshes its list off `slotchange`, a task + * after the workbench's own render. */ + settleTabs(): Promise<void> +} + +/** Keys the workbench, its bars and its drag handles persist. Cleared between + * tests so specs can't leak layout state into each other. */ +const PERSISTED_KEYS = [ + 'toolbar', + 'workbenchSidebar', + 'activeWorkbenchTab', + 'activeActionsTab', + 'toolbarHeight', + 'workbenchSidebarWidth', + 'traceTimelineHeight', + 'playerPaneHeight' +] + +export const DOCK_CACHE_ID = 'activeWorkbenchTab' +export const SIDEBAR_CACHE_ID = 'activeActionsTab' + +/** Mocha's root hook โ€” declared locally, as in `support/mount.ts`. */ +declare const afterEach: (teardown: () => void) => void + +const mountedHosts: Element[] = [] + +afterEach(() => { + for (const host of mountedHosts.splice(0)) { + host.remove() + } + for (const key of PERSISTED_KEYS) { + localStorage.removeItem(key) + } +}) + +const nextTask = () => new Promise<void>((resolve) => setTimeout(resolve, 0)) + +function tabBar(workbench: DevtoolsWorkbench, cacheId: string): DevtoolsTabs { + const bar = workbench.shadowRoot?.querySelector<DevtoolsTabs>( + `wdio-devtools-tabs[cacheid="${cacheId}"]` + ) + if (!bar) { + throw new Error(`the workbench rendered no tab bar for "${cacheId}"`) + } + return bar +} + +export function dockTabs(dock: DevtoolsTabs): DevtoolsTab[] { + return Array.from(dock.querySelectorAll<DevtoolsTab>('wdio-devtools-tab')) +} + +export const tabLabels = (bar: DevtoolsTabs): string[] => + dockTabs(bar).map((tab) => tab.getAttribute('label') ?? '') + +export const openTabLabels = (bar: DevtoolsTabs): string[] => + dockTabs(bar) + .filter((tab) => tab.hasAttribute('active')) + .map((tab) => tab.getAttribute('label') ?? '') + +/** The panel behind every open tab โ€” an empty array is a dock showing nothing. */ +export const openPanelTags = (bar: DevtoolsTabs): string[] => + dockTabs(bar) + .filter((tab) => tab.hasAttribute('active')) + .map((tab) => (tab.firstElementChild?.tagName ?? '').toLowerCase()) + +export function mountWorkbench( + contexts: WorkbenchContexts = {}, + props: { playerMode?: boolean } = {} +): Promise<WorkbenchHarness> { + const host = document.createElement('div') + document.body.append(host) + mountedHosts.push(host) + + // Same providers, same initial values as DataManagerController's constructor. + const provided: [unknown, unknown][] = [ + [mutationContext, []], + [logContext, []], + [consoleLogContext, contexts.consoleLogs ?? []], + [networkRequestContext, contexts.networkRequests ?? []], + [metadataContext, undefined], + [metadataBySessionContext, {}], + [commandContext, contexts.commands ?? []], + [sourceContext, undefined], + [suiteContext, contexts.suites ?? []], + [hasConnectionContext, true], + [ + baselineContext, + contexts.baselines ?? new Map<string, PreservedAttempt>() + ], + [selectedTestUidContext, contexts.selectedTestUid], + [framesContext, []], + [actionGroupsContext, undefined], + [transcriptContext, undefined] + ] + + const providers = new Map< + unknown, + ContextProvider<Context<unknown, unknown>> + >() + for (const [context, value] of provided) { + const provider = new ContextProvider(host, { + context: context as Context<unknown, unknown>, + initialValue: value + }) + // A plain div is no ReactiveElement, so @lit/context never fires the + // controller's connect signal โ€” see support/mount.ts. + provider.hostConnected() + providers.set(context, provider) + } + + return finishMount(host, providers, props) +} + +async function finishMount( + host: HTMLElement, + providers: Map<unknown, ContextProvider<Context<unknown, unknown>>>, + props: { playerMode?: boolean } +): Promise<WorkbenchHarness> { + const workbench = document.createElement( + 'wdio-devtools-workbench' + ) as DevtoolsWorkbench + if (props.playerMode) { + workbench.playerMode = true + } + host.append(workbench) + await workbench.updateComplete + + const dock = tabBar(workbench, DOCK_CACHE_ID) + const sidebar = tabBar(workbench, SIDEBAR_CACHE_ID) + + const settleTabs = async () => { + await nextTask() + await workbench.updateComplete + await dock.updateComplete + await sidebar.updateComplete + } + await settleTabs() + + return { + workbench, + dock, + sidebar, + settleTabs, + publishBaselines: async (next) => { + providers.get(baselineContext)?.setValue(next) + await settleTabs() + }, + publishSelectedTestUid: async (next) => { + providers.get(selectedTestUidContext)?.setValue(next) + await settleTabs() + } + } +} diff --git a/packages/app/test-ui/workbench/workbench.test.ts b/packages/app/test-ui/workbench/workbench.test.ts new file mode 100644 index 00000000..ced78bed --- /dev/null +++ b/packages/app/test-ui/workbench/workbench.test.ts @@ -0,0 +1,698 @@ +// The workbench is the dashboard's right-hand column: a browser pane, the +// actions sidebar, and the dock. It owns no data of its own โ€” every panel it +// mounts reads context โ€” so what it can get wrong is *which* panels exist, and +// the Compare tab is the one that comes and goes. That conditional is the join +// between DataManager publishing a baseline for the selected test and the +// compare panel rendering it; neither end's spec covers it. + +import { collectErrors } from '@components/workbench/errors/collect.js' +import { pairSteps } from '@components/workbench/compare/compareUtils.js' +import type { DevtoolsTabs } from '@components/tabs.js' + +import { shadow, shadowAll, text, texts } from '../support/queries.js' +import { + DOCK_CACHE_ID, + OTHER_TEST_UID, + SELECTED_TEST_UID, + SIDEBAR_CACHE_ID, + UNPRESERVED_TEST_UID, + baselineMap, + consoleLogs, + dockTabs, + erroringCommand, + failingSuites, + liveCommands, + mountWorkbench, + openPanelTags, + openTabLabels, + networkRequests, + preservedAttempt, + preservedCommands, + tabLabels +} from './workbench-fixtures.js' +import type { WorkbenchHarness } from './workbench-fixtures.js' + +const BROWSER = 'wdio-devtools-browser' +const TIMELINE = 'wdio-devtools-trace-timeline' +const PLAYER_CONTROLS = 'wdio-devtools-trace-player-controls' +const COMPARE_PANEL = 'wdio-devtools-compare' +const TAB_BUTTON = 'nav button' +const TAB_LABEL = 'nav button > span:first-child' +const BADGE = '.tab-badge' +const DANGER_BADGE = '.tab-badge--danger' +const ACTIVE_BUTTON = 'nav button.tab-btn--active' +const SIDEBAR_SECTION = 'section[data-sidebar]' +const RESTORE_SIDEBAR = 'button:has(icon-mdi-arrow-collapse-right)' +const COLLAPSE_SIDEBAR = 'button:has(icon-mdi-arrow-collapse-left)' +const COLLAPSE_DOCK = 'button:has(icon-mdi-arrow-collapse-down)' +const RESTORE_DOCK = 'button:has(icon-mdi-arrow-collapse-up)' +const STEP_ROW = '.step-row' +const EMPTY_STATE = '.empty-state' + +/** The dock in live mode. Player mode appends A11y + Transcript, and a baseline + * preserved for the selected test appends Compare โ€” both asserted below. */ +const LIVE_DOCK = ['Source', 'Log', 'Console', 'Network', 'Errors'] + +const SIDEBAR_TABS = ['Actions', 'Metadata'] + +/** Rows the compare panel owes for the fixture pair, computed with the same + * pairing function the panel calls โ€” a workbench that wired the panel to the + * wrong command stream renders a different number. */ +const PAIRED_ROWS = pairSteps(preservedCommands(), liveCommands).length + +function button(bar: DevtoolsTabs, label: string): HTMLButtonElement { + const found = shadowAll<HTMLButtonElement>(bar, TAB_BUTTON).find( + (candidate) => text(candidate).startsWith(label) + ) + if (!found) { + throw new Error(`no tab button rendered for "${label}"`) + } + return found +} + +async function clickTab( + harness: WorkbenchHarness, + bar: DevtoolsTabs, + label: string +): Promise<void> { + button(bar, label).click() + await harness.settleTabs() +} + +async function clickInWorkbench( + harness: WorkbenchHarness, + selector: string +): Promise<void> { + const target = shadow<HTMLButtonElement>(harness.workbench, selector) + if (!target) { + throw new Error(`the workbench rendered no ${selector}`) + } + target.click() + await harness.settleTabs() +} + +const badgeFor = (bar: DevtoolsTabs, label: string): string | null => { + const found = shadowAll(bar, TAB_BUTTON).find((candidate) => + text(candidate).startsWith(label) + ) + return found ? (shadow(found, BADGE)?.textContent ?? null) : null +} + +const openTab = (label?: string) => + window.dispatchEvent(new CustomEvent('open-dock-tab', { detail: { label } })) + +/** The baseline the fixtures preserve, keyed to whichever test asked for it. */ +const preservedFor = (uid: string) => + baselineMap([uid, preservedAttempt({ testUid: uid })]) + +/** A drag controller hunts for its slider once more 50ms after it is built, so + * a handle the current mode never renders complains just after the mount. */ +const DRAG_RETRY_SETTLE = 150 + +/** Every `console.warn` raised by a mount, up to the drag handles' retry. */ +async function warningsWhileMounting( + mountOne: () => Promise<unknown> +): Promise<string[]> { + const warnings: string[] = [] + const original = console.warn + console.warn = (...args: unknown[]) => { + warnings.push(args.map(String).join(' ')) + } + try { + await mountOne() + await new Promise<void>((resolve) => setTimeout(resolve, DRAG_RETRY_SETTLE)) + } finally { + console.warn = original + } + return warnings +} + +describe('wdio-devtools-workbench', () => { + describe('default render', () => { + it('renders the browser pane and both tab bars', async () => { + const { workbench, dock, sidebar } = await mountWorkbench() + + expect(shadowAll(workbench, BROWSER)).toHaveLength(1) + expect(dock.getAttribute('cacheId')).toBe(DOCK_CACHE_ID) + expect(sidebar.getAttribute('cacheId')).toBe(SIDEBAR_CACHE_ID) + }) + + it('docks the live panels in order', async () => { + const { dock } = await mountWorkbench() + + expect(tabLabels(dock)).toEqual(LIVE_DOCK) + expect(texts(dock, TAB_LABEL)).toEqual(LIVE_DOCK) + }) + + it('puts the matching panel behind every dock label', async () => { + const { dock } = await mountWorkbench() + + // A label wired to the wrong panel โ€” or to none โ€” is what this catches. + expect( + dockTabs(dock).map((tab) => + (tab.firstElementChild?.tagName ?? '').toLowerCase() + ) + ).toEqual([ + 'wdio-devtools-source', + 'wdio-devtools-logs', + 'wdio-devtools-console-logs', + 'wdio-devtools-network', + 'wdio-devtools-errors' + ]) + }) + + it('offers the actions and metadata tabs beside the browser', async () => { + const { sidebar } = await mountWorkbench() + + expect(tabLabels(sidebar)).toEqual(SIDEBAR_TABS) + expect(openTabLabels(sidebar)).toEqual(['Actions']) + }) + + it('opens the first dock tab', async () => { + const { dock } = await mountWorkbench() + + expect(openTabLabels(dock)).toEqual(['Source']) + expect(text(shadow(dock, ACTIVE_BUTTON))).toBe('Source') + }) + + it('renders no player chrome in live mode', async () => { + const { workbench } = await mountWorkbench() + + expect(shadowAll(workbench, TIMELINE)).toHaveLength(0) + expect(shadowAll(workbench, PLAYER_CONTROLS)).toHaveLength(0) + }) + + it('leaves the counted tabs unbadged until something is captured', async () => { + const { dock } = await mountWorkbench() + + expect(shadowAll(dock, BADGE)).toHaveLength(0) + }) + }) + + describe('player mode', () => { + it('adds the timeline strip and the playback controls', async () => { + const { workbench } = await mountWorkbench({}, { playerMode: true }) + + expect(shadowAll(workbench, TIMELINE)).toHaveLength(1) + expect(shadowAll(workbench, PLAYER_CONTROLS)).toHaveLength(1) + }) + + it('docks the trace-only panels after the live ones', async () => { + const { dock } = await mountWorkbench({}, { playerMode: true }) + + expect(tabLabels(dock)).toEqual([...LIVE_DOCK, 'A11y', 'Transcript']) + }) + + it('keeps the browser pane and the sidebar', async () => { + const { workbench, sidebar } = await mountWorkbench( + {}, + { playerMode: true } + ) + + expect(shadowAll(workbench, BROWSER)).toHaveLength(1) + expect(tabLabels(sidebar)).toEqual(SIDEBAR_TABS) + }) + }) + + describe('switching dock tabs', () => { + it('shows the panel of the tab that is clicked', async () => { + const harness = await mountWorkbench() + + await clickTab(harness, harness.dock, 'Network') + + expect(openTabLabels(harness.dock)).toEqual(['Network']) + expect(text(shadow(harness.dock, ACTIVE_BUTTON))).toContain('Network') + }) + + it('opens the dock tab a component asks for', async () => { + // The A11y overlay jumps the dock to its own tab this way. + const harness = await mountWorkbench({}, { playerMode: true }) + + openTab('A11y') + await harness.settleTabs() + + expect(openTabLabels(harness.dock)).toEqual(['A11y']) + }) + + it('ignores a request for a tab the dock does not have', async () => { + const harness = await mountWorkbench() + + openTab('A11y') + await harness.settleTabs() + + expect(openTabLabels(harness.dock)).toEqual(['Source']) + }) + + it('leaves the sidebar tab alone when the dock tab changes', async () => { + const harness = await mountWorkbench() + + await clickTab(harness, harness.dock, 'Errors') + + expect(openTabLabels(harness.sidebar)).toEqual(['Actions']) + }) + + it('leaves the dock tab alone when the sidebar tab changes', async () => { + const harness = await mountWorkbench() + + await clickTab(harness, harness.sidebar, 'Metadata') + + expect(openTabLabels(harness.sidebar)).toEqual(['Metadata']) + expect(openTabLabels(harness.dock)).toEqual(['Source']) + }) + + it('remembers each bar under its own cache id', async () => { + const harness = await mountWorkbench() + + await clickTab(harness, harness.dock, 'Console') + await clickTab(harness, harness.sidebar, 'Metadata') + + expect(localStorage.getItem(DOCK_CACHE_ID)).toBe('Console') + expect(localStorage.getItem(SIDEBAR_CACHE_ID)).toBe('Metadata') + }) + + it('reopens the dock tab it remembered', async () => { + localStorage.setItem(DOCK_CACHE_ID, 'Errors') + + const { dock } = await mountWorkbench() + + expect(openTabLabels(dock)).toEqual(['Errors']) + }) + }) + + describe('dock counts', () => { + it('counts the console entries on the Console tab', async () => { + const logs = consoleLogs() + const { dock } = await mountWorkbench({ consoleLogs: logs }) + + expect(badgeFor(dock, 'Console')).toBe(String(logs.length)) + }) + + it('counts the network requests on the Network tab', async () => { + const requests = networkRequests() + const { dock } = await mountWorkbench({ networkRequests: requests }) + + expect(badgeFor(dock, 'Network')).toBe(String(requests.length)) + }) + + it('counts the collected errors on the Errors tab', async () => { + const commands = [...liveCommands, erroringCommand] + const suites = failingSuites() + // Derived through the same collector the workbench calls: a count taken + // from the raw command list instead would miss the de-duplication. + const expected = collectErrors(commands, suites) + const { dock } = await mountWorkbench({ commands, suites }) + + expect(expected.length).toBeGreaterThan(0) + expect(badgeFor(dock, 'Errors')).toBe(String(expected.length)) + }) + + it('tints the error count and nothing else', async () => { + const { dock } = await mountWorkbench({ + commands: [erroringCommand], + suites: failingSuites() + }) + + expect(shadowAll(dock, DANGER_BADGE)).toHaveLength(1) + expect( + shadow(dock, DANGER_BADGE)?.previousElementSibling?.textContent + ).toBe('Errors') + }) + + it('leaves the Errors tab unbadged for a clean run', async () => { + const { dock } = await mountWorkbench({ commands: liveCommands }) + + expect(badgeFor(dock, 'Errors')).toBe(null) + }) + }) + + describe('collapsing the panes', () => { + it('hides the dock and offers a way back', async () => { + const harness = await mountWorkbench() + + await clickInWorkbench(harness, COLLAPSE_DOCK) + + expect([...harness.dock.classList]).toContain('hidden') + expect(shadowAll(harness.workbench, RESTORE_DOCK)).toHaveLength(1) + expect(localStorage.getItem('toolbar')).toBe('true') + }) + + it('starts with the dock collapsed when it was left that way', async () => { + localStorage.setItem('toolbar', 'true') + + const { dock, workbench } = await mountWorkbench() + + expect([...dock.classList]).toContain('hidden') + expect(shadowAll(workbench, RESTORE_DOCK)).toHaveLength(1) + }) + + it('collapses the actions sidebar to nothing', async () => { + const harness = await mountWorkbench() + + await clickInWorkbench(harness, COLLAPSE_SIDEBAR) + + expect( + shadow(harness.workbench, SIDEBAR_SECTION)?.getAttribute('style') + ).toContain('width:0') + expect(shadowAll(harness.workbench, RESTORE_SIDEBAR)).toHaveLength(1) + expect(localStorage.getItem('workbenchSidebar')).toBe('true') + }) + + it('brings the sidebar back', async () => { + const harness = await mountWorkbench() + await clickInWorkbench(harness, COLLAPSE_SIDEBAR) + + await clickInWorkbench(harness, RESTORE_SIDEBAR) + + expect( + shadow(harness.workbench, SIDEBAR_SECTION)?.getAttribute('style') + ).not.toContain('width:0') + expect(localStorage.getItem('workbenchSidebar')).toBe('false') + }) + }) + + describe('the resize handles', () => { + // Each mode renders only its own handles, so the ones it leaves out must + // stay quiet rather than reporting their missing slider on every mount. + it('mounts live mode without complaining about the player handles', async () => { + const warnings = await warningsWhileMounting(() => + mountWorkbench({ commands: liveCommands }) + ) + + expect(warnings).toEqual([]) + }) + + it('mounts player mode without complaining about the live handle', async () => { + const warnings = await warningsWhileMounting(() => + mountWorkbench({ commands: liveCommands }, { playerMode: true }) + ) + + expect(warnings).toEqual([]) + }) + }) + + describe('the Compare tab', () => { + it('offers no Compare tab before a baseline is preserved', async () => { + const { dock, workbench } = await mountWorkbench({ + commands: liveCommands, + selectedTestUid: SELECTED_TEST_UID + }) + + expect(tabLabels(dock)).toEqual(LIVE_DOCK) + expect(shadowAll(workbench, COMPARE_PANEL)).toHaveLength(0) + }) + + it('offers no Compare tab while the baseline map is empty', async () => { + const { dock } = await mountWorkbench({ + baselines: baselineMap(), + selectedTestUid: SELECTED_TEST_UID + }) + + expect(tabLabels(dock)).toEqual(LIVE_DOCK) + }) + + it('adds the Compare tab once a baseline is preserved', async () => { + const { dock, workbench } = await mountWorkbench({ + baselines: preservedFor(SELECTED_TEST_UID), + selectedTestUid: SELECTED_TEST_UID, + commands: liveCommands + }) + + expect(tabLabels(dock)).toEqual([...LIVE_DOCK, 'Compare']) + expect(shadowAll(workbench, COMPARE_PANEL)).toHaveLength(1) + }) + + it('offers no Compare tab until a test is selected', async () => { + const { dock, workbench } = await mountWorkbench({ + baselines: preservedFor(SELECTED_TEST_UID), + commands: liveCommands + }) + + expect(tabLabels(dock)).toEqual(LIVE_DOCK) + expect(shadowAll(workbench, COMPARE_PANEL)).toHaveLength(0) + }) + + it('leaves the Compare tab uncounted', async () => { + const { dock } = await mountWorkbench({ + baselines: baselineMap( + [SELECTED_TEST_UID, preservedAttempt()], + [OTHER_TEST_UID, preservedAttempt({ testUid: OTHER_TEST_UID })] + ), + selectedTestUid: SELECTED_TEST_UID, + commands: liveCommands + }) + + // The tab holds the selected test's comparison and no other, so counting + // every preserved baseline would contradict what it opens onto. + expect(tabLabels(dock)).toContain('Compare') + expect(badgeFor(dock, 'Compare')).toBe(null) + }) + + it('shows the comparison when the tab is clicked', async () => { + const harness = await mountWorkbench({ + baselines: preservedFor(SELECTED_TEST_UID), + selectedTestUid: SELECTED_TEST_UID, + commands: liveCommands + }) + + await clickTab(harness, harness.dock, 'Compare') + const panel = shadow(harness.workbench, COMPARE_PANEL) + + expect(openTabLabels(harness.dock)).toEqual(['Compare']) + // The panel got both sides through the workbench's own contexts, so it + // pairs the preserved run against the live one instead of prompting. + expect(shadowAll(panel!, EMPTY_STATE)).toHaveLength(0) + expect(shadowAll(panel!, STEP_ROW)).toHaveLength(PAIRED_ROWS) + }) + + it('adds the Compare tab when the baseline arrives after the mount', async () => { + const harness = await mountWorkbench({ + selectedTestUid: SELECTED_TEST_UID, + commands: liveCommands + }) + expect(tabLabels(harness.dock)).toEqual(LIVE_DOCK) + + await harness.publishBaselines(preservedFor(SELECTED_TEST_UID)) + + expect(tabLabels(harness.dock)).toEqual([...LIVE_DOCK, 'Compare']) + expect(texts(harness.dock, TAB_LABEL)).toContain('Compare') + }) + + it('drops the Compare tab when the baseline is cleared', async () => { + const harness = await mountWorkbench({ + baselines: preservedFor(SELECTED_TEST_UID), + selectedTestUid: SELECTED_TEST_UID, + commands: liveCommands + }) + expect(tabLabels(harness.dock)).toContain('Compare') + + await harness.publishBaselines(baselineMap()) + + expect(tabLabels(harness.dock)).toEqual(LIVE_DOCK) + expect(texts(harness.dock, TAB_LABEL)).toEqual(LIVE_DOCK) + expect(shadowAll(harness.workbench, COMPARE_PANEL)).toHaveLength(0) + }) + + it("keeps the tab when another test's baseline is cleared", async () => { + const harness = await mountWorkbench({ + baselines: baselineMap( + [SELECTED_TEST_UID, preservedAttempt()], + [OTHER_TEST_UID, preservedAttempt({ testUid: OTHER_TEST_UID })] + ), + selectedTestUid: SELECTED_TEST_UID, + commands: liveCommands + }) + + await harness.publishBaselines(preservedFor(SELECTED_TEST_UID)) + + expect(tabLabels(harness.dock)).toContain('Compare') + expect(shadowAll(harness.workbench, COMPARE_PANEL)).toHaveLength(1) + }) + + // What the panel renders is the SELECTED test's baseline, so a baseline held + // for any other test is nothing the tab can show โ€” it would open onto the + // panel's own "no baseline preserved" prompt. + it('drops the Compare tab when the baseline belongs to another test', async () => { + const harness = await mountWorkbench({ + baselines: preservedFor(OTHER_TEST_UID), + selectedTestUid: SELECTED_TEST_UID, + commands: liveCommands + }) + + expect(tabLabels(harness.dock)).toEqual(LIVE_DOCK) + expect(shadowAll(harness.workbench, COMPARE_PANEL)).toHaveLength(0) + }) + + it("drops the tab when the selected test's own baseline is cleared", async () => { + // The panel's Clear button drops exactly this one; another test keeping + // its baseline must not leave a tab with nothing to compare. + const harness = await mountWorkbench({ + baselines: baselineMap( + [SELECTED_TEST_UID, preservedAttempt()], + [OTHER_TEST_UID, preservedAttempt({ testUid: OTHER_TEST_UID })] + ), + selectedTestUid: SELECTED_TEST_UID, + commands: liveCommands + }) + + await harness.publishBaselines(preservedFor(OTHER_TEST_UID)) + + expect(tabLabels(harness.dock)).toEqual(LIVE_DOCK) + expect(shadowAll(harness.workbench, COMPARE_PANEL)).toHaveLength(0) + }) + + it('adds Compare after the trace-only tabs in player mode', async () => { + const { dock } = await mountWorkbench( + { + baselines: preservedFor(SELECTED_TEST_UID), + selectedTestUid: SELECTED_TEST_UID, + commands: liveCommands + }, + { playerMode: true } + ) + + expect(tabLabels(dock)).toEqual([ + ...LIVE_DOCK, + 'A11y', + 'Transcript', + 'Compare' + ]) + }) + + // Dropping the tab that is OPEN has to hand the dock back to a tab it still + // renders, or the dock shows no panel at all until the user clicks one. + it('falls back to the first dock tab when the open Compare tab is dropped', async () => { + const harness = await mountWorkbench({ + baselines: preservedFor(SELECTED_TEST_UID), + selectedTestUid: SELECTED_TEST_UID, + commands: liveCommands + }) + await clickTab(harness, harness.dock, 'Compare') + + await harness.publishBaselines(baselineMap()) + + expect(openTabLabels(harness.dock)).toEqual(['Source']) + expect(text(shadow(harness.dock, ACTIVE_BUTTON))).toBe('Source') + }) + + it('falls back to the first dock tab when the remembered one is gone', async () => { + localStorage.setItem(DOCK_CACHE_ID, 'Compare') + + const { dock } = await mountWorkbench({ + selectedTestUid: SELECTED_TEST_UID, + commands: liveCommands + }) + + expect(tabLabels(dock)).toEqual(LIVE_DOCK) + expect(openTabLabels(dock)).toEqual(['Source']) + expect(text(shadow(dock, ACTIVE_BUTTON))).toBe('Source') + }) + }) + + // The other half of the same join: the baseline map holds still and the + // SELECTION moves. Preserving auto-selects the preserved test, so this is the + // step right after it โ€” and the panel only ever renders the selected test's + // baseline, so the tab has to follow the selection, not just the map. + // (Today only the preserve auto-select and the popout hand-off publish this + // context; a sidebar row click updates the explorer's own highlight only.) + describe('the Compare tab following the selection', () => { + it('drops the tab when the selection moves to a test with no baseline', async () => { + const harness = await mountWorkbench({ + baselines: preservedFor(SELECTED_TEST_UID), + selectedTestUid: SELECTED_TEST_UID, + commands: liveCommands + }) + expect(tabLabels(harness.dock)).toEqual([...LIVE_DOCK, 'Compare']) + + await harness.publishSelectedTestUid(OTHER_TEST_UID) + + expect(tabLabels(harness.dock)).toEqual(LIVE_DOCK) + expect(texts(harness.dock, TAB_LABEL)).toEqual(LIVE_DOCK) + expect(shadowAll(harness.workbench, COMPARE_PANEL)).toHaveLength(0) + }) + + it('brings the tab back when the preserved test is selected again', async () => { + const harness = await mountWorkbench({ + baselines: preservedFor(SELECTED_TEST_UID), + selectedTestUid: SELECTED_TEST_UID, + commands: liveCommands + }) + await harness.publishSelectedTestUid(OTHER_TEST_UID) + + await harness.publishSelectedTestUid(SELECTED_TEST_UID) + await clickTab(harness, harness.dock, 'Compare') + const panel = shadow(harness.workbench, COMPARE_PANEL) + + expect(tabLabels(harness.dock)).toEqual([...LIVE_DOCK, 'Compare']) + // The returning tab opens onto the real pairing, not the panel's own + // "No baseline preserved." prompt. + expect(shadowAll(panel!, EMPTY_STATE)).toHaveLength(0) + expect(shadowAll(panel!, STEP_ROW)).toHaveLength(PAIRED_ROWS) + }) + + it('offers no tab for a test no baseline was ever preserved for', async () => { + const harness = await mountWorkbench({ + baselines: preservedFor(SELECTED_TEST_UID), + commands: liveCommands + }) + + await harness.publishSelectedTestUid(UNPRESERVED_TEST_UID) + + expect(tabLabels(harness.dock)).toEqual(LIVE_DOCK) + expect(shadowAll(harness.workbench, COMPARE_PANEL)).toHaveLength(0) + expect(openPanelTags(harness.dock)).toEqual(['wdio-devtools-source']) + }) + + it('waits for the other test to be selected before offering its baseline', async () => { + const harness = await mountWorkbench({ + selectedTestUid: SELECTED_TEST_UID, + commands: liveCommands + }) + + await harness.publishBaselines(preservedFor(OTHER_TEST_UID)) + expect(tabLabels(harness.dock)).toEqual(LIVE_DOCK) + + await harness.publishSelectedTestUid(OTHER_TEST_UID) + await clickTab(harness, harness.dock, 'Compare') + + expect(tabLabels(harness.dock)).toEqual([...LIVE_DOCK, 'Compare']) + expect( + shadowAll(shadow(harness.workbench, COMPARE_PANEL)!, STEP_ROW) + ).toHaveLength(PAIRED_ROWS) + }) + + // Same hand-back as clearing the baseline under the open tab: a selection + // that unmounts the OPEN Compare tab must leave a panel on screen. + it('falls back to the first dock tab when the selection drops the open Compare tab', async () => { + const harness = await mountWorkbench({ + baselines: preservedFor(SELECTED_TEST_UID), + selectedTestUid: SELECTED_TEST_UID, + commands: liveCommands + }) + await clickTab(harness, harness.dock, 'Compare') + expect(openTabLabels(harness.dock)).toEqual(['Compare']) + + await harness.publishSelectedTestUid(OTHER_TEST_UID) + + expect(openTabLabels(harness.dock)).toEqual(['Source']) + expect(openPanelTags(harness.dock)).toEqual(['wdio-devtools-source']) + expect(text(shadow(harness.dock, ACTIVE_BUTTON))).toBe('Source') + }) + + it('leaves the fallback tab open when the Compare tab returns', async () => { + const harness = await mountWorkbench({ + baselines: preservedFor(SELECTED_TEST_UID), + selectedTestUid: SELECTED_TEST_UID, + commands: liveCommands + }) + await clickTab(harness, harness.dock, 'Compare') + await harness.publishSelectedTestUid(OTHER_TEST_UID) + + await harness.publishSelectedTestUid(SELECTED_TEST_UID) + + // The tab is offered again, but re-selecting a test is not a request to + // jump panels โ€” the dock stays where the fallback left it. + expect(tabLabels(harness.dock)).toEqual([...LIVE_DOCK, 'Compare']) + expect(openTabLabels(harness.dock)).toEqual(['Source']) + }) + }) +}) diff --git a/packages/app/tests/ambient-globals.test.ts b/packages/app/tests/ambient-globals.test.ts new file mode 100644 index 00000000..cbb219f7 --- /dev/null +++ b/packages/app/tests/ambient-globals.test.ts @@ -0,0 +1,174 @@ +/** + * Guards the ambient type surface the app relies on. A bad `import('โ€ฆ').X` + * reference in `vite-env.d.ts` (a name the target module doesn't export) does + * not fail the build โ€” TypeScript silently degrades it to `any`, so every + * listener on that event loses its checking. `app-test-run`/`app-test-stop` + * pointed at `sidebar/test-suite`, which only re-imports `TestRunDetail` + * without exporting it; both details were `any`. + * + * The `expectTypeOf` assertions below are compile-time only (this file is in + * `packages/app/tsconfig.json`, so `tsc --noEmit` enforces them); the runtime + * `expect`s pin the console filter to shared's canonical `ConsoleLog`. + */ + +import { describe, expect, expectTypeOf, it } from 'vitest' +import type { ContextType } from '@lit/context' +// Aliased so the bare `NetworkRequest` below still resolves to the ambient +// global under test rather than to this import. +import type { + ConsoleLog, + LogLevel, + LogSource, + NetworkRequest as SharedNetworkRequest +} from '@wdio/devtools-shared' +import { + filterConsoleLogs, + type ConsoleLevelFilter +} from '../src/components/workbench/console-filter.js' +import type { TestRunDetail } from '../src/components/sidebar/types.js' +import type { + consoleLogContext, + networkRequestContext +} from '../src/controller/context.js' +// Type-only: `constants.ts` reads `window` at module scope, so importing its +// value would drag happy-dom in for an assertion that is compile-time anyway. +import type { CONSOLE_SOURCE_BADGE } from '../src/controller/constants.js' +import { getResourceType, contentType } from '../src/utils/network-helpers.js' + +describe('vite-env.d.ts event-map references', () => { + it('resolves app-test-run detail to the canonical TestRunDetail', () => { + type Detail = GlobalEventHandlersEventMap['app-test-run']['detail'] + expectTypeOf<Detail>().not.toBeAny() + expectTypeOf<Detail>().toEqualTypeOf<TestRunDetail>() + }) + + it('resolves app-test-stop detail to the canonical TestRunDetail', () => { + type Detail = GlobalEventHandlersEventMap['app-test-stop']['detail'] + expectTypeOf<Detail>().not.toBeAny() + expectTypeOf<Detail>().toEqualTypeOf<TestRunDetail>() + }) + + it('keeps the other declared event details checked', () => { + expectTypeOf< + GlobalEventHandlersEventMap['app-status-filter']['detail'] + >().not.toBeAny() + expectTypeOf< + GlobalEventHandlersEventMap['app-mutation-select']['detail'] + >().not.toBeAny() + expectTypeOf< + GlobalEventHandlersEventMap['show-command']['detail']['command'] + >().not.toBeAny() + }) +}) + +describe('console filtering is typed against shared ConsoleLog', () => { + it('takes and returns the canonical shared type, not `any`', () => { + expectTypeOf(filterConsoleLogs).parameter(0).not.toBeAny() + expectTypeOf(filterConsoleLogs).parameter(0).toEqualTypeOf<ConsoleLog[]>() + expectTypeOf(filterConsoleLogs).returns.toEqualTypeOf<ConsoleLog[]>() + }) + + it('derives the level filter from shared LogLevel', () => { + expectTypeOf<ConsoleLevelFilter>().toEqualTypeOf<'all' | LogLevel>() + }) + + it('filters canonical ConsoleLog entries by level and search', () => { + const logs: ConsoleLog[] = [ + { type: 'log', args: ['hello world'], timestamp: 1, source: 'browser' }, + { type: 'error', args: ['boom'], timestamp: 2, source: 'test' }, + { type: 'warn', args: ['deprecated'], timestamp: 3, source: 'terminal' } + ] + + expect(filterConsoleLogs(logs, 'all', '')).toHaveLength(3) + expect(filterConsoleLogs(logs, 'error', '')).toEqual([logs[1]]) + expect(filterConsoleLogs(logs, 'all', 'WORLD')).toEqual([logs[0]]) + expect(filterConsoleLogs(logs, 'warn', 'boom')).toEqual([]) + }) + + it('preserves the source field the console badge reads', () => { + const logs: ConsoleLog[] = [ + { type: 'log', args: ['a'], timestamp: 1, source: 'terminal' } + ] + expect(filterConsoleLogs(logs, 'all', '')[0]?.source).toBe('terminal') + }) +}) + +/** + * The same silent-`any` failure mode as above, one file over: `script/types.d.ts` + * aliased `ConsoleLogs` to a name `collectors/consoleLogs.ts` never exported and + * declared `type NetworkRequest = NetworkRequest` (self-circular). Both degraded + * to `any` for every app consumer, and `skipLibCheck` hid the two errors. + */ +describe('script/types.d.ts ambient globals', () => { + it('resolves ConsoleLogs to shared ConsoleLog, not `any`', () => { + expectTypeOf<ConsoleLogs>().not.toBeAny() + expectTypeOf<ConsoleLogs>().toEqualTypeOf<ConsoleLog>() + }) + + it('resolves NetworkRequest to shared NetworkRequest, not `any`', () => { + expectTypeOf<NetworkRequest>().not.toBeAny() + expectTypeOf<NetworkRequest>().toEqualTypeOf<SharedNetworkRequest>() + }) +}) + +describe('network/console contexts carry the canonical shared types', () => { + it('types consoleLogContext as ConsoleLog[]', () => { + type Value = ContextType<typeof consoleLogContext> + expectTypeOf<Value>().not.toBeAny() + expectTypeOf<Value>().toEqualTypeOf<ConsoleLog[]>() + }) + + it('types networkRequestContext as NetworkRequest[]', () => { + type Value = ContextType<typeof networkRequestContext> + expectTypeOf<Value>().not.toBeAny() + expectTypeOf<Value>().toEqualTypeOf<SharedNetworkRequest[]>() + }) + + it('keys the console source badge off shared LogSource', () => { + expectTypeOf<typeof CONSOLE_SOURCE_BADGE>().not.toBeAny() + expectTypeOf<keyof typeof CONSOLE_SOURCE_BADGE>().toEqualTypeOf<LogSource>() + }) +}) + +describe('network helpers accept the canonical NetworkRequest', () => { + const request: SharedNetworkRequest = { + id: 'r1', + url: 'https://example.com/app.js', + method: 'GET', + type: 'script', + timestamp: 1, + startTime: 1, + responseHeaders: { 'content-type': 'application/javascript; charset=utf-8' } + } + + it('is typed against shared, not `any`', () => { + expectTypeOf(getResourceType).parameter(0).not.toBeAny() + expectTypeOf(getResourceType) + .parameter(0) + .toEqualTypeOf<SharedNetworkRequest>() + expectTypeOf(contentType).parameter(0).toEqualTypeOf<SharedNetworkRequest>() + }) + + it('classifies a request built from shared-only fields', () => { + expect(getResourceType(request)).toBe('JS') + expect(contentType(request)).toBe('application/javascript') + }) + + it('accepts the superset fields the local duplicate lacked', () => { + const withSupersetFields: SharedNetworkRequest = { + ...request, + headers: { accept: '*/*' }, + cookies: [], + navigation: 'nav-1', + redirectChain: [], + children: [], + response: { + fromCache: false, + headers: { 'content-type': 'application/javascript' }, + mimeType: 'application/javascript', + status: 200 + } + } + expect(getResourceType(withSupersetFields)).toBe('JS') + }) +}) diff --git a/packages/app/tests/compareUtils.test.ts b/packages/app/tests/compareUtils.test.ts index 6217170e..5401ec2f 100644 --- a/packages/app/tests/compareUtils.test.ts +++ b/packages/app/tests/compareUtils.test.ts @@ -62,6 +62,51 @@ describe('compareUtils', () => { ).toBe('error') }) + it('reads a message-less error by name, so two different ones stay divergent', () => { + // Both stringify to `[object Object]`, which equalised them and left the + // divergence invisible in Compare. + const timeout = cmd('click', [], { + error: { name: 'TimeoutError', message: '' } + }) + const stale = cmd('click', [], { + error: { name: 'StaleElementReferenceError', message: '' } + }) + + expect(commandsEqual(timeout, stale)).toBe(false) + expect(classifyDivergence(timeout, stale)).toBe('error') + // The same message-less error on both sides is still equal. + expect( + commandsEqual( + timeout, + cmd('click', [], { error: { name: 'TimeoutError', message: '' } }) + ) + ).toBe(true) + // A message-less error against no error at all is a divergence too. + expect(commandsEqual(timeout, cmd('click'))).toBe(false) + // And the row reaches the view as divergent. + expect( + pairSteps([cmd('url'), timeout], [cmd('url'), stale]).map( + (p) => p.divergent + ) + ).toEqual([false, true]) + }) + + it('prefers the error message over the name when one is present', () => { + expect( + commandsEqual( + cmd('click', [], { error: { name: 'TimeoutError', message: 'boom' } }), + cmd('click', [], { error: { name: 'TimeoutError', message: 'bang' } }) + ) + ).toBe(false) + // A whitespace-only message does not win over the name. + expect( + classifyDivergence( + cmd('click', [], { error: { name: 'TimeoutError', message: ' ' } }), + cmd('click', [], { error: { name: 'TimeoutError', message: '' } }) + ) + ).toBe('none') + }) + it('pairSteps locks the fork bit once execution diverges and handles uneven lengths', () => { const pairs = pairSteps( [cmd('url'), cmd('a'), cmd('b'), cmd('c')], @@ -72,6 +117,19 @@ describe('compareUtils', () => { expect(pairs[3].latest).toBeUndefined() }) + it('pairSteps keeps a row divergent after the fork even when its own commands match', () => { + const pairs = pairSteps( + [cmd('url'), cmd('a'), cmd('b')], + [cmd('url'), cmd('X'), cmd('b')] + ) + + expect(pairs.map((p) => p.divergent)).toEqual([false, true, true]) + // The last row is only divergent because of the latch โ€” on its own the two + // sides are identical. + expect(commandsEqual(pairs[2].baseline, pairs[2].latest)).toBe(true) + expect(classifyDivergence(pairs[2].baseline, pairs[2].latest)).toBe('none') + }) + it('extractExpectedFromStepText pulls the parameterized tail from Cucumber Then steps', () => { expect( extractExpectedFromStepText( diff --git a/packages/app/tests/console-filter.test.ts b/packages/app/tests/console-filter.test.ts index 382d7201..25d0a340 100644 --- a/packages/app/tests/console-filter.test.ts +++ b/packages/app/tests/console-filter.test.ts @@ -5,7 +5,7 @@ import { stripAnsi } from '../src/components/workbench/console-filter.js' -const ESC = '' +const ESC = '\u001b' function log( type: ConsoleLogs['type'], @@ -22,6 +22,14 @@ describe('stripAnsi', () => { ) }) + // A cursor sequence, not colour: the pattern must accept any trailing letter, + // or the ESC stays behind as an invisible byte in the rendered log line. + it('removes non-colour escape sequences', () => { + const cleaned = stripAnsi(`${ESC}[2Kdownloading${ESC}[1G done`) + expect(cleaned).toBe('downloading done') + expect([...cleaned].filter((c) => c < ' ')).toEqual([]) + }) + it('leaves plain text untouched', () => { expect(stripAnsi('no codes here')).toBe('no codes here') }) diff --git a/packages/app/tests/data-manager.test.ts b/packages/app/tests/data-manager.test.ts new file mode 100644 index 00000000..8b179f2e --- /dev/null +++ b/packages/app/tests/data-manager.test.ts @@ -0,0 +1,955 @@ +// @vitest-environment happy-dom +// +// DataManagerController is the app's state hub: every panel reads it through a +// `@lit/context` provider, and everything it publishes arrives as a WS frame. +// So the tests drive it the way the backend does โ€” a socket message in, a +// provider value out โ€” rather than calling its private handlers. The socket and +// the trace-probe fetch are the only two things stubbed; the rest of the path +// (parse โ†’ scope dispatch โ†’ merge helpers โ†’ provider) is the real code. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ReactiveController, ReactiveControllerHost } from 'lit' +import { + BASELINE_WS_SCOPE, + TRACE_API, + TraceType, + WS_SCOPE, + type CommandLog, + type NetworkRequest, + type PreservedAttempt, + type TraceLog, + type TracePlayerData, + type WsMessageScope +} from '@wdio/devtools-shared' + +import { CACHE_ID } from '../src/controller/constants.js' +import { DataManagerController } from '../src/controller/DataManager.js' +import { rerunState } from '../src/controller/rerunState.js' +import type { + SuiteStatsFragment, + TestStatsFragment +} from '../src/controller/types.js' + +const LOGIN_URL = 'https://the-internet.herokuapp.com/login' +const RUN_START = 1_700_000_000_000 +const SESSION = 'session-a' + +/** Host stand-in: a real element (the providers listen on it) that never runs + * the controller lifecycle itself, so each test decides when to connect. */ +class StubHost extends HTMLElement implements ReactiveControllerHost { + readonly controllers: ReactiveController[] = [] + updateRequests = 0 + + addController(controller: ReactiveController) { + this.controllers.push(controller) + } + + removeController(controller: ReactiveController) { + const index = this.controllers.indexOf(controller) + if (index !== -1) { + this.controllers.splice(index, 1) + } + } + + requestUpdate() { + this.updateRequests++ + } + + get updateComplete(): Promise<boolean> { + return Promise.resolve(true) + } +} +customElements.define('data-manager-stub-host', StubHost) + +/** Stands in for the backend's `/client` socket: the controller attaches its + * message listener on `open`, so frames are only delivered after that. */ +class FakeSocket { + static opened: FakeSocket[] = [] + readonly #listeners = new Map<string, ((event: unknown) => void)[]>() + closed = false + + constructor(readonly url: string) { + FakeSocket.opened.push(this) + } + + addEventListener(type: string, listener: (event: unknown) => void) { + this.#listeners.set(type, [...(this.#listeners.get(type) ?? []), listener]) + } + + removeEventListener() {} + + close() { + this.closed = true + } + + #emit(type: string, event: unknown) { + for (const listener of this.#listeners.get(type) ?? []) { + listener(event) + } + } + + connectionOpened() { + this.#emit('open', { type: 'open' }) + } + + connectionErrored() { + this.#emit('error', { type: 'error' }) + } + + /** One WS frame, serialized exactly as the backend broadcasts it. */ + deliver(scope: WsMessageScope | 'screencast', data: unknown) { + this.#emit('message', { data: JSON.stringify({ scope, data }) }) + } + + deliverRaw(payload: string) { + this.#emit('message', { data: payload }) + } +} + +interface Harness { + host: StubHost + manager: DataManagerController + /** The client socket, absent when the trace probe put the app in player mode. */ + socket: FakeSocket | undefined + deliver: (scope: WsMessageScope | 'screencast', data: unknown) => void +} + +const nextTask = () => new Promise<void>((resolve) => setTimeout(resolve, 0)) + +let traceProbe: () => Promise<unknown> +let fetchedUrls: string[] +let warnings: string[] + +/** The trace-probe response shape the controller reads: `ok`, `status`, `json()` + * only โ€” typed as the parts used rather than a full `Response` fake. */ +function probeResponse(status: number, body?: TracePlayerData) { + return { + ok: status >= 200 && status < 300, + status, + json: () => Promise.resolve(body) + } +} + +async function boot( + options: { trace?: TracePlayerData; probeFails?: boolean } = {} +): Promise<Harness> { + if (options.probeFails) { + traceProbe = () => Promise.reject(new Error('no trace server')) + } else if (options.trace) { + traceProbe = () => Promise.resolve(probeResponse(200, options.trace)) + } else { + traceProbe = () => Promise.resolve(probeResponse(204)) + } + + const host = document.createElement('data-manager-stub-host') as StubHost + document.body.append(host) + const manager = new DataManagerController(host) + manager.hostConnected() + await nextTask() + + const socket = FakeSocket.opened[FakeSocket.opened.length - 1] + socket?.connectionOpened() + return { + host, + manager, + socket, + deliver: (scope, data) => socket?.deliver(scope, data) + } +} + +function command(overrides: Partial<CommandLog> = {}): CommandLog { + return { + command: 'click', + args: ['#submit'], + timestamp: RUN_START, + ...overrides + } +} + +function request(overrides: Partial<NetworkRequest> = {}): NetworkRequest { + return { + id: 'req-1', + url: LOGIN_URL, + method: 'GET', + status: 200, + timestamp: RUN_START, + ...overrides + } as NetworkRequest +} + +function test( + uid: string, + overrides: Partial<TestStatsFragment> = {} +): TestStatsFragment { + return { + uid, + title: uid, + fullTitle: `login page ${uid}`, + file: '/specs/login.e2e.ts', + state: 'passed', + ...overrides + } +} + +function suite( + uid: string, + overrides: Partial<SuiteStatsFragment> = {} +): SuiteStatsFragment { + return { + uid, + title: uid, + fullTitle: uid, + file: '/specs/login.e2e.ts', + start: new Date(RUN_START), + tests: [], + suites: [], + ...overrides + } as SuiteStatsFragment +} + +/** A `suites` frame as the reporter sends it: chunks keyed by root-suite uid. */ +const suitesFrame = (...roots: SuiteStatsFragment[]) => + roots.map((root) => ({ [root.uid]: root })) + +function attempt(overrides: Partial<PreservedAttempt> = {}): PreservedAttempt { + return { + testUid: 'login-test', + scope: 'test', + capturedAt: RUN_START + 5000, + window: { start: RUN_START, end: RUN_START + 4000 }, + test: { title: 'fills the login form', state: 'failed' }, + commands: [command({ command: 'url', args: [LOGIN_URL] })], + consoleLogs: [], + networkRequests: [], + mutations: [], + sources: {}, + ...overrides + } +} + +function traceLog(overrides: Partial<TraceLog> = {}): TraceLog { + return { + mutations: [], + logs: ['run finished'], + consoleLogs: [], + networkRequests: [request()], + metadata: { sessionId: SESSION, type: TraceType.Testrunner }, + commands: [command()], + sources: { '/specs/login.e2e.ts': 'await browser.url(url)' }, + suites: suitesFrame(suite('login-suite')), + ...overrides + } +} + +const playerData = ( + overrides: Partial<TracePlayerData> = {} +): TracePlayerData => ({ + trace: traceLog(), + frames: [{ timestamp: RUN_START, screenshot: 'iVBORw0KGg' }], + startTime: RUN_START, + duration: 4000, + ...overrides +}) + +/** Every root suite the app publishes, flattened out of the keyed chunks. */ +const publishedSuites = ( + manager: DataManagerController +): SuiteStatsFragment[] => + (manager.suitesContextProvider.value || []).flatMap((chunk) => + Object.values(chunk) + ) + +const publishedUids = (manager: DataManagerController): string[] => + (manager.suitesContextProvider.value || []).flatMap((chunk) => + Object.keys(chunk) + ) + +beforeEach(() => { + FakeSocket.opened = [] + fetchedUrls = [] + warnings = [] + rerunState.activeRerunSuiteUid = undefined + rerunState.isTopLevelRerun = false + localStorage.clear() + document.body.innerHTML = '' + traceProbe = () => Promise.resolve(probeResponse(204)) + vi.stubGlobal('WebSocket', FakeSocket) + vi.stubGlobal('fetch', (url: string) => { + fetchedUrls.push(String(url)) + return traceProbe() + }) + vi.spyOn(console, 'warn').mockImplementation((...args: unknown[]) => { + warnings.push(args.map(String).join(' ')) + }) +}) + +afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() +}) + +describe('DataManagerController', () => { + describe('bootstrap', () => { + it('probes the trace endpoint before opening a live socket', async () => { + await boot() + + expect(fetchedUrls).toEqual([TRACE_API.get]) + expect(FakeSocket.opened).toHaveLength(1) + expect(FakeSocket.opened[0].url).toContain('/client') + }) + + it('reports a connection once the socket opens', async () => { + const host = document.createElement('data-manager-stub-host') as StubHost + const manager = new DataManagerController(host) + manager.hostConnected() + await nextTask() + + expect(manager.hasConnection).toBe(false) + + FakeSocket.opened[0].connectionOpened() + + expect(manager.hasConnection).toBe(true) + }) + + it('ignores frames that arrive before the socket opened', async () => { + const host = document.createElement('data-manager-stub-host') as StubHost + const manager = new DataManagerController(host) + manager.hostConnected() + await nextTask() + + FakeSocket.opened[0].deliver('commands', [command()]) + + expect(manager.commandsContextProvider.value).toEqual([]) + }) + + it('plays a served trace instead of connecting to a socket', async () => { + const data = playerData() + const { manager } = await boot({ trace: data }) + + expect(manager.playerMode).toBe(true) + expect(manager.hasConnection).toBe(true) + expect(FakeSocket.opened).toHaveLength(0) + expect(manager.framesContextProvider.value).toEqual(data.frames) + expect(manager.commandsContextProvider.value).toEqual(data.trace.commands) + }) + + it('stays in live mode when the probe serves no trace', async () => { + const { manager } = await boot() + + expect(manager.playerMode).toBe(false) + expect(manager.framesContextProvider.value).toEqual([]) + }) + + it('falls back to the socket when the probe request fails', async () => { + const { manager } = await boot({ probeFails: true }) + + expect(manager.playerMode).toBe(false) + expect(FakeSocket.opened).toHaveLength(1) + expect(manager.hasConnection).toBe(true) + }) + + it('closes the socket when the host goes away', async () => { + const { manager, socket } = await boot() + + manager.hostDisconnected() + + expect(socket?.closed).toBe(true) + }) + + it('replays a cached trace when the socket errors', async () => { + const cached = traceLog({ logs: ['from the cache'] }) + localStorage.setItem(CACHE_ID, JSON.stringify(cached)) + const { manager, socket } = await boot() + + socket?.connectionErrored() + + expect(manager.logsContextProvider.value).toEqual(['from the cache']) + }) + + it('warns rather than throwing when the cache holds no trace', async () => { + const { socket } = await boot() + + socket?.connectionErrored() + + expect(warnings.join('\n')).toContain('cached trace file') + }) + }) + + describe('accumulating execution data', () => { + it('appends each commands frame to the ones before it', async () => { + const { manager, deliver } = await boot() + + deliver('commands', [command({ command: 'url', timestamp: RUN_START })]) + deliver('commands', [ + command({ command: 'click', timestamp: RUN_START + 10 }) + ]) + + expect( + manager.commandsContextProvider.value?.map((entry) => entry.command) + ).toEqual(['url', 'click']) + }) + + it('appends mutations and console logs the same way', async () => { + const { manager, deliver } = await boot() + + deliver('mutations', [{ type: 'attributes', timestamp: RUN_START }]) + deliver('mutations', [{ type: 'childList', timestamp: RUN_START + 5 }]) + deliver('consoleLogs', [{ type: 'log', args: ['first'] }]) + deliver('consoleLogs', [{ type: 'error', args: ['second'] }]) + + expect(manager.mutationsContextProvider.value).toHaveLength(2) + expect(manager.consoleLogsContextProvider.value).toHaveLength(2) + }) + + it('replaces the runner log buffer instead of appending it', async () => { + // The adapter resends the whole terminal buffer each time; appending would + // duplicate every line already on screen. + const { manager, deliver } = await boot() + + deliver('logs', ['spec started']) + deliver('logs', ['spec started', 'spec finished']) + + expect(manager.logsContextProvider.value).toEqual([ + 'spec started', + 'spec finished' + ]) + }) + + it('asks the host to re-render for every frame it accepts', async () => { + const { host, deliver } = await boot() + const before = host.updateRequests + + deliver('commands', [command()]) + + expect(host.updateRequests).toBeGreaterThan(before) + }) + + it('swaps a command in place when the adapter reissues it', async () => { + const { manager, deliver } = await boot() + deliver('commands', [ + command({ command: 'getText', timestamp: RUN_START }), + command({ command: 'click', timestamp: RUN_START + 50 }) + ]) + + deliver(WS_SCOPE.replaceCommand, { + oldTimestamp: RUN_START, + command: command({ command: 'expect.toHaveText', timestamp: RUN_START }) + }) + + expect( + manager.commandsContextProvider.value?.map((entry) => entry.command) + ).toEqual(['expect.toHaveText', 'click']) + }) + + it('dedups network requests by their request id', async () => { + const { manager, deliver } = await boot() + + deliver('networkRequests', [request({ id: 'req-1', status: 0 })]) + deliver('networkRequests', [request({ id: 'req-1', status: 302 })]) + + expect(manager.networkRequestsContextProvider.value).toHaveLength(1) + expect(manager.networkRequestsContextProvider.value?.[0].status).toBe(302) + }) + + it('keeps every request that carries no id', async () => { + const { manager, deliver } = await boot() + + deliver('networkRequests', [request({ id: undefined })]) + deliver('networkRequests', [request({ id: undefined })]) + + expect(manager.networkRequestsContextProvider.value).toHaveLength(2) + }) + + it('merges source files across frames', async () => { + const { manager, deliver } = await boot() + + deliver('sources', { '/specs/login.e2e.ts': 'first' }) + deliver('sources', { + '/specs/login.e2e.ts': 'second', + '/specs/checkout.e2e.ts': 'other' + }) + + expect(manager.sourcesContextProvider.value).toEqual({ + '/specs/login.e2e.ts': 'second', + '/specs/checkout.e2e.ts': 'other' + }) + }) + }) + + describe('metadata', () => { + it('publishes the running session as the active metadata', async () => { + const { manager, deliver } = await boot() + + deliver('metadata', { sessionId: SESSION, testEnv: 'chrome' }) + + expect(manager.metadataContextProvider.value?.testEnv).toBe('chrome') + expect( + Object.keys(manager.metadataBySessionContextProvider.value ?? {}) + ).toEqual([SESSION]) + }) + + it('attributes a session-less update to the session in flight', async () => { + const { manager, deliver } = await boot() + + deliver('metadata', { sessionId: SESSION, testEnv: 'chrome' }) + deliver('metadata', { url: LOGIN_URL }) + + const bySession = manager.metadataBySessionContextProvider.value ?? {} + expect(Object.keys(bySession)).toEqual([SESSION]) + expect(manager.metadataContextProvider.value?.url).toBe(LOGIN_URL) + expect(manager.metadataContextProvider.value?.testEnv).toBe('chrome') + }) + + it('exposes the capture type of the active session', async () => { + const { manager, deliver } = await boot() + + expect(manager.traceType).toBe(undefined) + + deliver('metadata', { sessionId: SESSION, type: 'trace' }) + + expect(manager.traceType).toBe('trace') + }) + }) + + describe('the suite tree', () => { + it('publishes one entry per root suite, keyed by uid', async () => { + const { manager, deliver } = await boot() + + deliver( + 'suites', + suitesFrame(suite('login-suite'), suite('checkout-suite')) + ) + + expect(publishedUids(manager)).toEqual(['login-suite', 'checkout-suite']) + }) + + it('folds repeated frames for one suite into a single entry', async () => { + const { manager, deliver } = await boot() + + deliver( + 'suites', + suitesFrame(suite('login-suite', { tests: [test('t-1')] })) + ) + deliver( + 'suites', + suitesFrame( + suite('login-suite', { + tests: [test('t-1'), test('t-2')], + end: new Date(RUN_START + 4000) + }) + ) + ) + + expect(publishedUids(manager)).toEqual(['login-suite']) + expect( + publishedSuites(manager)[0].tests?.map((entry) => entry.uid) + ).toEqual(['t-1', 't-2']) + }) + + it('dedups a test that is resent inside its suite', async () => { + const { manager, deliver } = await boot() + + deliver( + 'suites', + suitesFrame( + suite('login-suite', { tests: [test('t-1', { state: 'running' })] }) + ) + ) + deliver( + 'suites', + suitesFrame( + suite('login-suite', { tests: [test('t-1', { state: 'failed' })] }) + ) + ) + + const tests = publishedSuites(manager)[0].tests ?? [] + expect(tests).toHaveLength(1) + expect(tests[0].state).toBe('failed') + }) + + it('accepts a suites frame sent as a single object', async () => { + const { manager, deliver } = await boot() + + deliver('suites', { 'login-suite': suite('login-suite') }) + + expect(publishedUids(manager)).toEqual(['login-suite']) + }) + + it('drops entries that carry no uid', async () => { + const { manager, deliver } = await boot() + + deliver('suites', [{ ghost: { title: 'no uid' } }]) + + expect(publishedSuites(manager)).toEqual([]) + }) + + it('wipes execution data when a finished suite starts a new run', async () => { + const { manager, deliver } = await boot() + deliver( + 'suites', + suitesFrame(suite('login-suite', { end: new Date(RUN_START + 10) })) + ) + deliver('commands', [command()]) + + deliver( + 'suites', + suitesFrame( + suite('login-suite', { start: new Date(RUN_START + 60_000) }) + ) + ) + + expect(manager.commandsContextProvider.value).toEqual([]) + // The tree itself survives so the sidebar keeps its rows. + expect(publishedUids(manager)).toEqual(['login-suite']) + }) + + it('keeps execution data while the same run continues', async () => { + const { manager, deliver } = await boot() + deliver('suites', suitesFrame(suite('login-suite'))) + deliver('commands', [command()]) + + deliver( + 'suites', + suitesFrame( + suite('login-suite', { start: new Date(RUN_START + 60_000) }) + ) + ) + + expect(manager.commandsContextProvider.value).toHaveLength(1) + }) + }) + + describe('baselines', () => { + it('publishes a preserved attempt under its test uid', async () => { + const { manager, deliver } = await boot() + const preserved = attempt() + + deliver(BASELINE_WS_SCOPE.saved, { + testUid: 'login-test', + attempt: preserved + }) + + expect(manager.baselineContextProvider.value?.get('login-test')).toEqual( + preserved + ) + }) + + it('selects the preserved test so the comparison has a pair', async () => { + const { manager, deliver } = await boot() + + deliver(BASELINE_WS_SCOPE.saved, { + testUid: 'login-test', + attempt: attempt() + }) + + expect(manager.selectedTestUidContextProvider.value).toBe('login-test') + }) + + it('keeps earlier baselines when another test is preserved', async () => { + const { manager, deliver } = await boot() + + deliver(BASELINE_WS_SCOPE.saved, { + testUid: 'login-test', + attempt: attempt() + }) + deliver(BASELINE_WS_SCOPE.saved, { + testUid: 'checkout-test', + attempt: attempt({ testUid: 'checkout-test' }) + }) + + expect([ + ...(manager.baselineContextProvider.value?.keys() ?? []) + ]).toEqual(['login-test', 'checkout-test']) + }) + + it('publishes a fresh map rather than mutating the one consumers hold', async () => { + // A context value that changes identity is what makes `subscribe: true` + // consumers (the workbench's Compare tab) re-render. + const { manager, deliver } = await boot() + const before = manager.baselineContextProvider.value + + deliver(BASELINE_WS_SCOPE.saved, { + testUid: 'login-test', + attempt: attempt() + }) + + expect(manager.baselineContextProvider.value).not.toBe(before) + expect(before?.size).toBe(0) + }) + + it('drops only the cleared test from the map', async () => { + const { manager, deliver } = await boot() + deliver(BASELINE_WS_SCOPE.saved, { + testUid: 'login-test', + attempt: attempt() + }) + deliver(BASELINE_WS_SCOPE.saved, { + testUid: 'checkout-test', + attempt: attempt({ testUid: 'checkout-test' }) + }) + + deliver(BASELINE_WS_SCOPE.cleared, { testUid: 'login-test' }) + + expect([ + ...(manager.baselineContextProvider.value?.keys() ?? []) + ]).toEqual(['checkout-test']) + }) + + it('leaves the map alone when an unknown test is cleared', async () => { + const { manager, deliver } = await boot() + deliver(BASELINE_WS_SCOPE.saved, { + testUid: 'login-test', + attempt: attempt() + }) + + deliver(BASELINE_WS_SCOPE.cleared, { testUid: 'never-preserved' }) + + expect([ + ...(manager.baselineContextProvider.value?.keys() ?? []) + ]).toEqual(['login-test']) + }) + + it('publishes a fresh map on a clear too', async () => { + const { manager, deliver } = await boot() + deliver(BASELINE_WS_SCOPE.saved, { + testUid: 'login-test', + attempt: attempt() + }) + const before = manager.baselineContextProvider.value + + deliver(BASELINE_WS_SCOPE.cleared, { testUid: 'login-test' }) + + expect(manager.baselineContextProvider.value).not.toBe(before) + expect(manager.baselineContextProvider.value?.size).toBe(0) + }) + + it('keeps the selection when the baseline is cleared', async () => { + // The panel reads both; clearing the baseline is not a deselection. + const { manager, deliver } = await boot() + deliver(BASELINE_WS_SCOPE.saved, { + testUid: 'login-test', + attempt: attempt() + }) + + deliver(BASELINE_WS_SCOPE.cleared, { testUid: 'login-test' }) + + expect(manager.selectedTestUidContextProvider.value).toBe('login-test') + }) + + it('publishes the test the sidebar selects', async () => { + const { manager } = await boot() + + manager.setSelectedTestUid('checkout-test') + + expect(manager.selectedTestUidContextProvider.value).toBe('checkout-test') + }) + }) + + describe('rerun and stop control', () => { + it('wipes execution data but keeps the suite tree on a rerun clear', async () => { + const { manager, deliver } = await boot() + deliver( + 'suites', + suitesFrame(suite('login-suite', { tests: [test('t-1')] })) + ) + deliver('commands', [command()]) + deliver('consoleLogs', [{ type: 'log', args: ['noise'] }]) + deliver('networkRequests', [request()]) + + deliver(WS_SCOPE.clearExecutionData, { uid: 't-1', entryType: 'test' }) + + expect(manager.commandsContextProvider.value).toEqual([]) + expect(manager.consoleLogsContextProvider.value).toEqual([]) + expect(manager.networkRequestsContextProvider.value).toEqual([]) + expect(publishedUids(manager)).toEqual(['login-suite']) + }) + + it('marks the rerun test as pending so its row spins', async () => { + const { manager, deliver } = await boot() + deliver( + 'suites', + suitesFrame(suite('login-suite', { tests: [test('t-1')] })) + ) + + deliver(WS_SCOPE.clearExecutionData, { uid: 't-1', entryType: 'test' }) + + const [first] = publishedSuites(manager) + expect(first.tests?.[0].state).toBe('pending') + expect(first.state).toBe('running') + }) + + it('marks the whole tree running for a rerun with no uid', async () => { + const { manager, deliver } = await boot() + deliver( + 'suites', + suitesFrame( + suite('login-suite', { state: 'passed', tests: [test('t-1')] }), + suite('checkout-suite', { state: 'failed' }) + ) + ) + + deliver(WS_SCOPE.clearExecutionData, {}) + + expect(publishedSuites(manager).map((entry) => entry.state)).toEqual([ + 'running', + 'running' + ]) + }) + + it('empties the tree when the backend asks for it', async () => { + const { manager, deliver } = await boot() + deliver('suites', suitesFrame(suite('login-suite'))) + + deliver(WS_SCOPE.clearExecutionData, { clearSuiteTree: true }) + + expect(manager.suitesContextProvider.value).toEqual([]) + }) + + it('fails the tests still in flight when the run is stopped', async () => { + const { manager, deliver } = await boot() + deliver( + 'suites', + suitesFrame( + suite('login-suite', { + state: 'running', + tests: [test('t-1', { state: 'running', end: undefined })] + }) + ) + ) + + deliver(WS_SCOPE.testStopped, { stopped: true }) + + const [first] = publishedSuites(manager) + expect(first.tests?.[0].state).toBe('failed') + expect(first.tests?.[0].end).toBeDefined() + expect(first.state).toBe('failed') + }) + }) + + describe('screencast handoff', () => { + it('re-announces a ready screencast to the player as a window event', async () => { + const { deliver } = await boot() + const seen: unknown[] = [] + const listener = (event: Event) => + seen.push((event as CustomEvent).detail) + window.addEventListener('screencast-ready', listener) + + deliver('screencast', { + sessionId: SESSION, + startTime: RUN_START, + duration: 4000 + }) + window.removeEventListener('screencast-ready', listener) + + expect(seen).toEqual([ + { sessionId: SESSION, startTime: RUN_START, duration: 4000 } + ]) + }) + }) + + describe('malformed traffic', () => { + it('warns and keeps its state when a frame is not JSON', async () => { + const { manager, socket, deliver } = await boot() + deliver('commands', [command()]) + + socket?.deliverRaw('<html>gateway error</html>') + + expect(manager.commandsContextProvider.value).toHaveLength(1) + expect(warnings.join('\n')).toContain('socket message') + }) + + it('ignores a frame with no payload', async () => { + const { manager, deliver } = await boot() + + deliver('commands', null) + + expect(manager.commandsContextProvider.value).toEqual([]) + expect(warnings).toEqual([]) + }) + + it('ignores a scope it does not handle', async () => { + const { host, manager, deliver } = await boot() + + deliver('config' as WsMessageScope, { configFile: 'wdio.conf.ts' }) + + expect(manager.commandsContextProvider.value).toEqual([]) + expect(host.updateRequests).toBeGreaterThan(0) + }) + }) + + describe('loading a trace file', () => { + it('publishes every panelโ€™s data out of the trace', async () => { + const { manager } = await boot() + const trace = traceLog() + + manager.loadTraceFile(trace) + + expect(manager.commandsContextProvider.value).toEqual(trace.commands) + expect(manager.logsContextProvider.value).toEqual(trace.logs) + expect(manager.networkRequestsContextProvider.value).toEqual( + trace.networkRequests + ) + expect(manager.sourcesContextProvider.value).toEqual(trace.sources) + expect(publishedUids(manager)).toEqual(['login-suite']) + }) + + it('caches the trace so a socket failure can replay it', async () => { + const { manager } = await boot() + + manager.loadTraceFile(traceLog()) + + expect( + JSON.parse(localStorage.getItem(CACHE_ID) ?? 'null') + ).toMatchObject({ + logs: ['run finished'] + }) + }) + + it('still publishes a trace too large to cache', async () => { + const { manager } = await boot() + // A real quota rejection: the store is the only thing that can refuse. + const setItem = vi + .spyOn(window.localStorage, 'setItem') + .mockImplementation(() => { + throw new Error('QuotaExceededError') + }) + + manager.loadTraceFile(traceLog()) + setItem.mockRestore() + + expect(manager.commandsContextProvider.value).toHaveLength(1) + expect(warnings.join('\n')).toContain('Trace too large') + }) + + it('keys the traceโ€™s metadata by its session', async () => { + const { manager } = await boot() + + manager.loadTraceFile(traceLog()) + + expect( + Object.keys(manager.metadataBySessionContextProvider.value ?? {}) + ).toEqual([SESSION]) + }) + + it('publishes no session map for a trace without a session id', async () => { + const { manager } = await boot() + + manager.loadTraceFile( + traceLog({ metadata: { type: TraceType.Testrunner } }) + ) + + expect(manager.metadataBySessionContextProvider.value).toEqual({}) + }) + + it('attributes a later session-less metadata frame to the traceโ€™s session', async () => { + const { manager, deliver } = await boot() + manager.loadTraceFile(traceLog()) + + deliver('metadata', { url: LOGIN_URL }) + + expect( + Object.keys(manager.metadataBySessionContextProvider.value ?? {}) + ).toEqual([SESSION]) + }) + }) +}) diff --git a/packages/app/tests/duration.test.ts b/packages/app/tests/duration.test.ts index 92ab1c87..f405e2e9 100644 --- a/packages/app/tests/duration.test.ts +++ b/packages/app/tests/duration.test.ts @@ -9,17 +9,32 @@ import { import type { CommandLog, TraceMutation } from '@wdio/devtools-shared' describe('formatDuration', () => { - it('shows milliseconds under a second', () => { + it('shows milliseconds below a second', () => { expect(formatDuration(0)).toBe('0ms') expect(formatDuration(276)).toBe('276ms') - expect(formatDuration(1000)).toBe('1000ms') + expect(formatDuration(999)).toBe('999ms') }) - it('shows seconds with two decimals under a minute', () => { + it('shows seconds with two decimals below a minute', () => { expect(formatDuration(1430)).toBe('1.43s') expect(formatDuration(10760)).toBe('10.76s') }) + it('switches unit AT each boundary, not one millisecond past it', () => { + // A `>` test left the first value of each unit rendered in the unit below: + // a four-digit `1000ms`, and a minute-long step reported as `60.00s`. + expect(formatDuration(1000)).toBe('1.00s') + expect(formatDuration(60_000)).toBe('1m 0s') + }) + + it('truncates seconds rather than rounding into the next bucket', () => { + // `durationHeat` calls 1999ms mid and 2000ms slow. Rounding printed `2.00s` + // for both, so the two buckets showed the same label in different colours. + expect(formatDuration(1999)).toBe('1.99s') + expect(formatDuration(2000)).toBe('2.00s') + expect(formatDuration(59_999)).toBe('59.99s') + }) + it('shows minutes and seconds above a minute', () => { expect(formatDuration(60001)).toBe('1m 0s') expect(formatDuration(150000)).toBe('2m 30s') @@ -29,7 +44,7 @@ describe('formatDuration', () => { expect(formatDuration(1.02978515625)).toBe('1ms') expect(formatDuration(22.4)).toBe('22ms') expect(formatDuration(0.4)).toBe('0ms') - expect(formatDuration(999.7)).toBe('1000ms') + expect(formatDuration(999.7)).toBe('1.00s') expect(formatDuration(5049.9)).toBe('5.05s') expect(formatDuration(60000.6)).toBe('1m 0s') }) diff --git a/packages/app/tests/elapsed.test.ts b/packages/app/tests/elapsed.test.ts new file mode 100644 index 00000000..bf1232e2 --- /dev/null +++ b/packages/app/tests/elapsed.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect } from 'vitest' +import type { CommandLog, TraceMutation } from '@wdio/devtools-shared' + +import { elapsedSince, timelineStart } from '../src/utils/elapsed.js' + +/** Wall-clock origin of the run below โ€” every offset reads as ms into it. */ +const RUN_START = 1_700_000_000_000 + +const command = (offset: number): CommandLog => ({ + command: 'click', + args: [], + timestamp: RUN_START + offset +}) + +const documentLoad = (offset: number): TraceMutation => + ({ + type: 'childList', + url: 'https://example.com/login', + addedNodes: [], + removedNodes: [], + timestamp: RUN_START + offset + }) as TraceMutation + +describe('timelineStart', () => { + it('is the earliest timestamp in the series', () => { + expect(timelineStart([command(400), command(780), command(1260)])).toBe( + RUN_START + 400 + ) + }) + + it('ignores the order the entries arrived in', () => { + // Capture order is not timeline order: a replayed slice, a rerun or a + // reversed reader hands the views either. Taking `entries[0]` would make the + // baseline depend on delivery order. + expect(timelineStart([command(1260), command(400), command(780)])).toBe( + RUN_START + 400 + ) + }) + + it('is the entry itself for a lone entry', () => { + expect(timelineStart([command(400)])).toBe(RUN_START + 400) + }) + + it('is undefined for an empty series, not the epoch', () => { + expect(timelineStart([])).toBeUndefined() + }) + + it('measures a mixed command and mutation series alike', () => { + expect(timelineStart([command(400), documentLoad(120)])).toBe( + RUN_START + 120 + ) + }) +}) + +describe('elapsedSince', () => { + const commands = [command(400), command(780), command(1260)] + + it('reads zero for the first entry of the series', () => { + expect(elapsedSince(commands, commands[0])).toBe(0) + }) + + it('is the offset from the earliest entry', () => { + expect(elapsedSince(commands, commands[1])).toBe(380) + expect(elapsedSince(commands, commands[2])).toBe(860) + }) + + it('reads the same offset whatever order the series arrived in', () => { + const reversed = [...commands].reverse() + + // The player's action tree indexes commands as delivered, so its series can + // be unsorted โ€” an offset taken from `entries[0]` would go negative here. + expect(elapsedSince(reversed, commands[1])).toBe(380) + expect(elapsedSince(reversed, commands[0])).toBe(0) + }) + + it('reads zero for a lone entry, which is its own baseline', () => { + expect(elapsedSince([commands[1]], commands[1])).toBe(0) + }) + + it('reads zero against an empty series rather than a wall clock', () => { + // Nothing to measure against, so the badge shows 0:00 โ€” returning the raw + // timestamp would render as a 53-year duration. + expect(elapsedSince([], commands[1])).toBe(0) + }) + + it('keeps same-timestamp entries at the same offset', () => { + const tied = [command(400), command(400), command(500)] + + expect(tied.map((entry) => elapsedSince(tied, entry))).toEqual([0, 0, 100]) + }) + + it('times an entry against the series it is given, not against all of them', () => { + // Why the series stays the caller's choice: the flat actions list times its + // rows against the merged command + document-load list it renders, so its + // first row always reads zero even when a document load precedes the first + // command. The `show-command` emitters time the same command against the + // commands alone, because their consumer badges actions. + const merged = [documentLoad(120), ...commands] + + expect(elapsedSince(merged, commands[0])).toBe(280) + expect(elapsedSince(commands, commands[0])).toBe(0) + }) +}) diff --git a/packages/app/tests/errors-collect.test.ts b/packages/app/tests/errors-collect.test.ts index 0b482fca..33647150 100644 --- a/packages/app/tests/errors-collect.test.ts +++ b/packages/app/tests/errors-collect.test.ts @@ -71,7 +71,57 @@ describe('collectErrors', () => { callSource: 'file:///spec.ts:12:3', timestamp: 100 }) - expect(errors[0].command?.command).toBe('expect') + }) + + it('exposes only the fields the panel renders', () => { + // A row is a flat bag of display values โ€” no reference back to the command + // it came from. The panel dispatches nothing per row, so a re-added source + // object would be populated for no reader; this locks the shape. + const [fromCommand] = collectErrors( + [ + command({ + command: 'click', + callSource: 'file:///spec.ts:9:3', + error: { name: 'Error', message: 'boom', stack: 'at spec.ts:9' }, + timestamp: 100 + }) + ], + [] + ) + const [fromTest] = collectErrors( + [], + suiteMap({ + uid: 's', + state: 'failed', + tests: [ + failedTest({ + uid: 'a', + title: 'the scenario', + callSource: 'spec.ts:14:3', + error: { name: 'Error', message: 'hook failed' } + }) + ] + }) + ) + + expect(Object.keys(fromCommand).sort()).toEqual([ + 'actual', + 'callSource', + 'expected', + 'message', + 'stack', + 'timestamp', + 'title' + ]) + // A test row has no command behind it, so it carries no timestamp either. + expect(Object.keys(fromTest).sort()).toEqual([ + 'actual', + 'callSource', + 'expected', + 'message', + 'stack', + 'title' + ]) }) it('falls back to the command name when there is no title', () => { @@ -135,7 +185,8 @@ describe('collectErrors', () => { message: 'nope', callSource: 'steps.ts:8:1' }) - expect(errors[0].command).toBeUndefined() + // Nothing timed a test-level failure, so it has no ordering key. + expect(errors[0].timestamp).toBeUndefined() }) it('reads the first entry of errors[] when error is absent', () => { @@ -190,7 +241,8 @@ describe('collectErrors', () => { }) ) expect(errors).toHaveLength(1) - expect(errors[0].command?.command).toBe('expect') + // The command's row survives: it is headed by the action, not by the test. + expect(errors[0].title).toBe('expect') }) it('dedupes a Cucumber assertion listed as both a command and a reworded test error', () => { @@ -230,7 +282,7 @@ describe('collectErrors', () => { }) ) expect(errors).toHaveLength(1) - expect(errors[0].command?.command).toBe('expect.toHaveText') + expect(errors[0].title).toBe('expect.toHaveText') expect(errors[0].actual).toBe('"Your username is invalid!"') expect(errors[0].expected).toBe( 'StringContaining "You logged into a secure area!"' @@ -264,7 +316,8 @@ describe('collectErrors', () => { }) ) expect(errors).toHaveLength(1) - expect(errors[0].command?.command).toBe('click') + // The test row would have been headed by its uid (`step`); the command wins. + expect(errors[0].title).toBe('click') }) it('keeps a distinct test failure alongside command failures', () => { @@ -361,8 +414,9 @@ describe('collectErrors', () => { ], [] ) - expect(error.actual).toBe('Your username is invalid!') - expect(error.expected).toBe('You logged into a secure area!') + // Raw values are printed once, here, so the renderer never formats them. + expect(error.actual).toBe("'Your username is invalid!'") + expect(error.expected).toBe("'You logged into a secure area!'") }) it('extracts expected/received from a failed-test matcher error', () => { diff --git a/packages/app/tests/markers.test.ts b/packages/app/tests/markers.test.ts new file mode 100644 index 00000000..931fa32a --- /dev/null +++ b/packages/app/tests/markers.test.ts @@ -0,0 +1,442 @@ +// @vitest-environment happy-dom +// +// `renderMarker` is a three-tier cascade and returns a Lit template, so it is +// exercised by rendering into a detached host and reading the DOM back: +// +// 1. row-level divergence kind โ†’ "different command" / "args differ" / "โš  error" +// 2. status marker โ†’ "โœ— in failed step" / "โœ— failed" / "โœ“" +// 3. the "only here" pill โ†’ prepended to the status on a truncated row +// +// Tier 2 runs the real `isFailureSite`, so the step/commands passed in are the +// resolver's actual inputs rather than a pre-computed verdict. + +import { describe, it, expect } from 'vitest' +import { render, nothing } from 'lit' +import type { CommandLog, PreservedStep } from '@wdio/devtools-shared' + +import { + renderMarker, + type MarkerContext +} from '../src/components/workbench/compare/markers.js' +import type { DivergenceKind } from '../src/components/workbench/compare/compareUtils.js' +import { + findStepFor, + isFailureSite +} from '../src/components/workbench/compare/stepResolution.js' + +const RUN_START = 1_700_000_000_000 + +function cmd(overrides: Partial<CommandLog> = {}): CommandLog { + return { + command: 'click', + args: ['#submit'], + timestamp: RUN_START, + ...overrides + } as CommandLog +} + +function step(overrides: Partial<PreservedStep> = {}): PreservedStep { + return { + uid: 'step-1', + title: 'submits the form', + fullTitle: 'login page submits the form', + start: RUN_START - 100, + end: RUN_START + 1000, + state: 'passed', + ...overrides + } as PreservedStep +} + +interface Marker { + text: string + classes: string[] + title: string | null +} + +/** Every marker `renderMarker` produced, in render order. */ +function markers(opts: Partial<MarkerContext> = {}): Marker[] { + const host = document.createElement('div') + const ctx: MarkerContext = { + cmd: cmd(), + kind: 'none', + step: undefined, + allCmdsThisSide: [], + oneSideEntirelyEmpty: false, + ...opts + } + render(renderMarker(ctx), host) + return [...host.querySelectorAll('span')].map((span) => ({ + text: (span.textContent ?? '').replace(/\s+/g, ' ').trim(), + classes: [...span.classList], + title: span.getAttribute('title') + })) +} + +const labels = (opts: Partial<MarkerContext> = {}) => + markers(opts).map((marker) => marker.text) + +const only = (opts: Partial<MarkerContext> = {}): Marker => { + const found = markers(opts) + if (found.length !== 1) { + throw new Error(`expected exactly one marker, rendered ${found.length}`) + } + return found[0] +} + +describe('renderMarker', () => { + describe('no command', () => { + it('renders nothing for the dashed side of a truncated row', () => { + expect( + renderMarker({ + cmd: undefined, + kind: 'missing', + step: undefined, + allCmdsThisSide: [], + oneSideEntirelyEmpty: false + }) + ).toBe(nothing) + expect(markers({ cmd: undefined, kind: 'missing' })).toEqual([]) + }) + + it('renders nothing whatever the divergence kind says', () => { + const kinds: DivergenceKind[] = [ + 'none', + 'commandName', + 'args', + 'error', + 'missing' + ] + for (const kind of kinds) { + expect(markers({ cmd: undefined, kind })).toEqual([]) + } + }) + }) + + describe('row-level divergence (tier 1)', () => { + it('labels a step that ran a different command', () => { + const marker = only({ kind: 'commandName' }) + + expect(marker.text).toBe('different command') + expect(marker.classes).toEqual(['marker', 'command']) + expect(marker.title).toBe( + 'Different WebDriver command โ€” execution diverged at this step' + ) + }) + + it('labels a step whose arguments differ', () => { + const marker = only({ kind: 'args' }) + + expect(marker.text).toBe('args differ') + expect(marker.classes).toEqual(['marker', 'command']) + expect(marker.title).toBe( + 'Same command, different arguments (compare args in the expanded view)' + ) + }) + + it('names the WebDriver error of the side that carries one', () => { + const marker = only({ + kind: 'error', + cmd: cmd({ + error: { name: 'Error', message: 'element click intercepted' } + }) + }) + + expect(marker.text).toBe('โš  error') + expect(marker.classes).toEqual(['marker', 'error']) + expect(marker.title).toBe('WebDriver error: element click intercepted') + }) + + it('falls through to the status marker for the side that did not error', () => { + // The row's kind is `error` because the *other* side threw; this side has + // no message of its own, so it shows its own status instead. + const marker = only({ kind: 'error', cmd: cmd({ error: undefined }) }) + + expect(marker.text).toBe('โœ“') + expect(marker.classes).toEqual(['marker', 'ok']) + expect(marker.title).toBe('Identical') + }) + + it('falls through when the error was captured without a message', () => { + const marker = only({ + kind: 'error', + cmd: cmd({ error: { name: 'Error', message: '' } }) + }) + + expect(marker.text).toBe('โœ“') + }) + + it('takes precedence over the status marker of a failed step', () => { + const failed = step({ state: 'failed' }) + const command = cmd() + + // The command *is* the failure site, so tier 2 alone would say + // "โœ— in failed step" โ€” tier 1 wins. + expect(isFailureSite(command, failed, [command])).toBe(true) + expect( + labels({ + kind: 'args', + cmd: command, + step: failed, + allCmdsThisSide: [command] + }) + ).toEqual(['args differ']) + }) + }) + + describe('status marker (tier 2)', () => { + it('marks the failure site of a failed step, naming the step and its error', () => { + const failed = step({ + state: 'failed', + error: { message: 'Expect $(`#flash`) to have text "Secure Area"' } + }) + const command = cmd() + const marker = only({ + cmd: command, + step: failed, + allCmdsThisSide: [command] + }) + + expect(marker.text).toBe('โœ— in failed step') + expect(marker.classes).toEqual(['marker', 'error']) + expect(marker.title).toBe( + `Failed step: ${failed.fullTitle}\n${failed.error!.message}` + ) + }) + + it('names only the step when the failure carried no message', () => { + const failed = step({ state: 'failed', error: undefined }) + const command = cmd() + + expect( + only({ cmd: command, step: failed, allCmdsThisSide: [command] }).title + ).toBe(`Failed step: ${failed.fullTitle}`) + }) + + it('falls back to the step title, then its uid, to identify the step', () => { + const command = cmd() + const titleOnly = step({ state: 'failed', fullTitle: undefined }) + const uidOnly = step({ + state: 'failed', + fullTitle: undefined, + title: undefined + }) + + expect( + only({ cmd: command, step: titleOnly, allCmdsThisSide: [command] }) + .title + ).toBe(`Failed step: ${titleOnly.title}`) + expect( + only({ cmd: command, step: uidOnly, allCmdsThisSide: [command] }).title + ).toBe(`Failed step: ${uidOnly.uid}`) + }) + + it('ticks an earlier command of the same failed step', () => { + const failed = step({ state: 'failed' }) + const earlier = cmd({ command: 'getUrl', timestamp: RUN_START }) + const last = cmd({ command: 'getText', timestamp: RUN_START + 500 }) + const allCmdsThisSide = [earlier, last] + + // The resolver decides which of the two is the site; the marker follows. + expect(isFailureSite(earlier, failed, allCmdsThisSide)).toBe(false) + expect(isFailureSite(last, failed, allCmdsThisSide)).toBe(true) + expect(labels({ cmd: earlier, step: failed, allCmdsThisSide })).toEqual([ + 'โœ“' + ]) + expect(labels({ cmd: last, step: failed, allCmdsThisSide })).toEqual([ + 'โœ— in failed step' + ]) + }) + + it('marks one failure site when two commands share a millisecond', () => { + // Command timestamps are wall-clock ms, so two fast commands in the same + // step routinely land on the same one. A failed step has a single failure + // site โ€” the later of the two โ€” not one per tied timestamp. + const failed = step({ state: 'failed' }) + const earlier = cmd({ command: 'getText', timestamp: RUN_START + 500 }) + const last = cmd({ command: 'getAttribute', timestamp: RUN_START + 500 }) + const allCmdsThisSide = [earlier, last] + + expect( + allCmdsThisSide.filter((c) => isFailureSite(c, failed, allCmdsThisSide)) + ).toEqual([last]) + expect(labels({ cmd: earlier, step: failed, allCmdsThisSide })).toEqual([ + 'โœ“' + ]) + expect(labels({ cmd: last, step: failed, allCmdsThisSide })).toEqual([ + 'โœ— in failed step' + ]) + }) + + it('marks no failure site in a failed step that recorded no window', () => { + // Without a start/end there is nothing to resolve a site against, so no + // command may claim it โ€” including one whose timestamp is falsy. + const failed = step({ state: 'failed', start: undefined, end: undefined }) + const command = cmd({ timestamp: 0 }) + + expect(isFailureSite(command, failed, [command])).toBe(false) + expect( + labels({ cmd: command, step: failed, allCmdsThisSide: [command] }) + ).toEqual(['โœ“']) + }) + + it("marks a failed step's own erroring command even when a later command exists", () => { + const failed = step({ state: 'failed' }) + const errored = cmd({ + error: { name: 'Error', message: 'element click intercepted' }, + timestamp: RUN_START + }) + const later = cmd({ command: 'getText', timestamp: RUN_START + 500 }) + + expect( + labels({ + kind: 'none', + cmd: errored, + step: failed, + allCmdsThisSide: [errored, later] + }) + ).toEqual(['โœ— in failed step']) + }) + + it('marks a command that errored as failed when no step state resolved', () => { + // The latest side's live step state isn't tracked the way the baseline's + // PreservedStep is โ€” without this branch it would show a green โœ“. + const marker = only({ + cmd: cmd({ + error: { name: 'Error', message: 'element click intercepted' } + }), + step: undefined + }) + + expect(marker.text).toBe('โœ— failed') + expect(marker.classes).toEqual(['marker', 'error']) + expect(marker.title).toBe('Failed: element click intercepted') + }) + + it('marks a command that errored inside a step that passed as failed', () => { + const marker = only({ + cmd: cmd({ error: { name: 'Error', message: 'stale element' } }), + step: step({ state: 'passed' }) + }) + + expect(marker.text).toBe('โœ— failed') + }) + + it('titles a passing tick with the step the command ran in', () => { + const passed = step({ state: 'passed' }) + const marker = only({ cmd: cmd(), step: passed }) + + expect(marker.text).toBe('โœ“') + expect(marker.classes).toEqual(['marker', 'ok']) + expect(marker.title).toBe(`Step passed: ${passed.fullTitle}`) + }) + + it('falls back to the title, then the uid, of a passing step', () => { + expect(only({ step: step({ fullTitle: undefined }) }).title).toBe( + 'Step passed: submits the form' + ) + expect( + only({ step: step({ fullTitle: undefined, title: undefined }) }).title + ).toBe('Step passed: step-1') + }) + + it('ticks a command whose step is still running as identical', () => { + const marker = only({ cmd: cmd(), step: step({ state: 'running' }) }) + + expect(marker.text).toBe('โœ“') + expect(marker.title).toBe('Identical') + }) + + it('ticks a command that resolved to no step at all as identical', () => { + const marker = only({ cmd: cmd(), step: undefined }) + + expect(marker.text).toBe('โœ“') + expect(marker.title).toBe('Identical') + }) + + it('follows the step the timestamp resolver actually returns', () => { + // The step is resolved from the command's timestamp rather than handed in + // pre-matched, so a command outside every window gets the neutral tick. + const fill = step({ + uid: 'login-fill', + fullTitle: 'login page fills the form', + start: RUN_START, + end: RUN_START + 1000, + state: 'passed' + }) + const assertStep = step({ + uid: 'login-assert', + fullTitle: 'login page shows the flash', + start: RUN_START + 1500, + end: RUN_START + 2600, + state: 'failed' + }) + const baseline = { steps: [fill, assertStep] } as never + const inFill = cmd({ timestamp: RUN_START + 500 }) + const inAssert = cmd({ timestamp: RUN_START + 2100 }) + const outside = cmd({ timestamp: RUN_START + 5000 }) + + expect( + only({ + cmd: inFill, + step: findStepFor(inFill, 'baseline', baseline, []), + allCmdsThisSide: [inFill] + }).title + ).toBe(`Step passed: ${fill.fullTitle}`) + expect( + only({ + cmd: inAssert, + step: findStepFor(inAssert, 'baseline', baseline, []), + allCmdsThisSide: [inAssert] + }).text + ).toBe('โœ— in failed step') + expect( + only({ + cmd: outside, + step: findStepFor(outside, 'baseline', baseline, []), + allCmdsThisSide: [outside] + }).title + ).toBe('Identical') + }) + }) + + describe('"only here" pill (tier 3)', () => { + it('prepends the pill to the status of a truncated row', () => { + const rendered = markers({ kind: 'missing' }) + + expect(rendered.map((marker) => marker.text)).toEqual(['only here', 'โœ“']) + expect(rendered[0].classes).toEqual(['marker', 'info']) + expect(rendered[0].title).toBe( + 'Only present on this side โ€” the other run ended before this step' + ) + // The status stays last so the โœ“ keeps the right-edge column. + expect(rendered[1].classes).toEqual(['marker', 'ok']) + }) + + it('keeps the failure status of a truncated row alongside the pill', () => { + const failed = step({ state: 'failed' }) + const command = cmd() + + expect( + labels({ + kind: 'missing', + cmd: command, + step: failed, + allCmdsThisSide: [command] + }) + ).toEqual(['only here', 'โœ— in failed step']) + }) + + it('suppresses the pill when the other run produced no commands at all', () => { + const rendered = markers({ kind: 'missing', oneSideEntirelyEmpty: true }) + + expect(rendered.map((marker) => marker.text)).toEqual(['โœ“']) + }) + + it('renders no pill for a row both runs reached', () => { + const kinds: DivergenceKind[] = ['none', 'commandName', 'args', 'error'] + for (const kind of kinds) { + expect(labels({ kind })).not.toContain('only here') + } + }) + }) +}) diff --git a/packages/app/tests/network-helpers.test.ts b/packages/app/tests/network-helpers.test.ts index 2dc22d76..8872d679 100644 --- a/packages/app/tests/network-helpers.test.ts +++ b/packages/app/tests/network-helpers.test.ts @@ -1,7 +1,43 @@ +import { + REQUEST_TYPES, + type NetworkRequest, + type RequestType +} from '@wdio/devtools-shared' import { describe, it, expect } from 'vitest' -import { statusKind, getStatusClass } from '../src/utils/network-helpers.js' -import { STATUS_KIND } from '../src/utils/network-constants.js' +import { + statusKind, + getStatusClass, + getResourceType, + getFileName +} from '../src/utils/network-helpers.js' +import { + STATUS_KIND, + RESOURCE_TYPES, + RESOURCE_TYPE_BY_REQUEST_TYPE +} from '../src/utils/network-constants.js' + +function request(overrides: Partial<NetworkRequest> = {}): NetworkRequest { + return { + id: 'req-1', + url: 'https://example.com/page', + method: 'GET', + type: 'document', + timestamp: 0, + startTime: 0, + ...overrides + } +} + +/** A `type` outside the union โ€” an older trace, or a producer the vocabulary + * hasn't caught up with. The cast is the subject under test: the static type + * can't express this, and the helper still has to cope at runtime. */ +function offVocabulary( + type: string, + overrides: Partial<NetworkRequest> = {} +): NetworkRequest { + return { ...request(overrides), type: type as RequestType } +} describe('statusKind', () => { it('buckets 2xx as ok', () => { @@ -37,3 +73,122 @@ describe('getStatusClass', () => { expect(getStatusClass(undefined)).toBe('text-gray-500') }) }) + +// Expectations here are literals on purpose: every bucket is named, never +// computed from the table the function reads, so a mapping that loses a +// category can't agree with its own test. +describe('getResourceType', () => { + it('buckets every captured request type', () => { + expect(getResourceType(request({ type: 'document' }))).toBe('HTML') + expect(getResourceType(request({ type: 'stylesheet' }))).toBe('CSS') + expect(getResourceType(request({ type: 'script' }))).toBe('JS') + expect(getResourceType(request({ type: 'image' }))).toBe('Image') + expect(getResourceType(request({ type: 'font' }))).toBe('Font') + expect(getResourceType(request({ type: 'fetch' }))).toBe('Fetch') + expect(getResourceType(request({ type: 'other' }))).toBe('Other') + }) + + it('names a bucket for every word in the shared vocabulary', () => { + // The table is `Record<RequestType, ResourceType>`, so a new word breaks the + // build; this guards the other direction โ€” that no word was mapped by + // accident to the residual it would have reached without an entry. + expect(Object.keys(RESOURCE_TYPE_BY_REQUEST_TYPE).sort()).toEqual( + [...REQUEST_TYPES].sort() + ) + }) + + it('shares one bucket between fetch and xhr', () => { + expect(getResourceType(request({ type: 'xhr' }))).toBe('Fetch') + expect(getResourceType(request({ type: 'fetch' }))).toBe('Fetch') + }) + + it('classifies a request with no response headers and no file extension', () => { + expect( + getResourceType( + request({ + url: 'https://example.com/authenticate', + type: 'document', + responseHeaders: undefined + }) + ) + ).toBe('HTML') + }) + + it('trusts the captured type over the response content-type', () => { + // An XHR that fetched an HTML fragment: the capture side already decided + // this is a data request, and re-sniffing the header would overrule it. + expect( + getResourceType( + request({ + type: 'fetch', + responseHeaders: { 'content-type': 'text/html; charset=utf-8' } + }) + ) + ).toBe('Fetch') + }) + + it('reads a request whose type arrived capitalised', () => { + expect(getResourceType(offVocabulary('Document'))).toBe('HTML') + }) + + it('treats a body-carrying method with an unknown type as a data request', () => { + expect(getResourceType(offVocabulary('', { method: 'POST' }))).toBe('Fetch') + expect(getResourceType(offVocabulary('websocket', { method: 'PUT' }))).toBe( + 'Fetch' + ) + }) + + it('falls back to Other for an unknown type on a plain GET', () => { + expect(getResourceType(offVocabulary('websocket'))).toBe('Other') + expect(getResourceType(offVocabulary(''))).toBe('Other') + }) + + it('leaves no filter tab that no captured request can reach', () => { + const reachable = new Set<string>( + REQUEST_TYPES.map((type) => getResourceType(request({ type }))) + ) + + expect( + RESOURCE_TYPES.filter((tab) => tab !== 'All' && !reachable.has(tab)) + ).toEqual([]) + }) +}) + +describe('getFileName', () => { + it('names a request after the last segment of its path', () => { + expect(getFileName('https://example.com/js/vendor/jquery.min.js')).toBe( + 'jquery.min.js' + ) + expect(getFileName('https://example.com/a/b/')).toBe('b') + }) + + it('falls back to the host when the path names no file', () => { + expect(getFileName('https://example.com/')).toBe('example.com') + expect(getFileName('https://example.com')).toBe('example.com') + expect(getFileName('https://example.com/?limit=4')).toBe('example.com') + // The host without its port โ€” the column names the origin, not the socket. + expect(getFileName('http://localhost:8080/?q=1')).toBe('localhost') + }) + + it('keeps a query-bearing path that is nothing but separators', () => { + expect(getFileName('https://example.com//?q=1')).toBe('example.com//') + // Without a query there is nothing to disambiguate, so the host stands alone. + expect(getFileName('https://example.com//')).toBe('example.com') + }) + + it('normalises the host it displays', () => { + expect(getFileName('https://mรผnchen.example/?q=1')).toBe( + 'xn--mnchen-3ya.example' + ) + }) + + it('dashes a request with no URL of its own', () => { + expect(getFileName('')).toBe('-') + expect(getFileName('event')).toBe('-') + }) + + it('truncates a URL it cannot parse', () => { + expect(getFileName('not a url')).toBe('not a url') + expect(getFileName(`not a url ${'x'.repeat(60)}`)).toHaveLength(50) + }) +}) diff --git a/packages/app/tests/renderDetailBlock.test.ts b/packages/app/tests/renderDetailBlock.test.ts new file mode 100644 index 00000000..d8fa4bbb --- /dev/null +++ b/packages/app/tests/renderDetailBlock.test.ts @@ -0,0 +1,782 @@ +// @vitest-environment happy-dom +// +// The three exported renderers return Lit templates, so they are exercised by +// rendering into a detached host and reading the DOM back. Every value the +// block prints is derived through the same helper the renderer calls +// (`safeJson`, `cleanErrorMessage`, `computeDetailBlockData`) over the command +// under test, so a field that stops reaching its row fails. + +import { describe, it, expect } from 'vitest' +import { render, nothing } from 'lit' +import type { + CommandLog, + PreservedAttempt, + PreservedStep +} from '@wdio/devtools-shared' + +import { + renderDetailBlock, + renderDetailStepBanner, + renderExpectedActualAssertion, + type DetailBlockCtx +} from '../src/components/workbench/compare/renderDetailBlock.js' +import { + cleanErrorMessage, + safeJson +} from '../src/components/workbench/compare/compareUtils.js' +import { computeDetailBlockData } from '../src/components/workbench/compare/stepResolution.js' + +const RUN_START = 1_700_000_000_000 +const RED = 'var(--vscode-charts-red,#f48771)' +const GREEN = 'var(--vscode-charts-green,#73c373)' +const ESC = '\u001b' + +/** Control characters left in a rendered string, line breaks excluded. */ +const controlChars = (value: string): string[] => + [...value].filter((char) => { + const code = char.charCodeAt(0) + return code !== 0x0a && (code < 0x20 || code === 0x7f) + }) + +function cmd(overrides: Partial<CommandLog> = {}): CommandLog { + return { + command: 'getText', + args: ['#flash'], + timestamp: RUN_START, + ...overrides + } as CommandLog +} + +function step(overrides: Partial<PreservedStep> = {}): PreservedStep { + return { + uid: 'login-assert', + title: 'shows the secure-area flash', + fullTitle: 'login page shows the secure-area flash', + start: RUN_START - 100, + end: RUN_START + 1000, + state: 'passed', + ...overrides + } as PreservedStep +} + +function renderInto(template: unknown): HTMLElement { + const host = document.createElement('div') + render(template as never, host) + return host +} + +/** Trimmed, whitespace-collapsed text of every `<pre>`, in render order. */ +const preLines = (host: Element): string[] => + [...host.querySelectorAll('pre')].map((pre) => + (pre.textContent ?? '').replace(/\s+/g, ' ').trim() + ) + +const styleOf = (host: Element, index = 0): string => + host.querySelectorAll('pre')[index]?.getAttribute('style') ?? '' + +/** A ctx whose baseline and live sides carry different command lists, so a + * renderer reading the wrong one is visible. */ +function ctxWith( + overrides: { + baselineCommands?: CommandLog[] + liveCommands?: CommandLog[] + step?: PreservedStep | undefined + stepPerSide?: Partial<Record<'baseline' | 'latest', PreservedStep>> + } = {} +): DetailBlockCtx { + return { + baseline: { + commands: overrides.baselineCommands ?? [] + } as PreservedAttempt, + liveCommandsForSelectedUid: () => overrides.liveCommands ?? [], + findStepFor: (_c, side) => + overrides.stepPerSide ? overrides.stepPerSide[side] : overrides.step + } +} + +describe('renderDetailStepBanner', () => { + it('renders nothing when the command resolved to no step', () => { + expect(renderDetailStepBanner(undefined, 'ignored')).toBe(nothing) + expect( + preLines(renderInto(renderDetailStepBanner(undefined, 'x'))) + ).toEqual([]) + }) + + it('names the step it was given', () => { + const host = renderInto(renderDetailStepBanner(step(), 'login page fills')) + + expect(preLines(host)).toEqual(['step: login page fills']) + }) + + it('falls back to the step uid when the step text is empty', () => { + const banner = step({ fullTitle: undefined, title: undefined }) + const host = renderInto(renderDetailStepBanner(banner, '')) + + expect(preLines(host)).toEqual([`step: ${banner.uid}`]) + }) + + it('borders a failed step red and any other state green', () => { + expect( + styleOf( + renderInto(renderDetailStepBanner(step({ state: 'failed' }), 's')) + ) + ).toContain(`2px solid ${RED}`) + expect( + styleOf( + renderInto(renderDetailStepBanner(step({ state: 'passed' }), 's')) + ) + ).toContain(`2px solid ${GREEN}`) + expect( + styleOf( + renderInto(renderDetailStepBanner(step({ state: undefined }), 's')) + ) + ).toContain(`2px solid ${GREEN}`) + }) +}) + +describe('renderExpectedActualAssertion', () => { + it('renders expected, actual and the assertion message in that order', () => { + const host = renderInto( + renderExpectedActualAssertion( + 'Secure Area', + 'Your username is invalid!', + 'Expect $(`#flash`) to have text', + undefined + ) + ) + + expect(preLines(host)).toEqual([ + `expected: ${safeJson('Secure Area')}`, + `actual: ${safeJson('Your username is invalid!')}`, + 'assertion: Expect $(`#flash`) to have text' + ]) + expect(preLines(host)).toEqual([ + 'expected: "Secure Area"', + 'actual: "Your username is invalid!"', + 'assertion: Expect $(`#flash`) to have text' + ]) + }) + + it('renders nothing at all when it was handed no values', () => { + expect( + preLines( + renderInto( + renderExpectedActualAssertion( + undefined, + undefined, + undefined, + undefined + ) + ) + ) + ).toEqual([]) + }) + + it('renders a structured expected value in preference to the step-text fallback', () => { + const host = renderInto( + renderExpectedActualAssertion( + 'Secure Area', + undefined, + undefined, + 'from step' + ) + ) + + expect(preLines(host)).toEqual(['expected: "Secure Area"']) + }) + + it('marks a step-text fallback as derived, in its own titled row', () => { + const host = renderInto( + renderExpectedActualAssertion( + undefined, + undefined, + undefined, + 'Secure Area' + ) + ) + + expect(preLines(host)).toEqual(['expected (from step): Secure Area']) + expect(host.querySelectorAll('pre')[0].getAttribute('title')).toContain( + 'Derived from the step text' + ) + }) + + it('renders an actual value with no expected beside it', () => { + const host = renderInto( + renderExpectedActualAssertion( + undefined, + 'Login Page', + undefined, + undefined + ) + ) + + expect(preLines(host)).toEqual(['actual: "Login Page"']) + }) + + it('renders an assertion message with no values beside it', () => { + const host = renderInto( + renderExpectedActualAssertion( + undefined, + undefined, + 'assert.ok failed', + undefined + ) + ) + + expect(preLines(host)).toEqual(['assertion: assert.ok failed']) + }) + + it('renders a falsy-but-present expected and actual rather than dropping them', () => { + const host = renderInto( + renderExpectedActualAssertion(false, '', undefined, undefined) + ) + + expect(preLines(host)).toEqual(['expected: false', 'actual: ""']) + }) + + it('serialises object values through safeJson', () => { + const expected = { text: 'Secure Area' } + const host = renderInto( + renderExpectedActualAssertion(expected, undefined, undefined, undefined) + ) + + expect(preLines(host)).toEqual([`expected: ${safeJson(expected)}`]) + expect(preLines(host)).toEqual(['expected: {"text":"Secure Area"}']) + }) +}) + +describe('renderDetailBlock', () => { + describe('absent command', () => { + it('reports that the side had no command at this step', () => { + const host = renderInto( + renderDetailBlock('Latest', undefined, 'latest', ctxWith()) + ) + + expect(host.querySelector('h4')?.textContent).toBe('Latest') + expect(host.querySelector('em')?.textContent).toBe( + 'No command at this step' + ) + expect(preLines(host)).toEqual([]) + }) + }) + + describe('heading and args', () => { + it('heads the block with the label and the command name', () => { + const host = renderInto( + renderDetailBlock('Baseline', cmd(), 'baseline', ctxWith()) + ) + + expect( + (host.querySelector('h4')?.textContent ?? '').replace(/\s+/g, ' ') + ).toBe('Baseline ยท getText') + }) + + it('prints the args through safeJson', () => { + const command = cmd({ args: ['#username', 'tomsmith'] }) + const host = renderInto( + renderDetailBlock('Baseline', command, 'baseline', ctxWith()) + ) + + expect(preLines(host)[0]).toBe(`args: ${safeJson(command.args)}`) + expect(preLines(host)[0]).toBe('args: ["#username","tomsmith"]') + }) + + it('prints an empty args list rather than omitting the row', () => { + const host = renderInto( + renderDetailBlock('Baseline', cmd({ args: [] }), 'baseline', ctxWith()) + ) + + expect(preLines(host)[0]).toBe('args: []') + }) + + it('renders the step banner above the args when a step resolved', () => { + const banner = step() + const host = renderInto( + renderDetailBlock( + 'Baseline', + cmd(), + 'baseline', + ctxWith({ step: banner }) + ) + ) + + expect(preLines(host)).toEqual([ + `step: ${banner.fullTitle}`, + `args: ${safeJson(['#flash'])}`, + 'result: undefined' + ]) + }) + + it('renders no step banner when nothing resolved', () => { + const host = renderInto( + renderDetailBlock('Baseline', cmd(), 'baseline', ctxWith()) + ) + + expect(preLines(host)[0]).toBe('args: ["#flash"]') + }) + }) + + describe('result and error', () => { + it('prints the result through safeJson', () => { + const command = cmd({ result: 'You logged into a secure area!' }) + const host = renderInto( + renderDetailBlock('Baseline', command, 'baseline', ctxWith()) + ) + + expect(preLines(host)[1]).toBe(`result: ${safeJson(command.result)}`) + expect(preLines(host)[1]).toBe('result: "You logged into a secure area!"') + }) + + it("prints a command's error in place of its result, cleaned", () => { + // A real terminal colour code is `ESC[<n>m`; stripping the `[<n>m` alone + // leaves the invisible ESC behind in the rendered row. + const raw = `element click ${ESC}[31mintercepted${ESC}[39m` + const command = cmd({ + command: 'click', + result: 'ignored', + error: { name: 'Error', message: raw } + }) + const host = renderInto( + renderDetailBlock('Baseline', command, 'baseline', ctxWith()) + ) + + expect(preLines(host)).toEqual([ + `args: ${safeJson(['#flash'])}`, + `error: ${cleanErrorMessage(raw)}` + ]) + expect(preLines(host)[1]).toBe('error: element click intercepted') + }) + + it('leaves no control character or colour code in a rendered error', () => { + const command = cmd({ + command: 'click', + error: { + name: 'Error', + message: `${ESC}[1m${ESC}[31mExpected: ${ESC}[39m"Secure Area"${ESC}[22m` + } + }) + const host = renderInto( + renderDetailBlock('Baseline', command, 'baseline', ctxWith()) + ) + const rendered = preLines(host)[1] + + expect(rendered).toContain('Expected: "Secure Area"') + expect(controlChars(rendered)).toEqual([]) + expect(rendered).not.toMatch(/\[\d+m/) + }) + + it('names an error object that carries no message', () => { + const command = cmd({ + command: 'click', + error: { name: 'TimeoutError', message: '' } + }) + const host = renderInto( + renderDetailBlock('Baseline', command, 'baseline', ctxWith()) + ) + + expect(preLines(host)[1]).toBe('error: TimeoutError') + }) + + it('stringifies an error object that carries neither message nor name', () => { + const command = cmd({ + command: 'click', + error: { name: '', message: ' ' } + }) + const host = renderInto( + renderDetailBlock('Baseline', command, 'baseline', ctxWith()) + ) + + expect(preLines(host)[1]).toBe('error: [object Object]') + }) + }) + + describe('assertion commands', () => { + it("reads an assert command's collapsed result in preference to its args", () => { + const command = cmd({ + command: 'assert.strictEqual', + args: ['ignored-actual', 'ignored-expected'], + result: { + passed: false, + actual: 'Login Page', + expected: 'Secure Area' + }, + error: { name: 'AssertionError', message: 'strictEqual failed' } + }) + const host = renderInto( + renderDetailBlock('Baseline', command, 'baseline', ctxWith()) + ) + + // The collapsed values replace both the error row and the positional args. + expect(preLines(host)).toEqual([ + `args: ${safeJson(command.args)}`, + 'expected: "Secure Area"', + 'actual: "Login Page"' + ]) + }) + + it('reads the positional args of an assert command with no collapsed result', () => { + const command = cmd({ + command: 'verify.equal', + args: ['Login Page', 'Secure Area'], + error: { name: 'Error', message: 'verify.equal failed' } + }) + const host = renderInto( + renderDetailBlock('Baseline', command, 'baseline', ctxWith()) + ) + + expect(preLines(host)).toEqual([ + 'args: ["Login Page","Secure Area"]', + 'expected: "Secure Area"', + 'actual: "Login Page"' + ]) + }) + + it('falls back to the error of an assert command with a single argument', () => { + const command = cmd({ + command: 'assert.ok', + args: [false], + error: { name: 'Error', message: 'assert.ok failed' } + }) + const host = renderInto( + renderDetailBlock('Baseline', command, 'baseline', ctxWith()) + ) + + expect(preLines(host)).toEqual([ + 'args: [false]', + 'error: assert.ok failed' + ]) + }) + + it('falls back to the error of an assert command whose result is not an object', () => { + const command = cmd({ + command: 'expect.toHaveText', + args: ['Secure Area'], + result: 'not-collapsed', + error: { name: 'Error', message: 'toHaveText failed' } + }) + const host = renderInto( + renderDetailBlock('Baseline', command, 'baseline', ctxWith()) + ) + + expect(preLines(host)).toEqual([ + 'args: ["Secure Area"]', + 'error: toHaveText failed' + ]) + }) + + it('falls back to positional args when the result object carries neither value', () => { + const command = cmd({ + command: 'expect.toHaveText', + args: ['Login Page', 'Secure Area'], + result: { passed: false } + }) + const host = renderInto( + renderDetailBlock('Baseline', command, 'baseline', ctxWith()) + ) + + expect(preLines(host)).toEqual([ + 'args: ["Login Page","Secure Area"]', + 'expected: "Secure Area"', + 'actual: "Login Page"' + ]) + }) + + it('renders a collapsed result that carries only an actual value', () => { + const command = cmd({ + command: 'assert.ok', + args: [false], + result: { passed: false, actual: 'Login Page' } + }) + const host = renderInto( + renderDetailBlock('Baseline', command, 'baseline', ctxWith()) + ) + + expect(preLines(host)).toEqual(['args: [false]', 'actual: "Login Page"']) + }) + + it('leaves a non-assertion command on the result path', () => { + // `assertEqual` is not `assert.` โ€” the prefix test is on the dot. + const command = cmd({ + command: 'assertEqual', + args: ['Login Page', 'Secure Area'], + result: { actual: 'Login Page', expected: 'Secure Area' } + }) + const host = renderInto( + renderDetailBlock('Baseline', command, 'baseline', ctxWith()) + ) + + expect(preLines(host)).toEqual([ + 'args: ["Login Page","Secure Area"]', + `result: ${safeJson(command.result)}` + ]) + }) + }) + + describe("the failed step's values", () => { + const failed = step({ + state: 'failed', + error: { + message: 'Expect $(`#flash`) to have text', + expected: 'Secure Area', + actual: 'Your username is invalid!' + } + }) + + it("renders the step's expected, actual and assertion on the failure site", () => { + const command = cmd() + const ctx = ctxWith({ baselineCommands: [command], step: failed }) + const host = renderInto( + renderDetailBlock('Baseline', command, 'baseline', ctx) + ) + const data = computeDetailBlockData(command, failed, [command]) + + expect(data.atFailureSite).toBe(true) + expect(preLines(host)).toEqual([ + `step: ${failed.fullTitle}`, + `args: ${safeJson(command.args)}`, + 'result: undefined', + `expected: ${safeJson(data.expected)}`, + `actual: ${safeJson(data.actual)}`, + `assertion: ${data.assertionMessage}` + ]) + expect(preLines(host).slice(3)).toEqual([ + 'expected: "Secure Area"', + 'actual: "Your username is invalid!"', + 'assertion: Expect $(`#flash`) to have text' + ]) + }) + + it('reads the values off matcherResult when the error does not carry them', () => { + const matcherStep = step({ + state: 'failed', + error: { + matcherResult: { + expected: 'Secure Area', + actual: 'Your username is invalid!', + message: 'expected text to match' + } + } + }) + const command = cmd() + const host = renderInto( + renderDetailBlock( + 'Baseline', + command, + 'baseline', + ctxWith({ baselineCommands: [command], step: matcherStep }) + ) + ) + + expect(preLines(host).slice(3)).toEqual([ + 'expected: "Secure Area"', + 'actual: "Your username is invalid!"', + 'assertion: expected text to match' + ]) + }) + + it('renders no expected or actual on a command that is not the failure site', () => { + const earlier = cmd({ command: 'click', timestamp: RUN_START }) + const last = cmd({ timestamp: RUN_START + 500 }) + const host = renderInto( + renderDetailBlock( + 'Baseline', + earlier, + 'baseline', + ctxWith({ baselineCommands: [earlier, last], step: failed }) + ) + ) + + expect( + computeDetailBlockData(earlier, failed, [earlier, last]).atFailureSite + ).toBe(false) + expect(preLines(host)).toEqual([ + `step: ${failed.fullTitle}`, + 'args: ["#flash"]', + 'result: undefined' + ]) + }) + + it('renders the failed values on the later of two commands sharing a millisecond', () => { + const earlier = cmd({ command: 'click', timestamp: RUN_START }) + const last = cmd({ timestamp: RUN_START }) + const commands = [earlier, last] + const ctx = ctxWith({ baselineCommands: commands, step: failed }) + const rowsFor = (command: CommandLog) => + preLines( + renderInto(renderDetailBlock('Baseline', command, 'baseline', ctx)) + ) + + expect(computeDetailBlockData(last, failed, commands).atFailureSite).toBe( + true + ) + expect( + computeDetailBlockData(earlier, failed, commands).atFailureSite + ).toBe(false) + expect(rowsFor(last).some((line) => line.startsWith('expected:'))).toBe( + true + ) + expect( + rowsFor(earlier).some((line) => line.startsWith('expected:')) + ).toBe(false) + }) + + it('renders no expected or actual for a step that passed', () => { + const command = cmd({ result: 'You logged into a secure area!' }) + const host = renderInto( + renderDetailBlock( + 'Baseline', + command, + 'baseline', + ctxWith({ + baselineCommands: [command], + step: step({ state: 'passed' }) + }) + ) + ) + + expect(preLines(host)).toEqual([ + 'step: login page shows the secure-area flash', + 'args: ["#flash"]', + 'result: "You logged into a secure area!"' + ]) + }) + + it('derives the expected value from a Cucumber step title when none was surfaced', () => { + const cucumberStep = step({ + title: 'Then I should see a flash message saying "Secure Area"', + fullTitle: undefined, + state: 'failed' + }) + const command = cmd() + const host = renderInto( + renderDetailBlock( + 'Baseline', + command, + 'baseline', + ctxWith({ baselineCommands: [command], step: cucumberStep }) + ) + ) + const data = computeDetailBlockData(command, cucumberStep, [command]) + + expect(preLines(host)).toEqual([ + `step: ${cucumberStep.title}`, + 'args: ["#flash"]', + 'result: undefined', + `expected (from step): ${data.fallbackExpected}` + ]) + expect(preLines(host)[3]).toBe('expected (from step): "Secure Area"') + }) + }) + + describe('side selection', () => { + it("resolves the baseline side's failure site against the preserved commands", () => { + const failed = step({ + state: 'failed', + error: { message: 'to have text', expected: 'Secure Area' } + }) + const command = cmd({ timestamp: RUN_START }) + const later = cmd({ command: 'getTitle', timestamp: RUN_START + 500 }) + // Baseline holds only `command`, so it is the last command in the step and + // therefore the site; the live list holds a later one, which would make it + // *not* the site. Reading the wrong list flips the assertion rows. + const ctx = ctxWith({ + baselineCommands: [command], + liveCommands: [command, later], + step: failed + }) + + expect( + preLines( + renderInto(renderDetailBlock('Baseline', command, 'baseline', ctx)) + ) + ).toEqual([ + `step: ${failed.fullTitle}`, + 'args: ["#flash"]', + 'result: undefined', + 'expected: "Secure Area"', + 'assertion: to have text' + ]) + expect( + preLines( + renderInto(renderDetailBlock('Latest', command, 'latest', ctx)) + ) + ).toEqual([ + `step: ${failed.fullTitle}`, + 'args: ["#flash"]', + 'result: undefined' + ]) + }) + + it('asks the resolver for the side it was given', () => { + const asked: string[] = [] + const ctx: DetailBlockCtx = { + // Only `commands` is read on this path; the rest of PreservedAttempt is + // irrelevant to the assertion, hence the narrowing cast. + baseline: { commands: [] } as unknown as PreservedAttempt, + liveCommandsForSelectedUid: () => [], + findStepFor: (_c, side) => { + asked.push(side) + return undefined + } + } + + renderInto(renderDetailBlock('Latest', cmd(), 'latest', ctx)) + renderInto(renderDetailBlock('Baseline', cmd(), 'baseline', ctx)) + + expect(asked).toEqual(['latest', 'baseline']) + }) + + it('tolerates a comparison with no baseline preserved', () => { + const ctx: DetailBlockCtx = { + baseline: undefined, + liveCommandsForSelectedUid: () => [], + findStepFor: () => undefined + } + const host = renderInto( + renderDetailBlock('Baseline', cmd(), 'baseline', ctx) + ) + + expect(preLines(host)).toEqual(['args: ["#flash"]', 'result: undefined']) + }) + }) + + describe('screenshot', () => { + it('wraps a raw base64 screenshot in a png data URL', () => { + const host = renderInto( + renderDetailBlock( + 'Baseline', + cmd({ screenshot: 'iVBORw0KGg' }), + 'baseline', + ctxWith() + ) + ) + + expect(host.querySelector('img')?.getAttribute('src')).toBe( + 'data:image/png;base64,iVBORw0KGg' + ) + }) + + it('leaves an already-encoded data URL alone', () => { + const screenshot = 'data:image/jpeg;base64,iVBORw0KGg' + const host = renderInto( + renderDetailBlock( + 'Baseline', + cmd({ screenshot }), + 'baseline', + ctxWith() + ) + ) + + expect(host.querySelector('img')?.getAttribute('src')).toBe(screenshot) + }) + + it('renders no image for a command captured without one', () => { + const host = renderInto( + renderDetailBlock('Baseline', cmd(), 'baseline', ctxWith()) + ) + + expect(host.querySelectorAll('img')).toHaveLength(0) + }) + }) +}) diff --git a/packages/app/tests/request-detail.test.ts b/packages/app/tests/request-detail.test.ts new file mode 100644 index 00000000..5b922b6c --- /dev/null +++ b/packages/app/tests/request-detail.test.ts @@ -0,0 +1,368 @@ +// @vitest-environment happy-dom +// +// `renderNetworkRequestDetail` returns a Lit template, so it is exercised by +// rendering into a detached host and reading the DOM back โ€” asserting the +// template's `strings` would test the source text, not what a user sees. +// +// Every expected value that production formats is computed here through the +// same exported helper the renderer calls (`formatBytes`, `formatTime`, +// `statusKind`, `contentType`) over the request under test, so a fixture that +// stops reaching its column fails. The user-visible strings are pinned as +// literals alongside, so a change in the formatters fails too. + +import { describe, it, expect, beforeEach } from 'vitest' +import { render } from 'lit' +import type { NetworkRequest } from '@wdio/devtools-shared' + +import { renderNetworkRequestDetail } from '../src/components/workbench/network/request-detail.js' +import { + contentType, + formatBytes, + formatTime, + statusKind +} from '../src/utils/network-helpers.js' + +function req(partial: Partial<NetworkRequest> = {}): NetworkRequest { + return { + id: 'req-1', + url: 'https://the-internet.herokuapp.com/login', + method: 'GET', + timestamp: 0, + startTime: 0, + type: 'document', + ...partial + } as NetworkRequest +} + +let host: HTMLElement + +beforeEach(() => { + host = document.createElement('div') +}) + +function detail(request: NetworkRequest): HTMLElement { + render(renderNetworkRequestDetail(request), host) + const root = host.querySelector<HTMLElement>('.request-detail') + if (!root) { + throw new Error('renderNetworkRequestDetail rendered no .request-detail') + } + return root +} + +/** Trimmed, whitespace-collapsed text of every match. */ +const texts = (root: Element, selector: string): string[] => + [...root.querySelectorAll(selector)].map((el) => + (el.textContent ?? '').replace(/\s+/g, ' ').trim() + ) + +interface Section { + title: string + keys: string[] + values: string[] +} + +const sections = (root: Element): Section[] => + [...root.querySelectorAll('.detail-section')].map((section) => ({ + title: texts(section, '.detail-title')[0] ?? '', + keys: texts(section, '.k'), + values: texts(section, '.v') + })) + +const sectionNamed = (root: Element, title: string): Section => { + const found = sections(root).find((section) => section.title === title) + if (!found) { + throw new Error(`no detail section titled "${title}"`) + } + return found +} + +const sectionTitles = (root: Element) => + sections(root).map((section) => section.title) + +/** Class of the Status value cell โ€” the renderer stamps `kind-<statusKind>`. */ +const kindClassOf = (root: Element, index = 0): string | undefined => + [...root.querySelectorAll('.v')[index].classList].find((name) => + name.startsWith('kind-') + ) + +/** Raw lines of a `<pre>` body โ€” collapsing whitespace would erase the + * indentation that makes a pretty-printed body pretty-printed. */ +const preLines = (root: Element, index = 0): string[] => + (root.querySelectorAll('pre')[index]?.textContent ?? '').split('\n') + +describe('renderNetworkRequestDetail', () => { + describe('General section', () => { + it('renders the URL, method, status, type, timing and size of a finished request', () => { + const request = req({ + url: 'https://the-internet.herokuapp.com/api/session', + method: 'POST', + type: 'fetch', + status: 200, + statusText: 'OK', + responseHeaders: { 'content-type': 'application/json' }, + time: 8, + size: 320 + }) + const root = detail(request) + + const general = sectionNamed(root, 'General') + expect(general.keys).toEqual([ + 'Request URL', + 'Method', + 'Status', + 'Type', + 'Time', + 'Size' + ]) + // Derived: each cell must carry this request's own field through the + // renderer's own formatter. + expect(general.values).toEqual([ + request.url, + request.method, + `${request.status} ${request.statusText}`, + contentType(request), + formatTime(request.time), + formatBytes(request.size) + ]) + // Pinned: the strings a user actually reads. + expect(general.values).toEqual([ + 'https://the-internet.herokuapp.com/api/session', + 'POST', + '200 OK', + 'application/json', + '8ms', + '320B' + ]) + }) + + it('renders only the General section for a request with no headers or bodies', () => { + const root = detail(req({ status: 200 })) + + expect(sectionTitles(root)).toEqual(['General']) + }) + + it('dashes a status that never arrived and renders no status text', () => { + const request = req({ status: undefined, statusText: undefined }) + const root = detail(request) + + const general = sectionNamed(root, 'General') + expect(general.keys).toEqual(['Request URL', 'Method', 'Status', 'Type']) + expect(general.values[2]).toBe('โ€”') + }) + + it('renders a status code whose reason phrase was not captured', () => { + const root = detail(req({ status: 204, statusText: undefined })) + + expect(sectionNamed(root, 'General').values[2]).toBe('204') + }) + + it('buckets the status into the kind class the shared helper returns', () => { + const cases: Array<{ request: NetworkRequest; expected: string }> = [ + { request: req({ status: 200 }), expected: 'kind-ok' }, + { request: req({ status: 302 }), expected: 'kind-redirect' }, + { request: req({ status: 404 }), expected: 'kind-error' }, + { request: req({ status: 500 }), expected: 'kind-error' }, + { request: req({ status: undefined }), expected: 'kind-pending' }, + { request: req({ status: 100 }), expected: 'kind-pending' }, + { + request: req({ status: 200, error: 'net::ERR_ABORTED' }), + expected: 'kind-error' + } + ] + + for (const { request, expected } of cases) { + host = document.createElement('div') + const root = detail(request) + const derived = `kind-${statusKind(request.status, Boolean(request.error))}` + // Status is the third value cell of the General card. + expect(kindClassOf(root, 2)).toBe(derived) + expect(kindClassOf(root, 2)).toBe(expected) + } + }) + + it('labels the type from the response content-type, dropping its charset', () => { + const request = req({ + type: 'document', + responseHeaders: { 'content-type': 'text/html; charset=utf-8' } + }) + const root = detail(request) + + expect(sectionNamed(root, 'General').values[3]).toBe(contentType(request)) + expect(sectionNamed(root, 'General').values[3]).toBe('text/html') + }) + + it('falls back to the captured type when no response content-type arrived', () => { + const request = req({ type: 'font', responseHeaders: {} }) + const root = detail(request) + + expect(sectionNamed(root, 'General').values[3]).toBe(contentType(request)) + expect(sectionNamed(root, 'General').values[3]).toBe('font') + }) + + it('dashes the type of a request with neither a content-type nor a type', () => { + // `RequestType` has no empty member; the cast reproduces wire data that + // arrived without one, which the renderer still has to survive. + const request = req({ type: '' as NetworkRequest['type'] }) + const root = detail(request) + + expect(sectionNamed(root, 'General').values[3]).toBe(contentType(request)) + expect(sectionNamed(root, 'General').values[3]).toBe('-') + }) + + it('leaves out a zero timing rather than rendering it as 0ms', () => { + // `req.time ? โ€ฆ` is falsy for 0, so the row is dropped โ€” the renderer + // never reaches `formatTime(0)`. + const root = detail(req({ status: 200, time: 0, size: 0 })) + + expect(sectionNamed(root, 'General').keys).toEqual([ + 'Request URL', + 'Method', + 'Status', + 'Type' + ]) + expect(formatTime(0)).toBe('0.00ms') + }) + + it('renders a sub-second timing in seconds', () => { + const request = req({ status: 200, time: 1500 }) + const root = detail(request) + + const values = sectionNamed(root, 'General').values + expect(values[4]).toBe(formatTime(request.time)) + expect(values[4]).toBe('1.5s') + }) + + it('renders a transport error as its own error-flagged row', () => { + const request = req({ + status: undefined, + error: 'net::ERR_CONNECTION_REFUSED', + time: 200 + }) + const root = detail(request) + + const general = sectionNamed(root, 'General') + expect(general.keys).toEqual([ + 'Request URL', + 'Method', + 'Status', + 'Type', + 'Time', + 'Error' + ]) + expect(general.values[5]).toBe('net::ERR_CONNECTION_REFUSED') + // The missing status and the message are both flagged as errors. + expect(texts(root, '.v.kind-error')).toEqual([ + 'โ€”', + 'net::ERR_CONNECTION_REFUSED' + ]) + }) + }) + + describe('header sections', () => { + it('renders one row per request and response header', () => { + const root = detail( + req({ + status: 200, + requestHeaders: { accept: 'text/html', 'accept-encoding': 'gzip' }, + responseHeaders: { 'content-type': 'text/html', server: 'nginx' } + }) + ) + + expect(sectionNamed(root, 'Request Headers')).toEqual({ + title: 'Request Headers', + keys: ['accept', 'accept-encoding'], + values: ['text/html', 'gzip'] + }) + expect(sectionNamed(root, 'Response Headers').keys).toEqual([ + 'content-type', + 'server' + ]) + }) + + it('renders no header section for headers that were not captured', () => { + const root = detail( + req({ status: 200, requestHeaders: undefined, responseHeaders: {} }) + ) + + expect(sectionTitles(root)).toEqual(['General']) + }) + + it('renders no header section for an empty header bag', () => { + const root = detail(req({ status: 200, requestHeaders: {} })) + + expect(sectionTitles(root)).toEqual(['General']) + }) + }) + + describe('body sections', () => { + it('pretty-prints a JSON request and response body', () => { + const root = detail( + req({ + status: 200, + requestBody: '{"sku":"AB-1","qty":2}', + responseBody: '{"ok":true}' + }) + ) + + expect(sectionTitles(root)).toEqual([ + 'General', + 'Request Body', + 'Response Body' + ]) + expect(preLines(root, 0)).toEqual([ + '{', + ' "sku": "AB-1",', + ' "qty": 2', + '}' + ]) + expect(preLines(root, 1)).toEqual(['{', ' "ok": true', '}']) + }) + + it('renders a body that is not JSON verbatim', () => { + const body = '<!doctype html>\n<html lang="en">' + const root = detail(req({ status: 200, responseBody: body })) + + expect(preLines(root, 0)).toEqual(body.split('\n')) + }) + + it('renders a JSON scalar body as the scalar', () => { + const root = detail(req({ status: 200, responseBody: '42' })) + + expect(preLines(root, 0)).toEqual(['42']) + }) + + it('renders no body section for a body that was not captured', () => { + const root = detail( + req({ status: 200, requestBody: undefined, responseBody: undefined }) + ) + + expect(sectionTitles(root)).toEqual(['General']) + }) + + it('renders no body section for an empty body', () => { + const root = detail(req({ status: 200, requestBody: '' })) + + expect(sectionTitles(root)).toEqual(['General']) + }) + + it('renders the sections in request-then-response order', () => { + const root = detail( + req({ + status: 200, + requestHeaders: { accept: '*/*' }, + requestBody: '{"a":1}', + responseHeaders: { server: 'nginx' }, + responseBody: '{"b":2}' + }) + ) + + expect(sectionTitles(root)).toEqual([ + 'General', + 'Request Headers', + 'Request Body', + 'Response Headers', + 'Response Body' + ]) + }) + }) +}) diff --git a/packages/app/tests/suite-summary.test.ts b/packages/app/tests/suite-summary.test.ts index a36e4c12..deef083b 100644 --- a/packages/app/tests/suite-summary.test.ts +++ b/packages/app/tests/suite-summary.test.ts @@ -45,6 +45,7 @@ const summary = (overrides: Partial<SuiteSummary>): SuiteSummary => ({ failed: 0, running: 0, skipped: 0, + queued: 0, pending: 0, total: 0, ...overrides @@ -70,13 +71,16 @@ describe('computeSuiteSummary', () => { ] }) ) + // `c3` is REPORTED pending, so it counts as queued โ€” the run reached it. + // `pending` is reserved for entries nothing has reported on at all, which + // the next test covers with an undefined state. expect(computeSuiteSummary(input)).toEqual( summary({ passed: 1, failed: 1, running: 1, skipped: 1, - pending: 1, + queued: 1, total: 5 }) ) diff --git a/packages/app/tests/test-entry-state.test.ts b/packages/app/tests/test-entry-state.test.ts new file mode 100644 index 00000000..a9dd6419 --- /dev/null +++ b/packages/app/tests/test-entry-state.test.ts @@ -0,0 +1,368 @@ +import { describe, it, expect } from 'vitest' + +import { + computeEntryState, + getTestEntry +} from '../src/components/sidebar/test-entry-state.js' +import type { TestEntry } from '../src/components/sidebar/types.js' +import type { + SuiteStatsFragment, + TestStatsFragment +} from '../src/controller/types.js' + +const SPEC_FILE = '/repo/test/checkout.e2e.ts' +const FINISHED_AT = new Date(1_700_000_000_000) + +// Fragments are cast through `as never` for the same reason as +// suite-merge.test.ts: the declared shapes come from @wdio/reporter, and these +// specs need the off-contract values real payloads carry โ€” a suite whose state +// is null, a state string the sidebar has no rendering for, a missing `tests` +// key. +const test = ( + uid: string, + overrides: Record<string, unknown> = {} +): TestStatsFragment => + ({ + uid, + title: uid, + fullTitle: uid, + file: SPEC_FILE, + ...overrides + }) as never as TestStatsFragment + +/** No `state` by default: a suite carrying one is answered from that state, so + * the derive-from-children path is only reachable from a stateless suite โ€” + * which is also what the backend sends. */ +const suite = ( + uid: string, + overrides: Record<string, unknown> = {} +): SuiteStatsFragment => + ({ + uid, + title: uid, + fullTitle: uid, + file: SPEC_FILE, + tests: [], + suites: [], + ...overrides + }) as never as SuiteStatsFragment + +const passed = (uid: string) => test(uid, { state: 'passed', end: FINISHED_AT }) +const failed = (uid: string) => test(uid, { state: 'failed', end: FINISHED_AT }) +const skipped = (uid: string) => test(uid, { state: 'skipped' }) +const running = (uid: string) => test(uid, { state: 'running' }) +const queued = (uid: string) => test(uid, { state: 'pending' }) + +const keepAll = () => true + +/** + * `computeEntryState` is the sidebar's mapping of a shared outcome onto a row + * status โ€” the outcome derivation itself, and the truth table every consumer + * agrees on, live in `test-outcome.test.ts`. These specs cover the mapping and + * the two statuses only the tree has a name for. + */ +describe('computeEntryState', () => { + describe('suite', () => { + it('spins for a running test even over a stale terminal suite state', () => { + // A rerun clears end times but not the cached 'passed' on the suite. + expect( + computeEntryState( + suite('s', { state: 'passed', tests: [running('t')] }) + ) + ).toBe('running') + }) + + it('spins for a suite marked pending even when its children are terminal', () => { + expect( + computeEntryState( + suite('s', { state: 'pending', tests: [passed('t1'), failed('t2')] }) + ) + ).toBe('running') + }) + + it('spins for a suite marked running whose child never started', () => { + expect( + computeEntryState(suite('s', { state: 'running', tests: [test('t')] })) + ).toBe('running') + }) + + it('derives failed from a failed test when the suite carries no state', () => { + expect( + computeEntryState(suite('s', { tests: [passed('t1'), failed('t2')] })) + ).toBe('failed') + }) + + it('derives failed from a failure in a nested suite', () => { + const inner = suite('inner', { tests: [failed('t')] }) + expect( + computeEntryState( + suite('outer', { tests: [passed('t1')], suites: [inner] }) + ) + ).toBe('failed') + }) + + it('derives passed when something passed and no descendant failed', () => { + const inner = suite('inner', { tests: [skipped('t')] }) + expect( + computeEntryState( + suite('outer', { tests: [passed('t1')], suites: [inner] }) + ) + ).toBe('passed') + }) + + it('derives skipped for a suite that only ever skipped', () => { + expect( + computeEntryState(suite('s', { tests: [skipped('t1'), skipped('t2')] })) + ).toBe('skipped') + }) + + it('returns its own state before consulting its children', () => { + expect( + computeEntryState(suite('s', { state: 'passed', tests: [failed('t')] })) + ).toBe('passed') + expect( + computeEntryState(suite('s', { state: 'failed', tests: [passed('t')] })) + ).toBe('failed') + expect( + computeEntryState( + suite('s', { state: 'skipped', tests: [failed('t')] }) + ) + ).toBe('skipped') + }) + + it('derives from its children when its own state is one it cannot render', () => { + expect( + computeEntryState( + suite('s', { state: 'aborted', tests: [failed('t')] }) + ) + ).toBe('failed') + expect( + computeEntryState( + suite('s', { state: 'aborted', tests: [passed('t')] }) + ) + ).toBe('passed') + }) + + it('shows a suite nothing has reported on as not run, null state or no state', () => { + // The summary calls this same run 'idle'; a green check here was the + // divergence the shared derivation removed. + const child = test('t') + expect(computeEntryState(suite('s', { tests: [child] }))).toBe('pending') + expect( + computeEntryState(suite('s', { state: null, tests: [child] })) + ).toBe('pending') + }) + + it('shows an empty suite as not run', () => { + expect(computeEntryState(suite('s'))).toBe('pending') + expect(computeEntryState(suite('s', { state: null }))).toBe('pending') + expect( + computeEntryState(suite('s', { tests: undefined, suites: undefined })) + ).toBe('pending') + }) + }) + + describe('leaf test', () => { + it('maps every reported state onto the sidebar status', () => { + expect(computeEntryState(passed('t'))).toBe('passed') + expect(computeEntryState(failed('t'))).toBe('failed') + expect(computeEntryState(skipped('t'))).toBe('skipped') + expect(computeEntryState(running('t'))).toBe('running') + }) + + it('spins for a queued test rather than showing it as never run', () => { + expect(computeEntryState(queued('t'))).toBe('running') + }) + + it('passes a test that finished without a reported state', () => { + expect(computeEntryState(test('t', { end: FINISHED_AT }))).toBe('passed') + }) + + it('leaves a test that never started pending', () => { + expect(computeEntryState(test('t'))).toBe('pending') + }) + + it('falls back to the end stamp for a state it has no entry for', () => { + expect( + computeEntryState(test('t', { state: 'aborted', end: FINISHED_AT })) + ).toBe('passed') + expect(computeEntryState(test('t', { state: 'aborted' }))).toBe('pending') + }) + }) +}) + +describe('getTestEntry', () => { + it('maps a leaf test onto a childless test entry', () => { + const entry = getTestEntry( + test('t', { + title: 'applies a discount code', + fullTitle: 'Checkout flow applies a discount code', + state: 'failed', + end: FINISHED_AT, + callSource: 'checkout.e2e.ts:24:5', + featureFile: '/repo/test/checkout.feature', + featureLine: 12 + }), + keepAll + ) + + expect(entry).toEqual({ + uid: 't', + label: 'applies a discount code', + type: 'test', + state: 'failed', + callSource: 'checkout.e2e.ts:24:5', + specFile: SPEC_FILE, + fullTitle: 'Checkout flow applies a discount code', + featureFile: '/repo/test/checkout.feature', + featureLine: 12, + children: [] + }) + }) + + it('falls back from a missing fullTitle to the title', () => { + expect( + getTestEntry( + test('t', { title: 'signs in', fullTitle: undefined }), + keepAll + ).fullTitle + ).toBe('signs in') + }) + + it('leaves the label empty when the test has no title', () => { + const entry = getTestEntry( + test('t', { title: undefined, fullTitle: undefined }), + keepAll + ) + expect(entry.label).toBe('') + expect(entry.fullTitle).toBeUndefined() + }) + + it('derives the state of the entry rather than copying the fragment', () => { + expect(getTestEntry(test('t'), keepAll).state).toBe('pending') + expect(getTestEntry(queued('t'), keepAll).state).toBe('running') + }) + + it('maps a suite onto a suite entry holding its tests', () => { + const entry = getTestEntry( + suite('checkout-suite', { + title: 'Checkout flow', + tests: [passed('t1'), failed('t2')] + }), + keepAll + ) + + expect(entry).toMatchObject({ + uid: 'checkout-suite', + label: 'Checkout flow', + fullTitle: 'Checkout flow', + type: 'suite', + suiteType: 'suite', + state: 'failed', + specFile: SPEC_FILE + }) + expect(entry.children.map((child) => child.uid)).toEqual(['t1', 't2']) + }) + + it('leaves a suite label empty when the suite has no title', () => { + const entry = getTestEntry(suite('s', { title: undefined }), keepAll) + expect(entry.label).toBe('') + expect(entry.fullTitle).toBe('') + }) + + it('keeps the suite type reported for a suite with only tests', () => { + expect( + getTestEntry( + suite('login-scenario', { type: 'scenario', tests: [passed('t')] }), + keepAll + ).suiteType + ).toBe('scenario') + }) + + it('tags a suite that holds suites as a feature whatever its own type says', () => { + const scenario = suite('login-scenario', { + type: 'scenario', + tests: [passed('t')] + }) + expect( + getTestEntry( + suite('login-feature', { type: 'scenario', suites: [scenario] }), + keepAll + ).suiteType + ).toBe('feature') + }) + + it('orders tests before nested suites', () => { + const inner = suite('inner', { tests: [passed('t2')] }) + const entry = getTestEntry( + suite('outer', { tests: [passed('t1')], suites: [inner] }), + keepAll + ) + + expect(entry.children.map((child) => child.uid)).toEqual(['t1', 'inner']) + expect(entry.children.map((child) => child.type)).toEqual(['test', 'suite']) + }) + + it('maps a grandchild test through both levels', () => { + const inner = suite('login-scenario', { + type: 'scenario', + tests: [failed('login-invalid')] + }) + const entry = getTestEntry( + suite('login-feature', { suites: [inner] }), + keepAll + ) + + expect(entry.state).toBe('failed') + expect(entry.children[0]?.state).toBe('failed') + expect(entry.children[0]?.children[0]).toMatchObject({ + uid: 'login-invalid', + type: 'test', + state: 'failed' + }) + }) + + it('drops the children the filter rejects and keeps the suite', () => { + const entry = getTestEntry( + suite('s', { tests: [passed('keep'), passed('drop')] }), + (child) => child.uid === 'keep' + ) + + expect(entry.children.map((child) => child.uid)).toEqual(['keep']) + }) + + it('filters nested suites at every level', () => { + const inner = suite('inner', { tests: [passed('keep'), passed('drop')] }) + const entry = getTestEntry( + suite('outer', { suites: [inner] }), + (child) => child.uid !== 'drop' + ) + + expect(entry.children[0]?.children.map((child) => child.uid)).toEqual([ + 'keep' + ]) + }) + + it('offers the filter the mapped entry, and never the entry it was called on', () => { + const seen: TestEntry[] = [] + const root = suite('s', { tests: [passed('t1')] }) + + getTestEntry(root, (child) => { + seen.push(child) + return true + }) + + expect(seen.map((entry) => entry.uid)).toEqual(['t1']) + expect(seen[0]).toMatchObject({ uid: 't1', type: 'test', state: 'passed' }) + }) + + it('keeps a suite whose every child was filtered out, with no children left', () => { + const entry = getTestEntry( + suite('s', { tests: [passed('t1')] }), + () => false + ) + + expect(entry.uid).toBe('s') + expect(entry.children).toEqual([]) + }) +}) diff --git a/packages/app/tests/test-outcome.test.ts b/packages/app/tests/test-outcome.test.ts new file mode 100644 index 00000000..808890fc --- /dev/null +++ b/packages/app/tests/test-outcome.test.ts @@ -0,0 +1,478 @@ +import { describe, it, expect } from 'vitest' + +import { computeEntryState } from '../src/components/sidebar/test-entry-state.js' +import { + computeSuiteSummary, + deriveRunStatus +} from '../src/components/sidebar/suite-summary.js' +import { markRunningAsStopped } from '../src/controller/mark-running.js' +import { mergeSuite } from '../src/controller/suite-merge.js' +import type { + SuiteStatsFragment, + TestStatsFragment +} from '../src/controller/types.js' +import { + OUTCOME, + deriveEntryOutcome, + deriveGroupOutcome, + deriveOutcome, + emptyTally, + hasFailure, + hasInFlight, + isInFlight, + isSuiteEntry, + settledOutcome, + tallyOutcomes, + type StateTally +} from '../src/utils/test-outcome.js' + +const FINISHED_AT = new Date(1_700_000_000_000) + +// Fragments are cast through `as never` for the same reason as +// suite-merge.test.ts: the declared shapes come from @wdio/reporter, and these +// specs need the off-contract values real payloads carry โ€” a state of null, a +// state string no renderer has an entry for, a missing `tests` key. +const test = ( + uid: string, + overrides: Record<string, unknown> = {} +): TestStatsFragment => + ({ + uid, + title: uid, + fullTitle: uid, + ...overrides + }) as never as TestStatsFragment + +const suite = ( + uid: string, + overrides: Record<string, unknown> = {} +): SuiteStatsFragment => + ({ + uid, + title: uid, + fullTitle: uid, + tests: [], + suites: [], + ...overrides + }) as never as SuiteStatsFragment + +const passed = (uid: string) => test(uid, { state: 'passed', end: FINISHED_AT }) +const failed = (uid: string) => test(uid, { state: 'failed', end: FINISHED_AT }) +/** A skipped test carries no `end` โ€” `TestStats.skip()` never calls + * `complete()`, so nothing but its state says it settled. */ +const skipped = (uid: string) => test(uid, { state: 'skipped' }) +const running = (uid: string) => test(uid, { state: 'running' }) +const queued = (uid: string) => test(uid, { state: 'pending' }) +const unreported = (uid: string) => test(uid) + +const tally = (overrides: Partial<StateTally>): StateTally => ({ + ...emptyTally(), + ...overrides +}) + +/** + * The rows of the truth table, each naming the children of one stateless suite. + * Every derivation in the app is asserted against the same rows below, which is + * the point of the exercise: they used to disagree. + */ +const ROWS = { + allPassed: [passed('a'), passed('b')], + anyFailed: [passed('a'), failed('b')], + allSkipped: [skipped('a'), skipped('b')], + unreported: [unreported('a'), unreported('b')], + empty: [] as TestStatsFragment[], + running: [passed('a'), running('b')], + allQueued: [queued('a'), queued('b')], + queuedAfterFinished: [passed('a'), queued('b')], + skippedAndPassed: [passed('a'), skipped('b')], + skippedAndFailed: [failed('a'), skipped('b')] +} + +describe('deriveEntryOutcome', () => { + describe('leaf test', () => { + it('reads a reported outcome straight off the state', () => { + expect(deriveEntryOutcome(passed('t'))).toBe(OUTCOME.PASSED) + expect(deriveEntryOutcome(failed('t'))).toBe(OUTCOME.FAILED) + expect(deriveEntryOutcome(skipped('t'))).toBe(OUTCOME.SKIPPED) + expect(deriveEntryOutcome(running('t'))).toBe(OUTCOME.RUNNING) + }) + + it('reads a reported pending state as queued, not as never-run', () => { + expect(deriveEntryOutcome(queued('t'))).toBe(OUTCOME.QUEUED) + }) + + it('is idle for a test nothing has reported on', () => { + expect(deriveEntryOutcome(unreported('t'))).toBe(OUTCOME.IDLE) + }) + + it('falls back to the end stamp for a state it has no entry for', () => { + expect( + deriveEntryOutcome(test('t', { state: 'aborted', end: FINISHED_AT })) + ).toBe(OUTCOME.PASSED) + expect(deriveEntryOutcome(test('t', { state: 'aborted' }))).toBe( + OUTCOME.IDLE + ) + }) + }) + + describe('suite', () => { + it('derives every truth-table row from its children', () => { + const outcomes = Object.fromEntries( + Object.entries(ROWS).map(([name, tests]) => [ + name, + deriveEntryOutcome(suite('s', { tests })) + ]) + ) + + expect(outcomes).toEqual({ + allPassed: OUTCOME.PASSED, + anyFailed: OUTCOME.FAILED, + allSkipped: OUTCOME.SKIPPED, + unreported: OUTCOME.IDLE, + empty: OUTCOME.IDLE, + running: OUTCOME.RUNNING, + // Every run's first frame: the reporter announces its tests as pending + // before the first one executes. The leaves spin, so the suite holding + // them cannot show the not-run circle. + allQueued: OUTCOME.RUNNING, + queuedAfterFinished: OUTCOME.RUNNING, + skippedAndPassed: OUTCOME.PASSED, + skippedAndFailed: OUTCOME.FAILED + }) + }) + + it('reports its own terminal state over its children', () => { + expect( + deriveEntryOutcome( + suite('s', { state: 'passed', tests: [failed('t')] }) + ) + ).toBe(OUTCOME.PASSED) + expect( + deriveEntryOutcome( + suite('s', { state: 'failed', tests: [passed('t')] }) + ) + ).toBe(OUTCOME.FAILED) + }) + + it('runs while a child runs, whatever stale state it carries', () => { + // A rerun clears end stamps but not the previous run's suite state. + expect( + deriveEntryOutcome( + suite('s', { state: 'passed', tests: [running('t')] }) + ) + ).toBe(OUTCOME.RUNNING) + }) + + it('runs when its own state says a new run is starting', () => { + expect( + deriveEntryOutcome( + suite('s', { state: 'pending', tests: [passed('a'), failed('b')] }) + ) + ).toBe(OUTCOME.RUNNING) + }) + + it('runs while a test inside a nested suite runs', () => { + const inner = suite('inner', { state: 'passed', tests: [running('t')] }) + expect(deriveEntryOutcome(suite('outer', { suites: [inner] }))).toBe( + OUTCOME.RUNNING + ) + }) + + it('fails from a failure two levels down', () => { + const leaf = suite('leaf', { tests: [failed('t')] }) + const middle = suite('middle', { suites: [leaf] }) + expect(deriveEntryOutcome(suite('outer', { suites: [middle] }))).toBe( + OUTCOME.FAILED + ) + }) + + it('treats a null state the same as an absent one', () => { + expect( + deriveEntryOutcome(suite('s', { state: null, tests: [passed('t')] })) + ).toBe(OUTCOME.PASSED) + expect( + deriveEntryOutcome( + suite('s', { state: null, tests: [unreported('t')] }) + ) + ).toBe(OUTCOME.IDLE) + }) + + it('derives from its children when its own state is one it cannot render', () => { + expect( + deriveEntryOutcome( + suite('s', { state: 'aborted', tests: [failed('t')] }) + ) + ).toBe(OUTCOME.FAILED) + }) + + it('is idle for a suite whose children keys are present but unset', () => { + expect( + deriveEntryOutcome(suite('s', { tests: undefined, suites: undefined })) + ).toBe(OUTCOME.IDLE) + }) + + // An end stamp on a suite says its hooks finished, not that anything ran: + // a `describe` whose tests were all filtered out still gets `suite:end`. + // Calling that passed is the false green this module exists to prevent. + it('has no verdict for an empty suite that carries an end stamp', () => { + expect(deriveEntryOutcome(suite('s', { end: FINISHED_AT }))).toBe( + OUTCOME.IDLE + ) + }) + }) +}) + +describe('isSuiteEntry', () => { + it('reads either children key as a suite and neither as a leaf', () => { + expect(isSuiteEntry(suite('s'))).toBe(true) + expect(isSuiteEntry(suite('s', { tests: undefined }))).toBe(true) + expect(isSuiteEntry(test('t'))).toBe(false) + }) +}) + +describe('isInFlight', () => { + it('holds every entry that has not settled', () => { + expect(isInFlight(running('t'))).toBe(true) + expect(isInFlight(queued('t'))).toBe(true) + expect(isInFlight(unreported('t'))).toBe(true) + }) + + it('releases every entry that reported an outcome', () => { + expect(isInFlight(passed('t'))).toBe(false) + expect(isInFlight(failed('t'))).toBe(false) + // The regression this guards: a skipped test has settled even though it + // carries no end stamp. + expect(isInFlight(skipped('t'))).toBe(false) + }) +}) + +describe('tallyOutcomes', () => { + // `queued` counts its own bucket: a reporter marking a test pending means the + // run reached it, which reads as running above it โ€” folding it into `pending` + // (nothing reported at all) is what showed spinner leaves under a not-run + // parent on every run's first frame. + it('counts each outcome once, keeping queued apart from unreported', () => { + expect( + tallyOutcomes([ + passed('a'), + failed('b'), + running('c'), + skipped('d'), + queued('e'), + unreported('f') + ]) + ).toEqual( + tally({ + passed: 1, + failed: 1, + running: 1, + skipped: 1, + queued: 1, + pending: 1, + total: 6 + }) + ) + }) + + it('skips holes in the list instead of counting them', () => { + expect(tallyOutcomes([passed('a'), undefined])).toEqual( + tally({ passed: 1, total: 1 }) + ) + }) + + it('counts a nested suite by the outcome of its own subtree', () => { + const failing = suite('inner', { tests: [passed('a'), failed('b')] }) + expect(tallyOutcomes([passed('t'), failing])).toEqual( + tally({ passed: 1, failed: 1, total: 2 }) + ) + }) +}) + +describe('deriveOutcome', () => { + it('is idle with nothing to go on', () => { + expect(deriveOutcome(emptyTally())).toBe(OUTCOME.IDLE) + }) + + it('is idle when nothing has produced a result yet', () => { + expect(deriveOutcome(tally({ pending: 3, total: 3 }))).toBe(OUTCOME.IDLE) + }) + + it('runs while anything runs, even beside a stale failure count', () => { + expect(deriveOutcome(tally({ failed: 1, running: 1, total: 2 }))).toBe( + OUTCOME.RUNNING + ) + }) + + it('runs when results and unfinished work coexist', () => { + expect(deriveOutcome(tally({ passed: 2, pending: 1, total: 3 }))).toBe( + OUTCOME.RUNNING + ) + }) + + it('fails on any failure once the run has finished', () => { + expect(deriveOutcome(tally({ passed: 2, failed: 1, total: 3 }))).toBe( + OUTCOME.FAILED + ) + expect(deriveOutcome(tally({ skipped: 2, failed: 1, total: 3 }))).toBe( + OUTCOME.FAILED + ) + }) + + it('passes when something passed and nothing failed', () => { + expect(deriveOutcome(tally({ passed: 3, total: 3 }))).toBe(OUTCOME.PASSED) + expect(deriveOutcome(tally({ passed: 1, skipped: 2, total: 3 }))).toBe( + OUTCOME.PASSED + ) + }) + + it('is skipped when the run verified nothing at all', () => { + // Reporting "passed" for a run that executed no assertions is a false + // green: skipped is the only honest headline. + expect(deriveOutcome(tally({ skipped: 3, total: 3 }))).toBe(OUTCOME.SKIPPED) + }) +}) + +describe('deriveGroupOutcome', () => { + it('answers for a list of entries what deriveOutcome answers for its tally', () => { + for (const tests of Object.values(ROWS)) { + expect(deriveGroupOutcome(tests)).toBe( + deriveOutcome(tallyOutcomes(tests)) + ) + } + }) +}) + +describe('hasFailure / hasInFlight', () => { + it('flags a failure and nothing else', () => { + expect(hasFailure(tally({ failed: 1, total: 1 }))).toBe(true) + expect(hasFailure(tally({ passed: 1, skipped: 1, total: 2 }))).toBe(false) + }) + + it('flags running and pending work as still to come', () => { + expect(hasInFlight(tally({ running: 1, total: 1 }))).toBe(true) + expect(hasInFlight(tally({ pending: 1, total: 1 }))).toBe(true) + expect(hasInFlight(tally({ passed: 1, skipped: 1, total: 2 }))).toBe(false) + expect(hasInFlight(emptyTally())).toBe(false) + }) +}) + +describe('settledOutcome', () => { + it('returns the verdict once every entry settled', () => { + expect(settledOutcome(tally({ passed: 2, total: 2 }))).toBe(OUTCOME.PASSED) + expect(settledOutcome(tally({ passed: 1, failed: 1, total: 2 }))).toBe( + OUTCOME.FAILED + ) + expect(settledOutcome(tally({ skipped: 2, total: 2 }))).toBe( + OUTCOME.SKIPPED + ) + }) + + it('withholds a verdict while anything is unfinished or absent', () => { + expect( + settledOutcome(tally({ passed: 1, running: 1, total: 2 })) + ).toBeUndefined() + expect( + settledOutcome(tally({ passed: 1, pending: 1, total: 2 })) + ).toBeUndefined() + expect(settledOutcome(tally({ pending: 2, total: 2 }))).toBeUndefined() + expect(settledOutcome(emptyTally())).toBeUndefined() + }) +}) + +/** + * The reason the helper exists: the sidebar row, the summary pill, the suite + * merge and the stop handler used to derive "did this fail?" four different + * ways. Each row of the truth table is asserted against all four at once, so a + * future divergence fails here. + */ +describe('every consumer agrees on the truth table', () => { + const registry = (fragment: SuiteStatsFragment) => [ + { [fragment.uid]: fragment } + ] + + const rowStates = (tests: TestStatsFragment[]) => { + const fragment = suite('s', { tests }) + const stopped = markRunningAsStopped(registry(fragment)) + const merged = mergeSuite(fragment, suite('s', { tests }), {}) + return { + tree: computeEntryState(fragment), + summary: deriveRunStatus(computeSuiteSummary(registry(fragment))), + merged: merged.state, + stoppedTests: (stopped[0]?.['s']?.tests ?? []).map((t) => t.state) + } + } + + it('calls an all-passing suite passed everywhere', () => { + expect(rowStates(ROWS.allPassed)).toEqual({ + tree: 'passed', + summary: 'passed', + merged: 'passed', + stoppedTests: ['passed', 'passed'] + }) + }) + + it('calls a suite holding a failure failed everywhere', () => { + expect(rowStates(ROWS.anyFailed)).toEqual({ + tree: 'failed', + summary: 'failed', + merged: 'failed', + stoppedTests: ['passed', 'failed'] + }) + }) + + it('calls an all-skipped suite skipped everywhere, and stopping it keeps it skipped', () => { + expect(rowStates(ROWS.allSkipped)).toEqual({ + tree: 'skipped', + summary: 'skipped', + merged: 'skipped', + stoppedTests: ['skipped', 'skipped'] + }) + }) + + it('calls a suite nothing has reported on not-run in the tree and idle in the summary', () => { + const states = rowStates(ROWS.unreported) + expect(states.tree).toBe('pending') + expect(states.summary).toBe('idle') + // The merge is the one caller that writes rather than reads: an unreported + // child means the run is mid-flight, so the suite it stores spins. + expect(states.merged).toBe('running') + }) + + it('calls an empty suite not-run in the tree and idle in the summary', () => { + const states = rowStates(ROWS.empty) + expect(states.tree).toBe('pending') + expect(states.summary).toBe('idle') + }) + + it('calls a suite with a running test running everywhere', () => { + const states = rowStates(ROWS.running) + expect(states.tree).toBe('running') + expect(states.summary).toBe('running') + expect(states.merged).toBe('running') + }) + + it('calls a suite whose queued test follows a finished one running everywhere', () => { + const states = rowStates(ROWS.queuedAfterFinished) + expect(states.tree).toBe('running') + expect(states.summary).toBe('running') + expect(states.merged).toBe('running') + }) + + it('calls a suite of passed and skipped tests passed everywhere', () => { + expect(rowStates(ROWS.skippedAndPassed)).toEqual({ + tree: 'passed', + summary: 'passed', + merged: 'passed', + stoppedTests: ['passed', 'skipped'] + }) + }) + + it('calls a suite of failed and skipped tests failed everywhere', () => { + expect(rowStates(ROWS.skippedAndFailed)).toEqual({ + tree: 'failed', + summary: 'failed', + merged: 'failed', + stoppedTests: ['failed', 'skipped'] + }) + }) +}) diff --git a/packages/app/tests/vnode-transform.test.ts b/packages/app/tests/vnode-transform.test.ts new file mode 100644 index 00000000..4b7b4ed8 --- /dev/null +++ b/packages/app/tests/vnode-transform.test.ts @@ -0,0 +1,337 @@ +/** + * @vitest-environment happy-dom + */ +import { describe, expect, it } from 'vitest' +import type { VNode } from 'preact' + +import { transform } from '../src/components/browser/vnode-transform.js' +// The producers on the other side of the wire. `transform` only ever sees what +// these two emit, so the shapes below are taken from them rather than guessed: +// `parseDocument` serializes the full-DOM anchor (collector.captureCurrentDom), +// `parseFragment` every added node of a childList mutation (serializeMutation). +import { + assignRef, + parseDocument, + parseFragment +} from '../../script/src/utils.js' + +/** `transform`'s input type is module-private; this is the same shape. */ +interface Serialized { + type?: string + props?: { + children?: Serialized | Serialized[] | string | number + } & Record<string, unknown> +} + +/** Preact types a VNode's props by its component, so an anonymous element's + * captured attributes are only reachable as an index signature. */ +const propsOf = (node: VNode<{}>): Record<string, unknown> => + node.props as Record<string, unknown> + +const childrenOf = (node: VNode<{}>): unknown => propsOf(node).children + +/** One transformed child, as `h()` leaves it for a single-child node. */ +const onlyChild = (node: VNode<{}>): VNode<{}> => childrenOf(node) as VNode<{}> + +const childList = (node: VNode<{}>): VNode<{}>[] => + childrenOf(node) as VNode<{}>[] + +/** Captures `markup` the way the injected script does: stamp refs on every + * element, then serialize. Returns the payload that reaches `transform`. */ +function captureFragment(markup: string): Serialized { + const holder = document.createElement('div') + holder.innerHTML = markup + const element = holder.firstElementChild as Element + assignRef(element) + return parseFragment(element) as Serialized +} + +/** Captures a whole page the way `collector.captureCurrentDom` does. */ +function captureDocument(markup: string): Serialized { + document.documentElement.innerHTML = markup + assignRef(document.documentElement) + return parseDocument(document.documentElement) as Serialized +} + +describe('transform', () => { + describe('text passthrough', () => { + it('returns a text child unchanged', () => { + // parseNode() returns a bare string for a #text node, and Preact renders + // a string child as text โ€” so there is nothing to convert. + expect(transform('You logged into a secure area!')).toBe( + 'You logged into a secure area!' + ) + }) + + it('returns a numeric child unchanged', () => { + expect(transform(42)).toBe(42) + }) + + it('returns null unchanged', () => { + expect(transform(null)).toBeNull() + }) + }) + + describe('the fragment wrapper an added node arrives in', () => { + // parseFragment() serializes a documentFragment, whose nodeName has no + // tagName โ€” so `h(undefined, {}, root)` wraps every added node in a typeless + // node. That wrapper is what the ToDo in vnode-transform.ts is about. + it('is the shape packages/script actually produces for an added node', () => { + const captured = captureFragment('<p id="error">Invalid credentials</p>') + + expect(captured.type).toBeUndefined() + expect(Array.isArray(captured.props?.children)).toBe(false) + expect((captured.props?.children as Serialized).type).toBe('p') + }) + + it('is unwrapped to the element it wraps', () => { + const captured = captureFragment('<p id="error">Invalid credentials</p>') + + const node = transform(captured) + + expect(node.type).toBe('p') + expect(propsOf(node).id).toBe('error') + expect(childrenOf(node)).toBe('Invalid credentials') + }) + + it('carries the captured ref through the unwrap', () => { + // The ref is what the replay matches later mutations against, so losing it + // in the unwrap would strand every mutation targeting the inserted node. + const captured = captureFragment('<p id="error"></p>') + const ref = (captured.props?.children as Serialized).props?.[ + 'data-wdio-ref' + ] + + expect(typeof ref).toBe('string') + expect(propsOf(transform(captured))['data-wdio-ref']).toBe(ref) + }) + + it('discards the wrapper and keeps only the wrapped element', () => { + const node = transform({ + props: { class: 'wrapper', children: { type: 'span', props: {} } } + }) + + expect(node.type).toBe('span') + expect(propsOf(node).class).toBeUndefined() + }) + + it('is not unwrapped when the node has a type of its own', () => { + const node = transform({ + type: 'div', + props: { children: { type: 'span', props: {} } } + }) + + expect(node.type).toBe('div') + expect(onlyChild(node).type).toBe('span') + }) + + it('is not unwrapped when the wrapper holds several children', () => { + // A multi-root fragment stays typeless. serializeMutation() serializes + // each added node on its own, so this never reaches the replay โ€” but the + // branch is what keeps it from silently dropping the second root. + const node = transform({ + props: { + children: [ + { type: 'span', props: {} }, + { type: 'em', props: {} } + ] + } + }) + + expect(node.type).toBeUndefined() + expect(childList(node).map((child) => child.type)).toEqual(['span', 'em']) + }) + + it('is not unwrapped when its only child is text', () => { + const node = transform({ props: { children: 'Saved' } }) + + expect(node.type).toBeUndefined() + expect(childrenOf(node)).toBe('Saved') + }) + }) + + describe('children normalisation', () => { + it('leaves a single child as the one child, not a list', () => { + // `h(type, props, child)` stores the child itself; a list only appears + // from two children up. Both shapes come off the wire, so both are + // normalised here rather than downstream. + const node = transform({ + type: 'form', + props: { children: { type: 'input', props: { id: 'username' } } } + }) + + expect(Array.isArray(childrenOf(node))).toBe(false) + expect(onlyChild(node).type).toBe('input') + }) + + it('keeps several children as a list, in order', () => { + const node = transform({ + type: 'ul', + props: { + children: [ + { type: 'li', props: { id: 'first' } }, + { type: 'li', props: { id: 'second' } } + ] + } + }) + + expect(childList(node).map((child) => propsOf(child).id)).toEqual([ + 'first', + 'second' + ]) + }) + + it('transforms each child of a list rather than passing it through raw', () => { + const first: Serialized = { type: 'li', props: {} } + const second: Serialized = { type: 'li', props: {} } + + const node = transform({ + type: 'ul', + props: { children: [first, second] } + }) + + expect(childList(node)).not.toContain(first) + expect(childList(node)).not.toContain(second) + expect(childList(node).map((child) => child.type)).toEqual(['li', 'li']) + }) + + it('flattens a one-item child list to a bare child', () => { + // `h(type, props, ...[one])` is a three-argument call, so the two wire + // shapes converge โ€” a consumer can never tell an array of one from a + // single child, which is why nothing downstream branches on it. + const node = transform({ + type: 'form', + props: { children: [{ type: 'input', props: {} }] } + }) + + expect(Array.isArray(childrenOf(node))).toBe(false) + expect(onlyChild(node).type).toBe('input') + }) + + it('mixes text and element children in capture order', () => { + const node = transform({ + type: 'p', + props: { + // Real captures interleave text nodes with elements; SerializedVNode's + // children type doesn't model strings, so this mirrors runtime shape. + children: ['Signed in as ', { type: 'b', props: {} }, '.'] as never + } + }) + + expect( + childList(node).map((child) => + typeof child === 'string' ? child : child.type + ) + ).toEqual(['Signed in as ', 'b', '.']) + }) + + it('leaves a childless node with no children at all', () => { + const node = transform({ type: 'input', props: { id: 'username' } }) + + expect('children' in propsOf(node)).toBe(false) + }) + + it('leaves a node whose only child is an empty string childless', () => { + // parseNode() returns '' for a comment, which is how a comment is dropped + // from the replay instead of rendering as visible text. + const node = transform({ type: 'div', props: { children: '' } }) + + expect('children' in propsOf(node)).toBe(false) + }) + }) + + describe('props', () => { + it('spreads the captured attributes onto the rendered node', () => { + const node = transform({ + type: 'input', + props: { + id: 'username', + type: 'text', + value: 'tomsmith', + 'data-wdio-ref': '7' + } + }) + + expect(propsOf(node)).toMatchObject({ + id: 'username', + type: 'text', + value: 'tomsmith', + 'data-wdio-ref': '7' + }) + }) + + it('never renders the serialized child list as a prop', () => { + const serializedChild: Serialized = { type: 'span', props: {} } + + const node = transform({ + type: 'div', + props: { id: 'flash', children: serializedChild } + }) + + expect(propsOf(node).id).toBe('flash') + expect(childrenOf(node)).not.toBe(serializedChild) + }) + + it('tolerates a node captured without props', () => { + const node = transform({ type: 'br' }) + + expect(node.type).toBe('br') + expect(propsOf(node)).toEqual({}) + }) + }) + + describe('a captured page', () => { + const PAGE = + '<head><meta charset="utf-8"><title>Login' + + '
' + + it('rebuilds html > [head, body] as the document anchor carries it', () => { + const captured = captureDocument(PAGE) + + const root = transform(captured) + + expect(root.type).toBe('html') + expect(childList(root).map((child) => child.type)).toEqual([ + 'head', + 'body' + ]) + }) + + it('transforms nested children all the way down', () => { + const root = transform(captureDocument(PAGE)) + const body = childList(root)[1] + const form = onlyChild(body) + const username = onlyChild(form) + + expect(propsOf(form).id).toBe('login') + expect(username.type).toBe('input') + expect(propsOf(username).value).toBe('tomsmith') + }) + + it('keeps the ref of an element nested inside the page', () => { + const captured = captureDocument(PAGE) + const ref = document + .querySelector('#username') + ?.getAttribute('data-wdio-ref') + + const username = onlyChild(onlyChild(childList(transform(captured))[1])) + + expect(ref).toBeTruthy() + expect(propsOf(username)['data-wdio-ref']).toBe(ref) + }) + + it('gives a one-child head a bare child rather than a list', () => { + // snapshot.ts's #renderNewDocument prepends its by spreading + // head.props.children, so this shape โ€” a real page with a single + // โ€” is the one that makes the rebuild throw and the iframe stay blank. + const root = transform( + captureDocument('<head><title>Login') + ) + const head = childList(root)[0] + + expect(head.type).toBe('head') + expect(Array.isArray(childrenOf(head))).toBe(false) + expect(onlyChild(head).type).toBe('title') + }) + }) +}) diff --git a/packages/app/tsconfig.json b/packages/app/tsconfig.json index 3ea541a1..79c83106 100644 --- a/packages/app/tsconfig.json +++ b/packages/app/tsconfig.json @@ -3,5 +3,8 @@ "compilerOptions": { "ignoreDeprecations": "6.0", "noUncheckedSideEffectImports": false - } + }, + // Component specs need the browser-runner's mocha + expect globals, which the + // app build must not depend on โ€” they carry their own tsconfig. + "exclude": ["test-ui"] } diff --git a/packages/app/vite.config.ts b/packages/app/vite.config.ts index 206e439e..5bf9be0b 100644 --- a/packages/app/vite.config.ts +++ b/packages/app/vite.config.ts @@ -23,7 +23,7 @@ export default defineConfig({ } }, css: { - postcss: './postcss.config.cjs' + postcss: path.resolve(__dirname, './postcss.config.cjs') }, plugins: [ Icons({ @@ -32,8 +32,15 @@ export default defineConfig({ autoDefine: true, shadow: false }, + // Every path here is absolute because this config is also handed to the + // component-test runner, whose Vite servers start in worker processes with + // the repo root as cwd โ€” the defaults resolve against cwd and every icon + // then fails to load. + collectionsNodeResolvePath: __dirname, customCollections: { - custom: FileSystemIconLoader('./src/assets/icons') + custom: FileSystemIconLoader( + path.resolve(__dirname, './src/assets/icons') + ) } }) ] diff --git a/packages/app/wdio.conf.ts b/packages/app/wdio.conf.ts new file mode 100644 index 00000000..297a8fe6 --- /dev/null +++ b/packages/app/wdio.conf.ts @@ -0,0 +1,178 @@ +// Component tests for the Lit elements in packages/app/src โ€” mount one with +// explicit inputs, assert its shadow DOM in a real browser. +// +// The browser runner serves the specs through Vite, so it is handed the app's +// own vite.config.ts rather than a second copy of it: the components import +// `~icons/*` (unplugin-icons), Tailwind through postcss, and the `@` / `@core` +// / `@components` aliases โ€” none of which the runner's default Vite config +// resolves, so without it every spec fails at import. +// +// The `reference types` directive is load-bearing: it is what populates +// `WebdriverIO.BrowserRunnerOptions` with the `viteConfig` / `preset` / +// `headless` options used below. +// +// Headless by default so runs are quiet and reproducible; HEADED=1 shows the +// browser and the in-page mocha reporter. + +/// + +import { createRequire } from 'node:module' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +import appViteConfig from './vite.config.js' + +const appDir = path.dirname(fileURLToPath(import.meta.url)) +const headed = process.env.HEADED === '1' || process.env.HEADED === 'true' +// INSPECT=1 makes the browser attachable from an editor: a fixed CDP port, one +// worker so nothing contends for it, and headed so the paused page is visible. +const inspect = process.env.INSPECT === '1' || process.env.INSPECT === 'true' +const INSPECT_PORT = 9222 +const requireFromApp = createRequire(import.meta.url) + +// The runner asks Vite to pre-bundle these CJS packages so the browser can +// import them as ESM. Under pnpm none of them are reachable from this package, +// so Vite silently skips them and the first one the import graph touches throws +// `does not provide an export named 'default'` โ€” every spec fails at load. They +// are reachable from `webdriverio`, which depends on them, so each is mapped to +// its real path. Keep in step with the runner's own `optimizeDeps.include`. +const RUNNER_CJS_DEPS = [ + 'expect', + 'minimatch', + 'css-shorthand-properties', + 'lodash.merge', + 'lodash.zip', + 'ws', + 'lodash.clonedeep', + 'lodash.pickby', + 'lodash.flattendeep', + 'aria-query', + 'grapheme-splitter', + 'css-value', + 'rgb2hex', + 'p-iteration', + 'deepmerge-ts', + 'jest-util', + 'jest-matcher-utils', + 'split2' +] + +function tryResolve(require_: NodeJS.Require, id: string): string | undefined { + try { + return require_.resolve(id) + } catch { + // Not reachable from this base โ€” the caller falls through to the next one. + return undefined + } +} + +/** Real path per CJS dep, resolved from this package first and then from the + * packages that depend on them. A dep that resolves nowhere is left out: it is + * unreachable, so nothing can import it either. */ +function cjsDepAliases(): Record { + const bases = ['webdriverio', 'expect'] + .map((id) => tryResolve(requireFromApp, id)) + .filter((entry): entry is string => Boolean(entry)) + .map((entry) => createRequire(entry)) + const aliases: Record = {} + for (const dep of RUNNER_CJS_DEPS) { + for (const require_ of [requireFromApp, ...bases]) { + const resolved = tryResolve(require_, dep) + if (resolved) { + aliases[dep] = resolved + break + } + } + } + return aliases +} + +// COVERAGE=1 instruments packages/app/src with istanbul through Vite and +// collects `__coverage__` out of the browser, writing the report to +// packages/app/coverage. Opt-in, never on by default, for three measured +// reasons: instrumented specs run about twice as slow (heaviest spec 2.3s -> +// 4.7s, full suite 243s -> 283s); that margin costs roughly one spec per run its +// BiDi `browsingContext.navigate`, and under any parallel load it collapses +// (11 of 30 specs lost to driver timeouts while other work shared the machine); +// and `clean: true` wipes reportsDirectory at startup, so two concurrent +// instrumented runs silently destroy each other's report. Default-off keeps +// `pnpm test:ui` the fast 30/30-green signal it is. +const coverageEnabled = process.env.COVERAGE === '1' + +const COVERAGE: WebdriverIO.BrowserRunnerOptions['coverage'] = { + enabled: true, + // Relative to `rootDir` below โ€” the runner hands `cwd: rootDir` to + // vite-plugin-istanbul, so this is packages/app/src. + include: ['src/**'], + reportsDirectory: path.resolve(appDir, 'coverage'), + // `json` is the mergeable istanbul map: it is what a single combined number + // across this suite and `vitest --coverage` would be built from. + reporter: ['text', 'json', 'json-summary'] + // Deliberately no `lines`/`branches`/`functions`/`statements` floor, even + // though the runner supports one and @wdio/cli would turn a breach into exit + // code 1. Two measured reasons. The denominator cannot be pinned: istanbul + // instruments through Vite's transform, so only modules some spec imported are + // ever counted, and that set moves with the specs. And instrumentation costs + // the heaviest specs their BiDi navigate โ€” roughly one spec per run, a + // different one each time, surviving a retry, all of which pass uninstrumented + // โ€” and a lost spec takes its files' coverage with it. Consecutive full runs + // scored 83.55% and 80.70% for that reason alone. A floor tight enough to mean + // anything would fail on the flake; one loose enough to survive it would gate + // nothing. Add one once the instrumented suite is deterministically green. +} + +// The app's config declares `alias` as an object literal, so it is spread rather +// than concatenated as the array form would need. +const appAlias = appViteConfig.resolve?.alias as Record + +const viteConfig = { + ...appViteConfig, + resolve: { + ...appViteConfig.resolve, + alias: { ...appAlias, ...cjsDepAliases() } + } +} + +export const config: WebdriverIO.Config = { + runner: [ + 'browser', + { + preset: 'lit', + rootDir: appDir, + headless: !(headed || inspect), + viteConfig, + ...(coverageEnabled ? { coverage: COVERAGE } : {}) + } + ], + specs: ['./test-ui/**/*.test.ts'], + capabilities: [ + { + browserName: 'chrome', + ...(inspect + ? { + 'goog:chromeOptions': { + args: [`--remote-debugging-port=${INSPECT_PORT}`] + } + } + : {}) + } + ], + // 2, not the default 100 (a browser per spec file all at once): every worker + // carries its own Vite server plus a Chrome, and past 2 that contention alone + // pushes the heaviest specs over the 30s timeout โ€” at 4 three spec files fail + // there and the suite is also slower overall than at 2. One when inspecting, + // since a fixed debugging port can only serve a single browser. + maxInstances: inspect ? 1 : 2, + // Instrumented specs lose a BiDi navigate to a timeout about one spec per run + // (a timeout, never a failed assertion โ€” the same specs pass uninstrumented). + // A retry usually recovers it, though not always, so this reduces the noise + // rather than removing it. Only on the instrumented path. + ...(coverageEnabled ? { specFileRetries: 1 } : {}), + logLevel: 'warn', + framework: 'mocha', + reporters: ['spec'], + mochaOpts: { + ui: 'bdd', + timeout: 30000 + } +} diff --git a/packages/backend/src/framework-filters.ts b/packages/backend/src/framework-filters.ts index 92ff8290..730b7c6e 100644 --- a/packages/backend/src/framework-filters.ts +++ b/packages/backend/src/framework-filters.ts @@ -1,9 +1,31 @@ import type { RunnerRequestBody } from '@wdio/devtools-shared' -function escapeRegex(str: string): string { +/** + * Escape a test name for a grep-style rerun filter. These values are compiled + * as a regex (mocha's `grep()` does `new RegExp(str)`; cucumber's `--name` and + * jasmine's `grep` are documented as string-or-regexp), so a title carrying + * `()`, `[]` or `.` matches something other than itself โ€” usually nothing, and + * the rerun then silently runs no tests. + */ +export function escapeFilterRegex(str: string): string { return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') } +/** + * Grep pattern for a mocha/jasmine rerun, built from WDIO's `fullTitle`. + * + * Mocha matches the filter against its own SPACE-joined full title + * ("Login (failing) asserts the flash"), while `@wdio/mocha-framework` reports + * ancestry DOT-joined ("Login (failing).asserts the flash"), so a separator + * has to accept either form. Everything else is escaped: an unescaped `(` from + * a suite title like "Login (failing)" opens a capture group and the pattern + * then matches a string no test is named, so the runner filters everything out + * and reports the spec as skipped. + */ +export function buildTitleGrep(fullTitle: string): string { + return escapeFilterRegex(fullTitle).replace(/\\\./g, '[.\\s]') +} + export type FilterBuilder = (ctx: { specArg?: string payload: RunnerRequestBody @@ -46,7 +68,7 @@ const buildCucumberFilters: FilterBuilder = ({ specArg, payload }) => { } filters.push( '--cucumberOpts.name', - `^${rowNumber}:\\s*${escapeRegex(scenarioName)}$` + `^${rowNumber}:\\s*${escapeFilterRegex(scenarioName)}$` ) return filters } @@ -72,7 +94,7 @@ const buildMochaFilters: FilterBuilder = ({ specArg, payload }) => { filters.push('--spec', specArg) } if (payload.fullTitle) { - filters.push('--mochaOpts.grep', payload.fullTitle) + filters.push('--mochaOpts.grep', buildTitleGrep(payload.fullTitle)) } return filters } @@ -83,7 +105,7 @@ const buildJasmineFilters: FilterBuilder = ({ specArg, payload }) => { filters.push('--spec', specArg) } if (payload.fullTitle) { - filters.push('--jasmineOpts.grep', payload.fullTitle) + filters.push('--jasmineOpts.grep', buildTitleGrep(payload.fullTitle)) } return filters } @@ -112,7 +134,7 @@ const buildNightwatchCucumberFilters: FilterBuilder = ({ payload }) => { 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) + const escaped = escapeFilterRegex(payload.fullTitle) filters.push('--name', `^${escaped}$`) } return filters diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index a5b80d59..173e0a0f 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -23,6 +23,7 @@ import { BASELINE_API, BASELINE_WS_SCOPE, TRACE_API, + WORKER_WS_QUERY, WS_PATHS, WS_SCOPE, type BaselinePreserveRequest, @@ -60,6 +61,10 @@ const clients = new Set() let workerSocket: WebSocket | undefined let parentWorkerSocket: WebSocket | undefined +// Run the accumulated state belongs to. Compared against each connecting +// worker's `runId` so a new spec's worker is told apart from a new run. +let activeRunId: string | undefined + // sessionId โ†’ absolute path of the encoded .webm; queried by /api/video/:sessionId. const videoRegistry = new Map() @@ -133,6 +138,12 @@ async function handleTestRun( if (!body?.uid || !body.entryType) { return reply.code(400).send({ error: 'Invalid run payload' }) } + // Logged through fastify so it survives a config `logLevel` that silences + // @wdio/logger: a rerun that filters to nothing reports only "spec skipped", + // and these fields are what decide the filter. + reply.log.info( + `run ${body.entryType} uid=${body.uid} framework=${body.framework} spec=${body.specFile} title=${JSON.stringify(body.fullTitle)}` + ) // Broadcast a clear so popouts (which only see WS events) wipe too. broadcastToClients( JSON.stringify({ @@ -306,14 +317,22 @@ function registerWorkerWebSocket(s: FastifyInstance): void { // a different failed test still finds data; #updateNode handles window // expansion across reruns of the same test. const isRerunChild = testRunner.consumeRerunChildFlag() + const query = req.query as { reconnect?: string; runId?: string } // A mid-run session-change reconnect (e.g. after `browser.end()`) reopens // this socket; keep the accumulated run state so earlier tests' commands // survive for Preserve & Rerun instead of being wiped. - const isReconnect = - (req.query as { reconnect?: string })?.reconnect === '1' - if (!isRerunChild && !isReconnect) { + const isReconnect = query?.[WORKER_WS_QUERY.reconnect] === '1' + // A run opens one worker socket per spec file, so the second spec's + // worker is a fresh connect with neither flag set. Its run id matches, + // which is what keeps earlier specs preservable. + const runId = query?.[WORKER_WS_QUERY.runId] + const isSameRun = Boolean(runId) && runId === activeRunId + if (!isRerunChild && !isReconnect && !isSameRun) { messageBuffer.length = 0 baselineStore.resetActiveRun() + // Rerun children report their own id; leaving it unrecorded keeps the + // parent run's identity, so its later workers still read as same-run. + activeRunId = runId } workerSocket = socket if (!isRerunChild) { diff --git a/packages/backend/src/runner.ts b/packages/backend/src/runner.ts index 95b3a14f..cc9b0831 100644 --- a/packages/backend/src/runner.ts +++ b/packages/backend/src/runner.ts @@ -3,6 +3,7 @@ import fs from 'node:fs' import path from 'node:path' import url from 'node:url' import kill from 'tree-kill' +import logger from '@wdio/logger' import { parse as shellParse, quote as shellQuote } from 'shell-quote' import { REUSE_ENV, @@ -10,10 +11,11 @@ import { type RunnerRequestBody } from '@wdio/devtools-shared' import { WDIO_CONFIG_FILENAMES, NIGHTWATCH_CONFIG_FILENAMES } from './types.js' -import { getFilterBuilder } from './framework-filters.js' +import { escapeFilterRegex, getFilterBuilder } from './framework-filters.js' import { resolveNightwatchBin, resolveWdioBin } from './bin-resolver.js' const wdioBin = resolveWdioBin() +const log = logger('@wdio/devtools-runner') /** * Detect a `--name "{{testName}}"` slot anywhere in `template`, with optional @@ -40,14 +42,6 @@ function stripNameTestNameSlot(template: string): string { return template.slice(0, leftEdge) + template.slice(idx + NAME_SLOT.length) } -// Grep-style rerun filters (mocha --grep, jest/vitest -t, cucumber --name) treat -// the value as a REGEX, so a test name containing (), [], ., etc. matches -// nothing unless escaped. The slot is double-quoted in the template, so the -// added backslashes survive shell parsing and reach the runner as literals. -function escapeRerunName(name: string): string { - return name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') -} - class TestRunner { #child?: ChildProcess #lastPayload?: RunnerRequestBody @@ -85,6 +79,7 @@ class TestRunner { const command = this.#resolveGenericCommand(payload) this.#baseDir = process.env[RUNNER_ENV.RUNNER_CWD] || process.cwd() const { file, args } = this.#parseGenericCommand(command) + log.info(`rerun: ${file} ${args.join(' ')}`) return spawn(file, args, { cwd: this.#baseDir, env: childEnv, @@ -120,6 +115,10 @@ class TestRunner { delete childEnv[REUSE_ENV.RERUN_LABEL] } } + // A filter that matches nothing makes the runner report the spec as + // skipped with no other output, so the resolved argv is the only way to + // tell a bad spec path from a bad name filter. + log.info(`rerun: ${process.execPath} ${args.join(' ')}`) return spawn(process.execPath, args, { cwd: this.#baseDir, env: childEnv, @@ -211,7 +210,9 @@ class TestRunner { return `${stripped} ${shellQuote([featureSpec])}` } const name = payload.label || payload.fullTitle || '' - return template.replace(/\{\{testName\}\}/g, escapeRerunName(name)) + // The slot is double-quoted in the template, so the backslashes the escape + // adds survive shell parsing and reach the runner as literals. + return template.replace(/\{\{testName\}\}/g, escapeFilterRegex(name)) } #parseGenericCommand(command: string): { file: string; args: string[] } { diff --git a/packages/backend/src/trace-reader-constants.ts b/packages/backend/src/trace-reader-constants.ts index 737f0903..a3c23b8f 100644 --- a/packages/backend/src/trace-reader-constants.ts +++ b/packages/backend/src/trace-reader-constants.ts @@ -2,23 +2,19 @@ import { ACTION_MAP, ASSERT_ACTION_CLASS, LOG_LEVELS, + TRACE_STREAM_SUFFIXES, TRACKED_ASSERT_METHODS } from '@wdio/devtools-shared' /** Runtime lookup for narrowing foreign trace levels to the shared union. */ export const LOG_LEVEL_SET: ReadonlySet = new Set(LOG_LEVELS) -/** Every zip entry ending in this suffix is an NDJSON action-event stream. */ -export const TRACE_STREAM_SUFFIX = '.trace' - -/** Every zip entry ending in this suffix is an NDJSON HAR-snapshot stream. */ -export const NETWORK_STREAM_SUFFIX = '.network' - -/** Sidecar entries holding call stacks keyed by numeric call id. */ -export const STACKS_STREAM_SUFFIX = '.stacks' - -/** Every zip entry ending in this suffix is an NDJSON DOM-mutation stream. */ -export const MUTATIONS_STREAM_SUFFIX = '.mutations' +// Re-exported from the shared trace-file contract so the writer and reader +// cannot disagree about entry naming; the local names stay for readability. +export const TRACE_STREAM_SUFFIX = TRACE_STREAM_SUFFIXES.trace +export const NETWORK_STREAM_SUFFIX = TRACE_STREAM_SUFFIXES.network +export const STACKS_STREAM_SUFFIX = TRACE_STREAM_SUFFIXES.stacks +export const MUTATIONS_STREAM_SUFFIX = TRACE_STREAM_SUFFIXES.mutations /** Foreign screencast refs may be a bare sha1; probe image extensions too. */ export const FRAME_RESOURCE_SUFFIXES = ['', '.jpeg', '.png'] as const diff --git a/packages/backend/src/trace-reader-utils.ts b/packages/backend/src/trace-reader-utils.ts index d821986e..aa329a87 100644 --- a/packages/backend/src/trace-reader-utils.ts +++ b/packages/backend/src/trace-reader-utils.ts @@ -9,6 +9,7 @@ import { type LogLevel, type Metadata, type NetworkRequest, + type RequestType, type TracePlayerFrame, type Viewport } from '@wdio/devtools-shared' @@ -91,7 +92,7 @@ function headerArrayToRecord( } // Coarse resource type from mime so the Network panel can group requests. -function mimeToType(mimeType: string): string { +function mimeToType(mimeType: string): RequestType { if (!mimeType) { return 'other' } diff --git a/packages/backend/src/trace-reader.ts b/packages/backend/src/trace-reader.ts index 2abd0ae5..690f09bf 100644 --- a/packages/backend/src/trace-reader.ts +++ b/packages/backend/src/trace-reader.ts @@ -14,6 +14,7 @@ import fs from 'node:fs/promises' import { unzipSync, strFromU8 } from 'fflate' import { isMutationsTruncationMarker, + TRACE_ZIP_ENTRIES, type CommandLog, type NetworkRequest, type TraceLog, @@ -382,8 +383,8 @@ export function parseTraceZip(zip: Uint8Array): TracePlayerData { indexByCallId ) const startTime = earliestWallTime(merged.ctxs) - const transcript = files['transcript.md'] - ? strFromU8(files['transcript.md']) + const transcript = files[TRACE_ZIP_ENTRIES.transcript] + ? strFromU8(files[TRACE_ZIP_ENTRIES.transcript]) : undefined const trace: TraceLog = { mutations: parseMutationStreams( diff --git a/packages/backend/tests/framework-filters.test.ts b/packages/backend/tests/framework-filters.test.ts index faae96b7..df365007 100644 --- a/packages/backend/tests/framework-filters.test.ts +++ b/packages/backend/tests/framework-filters.test.ts @@ -57,6 +57,47 @@ describe('mocha filter builder', () => { fn({ specArg: undefined, payload: payload({ fullTitle: 'X' }) }) ).toEqual(['--mochaOpts.grep', 'X']) }) + + // These assert BEHAVIOUR, not the pattern's spelling: mocha compiles the grep + // with `new RegExp(str)` and matches it against its own SPACE-joined full + // title, while WDIO reports `fullTitle` DOT-joined. Asserting the string + // shape hides that mismatch โ€” the pattern can look perfectly escaped and + // still match no test, which surfaces only as "1 skipped". + const grepFor = (fullTitle: string): RegExp => { + const filters = fn({ + specArg: '/a.test.ts', + payload: payload({ fullTitle }) + }) + return new RegExp(filters[filters.indexOf('--mochaOpts.grep') + 1]) + } + + it('matches a title whose suite name carries regex metacharacters', () => { + expect( + grepFor( + 'Login (failing).asserts the wrong flash message so the run fails' + ).test('Login (failing) asserts the wrong flash message so the run fails') + ).toBe(true) + }) + + it('matches a plain dot-joined title', () => { + expect( + grepFor('Login.logs into the secure area with valid credentials').test( + 'Login logs into the secure area with valid credentials' + ) + ).toBe(true) + }) + + it('does not match a sibling test in the same spec', () => { + expect( + grepFor('Login.logs into the secure area with valid credentials').test( + 'Login shows an error message for an invalid username' + ) + ).toBe(false) + }) + + it('does not match a same-named test under a different suite', () => { + expect(grepFor('Login.logs in').test('Signup logs in')).toBe(false) + }) }) describe('jasmine filter builder', () => { @@ -66,6 +107,15 @@ describe('jasmine filter builder', () => { fn({ specArg: '/a.ts', payload: payload({ fullTitle: 'A' }) }) ).toEqual(['--spec', '/a.ts', '--jasmineOpts.grep', 'A']) }) + + it('matches a title whose suite name carries regex metacharacters', () => { + const filters = fn({ + specArg: '/a.ts', + payload: payload({ fullTitle: 'Login (x).works [1]' }) + }) + const grep = new RegExp(filters[filters.indexOf('--jasmineOpts.grep') + 1]) + expect(grep.test('Login (x) works [1]')).toBe(true) + }) }) describe('nightwatch filter builder', () => { diff --git a/packages/backend/tests/run-state-continuity.test.ts b/packages/backend/tests/run-state-continuity.test.ts new file mode 100644 index 00000000..7a906c95 --- /dev/null +++ b/packages/backend/tests/run-state-continuity.test.ts @@ -0,0 +1,284 @@ +/** + * The run-state seam. One test run opens a fresh worker socket per spec file, + * so the backend has to tell "next spec of this run" apart from "a new run". + * Reading a new spec as a new run empties the baseline accumulator mid-run and + * Preserve & Rerun 409s for every spec except the last one that ran โ€” a break + * no unit test sees, because each side works correctly in isolation. + */ + +import os from 'node:os' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { WebSocket } from 'ws' +import type { FastifyInstance } from 'fastify' +import { + BASELINE_API, + WORKER_WS_QUERY, + WS_PATHS, + type BaselinePreserveResponse +} from '@wdio/devtools-shared' +import { start } from '../src/index.js' +import { baselineStore } from '../src/baselineStore.js' +import * as utils from '../src/utils.js' + +vi.mock('../src/utils.js', () => ({ + getDevtoolsApp: vi.fn() +})) + +const WAIT_TIMEOUT_MS = 2000 + +let server: FastifyInstance | undefined + +async function boot(): Promise<{ server: FastifyInstance; port: number }> { + vi.mocked(utils.getDevtoolsApp).mockResolvedValue(os.tmpdir()) + const started = await start({ port: 0 }) + server = started.server + return started +} + +async function waitFor(predicate: () => boolean, what: string): Promise { + const deadline = Date.now() + WAIT_TIMEOUT_MS + while (Date.now() < deadline) { + if (predicate()) { + return + } + await new Promise((resolve) => setTimeout(resolve, 10)) + } + throw new Error(`timed out waiting for ${what}`) +} + +/** The run every worker in a test belongs to unless it says otherwise. */ +const RUN_ID = 'run-under-test' + +interface WorkerOptions { + runId?: string | null + reconnect?: boolean +} + +/** Open a worker socket the way `SessionCapturerBase` does. `runId: null` + * stands in for an adapter too old to send one. */ +async function connectWorker( + port: number, + opts: WorkerOptions = {} +): Promise { + const runId = opts.runId === undefined ? RUN_ID : opts.runId + const query = new URLSearchParams({ + ...(runId ? { [WORKER_WS_QUERY.runId]: runId } : {}), + ...(opts.reconnect ? { [WORKER_WS_QUERY.reconnect]: '1' } : {}) + }) + const socket = new WebSocket( + `ws://localhost:${port}${WS_PATHS.worker}?${query}` + ) + await new Promise((resolve, reject) => { + socket.once('open', () => resolve()) + socket.once('error', reject) + }) + return socket +} + +async function closeWorker(socket: WebSocket): Promise { + await new Promise((resolve) => { + socket.once('close', () => resolve()) + socket.close() + }) +} + +interface SpecReport { + suiteUid: string + testUid: string + file: string + start: number + state?: 'passed' | 'failed' +} + +/** One spec file's report: a suite holding a single test, plus the commands + * that ran inside that test's time window. */ +function specEvents(spec: SpecReport) { + const end = spec.start + 100 + const state = spec.state ?? 'passed' + return { + suites: [ + { + [spec.suiteUid]: { + uid: spec.suiteUid, + title: `suite ${spec.file}`, + file: spec.file, + start: spec.start, + end, + state, + tests: [ + { + uid: spec.testUid, + title: 'logs in', + fullTitle: `suite ${spec.file} logs in`, + start: spec.start, + end, + state + } + ], + suites: [] + } + } + ], + commands: [ + { timestamp: spec.start + 10, command: 'url', args: [spec.file] }, + { timestamp: spec.start + 20, command: 'click', args: ['#login'] } + ] + } +} + +/** Stream a spec's events over `socket` and wait until the store has them. + * Waiting on the store rather than a timer also orders the assertions: the + * message handler is registered at the END of the connect handler, so data + * becoming visible proves the connect handler already ran. */ +async function reportSpec(socket: WebSocket, spec: SpecReport): Promise { + const events = specEvents(spec) + socket.send(JSON.stringify({ scope: 'suites', data: events.suites })) + socket.send(JSON.stringify({ scope: 'commands', data: events.commands })) + await waitFor( + () => Boolean(baselineStore.snapshot(spec.testUid, 'test')), + `the backend to record ${spec.file}` + ) +} + +async function preserve( + target: FastifyInstance, + testUid: string +): Promise<{ statusCode: number; body: string }> { + const res = await target.inject({ + method: 'POST', + url: BASELINE_API.preserve, + payload: { testUid, scope: 'test' } + }) + return { statusCode: res.statusCode, body: res.body } +} + +const FAILING_SPEC: SpecReport = { + suiteUid: 'suite-login-fail', + testUid: 'test-login-fail', + file: '/login-fail.e2e.ts', + start: 1000, + state: 'failed' +} + +const PASSING_SPEC: SpecReport = { + suiteUid: 'suite-login', + testUid: 'test-login', + file: '/login.e2e.ts', + start: 2000 +} + +describe('run state across worker connects', () => { + beforeEach(() => { + vi.clearAllMocks() + baselineStore.resetActiveRun() + }) + + afterEach(async () => { + await server?.close() + server = undefined + baselineStore.resetActiveRun() + }) + + it('keeps an earlier spec preservable after the next spec opens its own worker socket', async () => { + const { server: target, port } = await boot() + + const workerA = await connectWorker(port) + await reportSpec(workerA, FAILING_SPEC) + await closeWorker(workerA) + + // A second spec file means a second worker process, so a brand new socket + // with no rerun flag and no reconnect marker โ€” the case that regressed. + const workerB = await connectWorker(port) + await reportSpec(workerB, PASSING_SPEC) + + const res = await preserve(target, FAILING_SPEC.testUid) + expect(res.statusCode).toBe(200) + + // The preserved window holds the first spec's commands only โ€” a fix that + // kept the data but merged both specs into one bucket would fail here. + const { attempt } = JSON.parse(res.body) as BaselinePreserveResponse + expect(attempt.commands.map((c) => c.command)).toEqual(['url', 'click']) + expect(attempt.commands.map((c) => c.args?.[0])).toEqual([ + FAILING_SPEC.file, + '#login' + ]) + + await closeWorker(workerB) + }) + + it('still preserves the spec whose worker is currently connected', async () => { + const { server: target, port } = await boot() + + const workerA = await connectWorker(port) + await reportSpec(workerA, FAILING_SPEC) + await closeWorker(workerA) + + const workerB = await connectWorker(port) + await reportSpec(workerB, PASSING_SPEC) + + const res = await preserve(target, PASSING_SPEC.testUid) + expect(res.statusCode).toBe(200) + + await closeWorker(workerB) + }) + + it('keeps run state across a mid-run session-change reconnect', async () => { + const { server: target, port } = await boot() + + const workerA = await connectWorker(port) + await reportSpec(workerA, FAILING_SPEC) + await closeWorker(workerA) + + const reconnected = await connectWorker(port, { reconnect: true }) + await reportSpec(reconnected, PASSING_SPEC) + + const res = await preserve(target, FAILING_SPEC.testUid) + expect(res.statusCode).toBe(200) + + await closeWorker(reconnected) + }) + + it('drops a previous run when a new run connects', async () => { + const { server: target, port } = await boot() + + const workerA = await connectWorker(port) + await reportSpec(workerA, FAILING_SPEC) + await closeWorker(workerA) + + const nextRun = await connectWorker(port, { runId: 'a-later-run' }) + await reportSpec(nextRun, PASSING_SPEC) + + const res = await preserve(target, FAILING_SPEC.testUid) + expect(res.statusCode).toBe(409) + + await closeWorker(nextRun) + }) + + it('drops a previous run when the worker reports no run id', async () => { + const { server: target, port } = await boot() + + const workerA = await connectWorker(port) + await reportSpec(workerA, FAILING_SPEC) + await closeWorker(workerA) + + const unidentified = await connectWorker(port, { runId: null }) + await reportSpec(unidentified, PASSING_SPEC) + + const res = await preserve(target, FAILING_SPEC.testUid) + expect(res.statusCode).toBe(409) + + await closeWorker(unidentified) + }) + + it('refuses a uid the run never reported', async () => { + const { server: target, port } = await boot() + + const workerA = await connectWorker(port) + await reportSpec(workerA, FAILING_SPEC) + + const res = await preserve(target, 'test-never-ran') + expect(res.statusCode).toBe(409) + + await closeWorker(workerA) + }) +}) diff --git a/packages/core/package.json b/packages/core/package.json index f251e41a..90be8676 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -2,7 +2,7 @@ "name": "@wdio/devtools-core", "version": "1.0.1", "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.", + "description": "Framework-agnostic capture/reporter logic shared by @wdio/devtools-* adapters. Workspace-internal, never published \u2014 code is inlined into each consuming adapter at build time.", "repository": { "type": "git", "url": "git+https://github.com/webdriverio/devtools.git", @@ -47,6 +47,7 @@ "@wdio/devtools-script": "workspace:*", "@wdio/devtools-shared": "workspace:^", "@xmldom/xmldom": "^0.9.8", + "fflate": "^0.8.2", "stacktrace-parser": "^0.1.11", "ws": "^8.21.0", "xpath": "^0.0.34", diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 758625cb..67f02439 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -31,6 +31,7 @@ export * from './bidi.js' export * from './console.js' export * from './uid.js' export * from './net.js' +export * from './request-type.js' export * from './stack.js' export * from './terminal-throttle.js' export * from './error.js' @@ -38,6 +39,7 @@ export * from './finalize-screencast.js' export * from './output-dir.js' export * from './performance-capture.js' export * from './retry-tracker.js' +export * from './run-id.js' export * from './screencast.js' export * from './screencast-trace.js' export * from './script-loader.js' diff --git a/packages/core/src/net.ts b/packages/core/src/net.ts index eaf385da..e5de905c 100644 --- a/packages/core/src/net.ts +++ b/packages/core/src/net.ts @@ -1,5 +1,9 @@ import * as net from 'node:net' +/** Kept exported here so importers predating the split keep resolving; the + * implementation is the browser-safe leaf `request-type.ts`. */ +export { getRequestType } from './request-type.js' + /** * Return true if the given TCP port on `hostname` cannot be bound for * listening (already in use, or otherwise unavailable). @@ -27,50 +31,3 @@ export async function findFreePort( } 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/request-type.ts b/packages/core/src/request-type.ts new file mode 100644 index 00000000..fe13869d --- /dev/null +++ b/packages/core/src/request-type.ts @@ -0,0 +1,54 @@ +import type { RequestType } from '@wdio/devtools-shared' + +/** + * Classify an HTTP request into a {@link RequestType}, preferring the response + * `mimeType` and falling back to URL extension heuristics. The categories are + * enumerated once, in shared's `REQUEST_TYPES`, so this text can't drift from + * them. A shape it can't place returns `'xhr'` โ€” never the vocabulary's + * `'other'`, which is reserved for producers that classify nothing at all. + * + * A pure leaf module: the capture side's only classifier, importable from any + * runtime. `net.ts` re-exports it under the same name for existing consumers, + * but that module pulls in `node:net` and so can't be bundled for the browser. + */ +export function getRequestType(url: string, mimeType?: string): RequestType { + 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/run-id.ts b/packages/core/src/run-id.ts new file mode 100644 index 00000000..e60b5b0c --- /dev/null +++ b/packages/core/src/run-id.ts @@ -0,0 +1,24 @@ +/** + * Identity for one test run, shared by every process that reports into it. + * The backend compares it across worker connects to tell "next spec of this + * run" (keep the accumulated state) from "a new run" (wipe it). + */ + +import { randomUUID } from 'node:crypto' +import { RUNNER_ENV } from '@wdio/devtools-shared' + +/** + * This run's id, generating and publishing one on first call. Runners fork + * workers with the launcher's environment, so a launcher that calls this + * before the run starts gives every worker the same id; a worker that gets + * here first can only speak for itself, and each sibling reads as its own run. + */ +export function resolveRunId(): string { + const existing = process.env[RUNNER_ENV.RUN_ID] + if (existing) { + return existing + } + const runId = randomUUID() + process.env[RUNNER_ENV.RUN_ID] = runId + return runId +} diff --git a/packages/core/src/script-loader.ts b/packages/core/src/script-loader.ts index 7cf1681d..fee66221 100644 --- a/packages/core/src/script-loader.ts +++ b/packages/core/src/script-loader.ts @@ -2,6 +2,8 @@ import fs from 'node:fs/promises' import path from 'node:path' import { createRequire } from 'node:module' +import { errorMessage } from './error.js' + const require = createRequire(import.meta.url) /** Browser-side expression that is true once the injected collector has @@ -26,6 +28,80 @@ export async function loadInjectableScript(): Promise { return `(async function() { ${scriptContent} })()` } +/** Schemes that carry no test-relevant DOM โ€” the browser's own start page, + * inline documents, and driver error pages. */ +const NON_INSTRUMENTABLE_SCHEMES = [ + 'about:', + 'data:', + 'chrome:', + 'chrome-error:', + 'edge:', + 'moz-extension:', + 'chrome-extension:' +] + +function isInstrumentableDocument(url: string | undefined): boolean { + if (!url) { + return false + } + return !NON_INSTRUMENTABLE_SCHEMES.some((scheme) => url.startsWith(scheme)) +} + +/** + * Drain the page-side collector, re-injecting it into the CURRENT document and + * retrying once when it isn't there. + * + * Both injection mechanisms (BiDi preload script, `