E2E capture harness (Playwright web/mock)

June 14, 2026 · View on GitHub

Automated E2E scenario runs that save PNG evidence captures of the UI. This is the web-only harness from issue #1609: it runs the React frontend in a plain Chromium browser with mocked Tauri IPC and events, so no Rust backend, live hardware data, or Tauri runtime is required.

Scope

  • In scope: deterministic scenario runs + PNG capture artifacts.
  • Out of scope: visual snapshot regression / baseline comparison is intentionally not part of this harness. Captures are evidence for human review, not compared against golden images.
  • The native Tauri smoke path (tauri-driver/WebDriver) is tracked separately in issue #1610. Note that tauri-driver only supports Linux and Windows; macOS has no WKWebView driver.

Running locally

npm run test:e2e

Playwright starts its own Vite dev server on port 1521 with vite dev --mode e2e (a regular npm run dev server on 1520 is never reused). Captures are written to:

test-results/captures/<scenario>.png

The first page load pays Vite's cold module-transform cost (babel + react-compiler) and can take >10 seconds; specs use an extended timeout for the initial render assertion.

How the mocks work

  • vite --mode e2e rewrites index.html to use src/main.e2e.tsx. The regular src/main.tsx keeps the production/Tauri app entry static, while the E2E entry installs mocks before dynamically importing the app.
  • src/e2e/mocks/installTauriMocks.ts is the single mock entry point:
    • mockIPC(..., { shouldMockEvents: true }) from @tauri-apps/api/mocks intercepts every invoke() (generated tauri-specta commands and plugin:<name>|<command> plugin calls) and implements plugin:event|listen/emit/unlisten.
    • mockWindows("main") fakes the current window label.
    • window.__TAURI_OS_PLUGIN_INTERNALS__ is set directly because platform() from @tauri-apps/plugin-os is a synchronous global read, not an IPC call.
    • The Tauri store plugin is backed by an in-memory Map, seeded from src/e2e/fixtures/store.ts.
    • Unhandled commands throw [e2e-mock] Unhandled invoke: <cmd> so coverage gaps surface immediately when new screens are added to scenarios.
  • Fixture data lives in src/e2e/fixtures/ (settings, hardware info, process list, storage health, and a deterministic hardware-monitor-update series built from fixed sine waves).
  • Tests push hardware events through window.__E2E__.emitHardwareUpdate / emitHardwareUpdateSeries, exposed by the mock installer.

Covered scenarios

SpecCaptures
e2e/dashboard.spec.tsdashboard, dashboard-gpu-secondary (GPU tab switch)
e2e/usage.spec.tsusage (mixed CPU/RAM/GPU chart)
e2e/cpu-detail.spec.tscpu-detail (info table + per-core charts)
e2e/insights.spec.tsinsights-main, insights-process
e2e/settings.spec.tssettings (General/About sections)

Insights pins the clock with page.clock.setFixedTime(...) because its archive query ranges derive from Date.now(); the mocked archive commands synthesize records from the requested start/end range (src/e2e/fixtures/archive.ts), so charts stay deterministic.

Pass criteria are DOM-level: fixture content visible via accessible selectors, interactions reflected in ARIA state, and captures saved. Pixels are not compared (no baselines). One targeted style guard exists: the usage spec compares the computed stroke of each chart series against the fixture colors, catching invalid color plumbing that would render series black while remaining "visible" to all other assertions.

Writing a scenario

Specs live in e2e/*.spec.ts (outside src/, so Vitest does not pick them up). Shared helpers live in e2e/helpers.ts. The shape of a scenario:

  1. await gotoApp(page) — loads / and waits for fixture content (first load can take >10s, see above).
  2. Seed chart history: await seedHardwareHistory(page).
  3. Navigate with await navigateTo(page, "<screen>") (clicks the side menu's accessible open <type> buttons) and interact via accessible selectors (getByRole("tab", ...), aria-labels, headings).
  4. Save the capture: await saveCapture(page, "<name>") — writes a full-page PNG (the whole scrollable page, not just the viewport) into test-results/captures/.

Determinism rules:

  • Locked viewport (1280x800), deviceScaleFactor: 1, dark color scheme, en-US locale, UTC timezone, reduced motion (see playwright.config.ts).
  • Use fixture data only — never live hardware values.

Render performance smoke

Lightweight render performance checks reuse the same web/mock harness, but run as a separate suite:

npm run test:perf:render

The suite writes a JSON report and Playwright artifacts under:

test-results/render-perf/

These checks target coarse, CI-stable signals such as DOM element count, Long Task count, and generous interaction timing. They intentionally avoid strict FPS, CPU, and memory gates. Thresholds are moderate rather than extremely loose because this suite is meant to catch obvious render regressions even while it starts as an observation signal. In CI the test-render-perf job runs only for frontend pull requests, uploads artifacts, and stays outside the merge gate.

Render memory smoke

Frontend memory-growth checks also reuse the web/mock harness, but they measure Dashboard behavior while mocked Tauri IPC events continue to arrive over time:

npm run test:perf:render-memory

This suite starts a deterministic hardware-monitor-update stream through the mocked Tauri event path, then samples Chromium through CDP:

  • Runtime.getHeapUsage
  • Performance.getMetrics
  • Memory.getDOMCounters

The report is written under:

test-results/render-memory/

The check watches JS heap growth, DOM document/node counters, and JavaScript listener growth from a warmup baseline plus the later-window heap slope. This is a frontend memory-growth signal, not a Windows WebView2 process-memory check; the existing full performance workflow remains responsible for real Tauri process-level CPU/RSS monitoring. In CI the test-render-memory-perf job runs only for frontend pull requests, uploads artifacts, and stays non-blocking.

CI

The test-e2e-web job in .github/workflows/ci.yml runs the suite on ubuntu-latest and uploads test-results/captures/ as the e2e-captures artifact with if: always(), so captures survive failed runs. The Playwright HTML report and per-test output are uploaded only on failure.

For same-repo pull requests the job also posts the captures inline as a sticky PR comment (.github/scripts/comment-e2e-captures.sh): images are force-pushed to the single-commit e2e-captures branch under pr-<number>/ and embedded via raw.githubusercontent.com URLs. The branch is disposable — it can be deleted at any time and will be recreated by the next run. Fork PRs are skipped (their token is read-only).

Native Tauri smoke

Issue #1610 adds a separate native smoke path that launches the real Tauri desktop application through tauri-driver and WebDriver. This is intentionally not another deterministic fixture harness: it uses the normal frontend build, real Tauri IPC/plugin calls, and the real backend startup path.

Run it locally with:

npm run test:e2e:native

The native runner is named by screen and scenario:

e2e-native/settings/native-first-run-smoke.ts

Shared non-scenario code lives under e2e-native/support/.

Type-check the native runner without launching the app:

npm run typecheck:e2e:native

The command builds a debug, no-bundle app with the dedicated E2E config:

npm run tauri build -- --debug --no-bundle --config src-tauri/tauri.e2e.conf.json

The native smoke sets HARDVIZ_E2E=1, which switches the runtime identity to HardwareVisualizerE2E. Before each run the HardwareVisualizerE2E app data directory is deleted after a path-safety check. This deliberately exercises the first-run path without touching normal release or development data.

The smoke also sets HARDVIZ_E2E_DEFAULT_LANGUAGE=en, which is honored only when HARDVIZ_E2E=1. This keeps first-run settings and text assertions independent from the runner's OS locale.

It also sets HARDVIZ_E2E_FORCE_CLOSE_TO_TRAY_PROMPT=1. This only makes the frontend is_close_to_tray_available command return true, so the first-run prompt is deterministic under headless Linux CI. Tray setup and the normal window-close lifecycle still run through the real app path; this smoke does not assert OS tray/status-notifier integration.

Current native scenario:

  1. Launch the real Tauri app binary through tauri-driver.
  2. Wait for the first-run Close-to-Tray prompt.
  3. Dismiss the prompt with the close button.
  4. Open Settings through the side menu.
  5. Assert that General and About render.
  6. Assert that About shows a non-empty Version: value from the real Tauri app plugin.
  7. Save test-results/captures/settings/native-first-run-smoke.png.

If the scenario fails after the WebDriver session starts, the runner tries to save test-results/captures/settings/native-first-run-smoke-failure.png. Native capture paths use screen/scenario naming. These PNGs are evidence for debugging and review only; they are not compared against visual baselines.

Native prerequisites:

  • tauri-driver, installed with cargo install tauri-driver --locked.
  • Linux: webkit2gtk-driver, xvfb, and appindicator dependencies such as libayatana-appindicator3-dev.
  • Windows: a compatible EdgeDriver/WebView2 WebDriver setup is required for local experimentation, but Windows CI is not part of the initial native smoke. If tauri-driver finds an incompatible native driver first, set TAURI_NATIVE_DRIVER_BIN to the matching driver path.
  • macOS: desktop tauri-driver coverage is not practical because WKWebView has no corresponding desktop WebDriver path.

The test-e2e-native CI job runs only on ubuntu-22.04. It uploads test-results/captures/ as the e2e-native-captures artifact with if: always(), so evidence survives failures. Native captures are not published through .github/scripts/comment-e2e-captures.sh; that PR comment remains dedicated to the Playwright web/mock review captures.