返回 DeepSeek-Reasonix
lifecycle.test.ts
根目录 / desktop / electron / src / main / lifecycle.test.ts
1 import assert from "node:assert/strict";
2 import { test } from "node:test";
3 import { QuitSequencer } from "./lifecycle.js";
4
5 const silent = { info() {}, warn() {}, error() {} };
6 const tick = () => new Promise((resolve) => setImmediate(resolve));
7
8 test("recovery bounds a wedged draft save, confirms loss, and shuts down before relaunch", async () => {
9 const events: string[] = [];
10 let q!: QuitSequencer;
11 const { app, calls } = fakeApp(() => q);
12 q = new QuitSequencer({
13 app, log: silent, recoveryDraftTimeoutMs: 5,
14 service: { beforeClose: async () => false, shutdown: async () => { events.push("shutdown"); } },
15 flushRenderer: () => new Promise(() => {}),
16 confirmRecoveryDraftLoss: async () => { events.push("confirm"); return true; },
17 onCloseAllowed: () => { events.push("closed"); },
18 });
19 q.recoverRenderer(["--reasonix-graphics-recovery"]);
20 q.recoverRenderer(["duplicate"]);
21 await new Promise(resolve => setTimeout(resolve, 30));
22 assert.equal(q.currentPhase, "completed");
23 assert.deepEqual(events, ["confirm", "shutdown", "closed"]);
24 assert.equal(calls.filter(c => c.startsWith("relaunch:")).length, 1);
25 assert.ok(calls.includes("relaunch:--reasonix-graphics-recovery"));
26 });
27
28 test("cancelled recovery releases editing without leaving a stale relaunch armed", async () => {
29 let q!: QuitSequencer, fail = true, shutdowns = 0, resumed = 0;
30 const { app, calls } = fakeApp(() => q);
31 q = new QuitSequencer({
32 app, log: silent,
33 service: { beforeClose: async () => false, shutdown: async () => { shutdowns++; } },
34 flushRenderer: async () => { if (fail) throw new Error("renderer gone"); },
35 confirmRecoveryDraftLoss: async () => false,
36 resumeRenderer: async () => { resumed++; },
37 onPrepareFailed: () => { assert.fail("recovery cancellation must not open another failure prompt"); },
38 onCloseAllowed() {},
39 });
40 q.recoverRenderer(["--reasonix-graphics-recovery"]);
41 await tick(); await tick();
42 assert.equal(q.currentPhase, "idle"); assert.equal(shutdowns, 0);
43 assert.equal(resumed, 1);
44 fail = false; app.quit(); await tick(); await tick();
45 assert.equal(shutdowns, 1);
46 assert.equal(calls.some(c => c.startsWith("relaunch:")), false);
47 });
48
49 for (const lateFailure of [false, true]) {
50 test(`a cancelled recovery's late draft ${lateFailure ? "failure" : "success"} cannot complete a newer quit`, async t => {
51 t.mock.timers.enable({ apis: ["setTimeout"] });
52 const oldFlush = deferred(), nextFlush = deferred();
53 let q!: QuitSequencer, saves = 0, shutdowns = 0;
54 const { app, calls } = fakeApp(() => q);
55 q = new QuitSequencer({
56 app, log: silent, recoveryDraftTimeoutMs: 5,
57 service: { beforeClose: async () => false, shutdown: async () => { shutdowns++; } },
58 flushRenderer: () => ++saves === 1 ? oldFlush.promise : nextFlush.promise,
59 confirmRecoveryDraftLoss: async () => false,
60 resumeRenderer: async () => {}, onCloseAllowed() {},
61 });
62 q.recoverRenderer(["--reasonix-graphics-recovery"]);
63 t.mock.timers.tick(5); await tick();
64 assert.equal(q.currentPhase, "idle");
65 app.quit();
66 if (lateFailure) oldFlush.reject(new Error("late draft failure")); else oldFlush.resolve();
67 await tick();
68 assert.equal(shutdowns, 0, "the new quit must wait for its own draft flush");
69 nextFlush.resolve(); await tick(); await tick();
70 assert.equal(shutdowns, 1);
71 assert.equal(calls.some(c => c.startsWith("relaunch:")), false);
72 });
73 }
74
75 function deferred<T = void>() {
76 let resolve!: (value: T | PromiseLike<T>) => void;
77 let reject!: (reason?: unknown) => void;
78 const promise = new Promise<T>((done, fail) => { resolve = done; reject = fail; });
79 return { promise, resolve, reject };
80 }
81
82 function fakeApp(sequencer: () => QuitSequencer) {
83 const calls: string[] = [];
84 // Mirrors Electron: app.quit() re-enters before-quit until the sequencer lets it pass.
85 const app = {
86 quit: () => {
87 calls.push("quit");
88 if (sequencer().onBeforeQuit()) calls.push("exit");
89 },
90 relaunch: (args: string[], execPath?: string) => calls.push(`relaunch:${args.join(",")}${execPath ? `@${execPath}` : ""}`),
91 };
92 return { app, calls };
93 }
94
95 function build(options: { prevent?: boolean; beforeCloseError?: Error; flushError?: Error; } = {},) {
96 const log: string[] = [];
97 let sequencer!: QuitSequencer;
98 const { app, calls } = fakeApp(() => sequencer);
99 const service = {
100 beforeClose: async (reason: string) => {
101 log.push(`beforeClose:${reason}`);
102 if (options.beforeCloseError) throw options.beforeCloseError;
103 return options.prevent ?? false;
104 },
105 shutdown: async () => {
106 log.push("shutdown");
107 },
108 };
109 sequencer = new QuitSequencer({
110 service,
111 app,
112 flushRenderer: async () => {
113 log.push("flush");
114 if (options.flushError) throw options.flushError;
115 },
116 resumeRenderer: async () => { log.push("resume"); },
117 onCloseAllowed: () => log.push("closeAllowed"),
118 log: silent,
119 });
120 return { sequencer, app, calls, log };
121 }
122
123 test("a plain quit asks Go, shuts the service down once, then exits", async () => {
124 const { sequencer, app, calls, log } = build();
125 app.quit();
126 assert.equal(sequencer.currentPhase, "preparing");
127 await tick();
128 await tick();
129 assert.deepEqual(log, ["flush", "beforeClose:quit", "shutdown", "closeAllowed"]);
130 assert.deepEqual(calls, ["quit", "quit", "quit", "exit"]);
131 assert.equal(sequencer.currentPhase, "completed");
132 });
133
134 test("Go can veto the quit; the shell stays running", async () => {
135 const { sequencer, app, calls, log } = build({ prevent: true });
136 app.quit();
137 await tick();
138 assert.deepEqual(log, ["flush", "beforeClose:quit", "resume"]);
139 assert.deepEqual(calls, ["quit"]);
140 assert.equal(sequencer.currentPhase, "idle");
141 app.quit();
142 await tick();
143 assert.deepEqual(log, ["flush", "beforeClose:quit", "resume", "flush", "beforeClose:quit", "resume"], "a later quit asks again");
144 });
145
146 test("re-entrant quits while asking do not ask twice", async () => {
147 const { app, calls, log } = build({ prevent: true });
148 app.quit();
149 app.quit();
150 await tick();
151 assert.deepEqual(log, ["flush", "beforeClose:quit", "resume"]);
152 assert.deepEqual(calls, ["quit", "quit"]);
153 });
154
155 test("repeated native window closes share one draft save and one shutdown", async () => {
156 const saved = deferred();
157 const events: string[] = [];
158 let q!: QuitSequencer;
159 const { app, calls } = fakeApp(() => q);
160 q = new QuitSequencer({
161 service: {
162 beforeClose: async () => { events.push("beforeClose"); return false; },
163 shutdown: async () => { events.push("shutdown"); },
164 },
165 app,
166 flushRenderer: async () => { events.push("flush"); await saved.promise; },
167 onCloseAllowed: () => events.push("closeAllowed"),
168 log: silent,
169 });
170
171 const first = q.requestWindowClose();
172 const second = q.requestWindowClose();
173 assert.equal(first, second);
174 assert.deepEqual(events, ["flush"]);
175 saved.resolve();
176 await Promise.all([first, second]);
177 await tick();
178 await tick();
179
180 assert.deepEqual(events, ["flush", "beforeClose", "shutdown", "closeAllowed"]);
181 assert.equal(events.filter(event => event === "shutdown").length, 1);
182 assert.equal(calls.at(-1), "exit");
183 });
184
185 test("an explicit quit upgrades an in-flight background window close", async () => {
186 const policy = deferred<boolean>();
187 const events: string[] = [];
188 const reasons: string[] = [];
189 let q!: QuitSequencer;
190 const { app } = fakeApp(() => q);
191 q = new QuitSequencer({
192 service: {
193 beforeClose: async () => { events.push("beforeClose"); return policy.promise; },
194 shutdown: async (reason) => { reasons.push(reason ?? ""); events.push("shutdown"); },
195 },
196 app,
197 flushRenderer: async () => { events.push("flush"); },
198 resumeRenderer: async () => { events.push("resume"); },
199 onWindowClosePrevented: () => events.push("hidden"),
200 onCloseAllowed: () => events.push("closeAllowed"),
201 log: silent,
202 });
203
204 const close = q.requestWindowClose();
205 await tick();
206 q.requestQuit("system_signal");
207 policy.resolve(true);
208 await close;
209 await tick();
210 await tick();
211
212 assert.deepEqual(reasons, ["system_signal"]);
213 assert.equal(events.filter(event => event === "flush").length, 1);
214 assert.equal(events.filter(event => event === "shutdown").length, 1);
215 assert.ok(!events.includes("hidden"));
216 assert.ok(!events.includes("resume"));
217 });
218
219 test("a background close restores draft editing, hides, and remains reusable", async () => {
220 const events: string[] = [];
221 let policyChecks = 0;
222 let q!: QuitSequencer;
223 const { app } = fakeApp(() => q);
224 q = new QuitSequencer({
225 service: {
226 beforeClose: async () => { policyChecks++; return true; },
227 shutdown: async () => { events.push("shutdown"); },
228 },
229 app,
230 flushRenderer: async () => { events.push("flush"); },
231 resumeRenderer: async () => { events.push("resume"); },
232 onWindowClosePrevented: () => events.push("hidden"),
233 onCloseAllowed: () => events.push("closeAllowed"),
234 log: silent,
235 });
236
237 await q.requestWindowClose();
238 assert.deepEqual(events, ["flush", "resume", "hidden"]);
239 assert.equal(q.currentPhase, "idle");
240 assert.equal(q.isQuitting, false);
241 await q.requestWindowClose();
242 assert.equal(policyChecks, 2, "a reopened window can close to background again");
243 assert.deepEqual(events, ["flush", "resume", "hidden", "flush", "resume", "hidden"]);
244 assert.ok(!events.includes("shutdown"));
245 });
246
247 for (const trigger of ["quit", "relaunch"] as const) {
248 test(`${trigger} during background resume upgrades only after the preparation releases ownership`, async () => {
249 const resumed = deferred();
250 const events: string[] = [];
251 let q!: QuitSequencer;
252 const { app, calls } = fakeApp(() => q);
253 q = new QuitSequencer({
254 service: {
255 beforeClose: async () => true,
256 shutdown: async reason => { events.push(`shutdown:${reason}`); },
257 }, app,
258 flushRenderer: async () => { events.push("flush"); },
259 resumeRenderer: async () => { events.push("resume"); await resumed.promise; },
260 onWindowClosePrevented: () => events.push("hidden"), onCloseAllowed() {}, log: silent,
261 });
262 const closing = q.requestWindowClose();
263 await tick();
264 assert.deepEqual(events, ["flush", "resume"]);
265 assert.equal(q.currentPhase, "preparing");
266 if (trigger === "quit") q.requestQuit("system_signal");
267 else q.relaunch(["--updated"]);
268 assert.equal(q.requestWindowClose(), closing);
269 resumed.resolve();
270 await closing;
271 await tick();
272 assert.deepEqual(events, ["flush", "resume", "flush", `shutdown:${trigger === "quit" ? "system_signal" : "update_restart"}`]);
273 assert.equal(q.currentPhase, "completed");
274 assert.equal(calls.at(-1), "exit");
275 });
276 }
277
278 for (const approved of [false, true]) {
279 test(`quit during draft failure prompt can retry (${approved ? "approved" : "window"} close)`, async () => {
280 const prompt = deferred();
281 let saves = 0, prompts = 0, shutdowns = 0;
282 let q!: QuitSequencer;
283 const { app } = fakeApp(() => q);
284 q = new QuitSequencer({
285 service: { beforeClose: async () => false, shutdown: async () => { shutdowns++; } }, app,
286 flushRenderer: async () => { if (++saves === 1) throw new Error("draft conflict"); },
287 onPrepareFailed: async () => { prompts++; await prompt.promise; },
288 onCloseAllowed() {}, log: silent,
289 });
290 if (approved) q.approve(); else void q.requestWindowClose();
291 await tick();
292 assert.equal(prompts, 1);
293 assert.equal(q.currentPhase, "preparing");
294 q.requestQuit(); q.requestQuit(); void q.requestWindowClose();
295 assert.equal(saves, 1);
296 prompt.resolve();
297 await tick();
298 assert.equal(saves, 2);
299 assert.equal(shutdowns, 1);
300 assert.equal(q.currentPhase, "completed");
301 });
302 }
303
304 test("a quit received while a veto resumes editing starts a new policy check", async () => {
305 const resumed = deferred();
306 let checks = 0, saves = 0, shutdowns = 0;
307 let q!: QuitSequencer;
308 const { app } = fakeApp(() => q);
309 q = new QuitSequencer({
310 service: { beforeClose: async () => ++checks === 1, shutdown: async () => { shutdowns++; } }, app,
311 flushRenderer: async () => { saves++; }, resumeRenderer: () => resumed.promise,
312 onCloseAllowed() {}, log: silent,
313 });
314 q.requestQuit(); await tick();
315 q.requestQuit(); resumed.resolve(); await tick();
316 assert.equal(checks, 2);
317 assert.equal(saves, 2);
318 assert.equal(shutdowns, 1);
319 assert.equal(q.currentPhase, "completed");
320 });
321
322 test("host/app.quit approval skips beforeClose and goes straight to shutdown", async () => {
323 const { sequencer, calls, log } = build({ prevent: true });
324 sequencer.approve();
325 await tick();
326 assert.deepEqual(log, ["flush", "shutdown", "closeAllowed"]);
327 assert.deepEqual(calls, ["quit", "quit", "exit"]);
328 });
329
330 test("a failed beforeClose does not trap the user in a shell that cannot quit", async () => {
331 const { app, calls, log } = build({ beforeCloseError: new Error("service dead"), });
332 app.quit();
333 await tick();
334 await tick();
335 assert.deepEqual(log, ["flush", "beforeClose:quit", "shutdown", "closeAllowed"]);
336 assert.equal(calls[calls.length - 1], "exit");
337 });
338
339 test("a failed renderer flush cancels quit before Go is asked", async () => {
340 const { sequencer, app, calls, log } = build({ flushError: new Error("draft conflict"), });
341 app.quit();
342 await tick();
343 assert.deepEqual(log, ["flush"]);
344 assert.deepEqual(calls, ["quit"]);
345 assert.equal(sequencer.currentPhase, "idle");
346 assert.equal(sequencer.isQuitting, false);
347 });
348
349 test("direct approval also withholds shutdown when renderer flush fails", async () => {
350 const { sequencer, calls, log } = build({ flushError: new Error("draft conflict"), });
351 sequencer.approve();
352 await tick();
353 assert.deepEqual(log, ["flush"]);
354 assert.deepEqual(calls, ["quit"]);
355 assert.equal(sequencer.currentPhase, "idle");
356 assert.equal(sequencer.isQuitting, false);
357 });
358
359 test("relaunch runs the shutdown and re-spawns with the requested args", async () => {
360 const { sequencer, calls, log } = build();
361 sequencer.relaunch(["--after-update"]);
362 await tick();
363 assert.deepEqual(log, ["flush", "shutdown", "closeAllowed"]);
364 assert.deepEqual(calls, ["quit", "relaunch:--after-update", "quit", "exit"]);
365 });
366
367 test("relaunch waits for shutdown then starts the committed stable launcher", async () => {
368 const { sequencer, calls, log } = build();
369 sequencer.relaunch(["--after-update"], "/opt/reasonix/reasonix-launcher");
370 assert.deepEqual(calls, ["quit"]);
371 await tick();
372 assert.deepEqual(log, ["flush", "shutdown", "closeAllowed"]);
373 assert.deepEqual(calls, ["quit", "relaunch:--after-update@/opt/reasonix/reasonix-launcher", "quit", "exit"]);
374 });
375
376 test("the first exit trigger keeps ownership of the shutdown reason", async () => {
377 const reasons: string[] = [];
378 let q!: QuitSequencer;
379 const { app } = fakeApp(() => q);
380 q = new QuitSequencer({
381 service: {
382 beforeClose: async () => false,
383 shutdown: async (reason) => { reasons.push(reason ?? ""); },
384 },
385 app,
386 flushRenderer: async () => {},
387 onCloseAllowed() {},
388 log: silent,
389 });
390
391 q.relaunch(["--after-update"]);
392 q.requestQuit("system_signal");
393 await tick();
394 await tick();
395
396 assert.deepEqual(reasons, ["update_restart"]);
397 });
398
399 test("cleanup failure cannot skip later cleanup or the final quit deadline", async () => {
400 const events: string[] = [];
401 let deadline: (() => void) | undefined;
402 let q!: QuitSequencer;
403 q = new QuitSequencer({
404 service: { beforeClose: async () => false, shutdown: async () => { events.push("stopped"); }, },
405 app: { quit: () => { if (q.onBeforeQuit()) events.push("quit"); }, exit: () => events.push("forced"), relaunch() {}, },
406 onCloseAllowed: () => { throw new Error("window cleanup"); },
407 cleanup: [{ name: "tray", run: () => { events.push("tray"); }, },],
408 schedule: (run, ms) => { assert.equal(ms, 5000); deadline = run; }, log: silent,
409 });
410 q.approve(); await tick();
411 assert.deepEqual(events, ["stopped", "tray", "quit"]);
412 deadline?.(); assert.equal(events.at(-1), "forced");
413 });
414
415 test("service termination failure withholds shell exit and permits an exit retry", async () => {
416 let attempts = 0;
417 let exits = 0;
418 let resumes = 0;
419 let q!: QuitSequencer;
420 q = new QuitSequencer({
421 service: { beforeClose: async () => false, shutdown: async () => { if (++attempts === 1) throw new Error("child alive"); }, },
422 app: { quit: () => { if (q.onBeforeQuit()) exits++; }, relaunch() {}, },
423 resumeRenderer: async () => { resumes++; },
424 onCloseAllowed() {}, log: silent,
425 });
426 q.approve(); await tick();
427 assert.equal(exits, 0); assert.equal(q.isQuitting, true); assert.equal(q.currentPhase, "failed");
428 assert.equal(resumes, 0);
429 q.approve();
430 await tick();
431 assert.equal(exits, 1);
432 });
433
434 test("shutdown failure prompt can retry without restoring renderer editing", async () => {
435 let attempts = 0;
436 let prompts = 0;
437 let exits = 0;
438 let
439 q!: QuitSequencer;
440 q = new QuitSequencer({
441 service: {
442 beforeClose: async () => false,
443 shutdown: async () => {
444 if (++attempts === 1) throw new Error("disk full");
445 },
446 },
447 app: {
448 quit: () => {
449 if (q.onBeforeQuit()) exits++;
450 },
451 relaunch() {},
452 },
453 onShutdownFailed: async (message) => {
454 prompts++;
455 assert.match(message, /disk full/);
456 return true;
457 },
458 onCloseAllowed() {},
459 log: silent,
460 });
461 q.approve(); await tick();
462 await tick();
463 await tick(); assert.equal(attempts, 2);
464 assert.equal(prompts, 1);
465 assert.equal(exits, 1);
466 assert.equal(q.currentPhase, "completed");
467 });
468
469 test("repeated close and quit requests share one pending shutdown failure prompt", async () => {
470 const prompt = deferred<boolean>();
471 let attempts = 0;
472 let prompts = 0;
473 let q!: QuitSequencer;
474 q = new QuitSequencer({
475 service: {
476 beforeClose: async () => false,
477 shutdown: async () => { attempts++; throw new Error("save failed"); },
478 },
479 app: { quit: () => { q.onBeforeQuit(); }, relaunch() {}, },
480 onShutdownFailed: async () => { prompts++; return prompt.promise; },
481 onCloseAllowed() {},
482 log: silent,
483 });
484
485 q.approve();
486 await tick();
487 assert.equal(prompts, 1);
488 q.requestWindowClose();
489 q.requestQuit();
490 assert.equal(attempts, 1);
491 assert.equal(prompts, 1);
492 prompt.resolve(false);
493 await tick();
494 assert.equal(q.currentPhase, "failed");
495 });
496
496 lines TYPESCRIPT