Open History to see the last commit
1import type { DesktopUpdateState } from "@t3tools/contracts";
2import { describe, expect, it } from "@effect/vitest";
3import * as Deferred from "effect/Deferred";
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as Layer from "effect/Layer";
7import * as Option from "effect/Option";
8import * as Ref from "effect/Ref";
9import * as Stream from "effect/Stream";
10import * as SubscriptionRef from "effect/SubscriptionRef";
11import * as DesktopEnvironment from "./DesktopEnvironment.ts";
12import * as DesktopState from "./DesktopState.ts";
13import * as ElectronApp from "../electron/ElectronApp.ts";
14import * as ElectronShell from "../electron/ElectronShell.ts";
15import * as ElectronTheme from "../electron/ElectronTheme.ts";
16import * as ElectronWindow from "../electron/ElectronWindow.ts";
17import * as DesktopUpdates from "../updates/DesktopUpdates.ts";
18
19import { createInitialDesktopUpdateState } from "../updates/updateMachine.ts";
20import { listen, resolveStartupRecoveryOffer } from "./DesktopStartupRecovery.ts";
21
22const updateState = (patch: Partial<DesktopUpdateState>): DesktopUpdateState => ({
23 ...createInitialDesktopUpdateState(
24 "0.1.3",
25 { hostArch: "arm64", appArch: "arm64", runningUnderArm64Translation: false },
26 "latest",
27 ),
28 enabled: true,
29 ...patch,
30});
31
32describe("resolveStartupRecoveryOffer", () => {
33 it("offers a newer version before going back", () => {
34 expect(
35 resolveStartupRecoveryOffer(
36 updateState({ status: "available", availableVersion: "0.1.4" }),
37 "0.1.2",
38 ),
39 ).toEqual({ _tag: "Update", version: "0.1.4" });
40 });
41
42 it("offers a retained download after a later check fails", () => {
43 expect(
44 resolveStartupRecoveryOffer(
45 updateState({ status: "error", errorContext: "check", downloadedVersion: "0.1.4" }),
46 null,
47 ),
48 ).toEqual({ _tag: "Update", version: "0.1.4" });
49 });
50
51 it("waits for the update check before offering to go back", () => {
52 expect(resolveStartupRecoveryOffer(updateState({ status: "checking" }), "0.1.2")).toEqual({
53 _tag: "Checking",
54 });
55 });
56
57 it("offers the last version that started when no update exists", () => {
58 expect(resolveStartupRecoveryOffer(updateState({ status: "up-to-date" }), "0.1.2")).toEqual({
59 _tag: "Reinstall",
60 version: "0.1.2",
61 });
62 });
63
64 it("does not offer an update that already failed to apply here", () => {
65 expect(
66 resolveStartupRecoveryOffer(
67 updateState({
68 status: "downloaded",
69 downloadedVersion: "0.1.4",
70 notAppliedVersion: "0.1.4",
71 }),
72 "0.1.2",
73 ),
74 ).toEqual({ _tag: "Reinstall", version: "0.1.2" });
75 });
76
77 it("blames the managed policy unless a newer version can read it", () => {
78 expect(
79 resolveStartupRecoveryOffer(
80 updateState({ status: "available", availableVersion: "0.1.4" }),
81 "0.1.2",
82 true,
83 ),
84 ).toEqual({ _tag: "Update", version: "0.1.4" });
85 expect(
86 resolveStartupRecoveryOffer(updateState({ status: "up-to-date" }), "0.1.2", true),
87 ).toEqual({ _tag: "ManagedPolicy" });
88 });
89
90 it("falls back to the releases page when this version never started", () => {
91 expect(resolveStartupRecoveryOffer(updateState({ status: "disabled" }), "0.1.3")).toEqual({
92 _tag: "Releases",
93 });
94 expect(resolveStartupRecoveryOffer(updateState({ status: "up-to-date" }), null)).toEqual({
95 _tag: "Releases",
96 });
97 });
98});
99
100it.effect.each([
101 { visible: false, started: false, closeMain: false },
102 { visible: true, started: false, closeMain: false },
103 { visible: true, started: true, closeMain: false },
104 { visible: true, started: false, closeMain: true },
105])("closes recovery safely with closeMain=$closeMain", ({ visible, started, closeMain }) =>
106 Effect.scoped(
107 Effect.gen(function* () {
108 const checked = yield* Deferred.make<void>();
109 const remembered = yield* Deferred.make<void>();
110 const shouldRecover = !visible || !started;
111 const closed = Promise.withResolvers<void>();
112 const loaded: string[] = [];
113 const appListeners = new Map<string, (...args: readonly unknown[]) => void>();
114 const mainListeners = new Map<string, () => void>();
115 let closeCount = 0;
116 let mainVisible = visible;
117 const main = {
118 isDestroyed: () => false,
119 isVisible: () => mainVisible,
120 on: (event: string, listener: () => void) => mainListeners.set(event, listener),
121 once: (event: string, listener: () => void) => mainListeners.set(event, listener),
122 } as unknown as Electron.BrowserWindow;
123 const mainRef = yield* Ref.make(Option.some(main));
124 const recovery = {
125 isDestroyed: () => false,
126 close: () => {
127 closeCount++;
128 closed.resolve();
129 },
130 loadURL: (url: string) => {
131 loaded.push(url);
132 return Promise.resolve();
133 },
134 once: () => {},
135 webContents: { setWindowOpenHandler: () => {}, on: () => {} },
136 } as unknown as Electron.BrowserWindow;
137 const state = yield* DesktopState.DesktopState;
138 if (started) yield* SubscriptionRef.set(state.primaryBackendStartup, { _tag: "Ready" });
139 yield* listen.pipe(
140 Effect.provide(
141 Layer.mergeAll(
142 FileSystem.layerNoop({
143 readFileString: () => Effect.succeed("0.1.2"),
144 writeFileString: () => Deferred.succeed(remembered, undefined).pipe(Effect.asVoid),
145 }),
146 Layer.succeed(DesktopEnvironment.DesktopEnvironment, {
147 appVersion: "0.1.3",
148 stateDir: "/task/state",
149 logDir: "/task/logs",
150 path: { join: (...parts: string[]) => parts.join("/") },
151 } as unknown as DesktopEnvironment.DesktopEnvironment["Service"]),
152 Layer.succeed(ElectronApp.ElectronApp, {
153 on: (event: string, listener: (...args: readonly unknown[]) => void) =>
154 Effect.sync(() => {
155 appListeners.set(event, listener);
156 }),
157 } as unknown as ElectronApp.ElectronApp["Service"]),
158 Layer.succeed(
159 ElectronShell.ElectronShell,
160 {} as ElectronShell.ElectronShell["Service"],
161 ),
162 Layer.succeed(ElectronTheme.ElectronTheme, {
163 shouldUseDarkColors: Effect.succeed(false),
164 } as ElectronTheme.ElectronTheme["Service"]),
165 Layer.succeed(ElectronWindow.ElectronWindow, {
166 main: Ref.get(mainRef),
167 create: () => Effect.succeed(recovery),
168 } as unknown as ElectronWindow.ElectronWindow["Service"]),
169 Layer.succeed(DesktopUpdates.DesktopUpdates, {
170 subscribe: Effect.succeed({
171 latest: updateState({ status: "disabled" }),
172 changes: Stream.empty,
173 }),
174 check: () => Deferred.succeed(checked, undefined),
175 } as unknown as DesktopUpdates.DesktopUpdates["Service"]),
176 ),
177 ),
178 );
179 appListeners.get("browser-window-created")?.({}, main);
180 if (started) yield* Deferred.await(remembered);
181 yield* SubscriptionRef.set(state.primaryBackendStartup, {
182 _tag: "Failing",
183 reason: "Server failed before readiness",
184 managedPolicy: false,
185 output: "Failure details",
186 });
187 yield* Deferred.await(checked);
188 expect(loaded.length).toBe(shouldRecover ? 1 : 0);
189 if (shouldRecover) {
190 expect(decodeURIComponent(loaded[0]!)).toContain("Failure details");
191 if (closeMain) yield* Ref.set(mainRef, Option.none());
192 yield* SubscriptionRef.set(state.primaryBackendStartup, { _tag: "Ready" });
193 yield* Deferred.await(remembered);
194 if (closeMain || !visible) {
195 expect(closeCount).toBe(0);
196 if (closeMain) {
197 yield* Ref.set(mainRef, Option.some(main));
198 mainVisible = false;
199 appListeners.get("browser-window-created")?.({}, main);
200 }
201 mainListeners.get("ready-to-show")?.();
202 expect(closeCount).toBe(0);
203 mainVisible = true;
204 const show = mainListeners.get("show");
205 expect(show).toBeDefined();
206 show!();
207 }
208 yield* Effect.promise(() => closed.promise);
209 expect(closeCount).toBe(1);
210 }
211 }),
212 ).pipe(Effect.provide(DesktopState.layer)),
213);