1import * as NodeServices from "@effect/platform-node/NodeServices";
2import type { DesktopUpdateState } from "@t3tools/contracts";
3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import * as PlatformError from "effect/PlatformError";
6import * as Layer from "effect/Layer";
7import * as Option from "effect/Option";
8import { HttpClient, HttpClientResponse } from "effect/http";
9
10import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts";
11import * as DesktopConfig from "../app/DesktopConfig.ts";
12import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
13import * as ElectronApp from "../electron/ElectronApp.ts";
14import * as DesktopForkUpdates from "./DesktopForkUpdates.ts";
15import { ForkReleaseManifest } from "@t3tools/shared/forkUpdateDirectory";
16import * as ElectronUpdater from "../electron/ElectronUpdater.ts";
17import * as ElectronWindow from "../electron/ElectronWindow.ts";
18import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
19import * as DesktopState from "../app/DesktopState.ts";
20import * as DesktopUpdates from "./DesktopUpdates.ts";
21import * as DesktopLocalUi from "./DesktopLocalUi.ts";
22
23/** Shared DesktopUpdates test harness: a fully stubbed updater layer whose
24 electron-updater events are driven by hand via `emit`. Used by
25 DesktopUpdates.test.ts and DesktopRemoteUpdates.test.ts. */
26
27export const flushCallbacks = Effect.yieldNow;
28
29export interface UpdatesHarnessOptions {
30 readonly checkForUpdates?: Effect.Effect<
31 void,
32 ElectronUpdater.ElectronUpdaterCheckForUpdatesError
33 >;
34 readonly beforeSetUpdateChannel?: Effect.Effect<void>;
35 readonly setUpdateChannelError?: DesktopAppSettings.DesktopSettingsWriteError;
36 readonly setDisableDifferentialDownload?: Effect.Effect<void>;
37 readonly downloadUpdate?: Effect.Effect<void>;
38 readonly uiOnly?: boolean;
39 readonly uiVersion?: string;
40 readonly quitAndInstall?: Effect.Effect<void, ElectronUpdater.ElectronUpdaterQuitAndInstallError>;
41 readonly stopBackend?: Effect.Effect<void>;
42 readonly startBackend?: Effect.Effect<void>;
43 readonly env?: Record<string, string | undefined>;
44 readonly platform?: NodeJS.Platform;
45 /** Contents of the resources/package-type marker a Linux package ships. */
46 readonly packageType?: string | undefined;
47 /** Contents of the install record a previous run left behind. */
48 readonly installRecord?: string | undefined;
49 readonly appUpdateYml?: string;
50 readonly releaseManifest?: () => typeof ForkReleaseManifest.Type;
51 readonly forkKind?: "ui" | "code" | "shell";
52}
53
54export function makeHarness(options: UpdatesHarnessOptions = {}) {
55 let checkCount = 0;
56 let quitAndInstallCount = 0;
57 let downloadCount = 0;
58 let allowDowngrade = false;
59 let fullChangelog = false;
60 const feedUrls: ElectronUpdater.ElectronUpdaterFeedUrl[] = [];
61 const channels: string[] = [];
62 const listeners = new Map<string, Set<(...args: readonly unknown[]) => void>>();
63 const sentStates: DesktopUpdateState[] = [];
64 const installSteps: string[] = [];
65
66 const addListener = (eventName: string, listener: (...args: readonly unknown[]) => void) => {
67 const eventListeners = listeners.get(eventName) ?? new Set();
68 eventListeners.add(listener);
69 listeners.set(eventName, eventListeners);
70 };
71
72 const removeListener = (eventName: string, listener: (...args: readonly unknown[]) => void) => {
73 const eventListeners = listeners.get(eventName);
74 if (!eventListeners) {
75 return;
76 }
77 eventListeners.delete(listener);
78 if (eventListeners.size === 0) {
79 listeners.delete(eventName);
80 }
81 };
82
83 const layerUpdater = Layer.succeed(ElectronUpdater.ElectronUpdater, {
84 setFeedURL: (options) =>
85 Effect.sync(() => {
86 feedUrls.push(options);
87 }),
88 setAutoDownload: () => Effect.void,
89 setAutoInstallOnAppQuit: () => Effect.void,
90 setChannel: (channel) =>
91 Effect.sync(() => {
92 channels.push(channel);
93 }),
94 setAllowPrerelease: () => Effect.void,
95 allowDowngrade: Effect.sync(() => allowDowngrade),
96 setAllowDowngrade: (value) =>
97 Effect.sync(() => {
98 allowDowngrade = value;
99 }),
100 setFullChangelog: (value) =>
101 Effect.sync(() => {
102 fullChangelog = value;
103 }),
104 setDisableDifferentialDownload: () => options.setDisableDifferentialDownload ?? Effect.void,
105 checkForUpdates: Effect.sync(() => {
106 checkCount += 1;
107 }).pipe(Effect.andThen(options.checkForUpdates ?? Effect.void)),
108 downloadUpdate: Effect.sync(() => {
109 downloadCount += 1;
110 }).pipe(
111 Effect.andThen(options.downloadUpdate ?? Effect.void),
112 Effect.as(["/download/update.zip"]),
113 ),
114 quitAndInstall: () =>
115 Effect.sync(() => {
116 quitAndInstallCount += 1;
117 installSteps.push("quitAndInstall");
118 }).pipe(Effect.andThen(options.quitAndInstall ?? Effect.void)),
119 on: (eventName, listener) =>
120 Effect.acquireRelease(
121 Effect.sync(() => {
122 addListener(eventName, listener as unknown as (...args: readonly unknown[]) => void);
123 }),
124 () =>
125 Effect.sync(() => {
126 removeListener(eventName, listener as unknown as (...args: readonly unknown[]) => void);
127 }),
128 ).pipe(Effect.asVoid),
129 } satisfies ElectronUpdater.ElectronUpdater["Service"]);
130
131 const layerWindow = Layer.succeed(ElectronWindow.ElectronWindow, {
132 create: () => Effect.die("unexpected BrowserWindow creation"),
133 main: Effect.succeedNone,
134 currentMainOrFirst: Effect.succeedNone,
135 focusedMainOrFirst: Effect.succeedNone,
136 setMain: () => Effect.void,
137 clearMain: () => Effect.void,
138 prepareReveal: () => Effect.succeed(false),
139 reveal: () => Effect.void,
140 sendAll: (_channel, state) =>
141 Effect.sync(() => {
142 sentStates.push(state as DesktopUpdateState);
143 }),
144 destroyAll: Effect.sync(() => {
145 installSteps.push("destroyAll");
146 }),
147 syncAllAppearance: () => Effect.void,
148 } satisfies ElectronWindow.ElectronWindow["Service"]);
149
150 const stubBackendInstance: DesktopBackendPool.DesktopBackendInstance = {
151 id: DesktopBackendPool.PRIMARY_INSTANCE_ID,
152 label: Effect.succeed("Windows"),
153 start: Effect.sync(() => {
154 installSteps.push("startBackend");
155 }).pipe(Effect.andThen(options.startBackend ?? Effect.void)),
156 stop: () => options.stopBackend ?? Effect.void,
157 currentConfig: Effect.succeedNone,
158 snapshot: Effect.succeed({
159 desiredRunning: false,
160 ready: false,
161 activePid: Option.none(),
162 restartAttempt: 0,
163 restartScheduled: false,
164 }),
165 waitForReady: () => Effect.succeed(true),
166 };
167 const layerBackend = DesktopBackendPool.layerTest([stubBackendInstance]);
168
169 const layerEnvironment = DesktopEnvironment.layer({
170 dirname: "/repo/apps/desktop/src",
171 homeDirectory: `/tmp/t3-desktop-updates-home-${process.pid}`,
172 platform: options.platform ?? "darwin",
173 processArch: "x64",
174 appVersion: "1.2.3",
175 appPath: "/repo",
176 isPackaged: true,
177 resourcesPath: "/missing/resources",
178 runningUnderArm64Translation: false,
179 }).pipe(
180 Layer.provide(
181 Layer.mergeAll(
182 NodeServices.layer,
183 DesktopConfig.layerTest({
184 T3CODE_HOME: `/tmp/t3-desktop-updates-test-${process.pid}`,
185 T3CODE_DESKTOP_MOCK_UPDATES: "true",
186 T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT: "4141",
187 ...options.env,
188 }),
189 ),
190 ),
191 );
192
193 let testSettings: DesktopAppSettings.DesktopSettings = {
194 ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS,
195 };
196 const setUpdateChannelError = options.setUpdateChannelError;
197 const layerSettings =
198 setUpdateChannelError || options.beforeSetUpdateChannel
199 ? Layer.succeed(DesktopAppSettings.DesktopAppSettings, {
200 get: Effect.sync(() => testSettings),
201 load: Effect.sync(() => testSettings),
202 setMainWindowBounds: () => Effect.die("unexpected main window bounds update"),
203 setServerExposureMode: () => Effect.die("unexpected server exposure update"),
204 setTailscaleServe: () => Effect.die("unexpected Tailscale Serve update"),
205 setUpdateChannel: (channel) =>
206 setUpdateChannelError
207 ? Effect.fail(setUpdateChannelError)
208 : (options.beforeSetUpdateChannel ?? Effect.void).pipe(
209 Effect.andThen(
210 Effect.sync(() => {
211 const changed = testSettings.updateChannel !== channel;
212 testSettings = {
213 ...testSettings,
214 updateChannel: channel,
215 updateChannelConfiguredByUser: true,
216 };
217 return { settings: testSettings, changed };
218 }),
219 ),
220 ),
221 setWslBackendEnabled: () => Effect.die("unexpected WSL backend toggle"),
222 setWslDistro: () => Effect.die("unexpected WSL distro change"),
223 setLocalEnvironmentEnabled: () => Effect.die("unexpected local environment toggle"),
224 setWslOnly: () => Effect.die("unexpected WSL-only toggle"),
225 applyWslWindowsFallback: Effect.die("unexpected WSL Windows fallback"),
226 applyWslWindowsFallbackInMemory: Effect.die("unexpected WSL Windows fallback"),
227 } satisfies DesktopAppSettings.DesktopAppSettings["Service"])
228 : DesktopAppSettings.layer;
229
230 // Tracks the restart markers and install records installs leave, so
231 // installs stay free of real disk I/O that would outrun the tests' settle loops.
232 const updateRestartMarkers = new Set<string>();
233 // Contents of the install record, by path once an install writes one.
234 const installRecords = new Map<string, string>();
235 let previousInstallRecord = options.installRecord;
236 const isInstallRecord = (path: string) =>
237 path.endsWith(DesktopUpdates.DESKTOP_UPDATE_INSTALL_RECORD_FILE);
238 const layerFileSystem = FileSystem.layerNoop({
239 readFileString: (path) => {
240 const installRecord = isInstallRecord(path)
241 ? (installRecords.get(path) ?? previousInstallRecord)
242 : undefined;
243 if (installRecord !== undefined) return Effect.succeed(installRecord);
244 if (
245 path === "/missing/resources/app-update.yml" &&
246 (options.appUpdateYml !== undefined || options.uiOnly)
247 )
248 return Effect.succeed(
249 options.appUpdateYml ??
250 "provider: generic\nurl: https://updates.example.test/fork/?ls=\n",
251 );
252 return path === "/missing/resources/package-type" && options.packageType !== undefined
253 ? Effect.succeed(options.packageType)
254 : Effect.fail(
255 PlatformError.systemError({
256 module: "FileSystem",
257 method: "readFileString",
258 _tag: "NotFound",
259 pathOrDescriptor: path,
260 }),
261 );
262 },
263 makeDirectory: () => Effect.void,
264 writeFileString: (path, data) =>
265 Effect.sync(() => {
266 if (isInstallRecord(path)) installRecords.set(path, data);
267 else updateRestartMarkers.add(path);
268 }),
269 remove: (path) =>
270 Effect.sync(() => {
271 updateRestartMarkers.delete(path);
272 installRecords.delete(path);
273 if (isInstallRecord(path)) previousInstallRecord = undefined;
274 }),
275 });
276
277 const uiInstalls: string[] = [];
278 let uiVersion = options.uiVersion ?? "1.2.3";
279 const layer = DesktopUpdates.layer.pipe(
280 Layer.provide(
281 Layer.succeed(DesktopLocalUi.DesktopLocalUi, {
282 currentVersion: Effect.sync(() => uiVersion),
283 assetDirectories: Effect.succeed(["/client"] as const),
284 apply: () => Effect.void,
285 reset: Effect.void,
286 }),
287 ),
288 Layer.provide(
289 Layer.succeed(DesktopForkUpdates.DesktopForkUpdates, {
290 check: () => Effect.sync(() => options.releaseManifest?.() ?? forkReleaseFixture("1.2.4")),
291 kind: () => Effect.succeed(options.forkKind ?? (options.uiOnly ? "ui" : "code")),
292 prepare: () => Effect.succeed(options.forkKind ?? (options.uiOnly ? "ui" : "code")),
293 installUi: (version) =>
294 Effect.sync(() => {
295 uiInstalls.push(version);
296 uiVersion = version;
297 return true;
298 }),
299 activateCode: () =>
300 Effect.sync(() => {
301 installSteps.push("activateCode");
302 return true;
303 }),
304 }),
305 ),
306 Layer.provide(
307 Layer.succeed(ElectronApp.ElectronApp, {
308 ...ElectronApp.make,
309 relaunch: () =>
310 Effect.sync(() => {
311 installSteps.push("relaunch");
312 }),
313 quit: Effect.sync(() => {
314 installSteps.push("quit");
315 }),
316 }),
317 ),
318 Layer.provide(
319 Layer.succeed(
320 HttpClient.HttpClient,
321 HttpClient.make((request) =>
322 Effect.succeed(
323 HttpClientResponse.fromWeb(
324 request,
325 new Response(
326 JSON.stringify(options.releaseManifest?.() ?? forkReleaseFixture("1.2.4")),
327 ),
328 ),
329 ),
330 ),
331 ),
332 ),
333 Layer.provide(layerFileSystem),
334 Layer.provideMerge(layerUpdater),
335 Layer.provideMerge(layerWindow),
336 Layer.provideMerge(layerBackend),
337 Layer.provideMerge(DesktopState.layer),
338 Layer.provideMerge(layerSettings),
339 Layer.provideMerge(
340 DesktopConfig.layerTest({
341 T3CODE_HOME: `/tmp/t3-desktop-updates-test-${process.pid}`,
342 T3CODE_DESKTOP_MOCK_UPDATES: "true",
343 T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT: "4141",
344 ...options.env,
345 }),
346 ),
347 Layer.provideMerge(layerEnvironment),
348 Layer.provideMerge(NodeServices.layer),
349 );
350
351 return {
352 layer,
353 checkCount: () => checkCount,
354 quitAndInstalls: () => quitAndInstallCount,
355 installSteps,
356 uiInstalls,
357 updateRestartMarkers,
358 installRecords,
359 /** The record the test started with, until the app removes it. */
360 previousInstallRecord: () => previousInstallRecord,
361 downloadCount: () => downloadCount,
362 feedUrls: (): ElectronUpdater.ElectronUpdaterFeedUrl[] => feedUrls,
363 channels,
364 fullChangelog: () => fullChangelog,
365 listenerCount: () =>
366 Array.from(listeners.values()).reduce(
367 (total, eventListeners) => total + eventListeners.size,
368 0,
369 ),
370 sentStates,
371 emit: (eventName: string, payload?: unknown) => {
372 for (const listener of listeners.get(eventName) ?? []) {
373 listener(payload);
374 }
375 },
376 };
377}
378
379export function forkReleaseFixture(version: string): typeof ForkReleaseManifest.Type {
380 const artifact = { file: "fixture.tar.gz", size: 1, sha256: "a".repeat(64) };
381 return {
382 version,
383 ui: artifact,
384 assets: artifact,
385 server: { ...artifact, version, runtime: "b".repeat(64) },
386 desktop: {
387 "darwin-x64": {
388 version,
389 ui: artifact,
390 assets: artifact,
391 shell: "c".repeat(64),
392 runtime: "d".repeat(64),
393 code: artifact,
394 app: { ...artifact, version },
395 },
396 },
397 };
398}