E2E coverage map & checklist

September 4, 2026 · View on GitHub

Status: reference. Not a plan to implement and then delete: it is a living map, updated by nearly every feature commit, which is why it sits in docs/ rather than docs/plans/.

The running map of what our WebdriverIO e2e suite covers and what it still needs. Update this whenever you add/change a spec. Harness + authoring rules live in docs/e2e-tests.md and the e2e skill.

  • Run: make e2e (build + run) · npm run test:e2e (iterate). ~40s serial.
  • Specs are grouped by area into ~10 files (one app launch each; cases run sequentially and self-clean). Add a new test as an it in the relevant group file. activity.e2e.ts is the one file that earns its own launch on structure rather than area: it switches WebDriver between window handles, and leaving the wrong window current would break whatever ran next in a shared file.
  • Specs: e2e/specs/*.e2e.ts · helpers: e2e/helpers.ts.
  • Legend: ✅ covered · ⬜ todo (P0 core / P1 important / P2 nice-to-have).

The rule

Every new feature with a UI/flow surface gets a spec; every change to an existing feature updates its spec. A PR that adds/changes a flow is not done until make e2e is green and this file reflects it.

Covered today

AreaWhat's assertedSpec
✅ App shellRenders; __termic exposes real store stateapp.e2e.ts
✅ NavigationDashboard ↔ History via real clicksapp.e2e.ts
✅ History scrollingThe archive pane fills its overlay instead of sizing to its content, so an archive taller than the window overflows INSIDE the scroller: the last row starts out of view and scrolling brings it in. Filtering down and back keeps the pane full-heightapp.e2e.ts
✅ Create (wizard)NewTaskDialog: name + shell CLI + Main-checkout → Create → task existstask.e2e.ts
✅ Non-blocking archive (GH #246)Confirming an archive never raises the busy-overlay click-blocker (sampled the whole time the archive runs, not once at the end) and the row leaves the sidebar on its own; a task with an archive in flight renders as the inert ArchivingTaskRow (spinner badge, clicking it selects nothing) and returns to a normal row if the flag clearstask.e2e.ts
✅ Non-blocking worktree create (GH #242)Worktree-mode Create closes the dialog synchronously, before the worktree is ready (not once it's done); while a task is pending, the sidebar shows a spinner-badged PendingTaskRow and the main pane shows CreatingTaskPane's live log, with no [role="dialog"] blocking the rest of the app; a failed create leaves a dismissible error state instead of an error dialogtask.e2e.ts
✅ Task spawnTask created; agent PTY comes alive; PTY write round-trips; agent OSC title reaches the apptask.e2e.ts
✅ Deep links (GH #192)A termic://new?… URL pre-fills the New Task dialog (name, prompt, task type) and creates nothing until the user presses Create; the prompt stays editable; a prompt at the cap is accepted and one past it is refused with a reason; an unregistered project, a missing project, an unknown action, an unknown task and a foreign scheme are each refused with a toast and no dialog; a confirmed link creates the task it described. termic://open?… selects an existing task by name or id with no dialog (navigation changes nothing to confirm) and closes a stale New Task dialog on its way through. Enters at handleDeepLink with the raw URL Rust queues, because WebDriver cannot ask macOS to open a URL schemedeep-link.e2e.ts
✅ Code intelligence (GH #174)With the pref off there is no chip and no server; with it on the chip appears only for a language a server answers for. The consent prompt states a memory figure, that the unit is the CHECKOUT, and that the grant lapses; cancelling starts nothing. Arming a checkout drives a REAL server over stdio (e2e/fixtures/fake-lsp.mjs) and its pushed diagnostic renders in the editor, which is only reachable if the host answered workspace/configuration with the right arity and merged workspaceFolders into initialize — the fake server refuses both otherwise. The child is spawned AT the checkout (it writes its log relative to its own cwd), two tasks on one checkout share one server, the server appears in Activity with no taskId, ⌘-click jumps to the definition from a usage and lists usages when clicked ON the definition; a completion whose label appears nowhere in the buffer reaches the popup (proving the SERVER's list arrives, which a local word scraper could otherwise fake); turning it off stops the server immediately rather than after the idle grace; and dropping the grant stops it and clears its diagnostics; the chip reports each phase a server passes through (starting, the server's own indexing progress, ready), and a server is reaped a grace period after its last editor closes while the checkout stays armed; a server left behind by an EARLIER page load is killed at startup while this page's survives (the reload leak: the map that keeps one server per checkout lives in the webview, so a reload abandons the processes it was tracking); Cmd+[ / Cmd+] are Back and Forward only: a folder listing walks its trail, and at the end of it (or with focus in the right panel) the key does nothing rather than falling through to switching tasks, which is what Opt+Cmd+Up/Down are forcodenav.e2e.ts, editor.e2e.ts
✅ Real servers, every language (GH #174, local only)Not WebdriverIO and not CI: make lsp-smoke starts the ACTUAL server for each of the seven languages against a fixture project in e2e/fixtures/lsp-projects/<language> and checks resolution, hover, a definition that crosses files, and workspace/symbol finding that definition (plus diagnostics where the fixture has a broken file). It is the only thing that can catch a server changing its behaviour under us, since the fake server in codenav.e2e.ts does what we told it to. --record writes each answer into src/lib/lsp/__fixtures__/, and symbolSearch.realservers.test.ts ranks all seven offline in the normal suite, so a ranking regression fails a merge even though the servers themselves cannot run therescripts/lsp-smoke.mjs, symbolSearch.realservers.test.ts
✅ Server downloads (GH #174, opt-in)E2E_LSP_DOWNLOADS=1 only, because it fetches ~35 MB from GitHub: each installable server (TypeScript 7, ty, rust-analyzer) downloads, verifies against the digest its release advertises, unpacks to the executable the manifest names, and lands under termic's own directory rather than the user's PATH; a second install is idempotent; lsp_check_update never claims an upgrade right after installing the latest, and is compared against upstream's real tag whenever the spec's own probe of the release API confirms it is answering (rule 21 in lsp.md: that API is 60 calls/hour per IP and a shared runner does not own it); and the downloaded TypeScript and Python servers then produce real squiggles on real type errors in the editor, which is the only thing that proves push-vs-pull diagnostics are handledcodenav-download.e2e.ts
✅ Agent workingAfter a real submit, the agent enters the working stateagent.e2e.ts
✅ Agent attentionAn agent you are not viewing flags completion (unread/done) when it finishesagent.e2e.ts
✅ CLI tabs (unit)termic tab on an UNMOUNTED task keeps every persisted agent and its session id, does not steal the default-target role, and a shell tab does not strand the task agentlessstore/cliTab.integration.test.ts
✅ CLI tab ids end to endOver the real control socket: tab returns a stable id; logs --tab resolves it to THAT tab's own PTY (the PtyRole.tab_id thread); send --tab delivers to the targeted tab only; tab -p confirms delivery into the new tab; status lists the strip with the same ids and 1-based indices; a missed selector is a typed not_found and a duplicate title refuses as ambiguouscli.e2e.ts
✅ CLI rename end to endOver the real control socket (GH #153): rename retitles by explicit name, the reply carries the persisted NEW name and old_name, the store reflects it live; a same-project duplicate refuses with a typed conflictcli.e2e.ts
✅ CLI send --tab (unit)Targeted send delivers/queues on the TARGET tab's own state, refuses vanished (unknown_tab), non-agent (not_sendable), dead (tab_not_live) targets, spawn-pending waits for the racing PTY, --resume/--fresh conflict, incapable --wait refusedstore/cliSend.integration.test.ts
✅ CLI tab close end to end (GH #185)Over the real socket: tab close --tab drops a secondary tab from the strip AND stops its PTY (its id stops resolving) while the task and its other tabs keep running; a SHELL tab is refused by logs (write-only) yet still closes, reporting tab_kind and the webview-observed killed_pty; the default tab refuses without --yes and nothing is killed on the way to the refusal; a missed selector is a typed not_found; a closed secondary leaves a closedTabs Resume entry; --yes closes the default tab, which stays durable in persisted_tabscli.e2e.ts
✅ CLI tab close, store side (unit)The webview half: a secondary tab leaves the durable set but keeps its session id in closedTabs, a shell tab closes with killedPty reported from the store (the only side that knows, since it carries no PtyRole) and leaves no Resume entry, the default tab closes yet stays durable with no entry, and an UNMOUNTED task is refused without touching persisted_tabs (running syncDurableTabs there would forget every secondary agent's session id on disk); unknown-tab, split-pane and missing-id refusals carry their sentinelsstore/cliTabClose.integration.test.ts
✅ CLI adopt worktree (GH #169)Over the real socket: new --from adopts an externally-created worktree (project resolved from its repo, name derived from its branch), re-adopting the same path and a plain non-worktree dir refuse cleanly, and --resume on both new and tab refuses an agent without id-resume support; --resume SEEDING (task agent_session_ids, tab sessionId, --resume <id> arg composition keeping --name) is pinned by cli_server.rs tests + agents.test.ts + store/cliTab.integration.test.tscli.e2e.ts
✅ CLI prompt library (Phase 4)Over the real socket: prompts lists the shipped library (ids + flags, no bodies); prompts show resolves ids and case-insensitive live titles, including disabled prompts, and a miss is a typed not_found; send -P composes body + blank line + -p text into the real agent; a bad -P on new fails fast with no task created. Selector precedence/ambiguity, composition, empty-body refusal and fail-fast ordering are pinned by cli_server.rs tests; the live-store contract by store/cliPrompts.integration.test.tscli.e2e.ts
✅ Windowless completionAn agent finishing while Termic is windowless still flags unread/done, even for the task that was activeapp.e2e.ts
✅ Pending work defers doneAn agent that backgrounds work and returns to its idle title holds the done badge back past byte-quiet (4s) and the settle timer (5s); done fires once its status line clearsagent.e2e.ts
✅ A hold that never clears endsA status line that never clears cannot pin a tab to "working": the absolute ceiling force-clears it (shortened for the test via localStorage.workDoneCeilingMs)agent.e2e.ts
✅ A premature done is taken backA done fired at a stage boundary is undone when the agent goes back to work (spinner returns, stale bullet drops), and the turn's real completion still firesagent.e2e.ts
✅ One turn, one notificationA two-stage turn produces TWO sidebar dots and ONE newsworthy edge (the thing the notifier turns into a banner), down BOTH paths: the heuristic one via the title (#stage) and the hook one via OSC 133;C/;D (#hookstage), which reach fireDone under opposite guards. GH #276agent.e2e.ts
✅ Split restoreA pane whose tabs are all gone collapses instead of restoring a blank leg; a task whose main tabs were all non-durable still restores with a main tab and a real activeTabtabs-layout.e2e.ts
✅ Agent notificationsOSC 9 raises attention carrying the agent's verbatim body; the "waiting for your input" idle nag raises nothingagent.e2e.ts
✅ Agent hook attentionA hook's OSC 777 raises attention and suppresses the false done the idle title would have fired past SETTLE_MSagent.e2e.ts
✅ Usage footerA status line's OSC 777 (#usage) puts subscription usage in the task footer, reports one window when only one was sent, and NEVER badges the tab: a trusted body skips every notification filter, so a report routed one branch late would ring a bell on every turn of every task. GH #277agent.e2e.ts
✅ Hooks own the turnWith an agent reporting its own state, the title going idle no longer ends a turn, and neither do the byte-quiet / scrollback / ceiling demoters: an ordinary turn stays out of done well past SETTLE_MS and QUIET_MSagent.e2e.ts
✅ Interrupt, title-corroboratedAn interrupt no agent reports (claude and agy fire NO hook for one, measured) clears the working state once the title goes idle, and leaves no done badge, since an interrupt is not a completionagent.e2e.ts
✅ Interrupt, quiet-corroboratedThe agy shape: an interrupted turn that reports neither a hook nor a title ends on the terminal falling quiet, with the fixture holding its busy title so the other route cannot be what firedagent.e2e.ts
✅ Clone inherits, not copiesCloning through the real card stores ONLY identity plus extends (command, args, icon and sandbox paths stay empty) and the clone's pill draws its parent's brand icon; an override is kept while every field it did not touch still follows the parentsettings.e2e.ts
✅ Reset overridesThe reset button clears every override and keeps identity (id, extends), which is the escape hatch for clones made before agents inheritedsettings.e2e.ts
✅ Agent hook installInstall merges into the user's own settings.json, keeping their hooks, unknown keys and key ORDER; the script lands executablesettings.e2e.ts
✅ Agent hook removalRemove restores the config byte for byte and deletes the script dirsettings.e2e.ts
✅ Agent hook refuses bad configA malformed settings.json is reported, never overwrittensettings.e2e.ts
✅ Agent hook honest rowsAn agent termic cannot wire says so instead of appearing installedsettings.e2e.ts
✅ Run tabsA custom run command opens a run tab whose PTY executes it; the sidebar's Run row carries the tab pill's two controls, a red Stop while the run is live (killing the PTY from the tree) and a Play once it is not, which fronts the run tab and asks its pane to start the command again. Collapsing the task moves that control INTO the header row (asserted by containment, either half of the pair) and expanding hands it back rather than showing it twice; the header shows one button per run tab and none at all once a fourth run tab takes the set past the caprun.e2e.ts
✅ Task archiveArchived task leaves the active boardtask.e2e.ts
✅ Task restoreArchived task shows in History; restore returns it to activetask.e2e.ts
✅ Tab close confirm (unit)The close prompt carries the same "Show this every time" opt-out (not the branch checkbox slot), stores it only when the close went through, and only a PANE tab close (no closedTabs entry, so no Resume) is dressed as destructivelib/closeTab.test.ts
✅ Archive confirmation"Show this every time" unticked then cancelled stores nothing; confirmed, it stores the opt-out AND the delete-branch answer; the next archive then runs with no dialog, deletes the branch and toasts the way back to History. Settings › Tasks keeps both toggles visible either way, flips the branch one, and turning the confirmation back on neither hides it nor rewrites its value. The branch toggle seeds the dialog's checkbox (singular and plural forms), which can still be overridden for one archive without changing the stored defaulttask.e2e.ts, settings.e2e.ts
✅ Branch as the task name (GH #260)With the pref off a task row shows its typed name and not its branch; turning it on swaps BOTH the sidebar row and the breadcrumb to the branch (once, not appended, and with the breadcrumb's "on " clause collapsed), leaves the name on the record untouched, and turning it back off restores the row; a main-checkout task is left on its typed name even though it does carry a branch. Settings → Tasks reaches the toggle next to the branch prefix and flips it both waystask.e2e.ts, settings.e2e.ts
✅ Multi-taskTwo tasks, independent/distinct PTYs, survive going inactive, switching workstask.e2e.ts
✅ Editor openClick a file → editor tab opens → CodeMirror loads the real contentseditor.e2e.ts
✅ Editor saveEdit in CodeMirror → dirty dot → Cmd+S → written to diskeditor.e2e.ts
✅ Scratchpads (GH #244)The "+" menu opens an untitled, permanently-dirty pad; the title is folded from as many buffer lines as fit (blank lines and heading/bullet marks stripped) and the syntax button names what the CONTENT sniffer picked, with a manual pick from the palette overriding it, persisting into the scratch index, and (for Markdown) earning the pad the same source/preview/split shell a .md file gets, rendering from the live buffer since there is no file yet; the close prompt's Cancel keeps both the tab and the pad; ⌘S opens the promote picker and turns the SAME tab into a clean edit tab on the chosen path, with the file really on disk and visible to git status; Discard closes the tab and drops the record so a relaunch cannot bring it back; a bulk close ("Close others") asks about every pad separately, and a Cancel there spares that one while the rest of the set still closesscratchpad.e2e.ts
✅ Git cleanClean working-tree status for the fixture repogit.e2e.ts
✅ Git dirtyModify a file → Commit panel leaves clean state, git status reports itgit.e2e.ts
✅ Git history / graphThe Git tab's History sub-tab lists real commits (made outside the app) newest first with lane gutters + ref chips; a commit expands into its files; a file opens a diff of THAT revision (asserted against a deliberately dirtied working tree) with no review affordances; the ref picker scopes to Auto, All and a named branch, unticking the last ref returns to Auto, First parent only collapses merged branches without emptying the graph, and the message search narrows the graph across the whole branch (a literal no-match query empties it, clearing restores); subjects indent to their own lane (GH #199, GH #208)git.e2e.ts
✅ Git branch compareThe Compare sub-tab lists committed, uncommitted and untracked work against the base in ONE list (the committed file being the case the Commit view structurally cannot show); the summary strip carries a file count + diffstat; the shared Filter box narrows the list and restores it; a file opens a base:<sha> diff whose left side is the base and right side the live worktree (fingerprint non-empty), review affordances still ON unlike a graph diff; picking another base re-runs the comparison; folders render as a tree, not flat paths (GH #208)git.e2e.ts
✅ Usages popup rowsThe fixture reports one location twice and one in a second file of the same basename: the popup shows three rows not four (deduped on uri+line+character) and labels each with the path it needs to be unambiguous, bare name otherwise (the rule itself is unit-tested in usageLabels.test.ts)codenav.e2e.ts
✅ Double-Shift modeSettings → Shortcuts puts a four-way select (off / left / not-in-a-terminal / either) on the read-only double-Shift row itself and each choice reaches the pref; the gesture obeys it live (off does nothing, left ignores the right Shift and fires on the left, either fires on the right too); the ⌘/ sheet drops the row when off and prints the chosen mode's own label otherwise; the Shortcuts row prints no second name for the gesture beside the selectsettings.e2e.ts, codenav.e2e.ts, app.e2e.ts
✅ Per-project code navigation tabThe project page offers a Code navigation sub-tab (named by codeIntelName, so either it or Code intelligence) carrying the three Auto start modes under a heading of their own; the language list appears only once auto start is on and is dropped from the record when it goes back Off; Scripts & run no longer carries any of it. The spec resets the project's code-nav fields, since the .e2e profile outlives the runsettings.e2e.ts
✅ SettingsToggling a preference lands in the prefs store + control reflects itsettings.e2e.ts
✅ Terminal renderer pickerAppearance → Terminal exposes the three-way webgl/canvas/dom picker on macOS (GH #140); the option list is asserted to be exactly those three, and driving the real segmented control through canvas and dom lands in prefs and keeps the legacy terminalGpuEnabled in syncsettings.e2e.ts
✅ Experimental badgingThe MCP section carries the Experimental badge (off by default) while the graduated Termic CLI page and rail item carry none (0.26.0); docs/ui.md ties the badge to being off by default, so a badge next to a shipped-enabled setting is a contradiction in one direction and a missing badge on a settling surface is one in the othersettings.e2e.ts
✅ MCP endpoint boundaryThe listener advertises itself only through the data-dir files; missing, wrong, and CLI tokens all get one indistinguishable bare 401; any Origin header is 403 even with a valid token; a CORS preflight is never answeredmcp.e2e.ts
✅ MCP protocol conformanceserver/discover returns the one served revision with resultType as a result field and serverInfo under the namespaced _meta key; a handshake initialize (the exact opening request claude 2.1.228 sends) is refused with -32022 carrying machine-readable data.supported; notifications are swallowed at 202 without header ceremony; missing or mismatched standard headers are -32020 while a missing required _meta field is -32602; an unimplemented method is -32601 at HTTP 404mcp.e2e.ts
✅ MCP tool surfacetools/list is deterministic and full-scope; task_new spawns the fake agent, task_send + task_log round-trip, task_wait settles with a typed outcome, and verb refusals come back as typed tool errors rather than protocol errorsmcp.e2e.ts
✅ MCP diff summarytask_diff counts a tracked edit against the task's base branch, asserting the counts rather than a bare success: the base is a remote-tracking ref, and a repo that cannot resolve it used to report zeros instead of an error (the diff pane, termic diff and this tool share one engine)mcp.e2e.ts
✅ MCP tabs and discoverytask_tab opens a tab and returns the id task_send / task_log then address, task_tab_close resolves that same id, and task_agents lists the ids task_new accepts; the tab's pty is awaited first because task_tab returns before its agent spawnsmcp.e2e.ts
✅ MCP lifecycleToggling the setting off unbinds the listener and removes both data-dir files; toggling on rebinds on the preferred port and mints a fresh credential, as every bind doesmcp.e2e.ts
✅ Editor theme per app modeAppearance → Editor offers a dark AND a light syntax theme select, each writing only its own pref; on the rendered side the app mode selects the matching pref, the light pref repaints while the app stays light, and a write to the dark pref changes nothing until the app goes darksettings.e2e.ts, editor.e2e.ts
✅ Select chromeEvery settings <select> computes appearance: none with the repainted chevron and reserves room for it, so WKWebView's native bevel cannot come back (one bare-element rule, all selects at once)settings.e2e.ts
✅ Default tasks pathThe global setting ships a real value (not an empty box); a new project's tasks_path starts empty so that setting applies; an absolute default lands worktrees at <default>/<project>/<task> and a relative one at <repo>/<default>/<task>, both verified on disk; a project's own tasks path overrides it; the project field shows the global-derived path as its placeholder while staying empty; the Settings → Tasks preview flips between the two halves of the rule as it is typed, and emptying the required field blocks the savesettings.e2e.ts
✅ Task port range (GH #271)The Settings → Tasks field shows the range actually in force (18100-65535) rather than an empty box over a hidden default; a privileged floor, an inverted pair and a range too narrow for one block are each rejected by name and block the save; a valid range clears the error, reports how many tasks fit, and reaches settings.jsonsettings.e2e.ts
✅ Preview browser (GH #245)The OS default is used when nothing is configured; an app-wide command is used with the URL appended and a query string's & left intact; a project override beats the app-wide setting; an empty project value forces the OS default despite an app-wide one (the tri-state a plain string cannot express); an unparseable command falls back to the OS default rather than leaving the link dead; {url} substitutes instead of appending; the General page saves a command to settings.json AND writes it through to the store terminal clicks read; a launcher missing from PATH is rejected in the field. The e2e binary records the argv instead of launching a browsersettings.e2e.ts
✅ TabsAdd a terminal tab via the "+" menu; switch active tabtabs-layout.e2e.ts
✅ Sidebar New submenuThe task row's menu offers the same entries as the tab strip's "+" (GH #197); picking one spawns into that row's task, wakes it, and keeps its seeded agent tabtabs-layout.e2e.ts
✅ Tab renameDouble-click inline edit commits the new nametabs-layout.e2e.ts
✅ Tab context menuRight-click a pill: Pin moves the tab to the head of the strip and a second pin appends to the end of that block; Unpin drops back to the first slot after it; Close to the right and Close others spare every pinned tab and the clicked one; both go disabled when they have nothing to close (GH #183)tabs-layout.e2e.ts
✅ Pinned pill has no close XA pinned pill offers "Unpin tab" where an unpinned one offers "Close tab", so one stray click cannot kill a live PTY; that control unpins (the tab survives) and the X comes back with it (GH #183)tabs-layout.e2e.ts
✅ Pinned tabs stay in viewA pinned tab lives outside the strip's scroller: with the strip flooded past overflow and scrolled to its end, the pinned pill has not moved a pixel and is still fully inside the bar (GH #183)tabs-layout.e2e.ts
✅ ThemePicker switches theme; palette class applied to <html>tabs-layout.e2e.ts
✅ Editor persistSingle-click = preview tab; double-click persists iteditor.e2e.ts
✅ Split panesUnsplit start; split-right → 2 leaves; split-below → 3tabs-layout.e2e.ts
✅ Message queueMessage held while working, drains on idleagent.e2e.ts
✅ Command paletteOpens/lists; filters; command activation closes it; Escape closes; the top-bar button toggles it open and shut and names the live binding in its label; top-bar tooltips (palette, Prompts, right-panel toggle) print their live glyphsapp.e2e.ts
✅ File finder⌘P lists the repo's files; selecting one opens an editor tabfiles.e2e.ts
✅ Git stage/unstage/commitStage → unstage → re-stage + commit → cleangit.e2e.ts
✅ Task rename/deleteRename updates store+sidebar; duplicate name refused (IPC) + toast (inline flow, GH #153); delete removes the task entirelytask.e2e.ts
✅ Git diffOpen a diff tab for a changed filegit.e2e.ts
✅ Inline review commentsSelect a diff line → tooltip → compose → save, three times; line numbers stay level with the code throughout (GH #157)git.e2e.ts
✅ Find in files⇧⌘F opens; the status line names the backend that actually ran (ripgrep or the git grep fallback) and the install hint shows only on the fallback (GH #181); a repo-present query returns a result row with the match highlighted; a pattern matches nothing as a literal and matches once the .* toggle turns regexp mode on; a differently-cased query matches until the Aa toggle turns match-case on (both toggles persist to prefs). Run the spec a second time with TERMIC_FIND_BACKEND=git-grep to exercise the fallback on a machine that has rgfiles.e2e.ts
✅ Markdown previewPreview view renders the README markdown (h1)editor.e2e.ts
✅ Directory linksA folder link recycles the preview tab into a listing and expands the tree; the folder README renders under it; folder rows, .. and links inside the README navigate in place; a file row or README file link pins the listing and opens alongside it; a hidden listing's README does not claim ⌘F; ⌘[ / ⌘] walk the folder trail, are declined when focus is in the bottom drawer or right panel, and fall through to task switching once the trail runs outeditor.e2e.ts
✅ Find in preview⌘F marks exactly the query text and nothing else (asserted on the <mark>s and their computed background, never a highlight registry), including the code spans a doc contains but the query doesn't touch; Enter/⇧Enter step with the counter and wrap both ways; a second query replaces the first instead of stacking; a query inside a code span still matches; a phrase the markdown source hard-wrapped still matches; a regex metacharacter stays literal; no match clears; Escape restores the document; a theme flip that rebuilds the DOM re-marks against the fresh oneeditor.e2e.ts
✅ ⌘F ownershipOnly the tab the reader is in opens a find bar: not a background task's mounted preview, not the visible preview while a split pane holds focus, not while a modal holds the focus trap, and not under the Settings overlay (which traps nothing, so only the store flag sees it). The tab takes the key back on close, and a tab recycled onto another file drops its marks. In split view the editor keeps ⌘F while the caret is in it and the preview claims it once clicked intoeditor.e2e.ts
✅ File treeCreate a folder → expand reveals its child → collapse hides it; re-expanding re-reads that dir from disk (a file added while collapsed shows up, the original stays); a chmod 000 folder keeps its cached contents through a settle reload and, when it cannot be read at all, offers a retry row that loads it once the permissions come back (GH #159); that row names the actual error (errno + path) and a folder symlinked out of the task says so instead of offering a pointless retry (GH #250)files.e2e.ts
✅ Drag a file to a terminalRow dragged onto a terminal sends the relative path to the PTY (no editor tab); released elsewhere types nothing; a plain click still opens the filefiles.e2e.ts
✅ Tab dragsReorder within the main strip; drop on a pane edge to split there; drag out of a pane back to maintabs-layout.e2e.ts
✅ Resize dragsSidebar edge widens + clamps at its minimum (persisted); split divider moves the ratio inside its clamptabs-layout.e2e.ts
✅ Sidebar project dragsReorder two projects; drop one into a group folder; move a whole folder as one blockprojects.e2e.ts
✅ Sidebar task dragsReorder tasks inside a project (siblings keep their relative order); the new order persists to the task files, so a cold load reads it back; a task dragged at another project's row clamps to its own list instead of movingtask.e2e.ts
✅ Settings reorder dragsPrompt rows reorder by their grip (and a click without movement does not); agent pills reorder within their kindsettings.e2e.ts
✅ Project default CLI vs the agent registryThe Default CLI select shows the SAVED agent id even when the registry no longer offers it (React re-points a valueless <select> at its first option, which used to make the page name an agent the project was never set to); reordering the agent pills leaves the saved default alone; renaming a custom agent carries every project pinned to it onto the new idsettings.e2e.ts
✅ Add project by EnterTyping a path into the Add project dialog's Repository root field and pressing Enter adds the project and closes the dialog, the same as clicking Addprojects.e2e.ts
✅ Launcher CLI orderThe project + menu leads with that project's default CLI and keeps the rest in registry order, Terminal last; when the default IS Terminal, that row is hoisted tooprojects.e2e.ts
✅ Resume submenuThe project + menu keeps archived sessions behind one Resume row; the submenu lists them and restores the picked one. The tab strip's + menu nests its closed tabs the same way: the top level shows one Resume row and no sessions, hovering it lists themprojects.e2e.ts, tabs-layout.e2e.ts
✅ Empty archiveHistory's Empty archive: cancelling keeps every task, confirming deletes them all for goodtask.e2e.ts
✅ Signal inspector layoutObserved titles render whole (no clipped column) and each row offers a copy buttonsettings.e2e.ts
✅ Dialogs/palettesShortcuts help, prompt palette, broadcast open (and close)app.e2e.ts
✅ More dialogsChangelog, welcome, race dialog openapp.e2e.ts
✅ Windowless modeClose backgrounds without killing the task; panes collapse to zero geometry; agent output keeps flowing; raise restores window + panesapp.e2e.ts
✅ Agent raceFire one prompt at 2 agents: cohort recorded, both spawn a PTY + receive the prompt (lastInputAt) + drive a fakeagent OSC title; runs on a no-remote repo; RaceDialog gates Start then steppers+prompt launch it; a name collision surfaces an error and records no new racetask.e2e.ts
✅ PreferencesSandbox default, editor font, terminal font setterssettings.e2e.ts
✅ Agent extrasYOLO toggle; aux (bottom) terminalagent.e2e.ts
✅ Worktree taskCreate a task on its own worktree branch (not repo-root); on a no-remote repo the default-base create falls back to local maintask.e2e.ts
✅ Project renameRename a project (add covered too)projects.e2e.ts
✅ Editor splitSplit view shows source + rendered preview togethereditor.e2e.ts
✅ Repo configSave a .termic.yaml field and read it backprojects.e2e.ts
✅ Multi member modesNew Task dialog for a multi project: git members seed on Worktree, "Set all" flips every row (and surfaces the live-checkout warning), and each member's last-used mode is remembered across dialog opens (localStorage newTaskMemberModes)projects.e2e.ts
✅ Multi main checkout (dialog)The multi New Task dialog has the host-level Main checkout / Worktree toggle: Main checkout hides the member list behind a "run live" note and Create opens the host's live checkout (is_main_checkout, path = host root, every member linked in), the same task shape the sidebar quick menu creates, so ⌘N and the + menu can't drift apartprojects.e2e.ts
✅ Multi files to copy (GH #264)A multi-repo task resolves files_to_copy at three levels: the host project's own list lands in the task root, a member with no override falls back to its OWN committed .termic.yaml, and a per-member override on the project's member entry wins for that member. Each list stays in its own lane (the host's globs never reach a member, one member's never reach another), and archiving + restoring re-copies all threeprojects.e2e.ts
✅ Docker command preview (GH #231)Settings → Docker Sandbox renders the real docker run argv for a task-less preview (hardening flags present, placeholder worktree path, agent command appended), and switching the agent in the picker re-renders instead of blanking the panelsettings.e2e.ts
✅ Clipboard image capture (ctrl+V)clipboard_image_capture reads a real image off the macOS pasteboard (set by the spec, restored after), encodes it as PNG and returns a saved path; a text clipboard is refused so the caller can forward the keystrokeagent.e2e.ts
✅ Image paste (Docker only)clipboard_image_save persists pasted bytes and names the file for its REAL format (non-image bytes are refused, not written as a fake .png); the terminal's capture-phase listener leaves BOTH pastes alone in an ordinary task (native ⌘V still reaches the agent, which reads the Mac clipboard itself) and consumes only the image paste once the task is flipped to Dockeragent.e2e.ts
✅ Dockerfile editor lifecycleThe Dockerfile section is collapsed by default and its CodeMirror instance exists only while open: expanding mounts it with the real content, collapsing destroys it, and a second expand brings it back (the init effect bails when a view already exists, so a destroy that did not clear the ref would leave an empty box)settings.e2e.ts
✅ Docker forge CLI loginThe same preview proves the shared gh / glab config dir reaches the spawn argv: both container paths mounted, GH_CONFIG_DIR/GLAB_CONFIG_DIR set to match, host side under docker-forge (shared, not per-agent) and never the user's own ~/.config/ghsettings.e2e.ts
✅ Setup scriptConfigure + launch a Setup tab that spawnsrun.e2e.ts
✅ Sidebar layoutSidebar width setter persiststabs-layout.e2e.ts
✅ Code editorOpen a .py file → CodeMirror renders with highlight tokenseditor.e2e.ts
✅ Git status chip contrastThe one-letter status chip on a changed file keeps ≥5.5:1 between its ink and its fill in BOTH themes, measured from computed style (the light theme's "modified" chip shipped at 4.96:1, so a WCAG-4.5 floor would not have caught it)git.e2e.ts
✅ Set syntax (GH #244)TypeScript, JavaScript and TSX highlight (the extensions this repo is mostly made of, and the ones a main-chunk bundling mistake broke once), a language nobody registered here highlights straight from CodeMirror's registry (PHP: no import, no entry, no case in a switch), a Makefile highlights at all (hand-written grammar, no upstream one exists), the breadcrumb's language button names what the extension picked, an extension-less file gets its language from its CONTENT, and a manual pick from the syntax palette overrides both: label switches, a buffer that had zero token spans gains them, the text survives the switch (a view rebuild would also produce tokens, while losing undo/cursor), and the palette closeseditor.e2e.ts
✅ Editor h-scroll gutterA long line scrolled fully right keeps the sticky gutter painting the host's surface, so code never shows through it (GH #161)editor.e2e.ts
✅ Commit & pushCommit with push to a bare remote; remote receives itgit.e2e.ts
✅ Inline git blameCursor line gets ONE annotation naming the real commit (real git blame, asserted on the fixture's own author + subject), nothing before the cursor leaves position 0, never more than one (no column, and no annotation on the phantom line a trailing newline creates), an edited line drops to "Not committed yet", the pref removes it and restores it on a parked cursor, and the palette's toggle-inline-blame row flips it. Mutates no fixture state on purpose: one extra commit in the shared history breaks git.e2e.ts's first-parent case. Card: resting on the annotation opens it with the real commit; the header's Open diff opens a commit: diff tab; Show in History expands that commit in the Graph; a click on the annotation does nothingeditor.e2e.ts
✅ Editor selection → agentA selection raises ONE gutter icon (never the diff's pill or hover button) and retracts with the selection; the composer offers Send + Add to pending; Send ships that one comment WITH the code and skips the queue; Add to pending queues it (card in place, editor keeps the stage, nothing sent); ⇧⌘L stacks a second one and no-ops with no selection; queued comments follow their code when lines are inserted above; the batch sends as one message carrying both bodies + both SHIFTED line attributions (asserted from the agent's PTY ring) and drains the queueeditor.e2e.ts
✅ Multi-repo Git panelTwo member repos: the panel opens on a CHANGED repo (never the clean host) with its files listed and no click; a second dirty repo adds its pill without stealing the selection; picking a pill swaps the list and stages into that repo onlygit.e2e.ts
✅ Discover reposScan a folder → returns its git reposprojects.e2e.ts
✅ Import worktreeLists importable (unopened) worktrees for a projectprojects.e2e.ts
✅ Project reorderReorder projectsprojects.e2e.ts
✅ Resume closed tabresumeClosedTab reopens a tab and consumes the entrytask.e2e.ts
✅ Copy agent CLI briefingThe task's right-click menu offers "Copy agent CLI briefing" in the copy/edit block (the short paste-into-a-prompt fragment carrying one task's address, src/lib/agentBriefing.ts), running it reaches the clipboard (asserted via the success toast: the webview has write-only clipboard permission, so the block's CONTENT is pinned by src/lib/agentBriefing.test.ts instead), and the command palette offers the same actiontask.e2e.ts
✅ Run stopKill a running run tab's PTY → it stopsrun.e2e.ts
✅ Project groupAssign a project to a groupprojects.e2e.ts
✅ Task sandboxEnable enforce mode then turn it off (per task)settings.e2e.ts
✅ Extra named ports (GH #196)The Repo Settings field autosaves typed names to the personal list and warns inline on invalid/reserved names; a task created after the config freezes consecutive name→port pairs from its own block, a second live task's block never overlaps, clearing the config leaves new tasks extras-free, and a name configured AFTER a task exists tops up into its buffer on the next spawn (task_ensure_extra_ports)settings.e2e.ts, task.e2e.ts
✅ Project add/removeAdd a git repo as a project; remove drops itprojects.e2e.ts
✅ Dashboard empty stateThe "No projects yet" card is a focusable button that opens the Add project dialog, and it disappears once a project existsprojects.e2e.ts
✅ Agent settingsDisable/re-enable an agent CLI via agentsSaveagent.e2e.ts
✅ Run config modalThe #124 run-commands manager opens for a projectrun.e2e.ts
✅ SVG source/preview toggleAn .svg opens on the rendered picture (the default stays "preview", so a file-tree click still shows the image), the same source / preview / split toolbar markdown uses switches to the editable source and to both at once, an UNSAVED edit re-renders the picture (the preview is fed by the editor buffer, not disk, so a disk-backed one could not move), and toggling writes the svgDefaultView pref for the next file (GH #247)editor.e2e.ts
✅ PDF previewA hidden PDF tab keeps its display (main tab and split pane) while a hidden terminal still goes to display:none; the embed URL is fingerprint-keyed, so only a real rewrite reloads iteditor.e2e.ts
✅ Unviewable file noticeA file that fails the UTF-8 read shows a calm centered notice (asserted on the copy, on both button labels, and on there being NO text painted with --color-err in the pane) instead of the old red error; a text file past the 2 MB read cap gets the same notice naming its size; a file that is BOTH binary and over the cap reports binary, since Rust sniffs for binary before it checks size and only that answer leads anywhere useful; recycling the preview tab onto a text file clears it; a genuine failure (missing file) still gets the raw red error with no buttons. Neither button is clicked: "Open in default app" would launch a registered app the suite cannot close, and Reveal would steal window focuseditor.e2e.ts
✅ Git branch bar layoutA long branch name shares its row instead of taking it: on Commit the filter keeps >=30% of the row and the chip stays inside it (measured geometry, since happy-dom has no layout), and on Compare the bar wraps to a second row so neither the base picker nor the target branch ends in an ellipsisgit.e2e.ts
✅ Activity monitorThe sidebar footer button opens a SECOND window (its own activity.html entry, found by polling the WebDriver handles: a new webview is listed before its document loads); a live agent appears under its project and task; Termic's own processes get their own group and do NOT double-count the agents' subtrees (every PTY is our child, so the app row's stop-set is the invariant); a row reports a real CPU percentage even though the harness window is permanently document.hidden, which is the occluded-window back-off working; Pause halts sampling and resume restarts it; re-opening focuses the existing window instead of spawning a second; closing the window drops the sampling session and a fresh one is grantable; every column header sorts (default CPU desc, a new column starts biggest-first except Name which starts A-to-Z, the previous column lets go) and the PID column renders real pids. Grouping / sorting / formatting are unit-tested in src/lib/activityGroups.test.ts, the sampler math + FFI in procmon.rs testsactivity.e2e.ts

| ✅ Start from an issue (GH #21/#22) | The fixture's local bare remote resolves as NOT a forge, so project_forge_issues reports unsupported-remote rather than an empty list, and buildIssuePrompt composes context + deferred thread + library instructions; the route in is exercised end to end (palette row → shared project picker flagged issue → the SAME New Task dialog with its issue column open beside the form → the non-forge explanation → "blank task instead" drops the column and keeps the dialog). Picking a real issue needs a real forge, so the prompt auto-fill it triggers is unit-tested in src/lib/issuePrompt.test.ts. | git.e2e.ts |

CLI control plane (Phase 1/2)

The socket verbs are covered by Rust integration tests against a real unix socket with a stub host (src-tauri/src/cli_server.rs tests: auth, every verb, streaming framing, full bidirectional attach sessions, watch/queue semantics), plus vitest for the store-level pieces (cliPromptReports, stopTask queue fail-fast, the unattended-restore mark). What that rig cannot see is the webview HANDLER side running in the real app; e2e specs to add:

  • ⬜ P1 send end to end: real socket request → send_prompt handler → fake-agent PTY receives the text; busy path queues and drains.
  • ⬜ P1 send --resume respawnKick: exited fake-agent tab respawns and receives the injection (covers the TerminalPane kick effect).
  • ⬜ P2 send --fresh adds a secondary tab without forgetting the persisted set (assert persisted_tabs after).
  • ⬜ P2 logs/result against a fake-agent transcript fixture.

Deferred (with rationale)

Lower-value or high-setup items left for later; the patterns to do them are all in place.

  • Second live agent in one task / quick-create — heavy fixture setup (agent-tab construction) for low marginal coverage. Resume (resume-tab) covers the reopen path. Multi-member projects came off this list: git.e2e.ts builds one (a non-git wrapper host + two throwaway repos in a tmp dir) for the multi-repo Git panel, so the fixture pattern exists to copy.
  • Run-at-repo-root (spotlight) — needs spotlight state; the run-tab mechanism is covered (run, run-scripts via proxy).
  • Configured .termic.yaml run scripts via the Run button — covered by proxy: setup-script (configured-script launch) + run (run-tab mechanism) + repo-config (config persistence). The live Run-button path has a config-cache nuance not worth the flake.
  • File create/rename/delete via context menu, file-tree reveal — need Radix context-menu driving (flaky, no clean IPC). Binary previews are no longer on this list: image preview is covered by files.e2e.ts, PDF preview by editor.e2e.ts (which builds a tiny valid PDF inline rather than committing a fixture).
  • Prompts management, keybindings editor — config-file editing, low value.

Environment-limited (not robustly testable here)

These are intentionally NOT covered by written specs — asserting them would be flaky or impossible in the occluded-window / embedded-WebDriver setup. Left as manual checks.

  • OS desktop notification delivery + completion sound on agent done — no in-webview signal to assert; the store-side attention/unread IS covered (agent.e2e.ts). Worth knowing WHY, since it looks reachable: Tauri routes every command through window.__TAURI_INTERNALS__.invoke, which a page can wrap, so counting the notification plugin's own command looks like a way in. It reads 0 regardless, because ipc.notify bails at ensureNotifyPermission() long before the plugin — an e2e binary asks the OS for nothing. The wrapper is left in the GH #276 cases (reported, never asserted) so a run with permission granted gets the count for free; the assertion is on the newsworthy unread edge, the last decision before notify().
  • Keyboard shortcuts into CodeMirror (e.g. its own ⌘F search panel) don't route reliably across window-focus states — manual check. Button-driven editor actions (Preview) ARE covered. This is specific to CodeMirror's keymap: the markdown preview's ⌘F is a plain window listener, so it dispatches fine as a synthetic keydown and IS covered (find-in-preview, editor.e2e.ts).
  • Real keystrokes into xterm / CodeMirror (contenteditable + WebGL canvas) — WebDriver key events don't route there reliably. Covered by proxy: PTY round-trips via ipc.ptyWrite (task-spawn, message-queue) and editor edits via the CodeMirror view API (editor-save).
  • Commit-and-push / setup script / resume-closed-tab — need mock-remote / .termic.yaml / multi-agent-tab infra with careful fixture cleanup; deferred, tracked above.
  • Cmd+click path/URL resolution in a terminal (GH #117, #240) — needs a coordinate-precise click on a specific CELL of the WebGL canvas, which means knowing which row the PTY text landed on (the xterm buffer is not exposed) over a .xterm-screen rect that is degenerate on an occluded window (clickContext bails on zero geometry by design). The read-only out-of-task tab it can open (GH #240) is reachable only through that click, so it inherits the same gap; its backing read is covered by the external_read_* Rust tests. The RESOLUTION logic is pure and unit-tested instead: src/lib/pathMatch.test.ts (relative suffix matching, absolute → task-relative mapping, the out-of-task verdict) and src/lib/termLinkOpener.test.ts (tokenising, URL/scp exclusion, :line:col).
  • The page a PDF is scrolled to — it lives in WKWebView's native PDF view, which exposes nothing to the DOM. The spec asserts the two mechanisms that keep that view (and its page) alive; the page itself is a manual check.
  • The two-bundle single-instance paths (the termic-beta -> termic command migration, and the macOS deep-link handoff that forwards a launch URL to whichever release app owns the socket). Both are RELEASE-only by construction: another_instance_running and reconcile_link return early under debug_assertions, deliberately, so a dev or e2e run can never mutate the user's real install or fight the shipped app for the data dir. Reproducing them needs two signed bundles in /Applications and a LaunchServices scheme claim, none of which the harness has. The decisions are unit-tested in Rust instead (reconcile_target, symlink_atomic, replaceable in cli_server.rs); the wiring is a manual check with make beta installed. See docs/ipc.md, "The termic command".

Known harness gotchas (read before writing a spec)

  • Terminal content is not in the DOM (WebGL canvas) — assert lastOutputAt/liveTitle/store, never innerText, for PTY output.
  • workState === "working" won't flip from a raw ipc.ptyWrite; termic gates it on a real submit through the input path.
  • Radix menus open on pointerdown — dispatch pointerdown/pointerup, not just .click() (see tabs-layout.e2e.ts).
  • Hover-gated controls (theme picker, History "Restore →") need a dispatched mouseover/mouseenter first, or drive the underlying store/IPC.
  • rAF-deferred effects are frozen when the window is occluded (e.g. the command palette's act()requestAnimationFrame). Assert the synchronous part, or drive the underlying store, rather than the deferred side effect.
  • Run/Setup tab PTY spawn is rAF-gated in TerminalPane, so a newly-added run tab's PTY lags on an occluded/offscreen window (CI). Assert the tab is created (launch wiring); PTY spawn/execution is covered by task-spawn's agent PTY.
  • Don't use WebdriverIO's native visibility ($().waitForDisplayed()/isDisplayed()): offscreen, it triggers Tauri window-state calls that time out 5s each (a trivial check took 47s). Use waitVisible()/clickWhenVisible() — a FAST client-side visibility check via browser.execute. The config polls every 100ms so waits fire on-condition, not on a timeout.
  • No fixed sleeps, everwaitUntil/waitFor*/auto-retrying expect only.
  • Screenshots are for humans, never assertions (the xterm canvas even reads black in captures).
  • Scope dialog queries to the SPECIFIC dialog, never a bare [role="dialog"]. Dialogs stack, and on an occluded window a closing dialog's rAF-driven unmount lags so a stale node lingers in the DOM — a bare selector then grabs the wrong dialog (a test can pass solo but fail as the last spec). Find the dialog by its title/content: [...document.querySelectorAll('[role="dialog"]')].find(d => d.textContent.includes("Start an agent race")). See the RaceDialog cases in task.e2e.ts.
  • The fixture repo has an origin remote (a sibling bare repo, .e2e/fixture-repo-origin.git), so origin/main resolves like a real cloned checkout. The project default base is origin/main, and worktree spawns (New Task, every Agent Race racer) branch from it — a local-only fixture would die with git branch … origin/main → not a valid object name. If a spec repoints origin (e.g. git.e2e.ts commit-push), it MUST restore the seeded origin in teardown, or later specs lose their base. resolve_base_ref (lib.rs) makes a genuinely remote-less repo fall back to local main — covered by the no-remote race + New Task cases in task.e2e.ts.
  • A spec that reads detectedClis is asserting on the MACHINE, not the app. CI installs no agent CLI, so anything gated on a real claude / grok / agy / opencode renders nothing there while passing on the author's laptop. The Agent hooks block failed this way for days: text never appeared: Agent hooks, the same message every run but not every run, which reads as flake and is really an environment dependency. Seed the store instead (useApp.setState({ detectedClis: ... }) in a before, restored in an after), and REPLACE the map rather than merging into it, so the spec sees the same one agent on a bare runner and on a laptop with four installed. The fixture's fakeagent cannot stand in for a supported agent: it is deliberately not one of the four, and that is what several of these specs assert.
  • Isolation: each spec creates its own task via openTask() and archives it in after; never assume the app launched on a particular view (self-establish it).