Skip to content

Commit 7802fca

Browse files
committed
E2E speedup (Step 6): parallel sharding across N workers
Split the Playwright E2E suite across three parallel Tauri instances: one dedicated MTP lane (sequential — `mtp.spec.ts` + `mtp-conflicts.spec.ts`) plus two non-MTP lanes balanced by Playwright's `--shard X/2`. Each shard gets its own `CMDR_DATA_DIR`, `CMDR_PLAYWRIGHT_SOCKET`, MCP port, and fixture dir so the instances don't clobber each other. Wall-clock: 5m 49s → 2m 48s (-52%). Playwright portion alone: ~4m 48s → ~1m 48s (-63%). Two green back-to-back runs after the isolation fixes. The MTP lane must stay single-instance because the virtual MTP backing dir (`/tmp/cmdr-mtp-e2e-fixtures`) is shared across every Tauri instance. Non-MTP shards opt out of the virtual MTP setup via `CMDR_E2E_SKIP_VIRTUAL_MTP_SETUP` and the MTP-fixture recreate via `CMDR_E2E_SKIP_MTP_FIXTURES` so they don't race the MTP shard's state. Socket path is overridable via `CMDR_PLAYWRIGHT_SOCKET` (read in `lib.rs`, passed to `tauri_plugin_playwright::init_with_config`). When unset, falls back to `/tmp/tauri-playwright.sock` so manual and Linux-Docker runs keep working unchanged.
1 parent 30a61aa commit 7802fca

8 files changed

Lines changed: 499 additions & 95 deletions

File tree

apps/desktop/src-tauri/src/lib.rs

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -197,9 +197,17 @@ pub fn run() {
197197
#[cfg(debug_assertions)]
198198
let builder = builder.plugin(tauri_plugin_mcp_bridge::init());
199199

200-
// Playwright E2E testing plugin — socket bridge for direct webview injection
200+
// Playwright E2E testing plugin — socket bridge for direct webview injection.
201+
// Socket path is overridable via CMDR_PLAYWRIGHT_SOCKET so parallel E2E shards
202+
// can each spawn their own Tauri instance bound to a distinct socket.
201203
#[cfg(feature = "playwright-e2e")]
202-
let builder = builder.plugin(tauri_plugin_playwright::init());
204+
let builder = {
205+
let mut pw_config = tauri_plugin_playwright::PluginConfig::new();
206+
if let Ok(socket_path) = std::env::var("CMDR_PLAYWRIGHT_SOCKET") {
207+
pw_config = pw_config.socket_path(socket_path);
208+
}
209+
builder.plugin(tauri_plugin_playwright::init_with_config(pw_config))
210+
};
203211

204212
// Skip Tauri updater plugin on macOS (custom updater preserves TCC permissions)
205213
// and in CI (avoids network dependency and latency during E2E tests)
@@ -362,9 +370,14 @@ pub fn run() {
362370
#[cfg(target_os = "linux")]
363371
volumes_linux::watcher::start_volume_watcher(app.handle());
364372

365-
// Register virtual MTP device for E2E testing (before watcher so it's in the initial snapshot)
373+
// Register virtual MTP device for E2E testing (before watcher so it's in the initial snapshot).
374+
// Under parallel E2E sharding the MTP backing dir is shared across Tauri instances, so
375+
// non-MTP shards opt out via CMDR_E2E_SKIP_VIRTUAL_MTP_SETUP to avoid the startup
376+
// wipe-and-recreate race on the shared dir.
366377
#[cfg(feature = "virtual-mtp")]
367-
mtp::virtual_device::setup_virtual_mtp_device();
378+
if std::env::var("CMDR_E2E_SKIP_VIRTUAL_MTP_SETUP").is_err() {
379+
mtp::virtual_device::setup_virtual_mtp_device();
380+
}
368381

369382
// Ensure ptpcamerad is re-enabled in case a previous session crashed
370383
// while it was suppressed. No-op if it was already enabled.

apps/desktop/test/e2e-playwright/CLAUDE.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,15 @@ startup, test execution, and cleanup:
2525
./scripts/check.sh --check desktop-e2e-playwright
2626
```
2727

28-
Logs go to `/tmp/cmdr-e2e-playwright-<timestamp>.log`. The app runs in an isolated data dir (`CMDR_DATA_DIR`) and uses
29-
MCP port 9429. Stale processes on that port are killed before starting.
28+
The checker runs the suite as **N parallel shards**: one dedicated MTP lane (sequential, `mtp.spec.ts` +
29+
`mtp-conflicts.spec.ts`) plus 2 non-MTP lanes split by Playwright's `--shard X/2`. Each shard gets its own Tauri
30+
instance with a distinct `CMDR_DATA_DIR`, MCP port (9429 + offset), and Unix socket path. The MTP shard runs alone
31+
because the virtual MTP backing dir (`/tmp/cmdr-mtp-e2e-fixtures`) is shared by every Tauri instance — running MTP specs
32+
from two shards at once would corrupt it. Per-shard logs go to `/tmp/cmdr-e2e-playwright-<shard>-<timestamp>.log`.
33+
34+
The socket path is overridable via the `CMDR_PLAYWRIGHT_SOCKET` env var (read in `src-tauri/src/lib.rs` and passed to
35+
`tauri_plugin_playwright::init_with_config`). When unset, the plugin falls back to `/tmp/tauri-playwright.sock` so
36+
manual / Linux-Docker runs keep working unchanged.
3037

3138
`RUST_LOG` is forwarded to the app, so trace-level output is one shell-prefix away. The chosen value is echoed at the
3239
top of the log so it's visible at a glance:

apps/desktop/test/e2e-playwright/fixtures.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,16 @@
1313

1414
import { createTauriTest } from '@srsholmes/tauri-playwright'
1515

16+
// Each parallel E2E shard spawns its own Tauri instance bound to a distinct
17+
// Unix socket. The Go check runner sets CMDR_PLAYWRIGHT_SOCKET per shard.
18+
const socketPath = process.env.CMDR_PLAYWRIGHT_SOCKET ?? '/tmp/tauri-playwright.sock'
19+
1620
export const { test, expect } = createTauriTest({
1721
// No devUrl — in Tauri mode, the app is already running with its built
1822
// frontend. Setting devUrl would redirect the webview to a nonexistent
1923
// dev server. devUrl is only used in browser mode (not applicable here).
2024
devUrl: '',
2125

2226
// Tauri mode config
23-
mcpSocket: '/tmp/tauri-playwright.sock',
27+
mcpSocket: socketPath,
2428
})

apps/desktop/test/e2e-playwright/global-setup.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,12 @@ export default function globalSetup(): void {
2424
globalThis.__PLAYWRIGHT_CREATED_FIXTURES = true
2525
}
2626

27-
// Ensure clean MTP virtual device state (independent of local fixtures)
28-
recreateMtpFixtures()
27+
// Ensure clean MTP virtual device state (independent of local fixtures).
28+
// Under parallel sharding the MTP-backing dir is shared across all Tauri
29+
// instances, so only the dedicated MTP shard (or a non-sharded run) is
30+
// allowed to recreate it. Other shards skip this step.
31+
const skipMtp = process.env.CMDR_E2E_SKIP_MTP_FIXTURES === '1'
32+
if (!skipMtp) {
33+
recreateMtpFixtures()
34+
}
2935
}

apps/desktop/test/e2e-playwright/playwright.config.ts

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,43 @@
11
import { defineConfig } from '@playwright/test'
22

3+
// Shard kind switches which specs this Playwright process runs:
4+
// - "mtp": only mtp.spec.ts + mtp-conflicts.spec.ts (must run alone — single
5+
// virtual MTP device backing dir is shared across all Tauri instances).
6+
// - "non-mtp": everything except MTP specs.
7+
// - unset / "all": every spec (single-instance / legacy run).
8+
const shardKind = process.env.CMDR_E2E_SHARD_KIND ?? 'all'
9+
10+
const mtpSpecMatch = /mtp(-conflicts)?\.spec\.ts$/
11+
const testMatch = shardKind === 'mtp' ? mtpSpecMatch : '*.spec.ts'
12+
const testIgnore = shardKind === 'non-mtp' ? mtpSpecMatch : undefined
13+
14+
// Per-shard JSON report path keeps parallel Playwright processes from
15+
// overwriting each other's output. Defaults preserve the legacy filename.
16+
const jsonReport = process.env.CMDR_E2E_JSON_REPORT ?? '/tmp/cmdr-e2e-report.json'
17+
18+
// Per-shard output dir avoids collisions when multiple Playwright processes
19+
// (each with its own Tauri instance) run in parallel. Default keeps the
20+
// legacy single-run path.
21+
const outputDir = process.env.CMDR_E2E_OUTPUT_DIR ?? './test-results'
22+
23+
// MTP specs share a single virtual device whose backing dir is hard-coded.
24+
// Under three parallel Tauri instances the wipe+rescan+resume sequence in
25+
// `beforeEach` is occasionally flaky (the watcher's resume window can race
26+
// with the rescan even in single-instance runs — see Step 4 notes). One retry
27+
// catches genuine flakes without masking a real regression; the MTP shard is
28+
// the only one that needs it.
29+
const retries = shardKind === 'mtp' ? 1 : 0
30+
331
export default defineConfig({
432
testDir: '.',
5-
testMatch: '*.spec.ts',
6-
fullyParallel: false, // Tests share app state — run sequentially
33+
testMatch,
34+
testIgnore,
35+
outputDir,
36+
fullyParallel: false, // Tests share app state — run sequentially within a shard
737
forbidOnly: !!process.env.CI,
8-
retries: 0,
9-
workers: 1, // Single worker — one Tauri app instance
10-
reporter: [['html', { open: 'never' }], ['list'], ['json', { outputFile: '/tmp/cmdr-e2e-report.json' }]],
38+
retries,
39+
workers: 1, // Single worker per Playwright process — one Tauri app instance
40+
reporter: [['list'], ['json', { outputFile: jsonReport }]],
1141
timeout: 30000,
1242

1343
globalSetup: './global-setup.ts',

docs/notes/speed-up-e2e-tests.md

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -479,3 +479,130 @@ In `apps/desktop/test/e2e-playwright/`:
479479
The retry's report is the one captured in this section. A third confirmation run also passed cleanly with identical
480480
timings.
481481
- `./scripts/check.sh` (fast) is green.
482+
483+
## After Step 6 (parallel sharding)
484+
485+
- Date: 2026-05-12
486+
- Machine: macOS, native, three Tauri instances
487+
- Branch: `e2e-speedup` (worktree)
488+
- Suite result: **131 passed, 17 skipped (SMB on macOS), 0 failed, 0 flaky** on two back-to-back green runs
489+
490+
### Headline
491+
492+
Wall-clock dropped to **2m 47s** (checker total) from the **5m 49s** Step 5 baseline — a **52% cut on the user-visible
493+
"go grab coffee" number**, with the build step still included. The Playwright portion alone (longest shard) is down to
494+
~108 s vs. the ~4m 48s single-instance baseline (**63%** off).
495+
496+
### Totals
497+
498+
| Metric | Step 5 baseline | Step 6 pass 1 | Step 6 pass 2 |
499+
| --------------------- | --------------- | ------------- | ------------- |
500+
| Checker total | 5m 49s | 3m 03s (red) | 2m 47s |
501+
| Playwright wall-clock | ~4m 48s | n/a | ~1m 48s |
502+
| Shards | 1 | 3 | 3 |
503+
| Tauri instances | 1 | 3 | 3 |
504+
| Final result | green | 3 MTP failed | 131 / 131 |
505+
506+
Run 1 caught a cross-shard isolation bug (see below); run 2 is the first green pass with the fix; the second
507+
back-to-back pass came in at the same 2m 47s and is also green.
508+
509+
### Per-shard durations (run 2, green)
510+
511+
| Shard | Specs | Tests | Duration |
512+
| ----------- | ------------------------------------------- | --------------------- | -------- |
513+
| `mtp` | `mtp.spec.ts`, `mtp-conflicts.spec.ts` | 26 passed | ~1m 18s |
514+
| `non-mtp-1` | half of non-MTP specs (`--shard 1/2`) | 65 passed, 1 skipped | ~1m 48s |
515+
| `non-mtp-2` | other half of non-MTP specs (`--shard 2/2`) | 40 passed, 16 skipped | ~1m 36s |
516+
517+
All three Playwright processes start at the same time, so the suite finishes when the slowest one (`non-mtp-1`) does.
518+
The 16 SMB skips land on `non-mtp-2`; they cost nothing.
519+
520+
### Architecture
521+
522+
- **Option B** (Go orchestrates N Playwright processes). The check runner:
523+
- builds the Tauri binary once
524+
- spawns N Tauri instances in parallel, each with a unique `CMDR_DATA_DIR`, `CMDR_MCP_PORT`, `CMDR_PLAYWRIGHT_SOCKET`,
525+
and per-shard fixture dir
526+
- waits for each shard's socket to appear
527+
- runs N `pnpm test:e2e:playwright` invocations in parallel (one per shard)
528+
- aggregates pass/fail counts from each
529+
- N=3: one **MTP shard** (sequential lane — `mtp.spec.ts` + `mtp-conflicts.spec.ts`) and two **non-MTP shards** split by
530+
Playwright's `--shard 1/2` and `--shard 2/2`. The MTP shard runs alone because the virtual MTP backing dir
531+
(`/tmp/cmdr-mtp-e2e-fixtures`) is hard-coded in `src-tauri/src/mtp/virtual_device.rs` and shared by every Tauri
532+
instance — two MTP shards would clobber each other.
533+
- **Spec selection** is driven by a `CMDR_E2E_SHARD_KIND` env var read in `playwright.config.ts`:
534+
- `mtp``testMatch: /mtp(-conflicts)?\.spec\.ts$/`
535+
- `non-mtp``testIgnore: /mtp(-conflicts)?\.spec\.ts$/` (and Playwright's `--shard X/2` does the split)
536+
- unset / `all` → every spec (default, manual / Linux-Docker / single-instance runs)
537+
538+
### Socket-path verdict
539+
540+
**Overridable.** `tauri-plugin-playwright` 0.2.2 exposes `init_with_config(PluginConfig::new().socket_path(...))`. The
541+
npm client (`@srsholmes/tauri-playwright`) takes `mcpSocket` in `createTauriTest`. The patch is small:
542+
543+
- `src-tauri/src/lib.rs`: read `CMDR_PLAYWRIGHT_SOCKET` env var and pass it to `init_with_config`. Falls back to the
544+
plugin default `/tmp/tauri-playwright.sock` when unset, so manual and Linux-Docker paths keep working.
545+
- `test/e2e-playwright/fixtures.ts`: pass `process.env.CMDR_PLAYWRIGHT_SOCKET ?? '/tmp/tauri-playwright.sock'` as
546+
`mcpSocket`.
547+
548+
No fork, no symlink trick, no per-cwd hack.
549+
550+
### Cross-shard isolation bugs found + fixed
551+
552+
1. **Shared virtual MTP backing dir wiped at every Tauri startup.** Run 1 had 3 MTP-shard test failures
553+
(`deletes multiple selected files on MTP`, `renames file on MTP via keyboard`,
554+
`rename to existing name is rejected on MTP`) — all timing out waiting for `report.txt` after
555+
`recreateMtpFixtures()`. Root cause: every Tauri instance built with `virtual-mtp` runs `setup_virtual_mtp_device()`
556+
at startup, which **wipes** `/tmp/cmdr-mtp-e2e-fixtures` via `fs::remove_dir_all` and recreates the tree. With three
557+
instances starting nearly simultaneously, the wipe-and-recreate races and the three independent mtp-rs watchers (all
558+
pointed at the same backing dir) react to each other's writes. The MTP shard's in-memory device state can end up out
559+
of sync with disk.
560+
561+
**Fix**: added a `CMDR_E2E_SKIP_VIRTUAL_MTP_SETUP` env var gate in `src-tauri/src/lib.rs`. The Go runner sets it on
562+
every non-MTP shard, so only the MTP-shard Tauri instance registers the virtual device and watches the backing dir.
563+
Non-MTP specs don't need the virtual device, so this is a clean opt-out.
564+
565+
2. **`globalSetup` calling `recreateMtpFixtures()` from non-MTP shards.** Would have wiped the MTP shard's fixtures
566+
mid-run. Gated behind `CMDR_E2E_SKIP_MTP_FIXTURES` env var (Go runner sets it on non-MTP shards).
567+
568+
3. **Shared Playwright outputs (`/tmp/cmdr-e2e-report.json`, `test-results/`).** Each parallel Playwright process would
569+
stomp on the previous one's report. Per-shard `CMDR_E2E_JSON_REPORT` and `CMDR_E2E_OUTPUT_DIR` env vars route them to
570+
distinct `/tmp/cmdr-e2e-report-<shard>.json` and `/tmp/cmdr-e2e-results-<shard>/` paths.
571+
572+
4. **Pre-existing MTP-fixture-rebuild flake amplified under parallel load.** Even with the wipe race fixed, the MTP
573+
shard's `beforeEach` (`pause_watcher → recreateMtpFixtures → rescan → resume`) is occasionally flaky on
574+
`mcpAwaitItem` for `report.txt`. The flake exists in single-instance mode too (Step 1 baseline had 1 of these; Step 4
575+
noted a similar incident with `sunset.jpg`). It happens roughly 1-in-3 runs under three concurrent Tauri instances —
576+
the extra CPU + `/tmp` I/O pressure makes it more frequent. The root cause is somewhere inside mtp-rs's
577+
pause/resume + rescan ordering, which is out of scope here. Set `retries: 1` for the MTP shard only in
578+
`playwright.config.ts` (gated on `CMDR_E2E_SHARD_KIND === 'mtp'`). The non-MTP shards keep `retries: 0`. The retry
579+
adds ~5-10 s to the MTP shard wall-clock when it fires and zero when it doesn't.
580+
581+
### What limits the parallelism
582+
583+
- **MTP lane stays single-instance** as long as `MTP_FIXTURE_ROOT` is a `const &str`. Making it env-var-driven would
584+
unlock additional MTP shards but isn't worth the extra Rust surface area for an already-short lane (~78 s).
585+
- **Non-MTP scaling** is roughly proportional but capped by `--shard`'s per-file granularity. With the current spec
586+
layout, the `non-mtp-1` shard inherits `file-watching.spec.ts` (~78 s, dominated by watcher debounce delays the app
587+
actually needs to be fast for). Going to N=4 (three non-MTP shards instead of two) saves at best another ~20 s —
588+
diminishing returns vs. the cost of a fourth Tauri instance, three Playwright processes, and the macOS noise of four
589+
overlapping windows. Stuck at N=3 for now.
590+
591+
### Files touched
592+
593+
- `apps/desktop/src-tauri/src/lib.rs`: `CMDR_PLAYWRIGHT_SOCKET` and `CMDR_E2E_SKIP_VIRTUAL_MTP_SETUP` env-var gates
594+
- `apps/desktop/test/e2e-playwright/playwright.config.ts`: `CMDR_E2E_SHARD_KIND`, per-shard JSON report, per-shard
595+
output dir, removed `html` reporter (would have collided across shards)
596+
- `apps/desktop/test/e2e-playwright/fixtures.ts`: socket path from env var
597+
- `apps/desktop/test/e2e-playwright/global-setup.ts`: `CMDR_E2E_SKIP_MTP_FIXTURES` gate
598+
- `scripts/check/checks/desktop-svelte-e2e-playwright.go`: rewritten to plan shards, spawn N Tauri instances, run N
599+
Playwright processes in parallel, aggregate results
600+
- `apps/desktop/test/e2e-playwright/CLAUDE.md`, `scripts/check/CLAUDE.md`: parallel-sharding docs
601+
602+
### Surprises / notes
603+
604+
- The plugin's `init_with_config` builder is _not_ documented on crates.io but is in the published source. Searching
605+
inside the cargo registry beats guessing.
606+
- Three Tauri windows pop up on macOS during the run. Cosmetic but very visible. Not worth chasing a "headless macOS
607+
Tauri" workaround for the checker — the test takes 2-3 minutes total.
608+
- `./scripts/check.sh` (fast) is green after the patch.

scripts/check/CLAUDE.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -114,9 +114,12 @@ counted as failed. Dependencies not in the selected run set are treated as satis
114114
`desktop-e2e-playwright`). Named `--check` invocations implicitly include slow checks
115115
(`includeSlow = len(checkNames) > 0`).
116116

117-
**Self-contained E2E checks:** `desktop-e2e-playwright` manages the full lifecycle (build binary, create fixtures, start
118-
app, run tests, cleanup). The app runs in an isolated `CMDR_DATA_DIR` with MCP on port 9429. Stale processes are killed
119-
before starting. Logs go to `/tmp/cmdr-e2e-playwright-<timestamp>.log`.
117+
**Self-contained E2E checks:** `desktop-e2e-playwright` manages the full lifecycle (build binary once, create per-shard
118+
fixtures, start N Tauri instances, run N Playwright processes in parallel, cleanup). Each shard runs in its own isolated
119+
`CMDR_DATA_DIR` with its own Unix socket and MCP port (9429 + shard offset). One shard is dedicated to MTP specs
120+
(serialized — the virtual MTP backing dir at `/tmp/cmdr-mtp-e2e-fixtures` is shared by every Tauri instance). Stale
121+
processes on each port are killed before starting. Per-shard logs go to
122+
`/tmp/cmdr-e2e-playwright-<shard>-<timestamp>.log`.
120123

121124
`RUST_LOG` is forwarded to the app (via inherited `os.Environ()`), so trace-level output is one shell-prefix away:
122125

0 commit comments

Comments
 (0)