Skip to content
Source
Sign inSign up
Open History to see the last commit

DesktopLifecycle.test.ts

365 lines13.7 KB

1import { assert, describe, it } from "@effect/vitest";
2import * as Deferred from "effect/Deferred";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Option from "effect/Option";
6import * as Ref from "effect/Ref";
7import * as TestClock from "effect/testing/TestClock";
8​
9import * as HostProcess from "@t3tools/shared/HostProcess";
10​
11import type * as Electron from "electron";
12​
13import * as ElectronApp from "../electron/ElectronApp.ts";
14import * as ElectronTheme from "../electron/ElectronTheme.ts";
15import * as ElectronWindow from "../electron/ElectronWindow.ts";
16import * as DesktopEnvironment from "./DesktopEnvironment.ts";
17import * as DesktopLifecycle from "./DesktopLifecycle.ts";
18import * as DesktopShutdown from "./DesktopShutdown.ts";
19import * as DesktopState from "./DesktopState.ts";
20import * as DesktopWindow from "../window/DesktopWindow.ts";
21​
22function layerElectronApp(
23 appListeners: Map<string, (...args: readonly unknown[]) => void>,
24 quit: Effect.Effect<void> = Effect.void,
25 exit: (code: number) => Effect.Effect<void> = () => Effect.void,
26 relaunch: (options: Electron.RelaunchOptions) => Effect.Effect<void> = () => Effect.void,
27) {
28 const registerListener = (eventName: string, listener: (...args: readonly unknown[]) => void) =>
29 Effect.acquireRelease(
30 Effect.sync(() => {
31 appListeners.set(eventName, listener);
32 }),
33 () =>
34 Effect.sync(() => {
35 appListeners.delete(eventName);
36 }),
37 ).pipe(Effect.asVoid);
38​
39 return Layer.succeed(ElectronApp.ElectronApp, {
40 metadata: Effect.die("unexpected metadata read"),
41 name: Effect.succeed("T3 Code"),
42 systemLocale: Effect.succeed("en-US"),
43 preferredSystemLanguages: Effect.succeed(["en-US"]),
44 whenReady: Effect.void,
45 requestSingleInstanceLock: Effect.succeed(true),
46 quit,
47 exit,
48 relaunch,
49 setPath: () => Effect.void,
50 setName: () => Effect.void,
51 setAboutPanelOptions: () => Effect.void,
52 setAppUserModelId: () => Effect.void,
53 getAppMetrics: Effect.succeed([]),
54 setAsDefaultProtocolClient: () => Effect.succeed(true),
55 setDesktopName: () => Effect.void,
56 setDockIcon: () => Effect.void,
57 appendCommandLineSwitch: () => Effect.void,
58 removeCommandLineSwitch: () => Effect.void,
59 onBeforeQuitForUpdate: (listener) => registerListener("before-quit-for-update", listener),
60 on: (eventName, listener) =>
61 registerListener(eventName, listener as unknown as (...args: readonly unknown[]) => void),
62 } satisfies ElectronApp.ElectronApp["Service"]);
64​
65const layerElectronTheme = Layer.succeed(ElectronTheme.ElectronTheme, {
66 shouldUseDarkColors: Effect.succeed(false),
67 setSource: () => Effect.void,
68 onUpdated: () => Effect.void,
69});
70​
71function layerElectronWindow(destroyAll: Effect.Effect<void> = Effect.void) {
72 return Layer.succeed(ElectronWindow.ElectronWindow, {
73 create: () => Effect.die("unexpected window creation"),
74 main: Effect.die("unexpected main window read"),
75 currentMainOrFirst: Effect.die("unexpected current window read"),
76 focusedMainOrFirst: Effect.die("unexpected focused window read"),
77 setMain: () => Effect.void,
78 clearMain: () => Effect.void,
79 prepareReveal: () => Effect.succeed(false),
80 reveal: () => Effect.void,
81 sendAll: () => Effect.void,
82 destroyAll,
83 syncAllAppearance: () => Effect.void,
84 });
86​
87function layerDesktopWindow(
88 input: {
89 readonly activate?: Effect.Effect<void>;
90 readonly flushMainWindowBounds?: Effect.Effect<void>;
91 } = {},
92) {
93 return Layer.succeed(DesktopWindow.DesktopWindow, {
94 createMain: Effect.die("unexpected window creation"),
95 ensureMain: Effect.die("unexpected window creation"),
96 revealOrCreateMain: Effect.die("unexpected window creation"),
97 activate: input.activate ?? Effect.void,
98 createMainIfBackendReady: Effect.void,
99 showConnectingSplash: Effect.void,
100 handleBackendReady: () => Effect.void,
101 handleBackendNotReady: Effect.void,
102 flushMainWindowBounds: input.flushMainWindowBounds ?? Effect.void,
103 prepareCaptureReveal: Effect.void,
104 dispatchMenuAction: () => Effect.void,
105 dispatchSnapShotEvent: () => Effect.void,
106 zoomMain: () => Effect.void,
107 runMainContentsCommand: () => Effect.void,
108 syncAppearance: Effect.void,
109 });
111​
112describe("DesktopLifecycle", () => {
113 it.effect.each(["darwin", "win32", "linux"] satisfies ReadonlyArray<NodeJS.Platform>)(
114 "lets the updater's quit event proceed on %s",
115 (platform) => {
116 const appListeners = new Map<string, (...args: readonly unknown[]) => void>();
117 let windowsDestroyed = false;
118 const layerEnvironment = Layer.succeed(DesktopEnvironment.DesktopEnvironment, {
119 platform,
120 isDevelopment: false,
121 appImagePath: Option.none(),
122 } as DesktopEnvironment.DesktopEnvironment["Service"]);
123​
124 const layer = DesktopLifecycle.layer.pipe(
125 Layer.provideMerge(layerElectronApp(appListeners)),
126 Layer.provideMerge(layerElectronTheme),
127 Layer.provideMerge(
128 layerElectronWindow(
129 Effect.sync(() => {
130 windowsDestroyed = true;
131 }),
132 ),
133 ),
134 Layer.provideMerge(layerDesktopWindow()),
135 Layer.provideMerge(layerEnvironment),
136 Layer.provideMerge(DesktopShutdown.layer),
137 Layer.provideMerge(DesktopState.layer),
138 );
139​
140 return Effect.scoped(
141 Effect.gen(function* () {
142 const lifecycle = yield* DesktopLifecycle.DesktopLifecycle;
143 yield* lifecycle.register;
144​
145 appListeners.get("before-quit-for-update")?.();
146 yield* Effect.yieldNow;
147​
148 let prevented = false;
149 const event = {
150 preventDefault: () => {
151 prevented = true;
152 },
153 } as Electron.Event;
154 appListeners.get("before-quit")?.(event);
155​
156 assert.isFalse(
157 prevented,
158 "cancelling this event prevents the updater from completing its relaunch",
159 );
160 assert.isTrue(windowsDestroyed);
161​
162 const state = yield* DesktopState.DesktopState;
163 assert.isTrue(yield* Ref.get(state.quitting));
164 }),
165 ).pipe(Effect.provide(layer));
166 },
167 );
168​
169 it.effect("destroys windows before waiting for backend shutdown", () =>
170 Effect.gen(function* () {
171 const appListeners = new Map<string, (...args: readonly unknown[]) => void>();
172 const shutdownRequested = yield* Deferred.make<void>();
173 const allowShutdown = yield* Deferred.make<void>();
174 const quitRequested = yield* Deferred.make<void>();
175 const events: string[] = [];
176​
177 const quit = Effect.sync(() => {
178 events.push("quit");
179 }).pipe(Effect.andThen(Deferred.succeed(quitRequested, undefined)), Effect.asVoid);
180 const destroyAll = Effect.sync(() => {
181 events.push("destroy");
182 });
183 const flushMainWindowBounds = Effect.sync(() => {
184 events.push("flush");
185 });
186​
187 const layerDesktopShutdown = Layer.succeed(DesktopShutdown.DesktopShutdown, {
188 request: Effect.sync(() => {
189 events.push("request");
190 }).pipe(Effect.andThen(Deferred.succeed(shutdownRequested, undefined)), Effect.asVoid),
191 awaitRequest: Deferred.await(shutdownRequested),
192 markComplete: Deferred.succeed(allowShutdown, undefined).pipe(Effect.asVoid),
193 awaitComplete: Deferred.await(allowShutdown),
194 isComplete: Deferred.isDone(allowShutdown),
195 });
196​
197 const layerEnvironment = Layer.succeed(DesktopEnvironment.DesktopEnvironment, {
198 platform: "darwin",
199 isDevelopment: false,
200 } as DesktopEnvironment.DesktopEnvironment["Service"]);
201​
202 const layer = DesktopLifecycle.layer.pipe(
203 Layer.provideMerge(layerElectronApp(appListeners, quit)),
204 Layer.provideMerge(layerElectronTheme),
205 Layer.provideMerge(layerElectronWindow(destroyAll)),
206 Layer.provideMerge(layerDesktopWindow({ flushMainWindowBounds })),
207 Layer.provideMerge(layerEnvironment),
208 Layer.provideMerge(layerDesktopShutdown),
209 Layer.provideMerge(DesktopState.layer),
210 );
211​
212 yield* Effect.scoped(
213 Effect.gen(function* () {
214 const lifecycle = yield* DesktopLifecycle.DesktopLifecycle;
215 yield* lifecycle.register;
216​
217 const event = { preventDefault: () => undefined } as Electron.Event;
218 appListeners.get("before-quit")?.(event);
219​
220 yield* Deferred.await(shutdownRequested);
221 const eventsBeforeCleanup = [...events];
222 yield* Deferred.succeed(allowShutdown, undefined);
223 yield* Deferred.await(quitRequested);
224​
225 assert.deepEqual(eventsBeforeCleanup, ["flush", "destroy", "request"]);
226 assert.deepEqual(events, ["flush", "destroy", "request", "quit"]);
227 }),
228 ).pipe(Effect.provide(layer));
229 }),
230 );
231​
232 it.effect("relaunches without the app link that launched it", () =>
233 Effect.gen(function* () {
234 const relaunched = yield* Deferred.make<Electron.RelaunchOptions>();
235 const layerEnvironment = Layer.succeed(DesktopEnvironment.DesktopEnvironment, {
236 platform: "linux",
237 isDevelopment: false,
238 } as DesktopEnvironment.DesktopEnvironment["Service"]);
239 const layer = DesktopLifecycle.layer.pipe(
240 Layer.provideMerge(
241 layerElectronApp(
242 new Map(),
243 Effect.void,
244 () => Effect.void,
245 (options) => Deferred.succeed(relaunched, options).pipe(Effect.asVoid),
246 ),
247 ),
248 Layer.provideMerge(layerElectronTheme),
249 Layer.provideMerge(layerElectronWindow()),
250 Layer.provideMerge(layerDesktopWindow()),
251 Layer.provideMerge(layerEnvironment),
252 Layer.provideMerge(DesktopShutdown.layer),
253 Layer.provideMerge(DesktopState.layer),
254 );
255​
256 const options = yield* Effect.gen(function* () {
257 const lifecycle = yield* DesktopLifecycle.DesktopLifecycle;
258 yield* (yield* DesktopShutdown.DesktopShutdown).markComplete;
259 yield* lifecycle.relaunch("network access changed");
260 return yield* Deferred.await(relaunched);
261 }).pipe(
262 Effect.provide(layer),
263 Effect.provideService(HostProcess.ExecutablePath, "/opt/T3 Code/t3code"),
264 Effect.provideService(HostProcess.Arguments, [
265 "/opt/T3 Code/t3code",
266 "--no-sandbox",
267 "T3CODE://pair?token=spent",
268 "t3code://new?prompt=hello",
269 ]),
270 );
271​
272 assert.deepEqual(options, { execPath: "/opt/T3 Code/t3code", args: ["--no-sandbox"] });
273 }),
274 );
275​
276 it.effect("exits on a repeated quit only once shutdown has stalled", () =>
277 Effect.gen(function* () {
278 const appListeners = new Map<string, (...args: readonly unknown[]) => void>();
279 const exitCodes: number[] = [];
280 const exit = (code: number) =>
281 Effect.sync(() => {
282 exitCodes.push(code);
283 });
284 const layerEnvironment = Layer.succeed(DesktopEnvironment.DesktopEnvironment, {
285 platform: "darwin",
286 isDevelopment: false,
287 } as DesktopEnvironment.DesktopEnvironment["Service"]);
288 const layer = DesktopLifecycle.layer.pipe(
289 Layer.provideMerge(layerElectronApp(appListeners, Effect.void, exit)),
290 Layer.provideMerge(layerElectronTheme),
291 Layer.provideMerge(layerElectronWindow()),
292 Layer.provideMerge(layerDesktopWindow()),
293 Layer.provideMerge(layerEnvironment),
294 // Nothing marks this shutdown complete, so it stays pending.
295 Layer.provideMerge(DesktopShutdown.layer),
296 Layer.provideMerge(DesktopState.layer),
297 );
298​
299 yield* Effect.scoped(
300 Effect.gen(function* () {
301 const lifecycle = yield* DesktopLifecycle.DesktopLifecycle;
302 yield* lifecycle.register;
303​
304 let prevented = 0;
305 const event = {
306 preventDefault: () => {
307 prevented += 1;
308 },
309 } as Electron.Event;
310 appListeners.get("before-quit")?.(event);
311 appListeners.get("before-quit")?.(event);
312 yield* TestClock.adjust("9999 millis");
313 appListeners.get("before-quit")?.(event);
314 assert.deepEqual(
315 exitCodes,
316 [],
317 "a repeat before the threshold must not cut shutdown short",
318 );
319​
320 yield* TestClock.adjust("1 millis");
321 appListeners.get("before-quit")?.(event);
322​
323 assert.deepEqual(exitCodes, [0]);
324 assert.equal(prevented, 4);
325 }),
326 ).pipe(Effect.provide(layer));
327 }),
328 );
329​
330 it.effect("ignores app activation while quitting", () =>
331 Effect.gen(function* () {
332 const appListeners = new Map<string, (...args: readonly unknown[]) => void>();
333 let activationCount = 0;
334 const activate = Effect.sync(() => {
335 activationCount += 1;
336 });
337 const layerEnvironment = Layer.succeed(DesktopEnvironment.DesktopEnvironment, {
338 platform: "darwin",
339 isDevelopment: false,
340 } as DesktopEnvironment.DesktopEnvironment["Service"]);
341 const layer = DesktopLifecycle.layer.pipe(
342 Layer.provideMerge(layerElectronApp(appListeners)),
343 Layer.provideMerge(layerElectronTheme),
344 Layer.provideMerge(layerElectronWindow()),
345 Layer.provideMerge(layerDesktopWindow({ activate })),
346 Layer.provideMerge(layerEnvironment),
347 Layer.provideMerge(DesktopShutdown.layer),
348 Layer.provideMerge(DesktopState.layer),
349 );
350​
351 yield* Effect.scoped(
352 Effect.gen(function* () {
353 const lifecycle = yield* DesktopLifecycle.DesktopLifecycle;
354 const state = yield* DesktopState.DesktopState;
355 yield* lifecycle.register;
356 yield* Ref.set(state.quitting, true);
357​
358 appListeners.get("activate")?.();
359​
360 assert.equal(activationCount, 0);
361 }),
362 ).pipe(Effect.provide(layer));
363 }),
364 );
365});