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