CodeNomad/packages/electron-app/electron/main/client-state-lifecycle.test.ts
Pascal André 0bab9e3438
fix(restore): simplify persistence and harden cross-platform cleanup (#602)
## Summary

- Follow up #578 by consolidating desktop persistence, restore
reconciliation, lifecycle coordination, and regression coverage.
- Preserve active drafts and attachments, request-scoped workspace
ownership, deletion tombstones, renderer authority, and bounded shutdown
behavior.
- Fix the reported macOS cleanup failure with targeted BSD process
queries and random-token-guarded process-group cleanup, without an
unverified PID fallback.

## Platform hardening

- Ignore development renderer origins in packaged Electron builds.
- Preserve staged Tauri navigation authority and handle confirmed
Windows session-end shutdown on the UI thread.
- Bound workspace launch preflight, runtime startup, and health
readiness.
- Retain cleanup ownership after unexpected leaders exit and verify
portable POSIX descendants by immutable identity or inherited launch
token.
- Add real Darwin-only process-group integration tests for macOS CI.

## Scope

- 96 files changed.
- 6,295 additions and 12,167 deletions, a net reduction of 5,872 lines
from the merged implementation.
- Consolidated duplicated tests while retaining focused race,
durability, cleanup, and platform contracts.

## Validation

- pm run typecheck
- pm run typecheck --workspace @neuralnomads/codenomad
- Electron native suite: 60 passed
- Tauri suite: 49 passed
- Focused server lifecycle/identity suite: 31 passed, 2 Darwin-only
skipped on Windows
- Focused UI restore/codec/reconciliation suite: 36 passed
- Broader server suite: 59 passed, 3 platform skips
- Broader UI suite: 97 passed, 1 skip; 2 Node 25 solid-toast loader
failures reproduced on the merged baseline
- git diff --check
- Final limited gatekeeper: PASS for server/macOS, UI restore, and
Electron/Tauri
2026-07-17 22:17:15 +01:00

159 lines
7.9 KiB
TypeScript

import assert from "node:assert/strict"
import { setTimeout as delay } from "node:timers/promises"
import test from "node:test"
import type { App, BrowserWindow } from "electron"
import { ClientStateLifecycle } from "./client-state-lifecycle"
import type { ClientStateManager } from "./client-state"
import type { CliProcessManager } from "./process-manager"
import type { WindowStateTracker } from "./window-state"
const tick = () => new Promise((resolve) => setImmediate(resolve))
function harness(options: {
flush?: () => Promise<unknown>
stop?: () => Promise<void>
nativeFlush?: () => Promise<void>
otherWindow?: boolean
sessionEndCleanupTimeoutMs?: number
sessionEndReleaseTimeoutMs?: number
release?: () => Promise<void>
} = {}) {
const windows = new Map<string, (event?: { preventDefault(): void }) => void>()
const appEvents = new Map<string, (event?: { preventDefault(): void }) => void>()
const calls: string[] = []
let exits = 0
const window = {
on: (name: string, handler: (event?: { preventDefault(): void }) => void) => windows.set(name, handler),
isDestroyed: () => false,
close: () => { calls.push("close"); windows.get("close")?.({ preventDefault: () => assert.fail("approved close prevented") }) },
hide: () => { calls.push("hide") },
show: () => { calls.push("show") },
webContents: { isDestroyed: () => false, getURL: () => "http://127.0.0.1:43123/workspace", executeJavaScript: () => { calls.push("renderer"); return options.flush?.() ?? Promise.resolve() } },
} as unknown as BrowserWindow
const other = { isDestroyed: () => false, hide: () => { calls.push("hide-other") } } as unknown as BrowserWindow
const app = { on: (name: string, handler: never) => appEvents.set(name, handler), quit: () => calls.push("quit"), exit: () => { exits++ } } as unknown as App
const manager = { isPrimary: true, flush: async () => {}, drainAndReleasePrimary: async () => { calls.push("release"); await options.release?.() } } as ClientStateManager
const cli = { shutdown: async () => { calls.push("stop"); await options.stop?.() } } as unknown as CliProcessManager
const lifecycle = new ClientStateLifecycle({ app, clientStateManager: manager, cliManager: cli, getMainWindow: () => window, getAllWindows: () => options.otherWindow ? [window, other] : [window], getAllowedRendererOrigins: () => ["http://127.0.0.1:43123"], isTrustedRendererOrigin: () => true, isWindows: true, sessionEndCleanupTimeoutMs: options.sessionEndCleanupTimeoutMs, sessionEndReleaseTimeoutMs: options.sessionEndReleaseTimeoutMs })
lifecycle.attachMainWindow(window, { flush: async () => { calls.push("native"); await options.nativeFlush?.() } } as unknown as WindowStateTracker)
lifecycle.registerAppEvents()
const close = () => { let prevented = false; windows.get("close")?.({ preventDefault: () => { prevented = true } }); return prevented }
return { appEvents, calls, close, exits: () => exits, lifecycle, window, windows }
}
test("close flushes renderer/native once before approval, even when repeated or renderer fails", async (t) => {
await t.test("ordinary", async () => {
const h = harness({ otherWindow: true })
assert.equal(h.close(), true)
await tick()
assert.deepEqual(h.calls, ["renderer", "native", "close"])
})
await t.test("coalesced", async () => {
let release!: () => void
const h = harness({ otherWindow: true, flush: () => new Promise<void>((resolve) => { release = resolve }) })
assert.equal(h.close(), true); assert.equal(h.close(), true)
assert.deepEqual(h.calls, ["renderer"])
release(); await tick()
assert.deepEqual(h.calls, ["renderer", "native", "close"])
})
await t.test("renderer failure", async () => {
const h = harness({ otherWindow: true, flush: async () => { throw new Error("failed") } })
assert.equal(h.close(), true); await tick()
assert.deepEqual(h.calls, ["renderer", "native", "close"])
})
})
test("late old-window detach preserves replacement tracker during shutdown", async () => {
const h = harness()
const replacement = { on: () => {} } as unknown as BrowserWindow
h.lifecycle.attachMainWindow(replacement, { flush: async () => { h.calls.push("replacement-native") } } as unknown as WindowStateTracker)
h.lifecycle.detachMainWindow(h.window)
h.appEvents.get("before-quit")?.({ preventDefault: () => {} })
await (h.lifecycle as any).shutdown
assert.deepEqual(h.calls, ["hide", "renderer", "replacement-native", "stop", "release"])
})
test("Windows session end vetoes termination until cleanup exits explicitly", async () => {
const h = harness()
let prevented = false
h.windows.get("query-session-end")?.({ preventDefault: () => { prevented = true } })
h.windows.get("session-end")?.()
await (h.lifecycle as any).sessionEnd; await tick()
assert.equal(prevented, true)
assert.deepEqual(h.calls, ["renderer", "native", "stop", "release"])
assert.equal(h.exits(), 1)
})
test("session end force-exits after the bounded window when an ordinary shutdown is hung", async () => {
const h = harness({ flush: () => new Promise(() => {}), sessionEndCleanupTimeoutMs: 10 })
let prevented = false
h.appEvents.get("before-quit")?.({ preventDefault: () => {} })
h.windows.get("query-session-end")?.({ preventDefault: () => { prevented = true } })
await delay(25)
assert.equal(prevented, true)
assert.deepEqual(h.calls, ["hide", "renderer", "release"])
assert.equal(h.exits(), 1)
})
test("ordinary quit hides promptly and waits for CLI stop confirmation", async () => {
let confirmStop!: () => void
const h = harness({ stop: () => new Promise<void>((resolve) => { confirmStop = resolve }) })
h.appEvents.get("before-quit")?.({ preventDefault: () => {} })
await tick()
assert.deepEqual(h.calls, ["hide", "renderer", "native", "stop"])
assert.equal(h.exits(), 0)
confirmStop()
await (h.lifecycle as any).shutdown; await tick()
assert.deepEqual(h.calls, ["hide", "renderer", "native", "stop", "release"])
assert.equal(h.exits(), 1)
})
test("ordinary quit does not exit when CLI cleanup is unconfirmed", async () => {
const h = harness({ stop: async () => { throw new Error("unconfirmed") } })
h.appEvents.get("before-quit")?.({ preventDefault: () => {} })
await assert.rejects((h.lifecycle as any).shutdown, /unconfirmed/)
await tick()
assert.equal(h.exits(), 0)
assert.deepEqual(h.calls, ["hide", "renderer", "native", "stop", "show"])
})
test("Windows session-end rejection fails open at the bounded deadline", async () => {
const h = harness({ stop: async () => { throw new Error("unconfirmed") }, sessionEndCleanupTimeoutMs: 10 })
h.appEvents.get("before-quit")?.({ preventDefault: () => {} })
h.windows.get("query-session-end")?.({ preventDefault: () => {} })
await delay(25)
assert.equal(h.exits(), 1)
assert.deepEqual(h.calls, ["hide", "renderer", "native", "stop", "release"])
})
test("Windows fail-open bounds a hanging primary release before app.exit", async () => {
const h = harness({
flush: () => new Promise(() => {}),
release: () => new Promise(() => {}),
sessionEndCleanupTimeoutMs: 30,
sessionEndReleaseTimeoutMs: 10,
})
h.windows.get("query-session-end")?.({ preventDefault: () => {} })
await delay(25)
assert.deepEqual(h.calls, ["renderer", "release"])
assert.equal(h.exits(), 0)
await delay(15)
assert.equal(h.exits(), 1)
})
test("CLI termination waits for the native snapshot flush", async () => {
let release!: () => void
const h = harness({ nativeFlush: () => new Promise<void>((resolve) => { release = resolve }) })
h.appEvents.get("before-quit")?.({ preventDefault: () => {} })
await tick()
assert.deepEqual(h.calls, ["hide", "renderer", "native"])
assert.equal(h.exits(), 0)
release(); await (h.lifecycle as any).shutdown
assert.deepEqual(h.calls, ["hide", "renderer", "native", "stop", "release"])
})
test("closing the final window hides it before requesting quit", () => {
const h = harness()
assert.equal(h.close(), true)
assert.deepEqual(h.calls, ["hide", "quit"])
})