1import { assert, describe, it } from "@effect/vitest";
2import { DESKTOP_UPDATE_RESTART_MARKER_FILE } from "@t3tools/contracts";
3import * as Cause from "effect/Cause";
4import * as Deferred from "effect/Deferred";
5import * as Duration from "effect/Duration";
6import * as Effect from "effect/Effect";
7import * as Fiber from "effect/Fiber";
8import * as Layer from "effect/Layer";
9import * as Logger from "effect/Logger";
10import * as Option from "effect/Option";
11import * as References from "effect/References";
12import * as Ref from "effect/Ref";
13import * as Stream from "effect/Stream";
14import * as TestClock from "effect/testing/TestClock";
15
16import * as ElectronUpdater from "../electron/ElectronUpdater.ts";
17import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
18import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
19import * as DesktopState from "../app/DesktopState.ts";
20import * as DesktopUpdates from "./DesktopUpdates.ts";
21import { flushCallbacks, makeHarness, forkReleaseFixture } from "./updatesTestHarness.ts";
22
23describe("DesktopUpdates", () => {
24 it.effect(
25 "discovers new private releases and uses them for either stored channel preference",
26 () => {
27 let latest = "0.0.2000000000";
28 const harness = makeHarness({
29 env: { T3CODE_DESKTOP_MOCK_UPDATES: "false" },
30 appUpdateYml: "provider: generic\nurl: https://updates.example.test/fork/?ls=\n",
31 releaseManifest: () => forkReleaseFixture(latest),
32 });
33 return Effect.scoped(
34 Effect.gen(function* () {
35 const updates = yield* DesktopUpdates.DesktopUpdates;
36 yield* updates.configure;
37 yield* updates.check("test");
38 assert.equal(harness.checkCount(), 0);
39 assert.equal(harness.downloadCount(), 0);
40 assert.equal((yield* updates.getState).status, "downloaded");
41 latest = "0.0.2000000001";
42 yield* updates.setChannel("nightly");
43 yield* updates.check("test");
44 const state = yield* updates.getState;
45 assert.equal(state.channel, "nightly");
46 assert.equal(state.status, "downloaded");
47 assert.equal(state.availableVersion, latest);
48 }),
49 ).pipe(Effect.provide(harness.layer));
50 },
51 );
52
53 it.effect("applies a compatible downloaded UI without stopping a backend or quitting", () => {
54 const harness = makeHarness({ uiOnly: true });
55 return Effect.scoped(
56 Effect.gen(function* () {
57 const updates = yield* DesktopUpdates.DesktopUpdates;
58 yield* updates.configure;
59 yield* updates.check("test");
60 const downloaded = yield* updates.download;
61 assert.equal(downloaded.state.installKind, "ui");
62 assert.equal(downloaded.state.status, "downloaded");
63 const result = yield* updates.install;
64 assert.equal(result.completed, true);
65 assert.equal(result.state.currentVersion, "1.2.3");
66 assert.equal(result.state.uiVersion, "1.2.4");
67 assert.equal(result.state.status, "up-to-date");
68 assert.deepEqual(harness.uiInstalls, ["1.2.4"]);
69 assert.equal(harness.quitAndInstalls(), 0);
70 assert.isEmpty(harness.installSteps);
71 assert.isEmpty([...harness.updateRestartMarkers]);
72 harness.emit("update-available", { version: "1.2.4" });
73 yield* flushCallbacks;
74 assert.equal((yield* updates.getState).status, "up-to-date");
75 }),
76 ).pipe(Effect.provide(harness.layer));
77 });
78
79 it.effect("downloads code automatically but activates it only after a confirmed install", () => {
80 let stopped = 0;
81 const harness = makeHarness({
82 appUpdateYml: "provider: generic\nurl: https://updates.example.test/fork/?ls=\n",
83 releaseManifest: () => forkReleaseFixture("0.0.2000000000"),
84 stopBackend: Effect.sync(() => {
85 stopped += 1;
86 }),
87 });
88 return Effect.scoped(
89 Effect.gen(function* () {
90 const updates = yield* DesktopUpdates.DesktopUpdates;
91 yield* updates.configure;
92 const checked = yield* updates.check("test");
93 assert.equal(checked.state.status, "downloaded");
94 assert.equal(checked.state.installKind, "app");
95 assert.deepEqual(harness.installSteps, []);
96 assert.equal(harness.downloadCount(), 0);
97 const installed = yield* updates.install;
98 assert.equal(installed.accepted, true);
99 assert.deepEqual(harness.installSteps, ["activateCode", "relaunch", "quit"]);
100 assert.equal(stopped, 1);
101 assert.equal(harness.quitAndInstalls(), 0);
102 }),
103 ).pipe(Effect.provide(harness.layer));
104 });
105
106 it.effect("retains a confirmed full restart for incompatible downloaded releases", () => {
107 const harness = makeHarness({ uiOnly: false });
108 return Effect.scoped(
109 Effect.gen(function* () {
110 const updates = yield* DesktopUpdates.DesktopUpdates;
111 yield* updates.configure;
112 harness.emit("update-available", { version: "1.2.4" });
113 yield* flushCallbacks;
114 assert.equal((yield* updates.download).state.installKind, "app");
115 const result = yield* updates.install;
116 assert.equal(result.accepted, true);
117 assert.equal(harness.quitAndInstalls(), 1);
118 assert.isEmpty(harness.uiInstalls);
119 }),
120 ).pipe(Effect.provide(harness.layer));
121 });
122
123 it.effect("does not offer an already-applied UI release after relaunch", () => {
124 const harness = makeHarness({ uiVersion: "1.2.4" });
125 return Effect.scoped(
126 Effect.gen(function* () {
127 const updates = yield* DesktopUpdates.DesktopUpdates;
128 yield* updates.configure;
129 harness.emit("update-available", { version: "1.2.4" });
130 yield* flushCallbacks;
131 assert.equal((yield* updates.getState).status, "up-to-date");
132 assert.equal((yield* updates.getState).currentVersion, "1.2.3");
133 }),
134 ).pipe(Effect.provide(harness.layer));
135 });
136
137 it.effect("does not offer an older private release", () => {
138 const harness = makeHarness({
139 env: { T3CODE_DESKTOP_MOCK_UPDATES: "false" },
140 appUpdateYml: "provider: generic\nurl: https://updates.example.test/fork/?ls=\n",
141 releaseManifest: () => forkReleaseFixture("0.0.2000000000"),
142 uiVersion: "0.0.2000000001",
143 });
144 return Effect.scoped(
145 Effect.gen(function* () {
146 const updates = yield* DesktopUpdates.DesktopUpdates;
147 yield* updates.configure;
148 yield* updates.check("test");
149 assert.equal(harness.checkCount(), 0);
150 assert.equal((yield* updates.getState).status, "up-to-date");
151 }),
152 ).pipe(Effect.provide(harness.layer));
153 });
154
155 it.effect("keeps a platform up to date when only another platform has a newer release", () => {
156 const release = forkReleaseFixture("0.0.2000000001");
157 const target = release.desktop["darwin-x64"]!;
158 const harness = makeHarness({
159 env: { T3CODE_DESKTOP_MOCK_UPDATES: "false" },
160 appUpdateYml: "provider: generic\nurl: https://updates.example.test/fork/?ls=\n",
161 releaseManifest: () => ({
162 ...release,
163 desktop: { "darwin-x64": { ...target, version: "0.0.2000000000" } },
164 }),
165 uiVersion: "0.0.2000000000",
166 });
167 return Effect.scoped(
168 Effect.gen(function* () {
169 const updates = yield* DesktopUpdates.DesktopUpdates;
170 yield* updates.configure;
171 yield* updates.check("test");
172 const state = yield* updates.getState;
173 assert.equal(state.status, "up-to-date");
174 assert.equal(state.availableVersion, null);
175 assert.equal(harness.downloadCount(), 0);
176 }),
177 ).pipe(Effect.provide(harness.layer));
178 });
179
180 it("preserves complete causes for update poller and event failures", () => {
181 const cause = Cause.combine(
182 Cause.fail(new Error("updater failed")),
183 Cause.die(new Error("updater defect")),
184 );
185 const pollerError = new DesktopUpdates.DesktopUpdatePollerError({
186 poller: "startup",
187 cause,
188 });
189 const eventError = new DesktopUpdates.DesktopUpdateEventHandlingError({
190 event: "download-progress",
191 cause,
192 });
193 const reportedError = new DesktopUpdates.DesktopUpdaterReportedError({
194 operation: "download",
195 cause,
196 });
197 const unexpectedActionError = new DesktopUpdates.DesktopUpdateUnexpectedActionError({
198 action: "install",
199 cause,
200 });
201
202 assert.strictEqual(pollerError.cause, cause);
203 assert.equal(pollerError.poller, "startup");
204 assert.equal(pollerError.message, "Desktop update startup poller failed.");
205 assert.strictEqual(eventError.cause, cause);
206 assert.equal(eventError.event, "download-progress");
207 assert.equal(eventError.message, "Failed to handle desktop update download-progress event.");
208 assert.strictEqual(reportedError.cause, cause);
209 assert.equal(reportedError.operation, "download");
210 assert.equal(reportedError.message, "Desktop updater download operation reported an error.");
211 assert.strictEqual(unexpectedActionError.cause, cause);
212 assert.equal(unexpectedActionError.action, "install");
213 assert.equal(
214 unexpectedActionError.message,
215 "Desktop update install action failed unexpectedly.",
216 );
217 });
218
219 it.effect("configures the updater and runs startup checks on the test clock", () => {
220 const harness = makeHarness();
221
222 return Effect.gen(function* () {
223 yield* Effect.scoped(
224 Effect.gen(function* () {
225 const updates = yield* DesktopUpdates.DesktopUpdates;
226 yield* updates.configure;
227
228 const state = yield* updates.getState;
229 assert.equal(state.enabled, true);
230 assert.equal(state.status, "idle");
231 assert.deepEqual(harness.feedUrls(), [
232 { provider: "generic", url: "http://localhost:4141" },
233 ]);
234 assert.equal(harness.listenerCount(), 6);
235 assert.equal(harness.checkCount(), 0);
236
237 yield* TestClock.adjust(Duration.millis(15_000));
238 assert.equal(harness.checkCount(), 1);
239 }),
240 );
241
242 assert.equal(harness.listenerCount(), 0);
243 }).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
244 });
245
246 it.effect("updates Linux .deb installs and leaves other non-AppImage installs off", () =>
247 Effect.gen(function* () {
248 const linuxState = (packageType: string | undefined) =>
249 Effect.scoped(
250 Effect.gen(function* () {
251 const updates = yield* DesktopUpdates.DesktopUpdates;
252 yield* updates.configure;
253 return yield* updates.getState;
254 }),
255 ).pipe(Effect.provide(makeHarness({ platform: "linux", packageType }).layer));
256
257 const deb = yield* linuxState("deb\n");
258 assert.equal(deb.status, "idle");
259
260 const unmarked = yield* linuxState(undefined);
261 assert.equal(unmarked.status, "disabled");
262 }),
263 );
264
265 it.effect("subscribe delivers the latest state plus subsequent changes", () => {
266 const harness = makeHarness();
267
268 return Effect.scoped(
269 Effect.gen(function* () {
270 const updates = yield* DesktopUpdates.DesktopUpdates;
271 yield* updates.configure;
272
273 const { latest, changes } = yield* updates.subscribe;
274 assert.equal(latest.status, "idle");
275
276 const nextState = yield* Stream.runHead(changes).pipe(Effect.forkChild);
277 yield* flushCallbacks;
278 harness.emit("update-available", { version: "1.2.4" });
279 yield* flushCallbacks;
280
281 const observed = yield* Fiber.join(nextState);
282 assert.equal(Option.getOrThrow(observed).status, "available");
283 assert.equal(Option.getOrThrow(observed).availableVersion, "1.2.4");
284 }),
285 ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
286 });
287
288 it.effect("updates and broadcasts state from updater events", () => {
289 const harness = makeHarness();
290
291 return Effect.scoped(
292 Effect.gen(function* () {
293 const updates = yield* DesktopUpdates.DesktopUpdates;
294 yield* updates.configure;
295
296 harness.emit("update-available", { version: "1.2.4" });
297 yield* flushCallbacks;
298
299 const state = yield* updates.getState;
300 assert.equal(state.status, "available");
301 assert.equal(state.availableVersion, "1.2.4");
302 assert.isNotNull(state.checkedAt);
303 assert.equal(harness.sentStates.at(-1)?.status, "available");
304 }),
305 ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
306 });
307
308 it.effect("enables nightly full changelog release notes and broadcasts summaries", () => {
309 const harness = makeHarness();
310
311 return Effect.scoped(
312 Effect.gen(function* () {
313 const updates = yield* DesktopUpdates.DesktopUpdates;
314 yield* updates.configure;
315
316 yield* updates.setChannel("nightly");
317 assert.equal(harness.fullChangelog(), true);
318
319 harness.emit("update-available", {
320 version: "1.2.4-nightly.20260709.766",
321 releaseNotes: [
322 {
323 version: "1.2.4-nightly.20260709.766",
324 note: `<h2>What's Changed</h2><ul><li>feat(client): persist offline environment data by <a>@juliusmarminge</a> in <a>#3795</a></li></ul><h2>Full Changelog</h2>`,
325 },
326 {
327 version: "1.2.4-nightly.20260709.765",
328 note: "- [codex] Upgrade Clerk stack by @juliusmarminge in #3821",
329 },
330 { version: "1.2.4-nightly.20260709.764", note: "- Change 764" },
331 { version: "1.2.4-nightly.20260709.763", note: "- Change 763" },
332 { version: "1.2.4-nightly.20260709.762", note: "- Change 762" },
333 { version: "1.2.4-nightly.20260709.761", note: "- Change 761" },
334 { version: "1.2.4-nightly.20260709.760", note: "- Change 760" },
335 ],
336 });
337 yield* flushCallbacks;
338
339 const state = yield* updates.getState;
340 assert.equal(state.status, "available");
341 assert.deepEqual(state.releaseNotes, [
342 {
343 version: "1.2.4-nightly.20260709.766",
344 items: ["feat(client): persist offline environment data by @juliusmarminge in #3795"],
345 totalItems: 1,
346 },
347 {
348 version: "1.2.4-nightly.20260709.765",
349 items: ["[codex] Upgrade Clerk stack by @juliusmarminge in #3821"],
350 totalItems: 1,
351 },
352 { version: "1.2.4-nightly.20260709.764", items: ["Change 764"], totalItems: 1 },
353 { version: "1.2.4-nightly.20260709.763", items: ["Change 763"], totalItems: 1 },
354 { version: "1.2.4-nightly.20260709.762", items: ["Change 762"], totalItems: 1 },
355 { version: "1.2.4-nightly.20260709.761", items: ["Change 761"], totalItems: 1 },
356 ]);
357 assert.equal(state.omittedReleaseCount, 1);
358 assert.deepEqual(harness.sentStates.at(-1)?.releaseNotes, state.releaseNotes);
359 assert.equal(harness.sentStates.at(-1)?.omittedReleaseCount, 1);
360 }),
361 ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
362 });
363
364 it.effect("checks for newer releases after an update has been downloaded", () => {
365 const harness = makeHarness();
366
367 return Effect.scoped(
368 Effect.gen(function* () {
369 const updates = yield* DesktopUpdates.DesktopUpdates;
370 yield* updates.configure;
371
372 harness.emit("update-available", {
373 version: "1.2.4",
374 releaseNotes: "## What's changed\n- fix: queued update",
375 });
376 yield* flushCallbacks;
377 harness.emit("update-downloaded", { version: "1.2.4" });
378 yield* flushCallbacks;
379
380 const result = yield* updates.check("poll");
381 assert.isTrue(result.checked);
382
383 harness.emit("update-available", { version: "1.2.4" });
384 yield* flushCallbacks;
385
386 const unchangedState = yield* updates.getState;
387 assert.equal(unchangedState.status, "downloaded");
388 assert.equal(unchangedState.downloadedVersion, "1.2.4");
389 assert.deepEqual(unchangedState.releaseNotes, [
390 { version: "1.2.4", items: ["fix: queued update"], totalItems: 1 },
391 ]);
392 assert.equal(unchangedState.omittedReleaseCount, 0);
393
394 const nextResult = yield* updates.check("poll");
395 assert.isTrue(nextResult.checked);
396
397 harness.emit("update-available", { version: "1.2.5" });
398 yield* flushCallbacks;
399
400 const state = yield* updates.getState;
401 assert.equal(state.status, "available");
402 assert.equal(state.availableVersion, "1.2.5");
403 assert.isNull(state.downloadedVersion);
404 }),
405 ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
406 });
407
408 it.effect("preserves a queued installer when the feed has no update", () => {
409 const harness = makeHarness();
410
411 return Effect.scoped(
412 Effect.gen(function* () {
413 const updates = yield* DesktopUpdates.DesktopUpdates;
414 yield* updates.configure;
415
416 harness.emit("update-available", {
417 version: "1.2.4",
418 releaseNotes: "## What's changed\n- fix: queued update",
419 });
420 yield* flushCallbacks;
421 harness.emit("update-downloaded", { version: "1.2.4" });
422 yield* flushCallbacks;
423
424 yield* updates.check("poll");
425 harness.emit("update-not-available");
426 yield* flushCallbacks;
427
428 const state = yield* updates.getState;
429 assert.equal(state.status, "downloaded");
430 assert.equal(state.availableVersion, "1.2.4");
431 assert.equal(state.downloadedVersion, "1.2.4");
432 assert.deepEqual(state.releaseNotes, [
433 { version: "1.2.4", items: ["fix: queued update"], totalItems: 1 },
434 ]);
435 assert.equal(state.omittedReleaseCount, 0);
436 assert.equal(state.downloadPercent, 100);
437 }),
438 ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
439 });
440
441 it.effect("preserves a queued installer when the feed offers another channel", () => {
442 const harness = makeHarness();
443
444 return Effect.scoped(
445 Effect.gen(function* () {
446 const updates = yield* DesktopUpdates.DesktopUpdates;
447 yield* updates.configure;
448
449 harness.emit("update-available", {
450 version: "1.2.4",
451 releaseNotes: "## What's changed\n- fix: queued update",
452 });
453 yield* flushCallbacks;
454 harness.emit("update-downloaded", { version: "1.2.4" });
455 yield* flushCallbacks;
456
457 yield* updates.check("poll");
458 harness.emit("update-available", { version: "1.2.5-nightly.20260710.1" });
459 yield* flushCallbacks;
460
461 const state = yield* updates.getState;
462 assert.equal(state.status, "downloaded");
463 assert.equal(state.availableVersion, "1.2.4");
464 assert.equal(state.downloadedVersion, "1.2.4");
465 assert.deepEqual(state.releaseNotes, [
466 { version: "1.2.4", items: ["fix: queued update"], totalItems: 1 },
467 ]);
468 assert.equal(state.omittedReleaseCount, 0);
469 assert.equal(state.downloadPercent, 100);
470 }),
471 ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
472 });
473
474 it.effect(
475 "rejects install while a refresh check is in progress and releases the reservation",
476 () =>
477 Effect.gen(function* () {
478 const checkStarted = yield* Deferred.make<void>();
479 const releaseCheck = yield* Deferred.make<void>();
480 const harness = makeHarness({
481 checkForUpdates: Deferred.succeed(checkStarted, undefined).pipe(
482 Effect.andThen(Deferred.await(releaseCheck)),
483 ),
484 });
485
486 yield* Effect.scoped(
487 Effect.gen(function* () {
488 const updates = yield* DesktopUpdates.DesktopUpdates;
489 yield* updates.configure;
490 harness.emit("update-downloaded", { version: "1.2.4" });
491 yield* flushCallbacks;
492
493 const checkFiber = yield* updates.check("manual").pipe(Effect.forkScoped);
494 yield* Deferred.await(checkStarted);
495
496 const installResult = yield* updates.install;
497 assert.isFalse(installResult.accepted);
498
499 yield* Deferred.succeed(releaseCheck, undefined);
500 const checkResult = yield* Fiber.join(checkFiber);
501 assert.isTrue(checkResult.checked);
502
503 const followUpCheck = yield* updates.check("manual");
504 assert.isTrue(followUpCheck.checked);
505 assert.equal(harness.checkCount(), 2);
506 }),
507 ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
508 }),
509 );
510
511 it.effect("rejects refresh checks while install is in progress", () =>
512 Effect.gen(function* () {
513 const installStarted = yield* Deferred.make<void>();
514 const releaseInstall = yield* Deferred.make<void>();
515 const harness = makeHarness({
516 stopBackend: Deferred.succeed(installStarted, undefined).pipe(
517 Effect.andThen(Deferred.await(releaseInstall)),
518 ),
519 });
520
521 yield* Effect.scoped(
522 Effect.gen(function* () {
523 const updates = yield* DesktopUpdates.DesktopUpdates;
524 yield* updates.configure;
525 harness.emit("update-downloaded", { version: "1.2.4" });
526 yield* flushCallbacks;
527
528 const installFiber = yield* updates.install.pipe(Effect.forkScoped);
529 yield* Deferred.await(installStarted);
530
531 const checkResult = yield* updates.check("manual");
532 assert.isFalse(checkResult.checked);
533 assert.equal(harness.checkCount(), 0);
534
535 yield* Deferred.succeed(releaseInstall, undefined);
536 const installResult = yield* Fiber.join(installFiber);
537 assert.isTrue(installResult.accepted);
538 }),
539 ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
540 }),
541 );
542
543 it.effect("keeps raw updater event failures out of update state", () => {
544 const harness = makeHarness();
545 const cause = new Error(
546 "request failed for https://user:secret@example.com/update?token=secret",
547 );
548
549 return Effect.scoped(
550 Effect.gen(function* () {
551 const updates = yield* DesktopUpdates.DesktopUpdates;
552 yield* updates.configure;
553
554 harness.emit("error", cause);
555 yield* flushCallbacks;
556
557 const state = yield* updates.getState;
558 assert.equal(state.status, "error");
559 assert.equal(state.message, "Desktop updater background operation reported an error.");
560 assert.notInclude(state.message ?? "", "secret");
561 }),
562 ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
563 });
564
565 it.effect("preserves a queued installer after a background updater error", () => {
566 const harness = makeHarness();
567
568 return Effect.scoped(
569 Effect.gen(function* () {
570 const updates = yield* DesktopUpdates.DesktopUpdates;
571 yield* updates.configure;
572 harness.emit("update-downloaded", { version: "1.2.4" });
573 yield* flushCallbacks;
574
575 harness.emit("error", new Error("background updater failure"));
576 yield* flushCallbacks;
577
578 const state = yield* updates.getState;
579 assert.equal(state.status, "error");
580 assert.equal(state.downloadedVersion, "1.2.4");
581 assert.isNull(state.errorContext);
582
583 const result = yield* updates.install;
584 assert.isTrue(result.accepted);
585 }),
586 ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
587 });
588
589 it.effect("logs the redacted cause of a rejected update check", () => {
590 const cause = Object.assign(
591 new Error("request failed for https://user:secret@example.com/update?token=secret"),
592 { code: "ERR_UPDATER_LATEST_VERSION_NOT_FOUND" },
593 );
594 const updaterError = new ElectronUpdater.ElectronUpdaterCheckForUpdatesError({
595 channel: null,
596 cause,
597 });
598 const harness = makeHarness({ checkForUpdates: Effect.fail(updaterError) });
599 const loggedAnnotations: Array<Record<string, unknown>> = [];
600 const logger = Logger.make(({ fiber }) => {
601 const annotations = fiber.getRef(References.CurrentLogAnnotations);
602 if (annotations.errorTag === "ElectronUpdaterCheckForUpdatesError") {
603 loggedAnnotations.push(annotations);
604 }
605 });
606
607 return Effect.scoped(
608 Effect.gen(function* () {
609 const updates = yield* DesktopUpdates.DesktopUpdates;
610 yield* updates.configure;
611
612 yield* updates.check("manual");
613
614 const state = yield* updates.getState;
615 const loggedAnnotation = loggedAnnotations.at(-1);
616 assert.isDefined(loggedAnnotation);
617 assert.equal(loggedAnnotation.errorTag, "ElectronUpdaterCheckForUpdatesError");
618 assert.isNull(loggedAnnotation.channel);
619 assert.equal(
620 loggedAnnotation.cause,
621 "Error [ERR_UPDATER_LATEST_VERSION_NOT_FOUND]: request failed for [redacted URL]",
622 );
623 assert.notProperty(loggedAnnotation, "error");
624 assert.notInclude(Object.values(loggedAnnotation).map(String).join(" "), "secret");
625 assert.equal(
626 state.message,
627 "Electron updater failed to check for updates on channel default.",
628 );
629 assert.notInclude(state.message ?? "", "secret");
630 }),
631 ).pipe(
632 Effect.provide(
633 Layer.mergeAll(
634 TestClock.layer(),
635 harness.layer,
636 Logger.layer([logger], { mergeWithExisting: false }),
637 ),
638 ),
639 );
640 });
641
642 it.effect("logs the redacted cause of an updater error event", () => {
643 const harness = makeHarness();
644 const cause = Object.assign(new Error("404 Not Found\nHeaders: authorization=secret"), {
645 code: "ERR_UPDATER_CHANNEL_FILE_NOT_FOUND",
646 });
647 const loggedAnnotations: Array<Record<string, unknown>> = [];
648 const logger = Logger.make(({ fiber }) => {
649 const annotations = fiber.getRef(References.CurrentLogAnnotations);
650 if (annotations.errorTag === "DesktopUpdaterReportedError") {
651 loggedAnnotations.push(annotations);
652 }
653 });
654
655 return Effect.scoped(
656 Effect.gen(function* () {
657 const updates = yield* DesktopUpdates.DesktopUpdates;
658 yield* updates.configure;
659
660 harness.emit("error", cause);
661 yield* flushCallbacks;
662
663 const loggedAnnotation = loggedAnnotations.at(-1);
664 assert.isDefined(loggedAnnotation);
665 assert.equal(loggedAnnotation.operation, "background");
666 assert.equal(
667 loggedAnnotation.cause,
668 "Error [ERR_UPDATER_CHANNEL_FILE_NOT_FOUND]: 404 Not Found",
669 );
670 assert.notInclude(JSON.stringify(loggedAnnotations), "secret");
671 const state = yield* updates.getState;
672 assert.equal(state.message, "Desktop updater background operation reported an error.");
673 }),
674 ).pipe(
675 Effect.provide(
676 Layer.mergeAll(
677 TestClock.layer(),
678 harness.layer,
679 Logger.layer([logger], { mergeWithExisting: false }),
680 ),
681 ),
682 );
683 });
684
685 it.effect("recovers download state after an unexpected setup failure", () => {
686 let disableDifferentialCalls = 0;
687 const harness = makeHarness({
688 setDisableDifferentialDownload: Effect.suspend(() => {
689 disableDifferentialCalls += 1;
690 return disableDifferentialCalls === 1
691 ? Effect.void
692 : Effect.die(new Error("download setup failed"));
693 }),
694 });
695
696 return Effect.scoped(
697 Effect.gen(function* () {
698 const updates = yield* DesktopUpdates.DesktopUpdates;
699 yield* updates.configure;
700 harness.emit("update-available", { version: "1.2.4" });
701 yield* flushCallbacks;
702
703 const result = yield* updates.download;
704 assert.isTrue(result.accepted);
705 assert.isFalse(result.completed);
706
707 const failedState = yield* updates.getState;
708 assert.equal(failedState.status, "available");
709 assert.equal(failedState.errorContext, "download");
710 assert.equal(failedState.message, "Desktop update download action failed unexpectedly.");
711
712 const changedState = yield* updates.setChannel("nightly");
713 assert.equal(changedState.channel, "nightly");
714 }),
715 ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
716 });
717
718 it.effect("restores download state and permits retry after interruption", () =>
719 Effect.gen(function* () {
720 const actionStarted = yield* Deferred.make<void>();
721 let disableDifferentialCalls = 0;
722 const harness = makeHarness({
723 setDisableDifferentialDownload: Effect.suspend(() => {
724 disableDifferentialCalls += 1;
725 if (disableDifferentialCalls === 1) {
726 return Effect.void;
727 }
728 if (disableDifferentialCalls === 2) {
729 return Deferred.succeed(actionStarted, undefined).pipe(Effect.andThen(Effect.never));
730 }
731 return Effect.void;
732 }),
733 });
734
735 yield* Effect.scoped(
736 Effect.gen(function* () {
737 const updates = yield* DesktopUpdates.DesktopUpdates;
738 yield* updates.configure;
739 harness.emit("update-available", { version: "1.2.4" });
740 yield* flushCallbacks;
741
742 const downloadFiber = yield* updates.download.pipe(Effect.forkScoped);
743 yield* Deferred.await(actionStarted);
744 yield* Fiber.interrupt(downloadFiber);
745
746 const interruptedState = yield* updates.getState;
747 assert.equal(interruptedState.status, "available");
748 assert.isNull(interruptedState.message);
749
750 const retry = yield* updates.download;
751 assert.isTrue(retry.accepted);
752 assert.isTrue(retry.completed);
753 }),
754 ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
755 }),
756 );
757
758 it.effect("clears quitting state after an unexpected install setup failure", () => {
759 const harness = makeHarness({
760 stopBackend: Effect.die(new Error("backend stop failed")),
761 });
762
763 return Effect.scoped(
764 Effect.gen(function* () {
765 const desktopState = yield* DesktopState.DesktopState;
766 const updates = yield* DesktopUpdates.DesktopUpdates;
767 yield* updates.configure;
768 harness.emit("update-downloaded", { version: "1.2.4" });
769 yield* flushCallbacks;
770
771 const result = yield* updates.install;
772 assert.isTrue(result.accepted);
773 assert.isFalse(result.completed);
774 assert.isFalse(yield* Ref.get(desktopState.quitting));
775
776 const failedState = yield* updates.getState;
777 assert.equal(failedState.status, "downloaded");
778 assert.equal(failedState.errorContext, "install");
779 assert.equal(failedState.message, "Desktop update install action failed unexpectedly.");
780
781 const changedState = yield* updates.setChannel("nightly");
782 assert.equal(changedState.channel, "nightly");
783 }),
784 ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
785 });
786
787 it.effect("marks the backend stop for an install as an update restart", () => {
788 let markersAtStop: ReadonlyArray<string> = [];
789 const harness = makeHarness({
790 stopBackend: Effect.sync(() => {
791 markersAtStop = [...harness.updateRestartMarkers];
792 }),
793 });
794
795 return Effect.scoped(
796 Effect.gen(function* () {
797 const environment = yield* DesktopEnvironment.DesktopEnvironment;
798 const updates = yield* DesktopUpdates.DesktopUpdates;
799 yield* updates.configure;
800 harness.emit("update-downloaded", { version: "1.2.4" });
801 yield* flushCallbacks;
802
803 assert.isTrue((yield* updates.install).accepted);
804 assert.deepEqual(markersAtStop, [
805 environment.path.join(environment.baseDir, "runtime", DESKTOP_UPDATE_RESTART_MARKER_FILE),
806 ]);
807 }),
808 ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
809 });
810
811 it.effect("records the version an install hands to the installer", () => {
812 let recordsAtStop: ReadonlyArray<string> = [];
813 const harness = makeHarness({
814 stopBackend: Effect.sync(() => {
815 recordsAtStop = [...harness.installRecords.values()];
816 }),
817 });
818
819 return Effect.scoped(
820 Effect.gen(function* () {
821 const updates = yield* DesktopUpdates.DesktopUpdates;
822 yield* updates.configure;
823 harness.emit("update-downloaded", { version: "1.2.4" });
824 yield* flushCallbacks;
825
826 assert.isTrue((yield* updates.install).accepted);
827 assert.deepEqual(
828 recordsAtStop.map((record) => JSON.parse(record)),
829 [{ fromVersion: "1.2.3", targetVersion: "1.2.4" }],
830 );
831 }),
832 ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
833 });
834
835 it.effect("flags an install that did not apply and keeps the flag through checks", () => {
836 const harness = makeHarness({
837 installRecord: JSON.stringify({ fromVersion: "1.2.3", targetVersion: "1.2.4" }),
838 });
839
840 return Effect.scoped(
841 Effect.gen(function* () {
842 const updates = yield* DesktopUpdates.DesktopUpdates;
843 yield* updates.configure;
844 assert.equal((yield* updates.getState).notAppliedVersion, "1.2.4");
845 // Kept, so the next start on 1.2.3 still knows 1.2.4 did not apply.
846 assert.isDefined(harness.previousInstallRecord());
847
848 harness.emit("update-available", { version: "1.2.4" });
849 yield* flushCallbacks;
850 const state = yield* updates.getState;
851 assert.equal(state.status, "available");
852 assert.equal(state.notAppliedVersion, "1.2.4");
853 }),
854 ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
855 });
856
857 it.effect("does not flag an install that applied", () => {
858 const harness = makeHarness({
859 installRecord: JSON.stringify({ fromVersion: "1.2.2", targetVersion: "1.2.3" }),
860 });
861
862 return Effect.scoped(
863 Effect.gen(function* () {
864 const updates = yield* DesktopUpdates.DesktopUpdates;
865 yield* updates.configure;
866 assert.isUndefined((yield* updates.getState).notAppliedVersion);
867 assert.isUndefined(harness.previousInstallRecord());
868 }),
869 ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
870 });
871
872 it.effect("drops the update restart marker when an install is interrupted", () =>
873 Effect.gen(function* () {
874 const stopping = yield* Deferred.make<void>();
875 const harness = makeHarness({
876 stopBackend: Deferred.succeed(stopping, undefined).pipe(Effect.andThen(Effect.never)),
877 });
878
879 yield* Effect.scoped(
880 Effect.gen(function* () {
881 const updates = yield* DesktopUpdates.DesktopUpdates;
882 yield* updates.configure;
883 harness.emit("update-downloaded", { version: "1.2.4" });
884 yield* flushCallbacks;
885
886 const installFiber = yield* updates.install.pipe(Effect.forkScoped);
887 yield* Deferred.await(stopping);
888 assert.equal(harness.updateRestartMarkers.size, 1);
889
890 yield* Fiber.interrupt(installFiber);
891 assert.equal(harness.updateRestartMarkers.size, 0);
892 }),
893 ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
894 }),
895 );
896
897 it.effect("keeps windows and restarts backends when quitAndInstall fails", () => {
898 const harness = makeHarness({
899 quitAndInstall: Effect.fail(
900 new ElectronUpdater.ElectronUpdaterQuitAndInstallError({
901 channel: "latest",
902 isSilent: true,
903 isForceRunAfter: true,
904 cause: new Error("installer refused"),
905 }),
906 ),
907 });
908
909 return Effect.scoped(
910 Effect.gen(function* () {
911 const desktopState = yield* DesktopState.DesktopState;
912 const updates = yield* DesktopUpdates.DesktopUpdates;
913 yield* updates.configure;
914 harness.emit("update-downloaded", { version: "1.2.4" });
915 yield* flushCallbacks;
916
917 const result = yield* updates.install;
918 assert.isTrue(result.accepted);
919 assert.isFalse(yield* Ref.get(desktopState.quitting));
920 assert.deepEqual(harness.installSteps, ["quitAndInstall", "startBackend"]);
921 // The restarted old backend must release its tunnel on a later quit.
922 assert.equal(harness.updateRestartMarkers.size, 0);
923 // The app never quit, so the next start must not report an unapplied install.
924 assert.equal(harness.installRecords.size, 0);
925 }),
926 ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
927 });
928
929 it.effect("holds the install reservation until failed-install recovery finishes", () => {
930 const recoveryStarted = Deferred.makeUnsafe<void>();
931 const releaseRecovery = Deferred.makeUnsafe<void>();
932 const harness = makeHarness({
933 quitAndInstall: Effect.fail(
934 new ElectronUpdater.ElectronUpdaterQuitAndInstallError({
935 channel: "latest",
936 isSilent: true,
937 isForceRunAfter: true,
938 cause: new Error("installer refused"),
939 }),
940 ),
941 startBackend: Deferred.succeed(recoveryStarted, undefined).pipe(
942 Effect.andThen(Deferred.await(releaseRecovery)),
943 ),
944 });
945
946 return Effect.scoped(
947 Effect.gen(function* () {
948 const updates = yield* DesktopUpdates.DesktopUpdates;
949 yield* updates.configure;
950 harness.emit("update-downloaded", { version: "1.2.4" });
951 yield* flushCallbacks;
952
953 const failedInstall = yield* updates.install.pipe(Effect.forkChild);
954 yield* Deferred.await(recoveryStarted);
955 assert.isFalse(yield* updates.isInstallActive);
956
957 const overlappingInstall = yield* updates.install;
958 assert.isFalse(overlappingInstall.accepted);
959 assert.equal(harness.quitAndInstalls(), 1);
960 harness.emit("error", new Error("duplicate native installer error"));
961 yield* flushCallbacks;
962 assert.deepEqual(harness.installSteps, ["quitAndInstall", "startBackend"]);
963
964 yield* Deferred.succeed(releaseRecovery, undefined);
965 const failedResult = yield* Fiber.join(failedInstall);
966 assert.equal(failedResult.state.errorContext, "install");
967
968 const retry = yield* updates.install;
969 assert.isTrue(retry.accepted);
970 assert.equal(harness.quitAndInstalls(), 2);
971 }),
972 ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
973 });
974
975 it.effect("recovers when quitAndInstall reports failure through an updater event", () => {
976 const harness = makeHarness();
977
978 return Effect.scoped(
979 Effect.gen(function* () {
980 const desktopState = yield* DesktopState.DesktopState;
981 const updates = yield* DesktopUpdates.DesktopUpdates;
982 yield* updates.configure;
983 harness.emit("update-downloaded", { version: "1.2.4" });
984 yield* flushCallbacks;
985
986 yield* updates.install;
987 assert.deepEqual(harness.installSteps, ["quitAndInstall"]);
988 harness.emit("error", new Error("native installer refused"));
989 yield* flushCallbacks;
990
991 assert.isFalse(yield* Ref.get(desktopState.quitting));
992 assert.deepEqual(harness.installSteps, ["quitAndInstall", "startBackend"]);
993 assert.equal((yield* updates.getState).errorContext, "install");
994 }),
995 ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
996 });
997
998 it.effect("rejects a prepared install when the downloaded version changed", () => {
999 const harness = makeHarness();
1000
1001 return Effect.scoped(
1002 Effect.gen(function* () {
1003 const updates = yield* DesktopUpdates.DesktopUpdates;
1004 yield* updates.configure;
1005 harness.emit("update-downloaded", { version: "1.2.5" });
1006 yield* flushCallbacks;
1007
1008 const result = yield* updates.installPrepared("1.2.4");
1009 assert.isFalse(result.accepted);
1010 assert.equal(harness.quitAndInstalls(), 0);
1011 }),
1012 ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
1013 });
1014
1015 it.effect("persists channel changes through the settings service", () => {
1016 const harness = makeHarness();
1017
1018 return Effect.scoped(
1019 Effect.gen(function* () {
1020 const settings = yield* DesktopAppSettings.DesktopAppSettings;
1021 const updates = yield* DesktopUpdates.DesktopUpdates;
1022 yield* updates.configure;
1023
1024 const state = yield* updates.setChannel("nightly");
1025 const persistedSettings = yield* settings.get;
1026
1027 assert.equal(state.channel, "nightly");
1028 assert.equal(persistedSettings.updateChannel, "nightly");
1029 assert.equal(persistedSettings.updateChannelConfiguredByUser, true);
1030 }),
1031 ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
1032 });
1033
1034 it.effect("does not persist an unchanged update channel as a user preference", () => {
1035 const harness = makeHarness();
1036
1037 return Effect.scoped(
1038 Effect.gen(function* () {
1039 const settings = yield* DesktopAppSettings.DesktopAppSettings;
1040 const updates = yield* DesktopUpdates.DesktopUpdates;
1041 yield* updates.configure;
1042
1043 const state = yield* updates.setChannel("latest");
1044 const persistedSettings = yield* settings.get;
1045
1046 assert.equal(state.channel, "latest");
1047 assert.equal(persistedSettings.updateChannel, "latest");
1048 assert.equal(persistedSettings.updateChannelConfiguredByUser, false);
1049 }),
1050 ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
1051 });
1052
1053 it.effect("fails channel changes with a typed error while a check is in progress", () =>
1054 Effect.gen(function* () {
1055 const checkStarted = yield* Deferred.make<void>();
1056 const releaseCheck = yield* Deferred.make<void>();
1057 const harness = makeHarness({
1058 checkForUpdates: Deferred.succeed(checkStarted, undefined).pipe(
1059 Effect.andThen(Deferred.await(releaseCheck)),
1060 ),
1061 });
1062
1063 yield* Effect.scoped(
1064 Effect.gen(function* () {
1065 const updates = yield* DesktopUpdates.DesktopUpdates;
1066 yield* updates.configure;
1067
1068 const checkFiber = yield* updates.check("manual").pipe(Effect.forkScoped);
1069 yield* Deferred.await(checkStarted);
1070
1071 const exit = yield* Effect.exit(updates.setChannel("nightly"));
1072 assert.equal(exit._tag, "Failure");
1073 if (exit._tag === "Failure") {
1074 const error = Cause.squash(exit.cause);
1075 assert.instanceOf(error, DesktopUpdates.DesktopUpdateActionInProgressError);
1076 assert.equal(error.action, "check");
1077 assert.equal(error.requestedChannel, "nightly");
1078 }
1079
1080 yield* Deferred.succeed(releaseCheck, undefined);
1081 yield* Fiber.join(checkFiber);
1082 }),
1083 ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
1084 }),
1085 );
1086
1087 it.effect("rejects checks while an update channel change is being persisted", () =>
1088 Effect.gen(function* () {
1089 const channelChangeStarted = yield* Deferred.make<void>();
1090 const releaseChannelChange = yield* Deferred.make<void>();
1091 const harness = makeHarness({
1092 beforeSetUpdateChannel: Deferred.succeed(channelChangeStarted, undefined).pipe(
1093 Effect.andThen(Deferred.await(releaseChannelChange)),
1094 ),
1095 });
1096
1097 yield* Effect.scoped(
1098 Effect.gen(function* () {
1099 const updates = yield* DesktopUpdates.DesktopUpdates;
1100 yield* updates.configure;
1101
1102 const channelFiber = yield* updates.setChannel("nightly").pipe(Effect.forkScoped);
1103 yield* Deferred.await(channelChangeStarted);
1104
1105 const checkResult = yield* updates.check("manual");
1106 assert.isFalse(checkResult.checked);
1107 assert.equal(harness.checkCount(), 0);
1108
1109 yield* Deferred.succeed(releaseChannelChange, undefined);
1110 const state = yield* Fiber.join(channelFiber);
1111
1112 assert.equal(state.channel, "nightly");
1113 assert.equal(harness.checkCount(), 1);
1114 }),
1115 ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
1116 }),
1117 );
1118
1119 it.effect("preserves settings failure context when an update channel cannot be persisted", () => {
1120 const diskFailure = new Error("disk exploded");
1121 const settingsFailure = new DesktopAppSettings.DesktopSettingsWriteError({
1122 operation: "replace-settings-file",
1123 path: "/tmp/settings.json",
1124 cause: diskFailure,
1125 });
1126 const harness = makeHarness({ setUpdateChannelError: settingsFailure });
1127
1128 return Effect.scoped(
1129 Effect.gen(function* () {
1130 const updates = yield* DesktopUpdates.DesktopUpdates;
1131 yield* updates.configure;
1132
1133 const error = yield* updates.setChannel("nightly").pipe(Effect.flip);
1134
1135 assert.instanceOf(error, DesktopUpdates.DesktopUpdateChannelPersistenceError);
1136 assert.equal(error.channel, "nightly");
1137 assert.strictEqual(error.cause, settingsFailure);
1138 assert.strictEqual(error.cause.cause, diskFailure);
1139 assert.equal(error.message, "Failed to persist the nightly desktop update channel.");
1140 assert.notInclude(error.message, diskFailure.message);
1141
1142 const checkResult = yield* updates.check("manual");
1143 assert.isTrue(checkResult.checked);
1144 assert.equal(harness.checkCount(), 1);
1145 }),
1146 ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
1147 });
1148});
1149
1150describe("describeUpdateErrorCause", () => {
1151 it("preserves network and nested failure reasons", () => {
1152 const networkError = Object.assign(new Error("getaddrinfo ENOTFOUND updates.example.com"), {
1153 code: "ENOTFOUND",
1154 });
1155 const cause = new Error("fetch failed", { cause: networkError });
1156
1157 assert.equal(
1158 DesktopUpdates.describeUpdateErrorCause(Cause.fail(cause)),
1159 "Error: fetch failed; caused by Error [ENOTFOUND]: getaddrinfo ENOTFOUND updates.example.com",
1160 );
1161 assert.include(DesktopUpdates.describeUpdateErrorCause(Cause.die(networkError)), "[ENOTFOUND]");
1162 });
1163
1164 it("removes URLs, credentials, response bodies, and headers from summaries", () => {
1165 const messages = [
1166 "Cannot download https://user:url-secret@example.com/update?token=query-secret, status 403",
1167 "Request failed: Bearer bearer-secret",
1168 "Request failed: Basic basic-secret",
1169 "Request failed: Authorization: Bearer header-secret",
1170 "Request failed: Cookie=session-secret",
1171 "Request failed: api_key=\"key-secret\" password='password-secret' access_token=token-secret",
1172 '404 Not Found\n{"body":"body-secret"}\nHeaders: cookie=header-secret',
1173 "Request failed\rHeaders: cookie=header-secret",
1174 ];
1175
1176 for (const message of messages) {
1177 const summary = DesktopUpdates.describeUpdateErrorCause(new Error(message));
1178 assert.include(summary, "Error:");
1179 assert.notInclude(summary, "secret");
1180 assert.notInclude(summary, "https://");
1181 assert.notInclude(summary, "Headers:");
1182 }
1183 assert.equal(
1184 DesktopUpdates.describeUpdateErrorCause("404 Not Found\nbody-secret"),
1185 "404 Not Found",
1186 );
1187 });
1188
1189 it("bounds long messages, parallel failures, and cyclic causes", () => {
1190 const longError = new Error("x".repeat(10_000));
1191 const parallel = Cause.combine(Cause.fail(longError), Cause.die(longError));
1192 assert.isAtMost(DesktopUpdates.describeUpdateErrorCause(parallel).length, 1024);
1193
1194 const cyclic = new Error("retry failed");
1195 cyclic.cause = cyclic;
1196 assert.include(DesktopUpdates.describeUpdateErrorCause(cyclic), "[truncated cause]");
1197 assert.isAtMost(DesktopUpdates.describeUpdateErrorCause(cyclic).length, 1024);
1198 });
1199
1200 it("does not serialize arbitrary payloads or let unreadable errors break logging", () => {
1201 assert.equal(DesktopUpdates.describeUpdateErrorCause({ payload: "secret" }), "object");
1202 const unreadable = Object.defineProperty({}, "message", {
1203 get() {
1204 throw new Error("unreadable error");
1205 },
1206 });
1207 assert.equal(DesktopUpdates.describeUpdateErrorCause(unreadable), "UnknownError");
1208 });
1209});