Skip to content

v2.8.0: durable store, truthful stop, testable IPC layer, e2e smoke test#12

Merged
tcballard merged 11 commits into
mainfrom
v2.8.0
Jun 10, 2026
Merged

v2.8.0: durable store, truthful stop, testable IPC layer, e2e smoke test#12
tcballard merged 11 commits into
mainfrom
v2.8.0

Conversation

@tcballard

Copy link
Copy Markdown
Owner

Implements the quick wins plus Milestones 0–2 from the technical audit (see AUDIT.md on the claude/repo-technical-audit-8wi6lm branch). 11 commits, reviewable one at a time.

Critical fixes

  • Saved projects can no longer be silently wiped (audit C1). ProjectStore reads now fail closed with a typed STORE_CORRUPT error instead of treating a corrupt/unreadable projects.json as empty; writes are atomic (temp file + rename); every successful save mirrors to projects.json.bak. An unreadable store at launch prompts Restore Backup / Start Fresh / Quit — the unreadable file is preserved aside, never deleted.
  • Stop is truthful (audit C2). SIGTERM → 5s grace → SIGKILL escalation on POSIX; stopped is only reported when an exit was actually observed, otherwise the project shows Running, no response and Stop can be retried. Verified against a real SIGTERM-trapping process. Readiness polling is cancelled via AbortSignal at stop (previously probed for up to 30 more seconds), and per-start run ids stop a previous run's late output/exit from bleeding into a restart.

High-leverage improvements

  • IPC handlers extracted to lib/ipcHandlers.js (audit A1/T1): all ~28 privileged channels are now built by a dependency-injected factory and unit-tested at ~90% line coverage (mutation guard, doctor actions, preview gating, dialog/clipboard paths). main.js keeps only window/tray/preview mechanics (846 → 616 lines). The preload contract test pins the channel names.
  • Shared constants single-sourced in public/shared-constants.js (audit A3): active statuses, Doctor checks/action ids, and the auto-URL pattern are defined once and consumed by both the renderer (script tag) and lib (require) — the phantom running status this drift had already caused is gone.
  • End-to-end smoke test (npm run test:e2e, Playwright + Electron): first-run → sample project → start → Ready → stop → clean exit, with an isolated config dir. Passing locally under xvfb in ~13s; runs in CI as an informational job until proven flake-free.

Quick wins

  • App version single-sourced from the main process (was hardcoded in three places; note: sandboxed preloads can't require app files, hence the sync app:version channel).
  • CI test matrix now includes Windows and macOS — first time the taskkill/shell: true code paths run in CI rather than at release-tag time.
  • Shell-metacharacter blocklist extended with %, ^, and quotes (cmd.exe expansion/escaping on the Windows shell path).
  • Dead code removed: inert new-window handler (event removed in Electron 22) and the unused dir:current IPC channel.

Safety nets

Failure-mode tests were committed before each critical fix, pinning the old broken behavior, then flipped by the fix — the diffs show exactly which behavior changed and why.

Test status

  • Jest: 17 suites, 112 tests, all passing (was 16/70 before this branch)
  • Playwright smoke test: passing locally under xvfb
  • Lint + Prettier: clean
  • package.json stays at 2.7.0 — bumping is a one-line release decision now that the version is single-sourced

This PR's CI run will be the first execution of the Windows/macOS matrix legs and the e2e job — if the Windows leg surfaces a real platform issue, that's the matrix doing its job.

https://claude.ai/code/session_01C6ffm2y19xXgQ2uGyoeLZF


Generated by Claude Code

claude added 11 commits June 10, 2026 08:19
The preload script hardcoded its own copy of the version; it now asks the
main process via a sync 'app:version' channel (app.getVersion() reads
package.json), so releases bump the version in one place. A plain require
of package.json would pass in Jest but throw in the real app because the
preload is sandboxed.

Also removes the unused 'dir:current' channel / getCurrentDirectory API
and the inert 'new-window' handler (the event was removed in Electron 22;
setWindowOpenHandler already governs window opening).

https://claude.ai/code/session_01C6ffm2y19xXgQ2uGyoeLZF
The lifecycle never emits 'running' (only starting / ready /
running-unresponsive / stopping / stopped / failed), but the renderer's
ACTIVE_STATUSES, status labels, and CSS carried it, letting the two sides
drift. The renderer set now mirrors lib/projectLifecycle.js.

https://claude.ai/code/session_01C6ffm2y19xXgQ2uGyoeLZF
Commands run through a shell on Windows, where cmd.exe expands %VAR% and
treats ^ as its escape character; none of these were in the metacharacter
blocklist, contradicting the documented guarantee on exactly the platform
that uses a shell. Quotes are rejected too so no quoting tricks survive.

https://claude.ai/code/session_01C6ffm2y19xXgQ2uGyoeLZF
The win32-specific spawn (shell: true) and taskkill process-tree paths
previously first ran in CI at release-tag time. Lint, format, and audit
stay ubuntu-only since they are platform-independent.

https://claude.ai/code/session_01C6ffm2y19xXgQ2uGyoeLZF
Safety-net tests that document two known gaps before they are fixed, so
the fixes flip explicit assertions instead of changing behavior silently:

- ProjectStore treats a corrupt, malformed, or unreadable projects.json
  as empty, and the next save then silently discards every previously
  saved project (the DATA LOSS test demonstrates the full wipe).
- ProjectLifecycle.stop reports 'stopped' after the 5s grace period even
  when the process ignored SIGTERM and never exited, and the default
  killProcessTree sends a single SIGTERM with no SIGKILL escalation.

Each pinned assertion is marked KNOWN GAP with the fix that flips it.

https://claude.ai/code/session_01C6ffm2y19xXgQ2uGyoeLZF
A corrupt or unreadable projects.json could previously cascade into
silently wiping every saved project: reads swallowed all errors and
returned an empty list, and the next save overwrote the file with it.

- Reads now fail closed with a typed STORE_CORRUPT error, so mutations
  refuse to run against an unreadable store instead of destroying it.
- Writes are atomic (temp file + rename), so a crash mid-write leaves
  either the old file or the new one, never a truncated mix.
- Every successful save mirrors the file to projects.json.bak.
- On launch, an unreadable store prompts Restore Backup / Start Fresh /
  Quit; Start Fresh moves the unreadable file aside with a timestamped
  name rather than deleting it.
- Tray refresh is guarded so a mid-session read failure cannot crash
  the lifecycle event handler.

Flips the M0 safety-net assertions that pinned the old behavior and
adds durability coverage (atomicity, backup mirroring, restore,
start-fresh preservation).

https://claude.ai/code/session_01C6ffm2y19xXgQ2uGyoeLZF
Stop previously sent a single SIGTERM, waited 5s, and reported 'stopped'
unconditionally - a process that ignored the signal kept running and
holding its port while the UI said otherwise.

- killProcessTree now escalates SIGTERM -> grace -> SIGKILL on POSIX and
  resolves with whether an exit was actually observed (taskkill /F was
  already forceful on Windows; it now also waits for the exit).
- stop() only reports 'stopped' on an observed exit; a survivor is shown
  as running-unresponsive with a Doctor warning, stays active so edits
  remain locked, and Stop can be retried.
- Readiness polling is cancelled through the AbortSignal support that
  readiness.js already had, replacing the readinessToken flag - stopping
  no longer leaves the URL being probed for up to 30 more seconds.
- Each start gets a run id; output and exit events from a previous run
  are ignored after a restart instead of bleeding into the new run.

Flips the M0 safety-net assertions and verified end-to-end against a
real SIGTERM-trapping process (killed after the 5s grace, ~5.0s stop).

https://claude.ai/code/session_01C6ffm2y19xXgQ2uGyoeLZF
main.js registered all ~28 privileged IPC channels as inline closures
over module globals, leaving the entire security boundary untestable -
and untested.

lib/ipcHandlers.js now builds the channel maps via createIpcHandlers(),
with Electron-bound seams (app, shell, dialog, clipboard, window
access, the preview controller) injected and the Electron-free lib
functions used directly, matching the codebase's existing DI style.
Preview bounds clamping is a pure exported function. main.js keeps only
window/tray/preview mechanics, update checks, store recovery, and app
lifecycle, and registers the maps verbatim (846 -> 616 lines).

New suite covers the previously untested glue at ~90% lines: the
running-project mutation guard, doctor-action application, preview
readiness gating, delete/stop preview cleanup, clipboard copies, dialog
cancel paths, and bounds clamping. The preload contract test continues
to pin the channel names.

Also adds a LOCALWRAP_USER_DATA escape hatch so end-to-end tests can
isolate their config directory.

https://claude.ai/code/session_01C6ffm2y19xXgQ2uGyoeLZF
The renderer hand-mirrored lib definitions (active statuses, Doctor
check list, action ids, mutating-action set, the auto-URL regex), and
they had already drifted once (the phantom 'running' status removed
earlier). public/shared-constants.js is now the single source: the
renderer loads it as a plain script tag before app.js (it has no module
system and a script-src 'self' CSP), lib modules require the same file,
and app.js fails fast with a clear error if the script tag is missing.
Renderer doctor-action ids reference the shared DOCTOR_ACTIONS map
instead of string literals.

https://claude.ai/code/session_01C6ffm2y19xXgQ2uGyoeLZF
One end-to-end journey covering every layer that unit tests cannot:
launch the real app with an isolated LOCALWRAP_USER_DATA config dir,
create the bundled sample project from the first-run empty state, start
it, watch readiness reach Ready against the real sample server, stop
it, and exit cleanly. Verified locally under xvfb (~13s).

Playwright drives the locally installed Electron binary, so no browser
downloads are needed. Jest ignores e2e/; the new CI job runs it under
xvfb on Linux and is informational (continue-on-error) until it has
proven itself flake-free.

https://claude.ai/code/session_01C6ffm2y19xXgQ2uGyoeLZF
@tcballard tcballard merged commit 83a99b2 into main Jun 10, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants