Written end-to-end tests (WebdriverIO)
August 26, 2026 · View on GitHub
Automated, repeatable e2e tests that launch the real Termic window, click
through real flows, and assert on live state. Purpose: catch regressions when
a feature changes. These are the written counterpart to the ad-hoc
automation bridge / e2e skill
(which stays, for agent-driven one-off verification).
Why WebdriverIO (and not a home-grown harness)
Termic on macOS renders in WKWebView, which exposes no Chrome DevTools
Protocol and no native WebDriver, so stock Playwright/Selenium can't attach.
The one established framework with a native macOS path is WebdriverIO via
@wdio/tauri-service's embedded provider: the Rust crate tauri-plugin-wdio
embeds a W3C WebDriver server inside the webview, and WebdriverIO speaks to it.
Real framework, standard API ($, waitUntil, auto-retrying expect), real
window, real screenshots. We did not invent a test framework.
Zero production footprint
Everything test-only is behind a Cargo feature, e2e:
src-tauri/Cargo.toml—tauri-plugin-wdio-webdriveris an optional dep;[features] e2e = ["dep:tauri-plugin-wdio-webdriver"].src-tauri/src/lib.rs— the plugin registration (tauri_plugin_wdio_webdriver::init()) is#[cfg(feature = "e2e")]. The plugin exposes no IPC commands and starts only an HTTP WebDriver server, so it needs no capability/ACL entry —capabilities/default.jsonis untouched.- All npm packages are
devDependencies.
A normal npm run tauri:dev and every release build contain none of it:
no plugin, no WebDriver server. (cfg(debug_assertions) can't gate a
dependency — Cargo ignores it with a warning — which is why this is a feature,
not a profile check.)
Note on the npm side: @wdio/tauri-service@1.2.0 ships a broken pin
(@wdio/native-utils@2.4.0) but imports a symbol only present in 2.5.0, so
package.json carries an overrides bumping @wdio/native-utils to 2.5.0.
Revisit when the tauri-service fixes its pin.
Run it
make e2e # build the --features e2e binary + run the whole suite
# or, à la carte:
npm run e2e:build # VITE_E2E=1 tauri build --debug --no-bundle --features e2e
npm run test:e2e # wdio run ./wdio.conf.ts (skip the rebuild while iterating on specs)
e2e:build produces a self-contained debug binary at
src-tauri/target/debug/termic (embedded frontend, so no vite server needed at
test time). Rebuild it after any Rust or frontend change. Screenshots land in
.e2e/artifacts/ (gitignored). Do NOT build the e2e binary with a bare
cargo build — that bakes in the dev-server URL and the window comes up blank
(about:blank); always go through e2e:build / make e2e.
The e2e binary is built with VITE_E2E=1, which exposes window.__termic
(stores + ipc + invoke) so specs can read real app state and drive real IPC.
That flag is unset in normal npm run build, so real release bundles still
tree-shake __termic out.
Tests run on a real Mac only — they launch a GUI window.
CI
An e2e job in .github/workflows/test.yml runs the suite on macos-14 for
PRs and pushes to main. It is not a required check yet — it's there to
surface flakiness under CI so we can harden it before gating merges. The
gitignored .e2e/ fixture profile is recreated by node scripts/e2e-seed.mjs
(templates in scripts/e2e-seed/); screenshots are skipped when CI is set
(the snap() helper). Artifacts upload on failure. To promote it to required,
add it to branch protection once it's proven stable over ~20-30 runs.
Isolation
wdio.conf.ts points the launched app at a throwaway TERMIC_DATA_DIR
(.e2e/profile, the same seeded profile the e2e skill uses: welcomed=true
- the
fixture-repoproject + the zero-tokenfakeagent). A run never touches your realtermic_devdata. Agent flows usefakeagent(scripts/fake-agent.sh) so no real tokens are spent.
Worktree tasks land in .e2e/tasks/ and nowhere else. They used to be
created under ~/termic_dev/tasks/fixture-repo, mixed in with the developer's
own dev tasks, where nothing could safely clean them up: a run killed mid-spec
left a worktree behind, and every later run then failed on a worktree already lives at … (or, once the directory was gone but the registration was not,
branch … is already checked out elsewhere). seed() now wipes that directory
and prunes the fixture repo's worktrees on every run.
The task RECORDS are swept separately, by wdio.conf.ts's onPrepare — that
runs on every test:e2e, including runs that skip the seed script.
The seeded fixture-repo carries an origin remote (a sibling bare repo,
.e2e/fixture-repo-origin.git) so origin/main resolves like a real cloned
checkout. This matters because the project default base is origin/main: any
worktree spawn that honors it (a plain New Task, and every Agent Race racer)
would otherwise die with git branch ... origin/main → not a valid object name.
A spec that repoints origin (e.g. git.e2e.ts's commit-push) MUST restore the
seeded origin in teardown, or the later agent race test loses its base.
A spec that commits must commit something NEW. make e2e reseeds, but
npm run test:e2e on its own does not, so the fixture repo carries the last
run's commits. git.e2e.ts used to write a fixed history-probe.txt with
fixed contents: on the second bare run git add staged nothing, git commit
exited non-zero, and the execSync throw took the whole describe with it (four
failures, none of them about what they claimed to test). Both the file name and
the subject are stamped now. The general rule: anything a spec commits should
be unique per run, and any assertion that a file is an ADD in that commit is
only true while the name is.
Writing a test for a new feature
The full authoring workflow lives in the e2e skill
(.claude/skills/e2e/SKILL.md) — load it when adding or updating tests. In
short: one spec file per feature area under e2e/specs/*.e2e.ts, one it per
user-observable outcome, built from the shared helpers in e2e/helpers.ts
(waitForAppShell, clickByText, waitForText, …). Read real state via
window.__termic rather than scraping the DOM.
The non-negotiable stability rules (this is what keeps the suite from going fuzzy):
- Never sleep. No
setTimeout/fixed waits. Usebrowser.waitUntil(...)or an auto-retryingexpect(...). Every wait is a condition, not a duration. - Assert on state, not pixels. Screenshots are for humans to eyeball, not for assertions. Assert DOM text/attributes, or app state.
- Terminal content is NOT in the DOM. xterm renders to a WebGL canvas —
innerTextnever contains PTY output. Assert terminal activity via app state (e.g.lastOutputAt) read withbrowser.execute, exactly as thee2eskill does. All other UI (sidebar, tabs, dialogs, Git panel) is normal DOM. - Stable selectors. Prefer role/visible-text; add a
data-testidonly where text is ambiguous or localized. Never depend on generated class names. - Deterministic fixtures. Reset/seed via the isolated profile; don't rely on state left by a previous test.
- Wait for READY, not for EXISTS. A resource existing is not the same as
it being able to do its job, and the gap between the two is where flake
lives. Before submitting to an agent use
waitForAgentReady(), notwaitForAgentPty(): the latter resolves the moment Rust reports aptyId, which says a process was spawned and nothing more. - Verify the action landed. An input event that dispatches without
throwing has not necessarily been handled.
submitToAgent()now checks thatlastInputAtadvanced, so a dropped submit fails at the submit with the real reason rather than 15s later at an unrelated assertion.
The badge flake, and what it taught us
For a while the CI run failed almost every time, on a different badge spec
each run, always with [null, null]. That pattern reads as randomness and is
why it went unfixed: a real regression fails the same spec every time.
One bug, not many. waitForAgentPty returned as soon as the PTY existed, so a
spec could dispatch keystrokes at an xterm that had not yet wired its
_inputEvent handler. The events went nowhere, submitToAgent still reported
success, the fixture never emitted its OSC, and the badge assertion timed out
15s later blaming the app. On a laptop the window between "PTY exists" and
"xterm accepts input" is invisible. On a loaded 3-core CI runner it is wide
enough to lose regularly, and which spec lost was luck.
The fix is rules 6 and 7. waitForAgentReady waits for the fixture's OSC title
to reach the store, which proves process spawned + script running + xterm
parsing + store wired in one condition. An xterm parsing OSC will deliver
input.
Generalise it: when a spec fails intermittently and the failure moves around, suspect a readiness precondition shared by all of them rather than N separate timing bugs. And prefer a condition that proves the whole chain over one that proves the first link.
Skeleton:
describe("my feature", () => {
it("does the observable thing", async () => {
await $("button=New task").click();
await expect($("[data-testid='task-view']")).toBeDisplayed(); // auto-retries
});
});
The e2e build never takes focus (and must not)
Each spec file launches its own instance, so a run launches seventeen. Under
--features e2e the app therefore sets ActivationPolicy::Accessory before
showing its window and skips the set_focus() a normal launch does
(lib.rs). On macOS set_focus() activates the app, and activating yanks the
user to whichever Space the window opened on: seventeen Space switches through
a four-minute run, on the machine of whoever is running the suite.
Nothing in the suite needed that focus. Specs drive the webview through
synthetic events, store writes and IPC, none of which care which app is
frontmost, and the window was already occluded anyway (see below). Do not add a
set_focus(), a .show() that activates, or an Accessory-to-Regular flip on
this path to make a spec pass: the spec is reaching for OS focus it should not
need, and the cost is the user's attention every time the suite runs.
The window is hidden, so nothing animates
document.hidden is true for the whole run: the harness never brings the
window to the front. WebKit freezes requestAnimationFrame in a window it
believes is occluded, so no rAF callback ever fires in a spec. Anything the
app defers to a frame is deferred forever.
CodeMirror schedules its layout measurement that way. Until it runs, CM's height map holds its unmeasured default of 14px per line while the rendered lines are really 20, so each gutter number sits 6px above its code and the gap grows down the file. A gutter-alignment spec then reports exactly the drift it exists to catch, produced entirely by the harness. Waiting does not help: the frame is never coming.
flushEditorMeasure() in helpers.ts runs the pending measure synchronously
(via coordsAtPos, whose public read flushes it) and returns how many editors
it touched, so a CodeMirror upgrade that moves the view handle fails loudly
instead of silently going back to measuring nothing. Call it before reading
any geometry out of a CodeMirror editor.
The same applies to product code that leans on rAF: it is invisible in a spec.
cd359ba moved the command palette's deferred effect from rAF to a macrotask
for the user-facing half of this (a palette command fired minutes late, over
whatever the user was doing by then, once the window came back).
The one maturity caveat
@wdio/tauri-service + tauri-plugin-wdio are young (1.x, late-2025 / 2026).
They are maintained by the WebdriverIO org, but if a version regresses, pin the
last-known-good @wdio/* and tauri-plugin-wdio together (they release in
lockstep). The bridge/e2e skill remains as a fallback for manual checks.
Typechecking the specs
npm run typecheck:e2e (tsc -p e2e/tsconfig.json --noEmit) covers e2e/,
perf/ and both wdio configs. None of it is in the app's tsc -b project, so
npm run build says nothing about it, and by the time anyone looked there were
71 errors: mostly spec-scope let taskId: string | undefined flowing into
browser.execute, whose callback param then cannot index the store, plus two
perf report units that were simply not in the union they claimed. It runs in CI
beside the unit tests now.
Two conventions that keep it clean. Spec-scope ids are declared with a definite
assignment (let taskId!: string) because a before hook assigns them and the
after hooks still guard at runtime. And anything read out of window.__termic
is annotated at the boundary: the store is loosely typed, so an unannotated
value passed into another execute arrives as WebdriverIO's HTMLElement
union and every use of it is an implicit any.