1import { assert, describe, it } from "@effect/vitest";
2import type {
3 DesktopTelemetryRequestDesktopUpdate,
4 DesktopTelemetryCommitDesktopUpdate,
5 DesktopTelemetryCancelDesktopUpdate,
6 DesktopUpdateStatusReport,
7} from "@t3tools/contracts";
8import * as Deferred from "effect/Deferred";
9import * as Duration from "effect/Duration";
10import * as Effect from "effect/Effect";
11import * as Fiber from "effect/Fiber";
12import * as Layer from "effect/Layer";
13import * as Queue from "effect/Queue";
14import * as Ref from "effect/Ref";
15import * as Stream from "effect/Stream";
16import * as TestClock from "effect/testing/TestClock";
17
18import * as ElectronUpdater from "../electron/ElectronUpdater.ts";
19import * as DesktopState from "../app/DesktopState.ts";
20import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts";
21import * as DesktopRemoteUpdates from "./DesktopRemoteUpdates.ts";
22import * as DesktopUpdates from "./DesktopUpdates.ts";
23import { makeHarness } from "./updatesTestHarness.ts";
24
25// The remote flow hops between the test runtime's fibers and the updater's
26// runPromise-driven event handlers, so settling needs real microtask turns,
27// not just fiber yields.
28const settle = Effect.gen(function* () {
29 for (let i = 0; i < 20; i += 1) {
30 yield* Effect.yieldNow;
31 yield* Effect.promise(() => Promise.resolve());
32 }
33});
34
35const request = (requestId: string): DesktopTelemetryRequestDesktopUpdate => ({
36 version: 1,
37 type: "requestDesktopUpdate",
38 requestId,
39});
40
41function runRemoteUpdatesTest(
42 harness: ReturnType<typeof makeHarness>,
43 body: (context: {
44 readonly reports: DesktopUpdateStatusReport[];
45 readonly requests: Queue.Queue<DesktopTelemetryRequestDesktopUpdate>;
46 readonly commits: Queue.Queue<DesktopTelemetryCommitDesktopUpdate>;
47 readonly cancellations: Queue.Queue<DesktopTelemetryCancelDesktopUpdate>;
48 }) => Effect.Effect<void, never, DesktopUpdates.DesktopUpdates | DesktopState.DesktopState>,
49) {
50 return Effect.scoped(
51 Effect.gen(function* () {
52 const requests = yield* Queue.unbounded<DesktopTelemetryRequestDesktopUpdate>();
53 const commits = yield* Queue.unbounded<DesktopTelemetryCommitDesktopUpdate>();
54 const cancellations = yield* Queue.unbounded<DesktopTelemetryCancelDesktopUpdate>();
55 const reports: DesktopUpdateStatusReport[] = [];
56 const publisher = DesktopTelemetryPublisher.DesktopTelemetryPublisher.of({
57 latest: Effect.succeedNone,
58 changes: Stream.empty,
59 encoded: Stream.empty,
60 handleControlForSource: () => Effect.void,
61 removeControlSource: () => Effect.void,
62 publishUpdateReport: (report) =>
63 Effect.sync(() => {
64 reports.push(report);
65 }),
66 updateRequests: Stream.fromQueue(requests),
67 updateCommits: Stream.fromQueue(commits),
68 updateCancellations: Stream.fromQueue(cancellations),
69 });
70
71 const updates = yield* DesktopUpdates.DesktopUpdates;
72 yield* updates.configure;
73 yield* DesktopRemoteUpdates.listen.pipe(
74 Effect.provideService(DesktopTelemetryPublisher.DesktopTelemetryPublisher, publisher),
75 );
76 yield* settle;
77 yield* body({ reports, requests, commits, cancellations });
78 }),
79 ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
80}
81
82function terminalReports(reports: DesktopUpdateStatusReport[]): DesktopUpdateStatusReport[] {
83 return reports.filter((report) => report.outcome !== undefined);
84}
85
86describe("DesktopRemoteUpdates", () => {
87 it.effect(
88 "leaves a UI-only update pending for the local renderer without restarting its backend",
89 () => {
90 const harness = makeHarness({ uiOnly: true });
91 return runRemoteUpdatesTest(harness, ({ reports, requests }) =>
92 Effect.gen(function* () {
93 yield* Queue.offer(requests, request("ui-only"));
94 yield* settle;
95 assert.equal(terminalReports(reports).at(-1)?.outcome, "up-to-date");
96 assert.equal((yield* (yield* DesktopUpdates.DesktopUpdates).getState).installKind, "ui");
97 assert.deepEqual(harness.installSteps, []);
98 assert.deepEqual(harness.uiInstalls, []);
99 }),
100 );
101 },
102 );
103
104 it.effect("drives check, download, and install with no local confirmation", () => {
105 const harness = makeHarness();
106
107 return runRemoteUpdatesTest(harness, ({ reports, requests, commits }) =>
108 Effect.gen(function* () {
109 yield* Queue.offer(requests, request("req-1"));
110 yield* settle;
111 assert.equal(harness.checkCount(), 1);
112
113 harness.emit("update-available", { version: "1.2.4" });
114 yield* settle;
115 harness.emit("update-downloaded", { version: "1.2.4" });
116 yield* settle;
117
118 const terminals = terminalReports(reports);
119 assert.equal(terminals.length, 1);
120 assert.equal(terminals[0]?.outcome, "ready-to-install");
121 assert.equal(terminals[0]?.requestId, "req-1");
122 assert.equal(harness.quitAndInstalls(), 0);
123
124 yield* Queue.offer(commits, {
125 version: 1,
126 type: "commitDesktopUpdate",
127 requestId: "req-1",
128 });
129 yield* settle;
130 assert.equal(harness.quitAndInstalls(), 1);
131
132 // The mirror stamped the in-run state changes with the request id.
133 const statuses = reports
134 .filter((report) => report.requestId === "req-1")
135 .map((report) => report.state.status);
136 assert.include(statuses, "available");
137 assert.include(statuses, "downloaded");
138 }),
139 );
140 });
141
142 it.effect("reports a failed outcome when quitAndInstall fails", () => {
143 const harness = makeHarness({
144 quitAndInstall: Effect.fail(
145 new ElectronUpdater.ElectronUpdaterQuitAndInstallError({
146 channel: "latest",
147 isSilent: true,
148 isForceRunAfter: true,
149 cause: new Error("spawn failed"),
150 }),
151 ),
152 });
153
154 return runRemoteUpdatesTest(harness, ({ reports, requests, commits }) =>
155 Effect.gen(function* () {
156 yield* Queue.offer(requests, request("req-4"));
157 yield* settle;
158 harness.emit("update-available", { version: "1.2.4" });
159 yield* settle;
160 harness.emit("update-downloaded", { version: "1.2.4" });
161 yield* settle;
162
163 yield* Queue.offer(commits, {
164 version: 1,
165 type: "commitDesktopUpdate",
166 requestId: "req-4",
167 });
168 yield* settle;
169
170 // Install failures reduce to status "downloaded" + errorContext
171 // "install"; the prepared result is followed by the commit failure.
172 const terminals = terminalReports(reports);
173 assert.deepEqual(
174 terminals.map((report) => report.outcome),
175 ["ready-to-install", "failed"],
176 );
177 assert.equal(terminals[1]?.state.errorContext, "install");
178 }),
179 );
180 });
181
182 it.effect("joins an in-progress install on commit instead of failing the token", () => {
183 const installStarted = Deferred.makeUnsafe<void>();
184 const releaseInstall = Deferred.makeUnsafe<void>();
185 const harness = makeHarness({
186 stopBackend: Deferred.succeed(installStarted, undefined).pipe(
187 Effect.andThen(Deferred.await(releaseInstall)),
188 ),
189 });
190
191 return runRemoteUpdatesTest(harness, ({ reports, requests, commits }) =>
192 Effect.gen(function* () {
193 const updates = yield* DesktopUpdates.DesktopUpdates;
194 yield* Queue.offer(requests, request("req-join-commit"));
195 yield* settle;
196 harness.emit("update-available", { version: "1.2.4" });
197 yield* settle;
198 harness.emit("update-downloaded", { version: "1.2.4" });
199 yield* settle;
200 assert.deepEqual(
201 terminalReports(reports).map((report) => report.outcome),
202 ["ready-to-install"],
203 );
204
205 // A local install takes the updater reservation and starts shutdown.
206 const localInstall = yield* updates.install.pipe(Effect.forkChild);
207 yield* Deferred.await(installStarted);
208 yield* Queue.offer(commits, {
209 version: 1,
210 type: "commitDesktopUpdate",
211 requestId: "req-join-commit",
212 });
213 yield* settle;
214
215 // No "failed" marker: that install relaunches the app and the
216 // client proves the handoff by reconnecting on the target version.
217 assert.deepEqual(
218 terminalReports(reports).map((report) => report.outcome),
219 ["ready-to-install"],
220 );
221 assert.equal(harness.quitAndInstalls(), 0);
222
223 yield* Deferred.succeed(releaseInstall, undefined);
224 yield* Fiber.join(localInstall);
225 }),
226 );
227 });
228
229 it.effect("does not join a normal app quit as an update install", () => {
230 const harness = makeHarness();
231
232 return runRemoteUpdatesTest(harness, ({ reports, requests, commits }) =>
233 Effect.gen(function* () {
234 const desktopState = yield* DesktopState.DesktopState;
235 yield* Queue.offer(requests, request("req-normal-quit"));
236 yield* settle;
237 harness.emit("update-available", { version: "1.2.4" });
238 yield* settle;
239 harness.emit("update-downloaded", { version: "1.2.4" });
240 yield* settle;
241
242 yield* Ref.set(desktopState.quitting, true);
243 yield* Queue.offer(commits, {
244 version: 1,
245 type: "commitDesktopUpdate",
246 requestId: "req-normal-quit",
247 });
248 yield* settle;
249
250 assert.deepEqual(
251 terminalReports(reports).map((report) => report.outcome),
252 ["ready-to-install", "failed"],
253 );
254 assert.equal(harness.quitAndInstalls(), 0);
255 }),
256 );
257 });
258
259 it.effect("does not misread a lingering install error as a failed retry", () => {
260 let installAttempts = 0;
261 const harness = makeHarness({
262 quitAndInstall: Effect.suspend(() => {
263 installAttempts += 1;
264 return installAttempts === 1
265 ? Effect.fail(
266 new ElectronUpdater.ElectronUpdaterQuitAndInstallError({
267 channel: "latest",
268 isSilent: true,
269 isForceRunAfter: true,
270 cause: new Error("first attempt failed"),
271 }),
272 )
273 : Effect.void;
274 }),
275 });
276
277 return runRemoteUpdatesTest(harness, ({ reports, requests, commits }) =>
278 Effect.gen(function* () {
279 yield* Queue.offer(requests, request("req-5"));
280 yield* settle;
281 harness.emit("update-available", { version: "1.2.4" });
282 yield* settle;
283 harness.emit("update-downloaded", { version: "1.2.4" });
284 yield* settle;
285 yield* Queue.offer(commits, {
286 version: 1,
287 type: "commitDesktopUpdate",
288 requestId: "req-5",
289 });
290 yield* settle;
291 // First run failed; state is "downloaded" with a lingering
292 // errorContext "install". The retry succeeds and must not report
293 // that leftover as a fresh failure.
294 yield* Queue.offer(requests, request("req-6"));
295 yield* settle;
296 yield* Queue.offer(commits, {
297 version: 1,
298 type: "commitDesktopUpdate",
299 requestId: "req-6",
300 });
301 yield* settle;
302
303 const retryTerminals = terminalReports(reports).filter(
304 (report) => report.requestId === "req-6",
305 );
306 assert.deepEqual(
307 retryTerminals.map((report) => report.outcome),
308 ["ready-to-install"],
309 );
310 assert.equal(harness.quitAndInstalls(), 2);
311 }),
312 );
313 });
314
315 it.effect("ignores an unrelated updater event while an install is starting", () => {
316 const stopStarted = Deferred.makeUnsafe<void>();
317 const releaseStop = Deferred.makeUnsafe<void>();
318 const harness = makeHarness({
319 stopBackend: Deferred.succeed(stopStarted, undefined).pipe(
320 Effect.andThen(Deferred.await(releaseStop)),
321 ),
322 });
323
324 return runRemoteUpdatesTest(harness, ({ reports, requests, commits }) =>
325 Effect.gen(function* () {
326 yield* Queue.offer(requests, request("req-late-event"));
327 yield* settle;
328 harness.emit("update-available", { version: "1.2.4" });
329 yield* settle;
330 harness.emit("update-downloaded", { version: "1.2.4" });
331 yield* settle;
332
333 yield* Queue.offer(commits, {
334 version: 1,
335 type: "commitDesktopUpdate",
336 requestId: "req-late-event",
337 });
338 yield* Deferred.await(stopStarted);
339 harness.emit("download-progress", { percent: 90 });
340 yield* settle;
341 yield* Deferred.succeed(releaseStop, undefined);
342 yield* settle;
343
344 assert.deepEqual(
345 terminalReports(reports)
346 .filter((report) => report.requestId === "req-late-event")
347 .map((report) => report.outcome),
348 ["ready-to-install"],
349 );
350 }),
351 );
352 });
353
354 it.effect("retries a download refused while the check still holds the reservation", () => {
355 // electron-updater emits update-available from inside checkForUpdates,
356 // before the check action releases its reservation. The download the
357 // remote flow forks in response is refused and must be retried once the
358 // reservation frees up, without burning a download attempt.
359 const releaseCheck = Deferred.makeUnsafe<void>();
360 const harness = makeHarness({ checkForUpdates: Deferred.await(releaseCheck) });
361
362 return runRemoteUpdatesTest(harness, ({ reports, requests }) =>
363 Effect.gen(function* () {
364 yield* Queue.offer(requests, request("req-8"));
365 yield* settle;
366 assert.equal(harness.checkCount(), 1);
367
368 // Fire "available" while the check reservation is still held.
369 harness.emit("update-available", { version: "1.2.4" });
370 yield* settle;
371 assert.equal(harness.downloadCount(), 0);
372
373 yield* Deferred.succeed(releaseCheck, undefined);
374 yield* settle;
375 yield* TestClock.adjust(Duration.millis(300));
376 yield* settle;
377 assert.equal(harness.downloadCount(), 1);
378
379 harness.emit("update-downloaded", { version: "1.2.4" });
380 yield* settle;
381 assert.deepEqual(
382 terminalReports(reports).map((report) => report.outcome),
383 ["ready-to-install"],
384 );
385 assert.equal(harness.quitAndInstalls(), 0);
386 }),
387 );
388 });
389
390 it.effect("waits for the download reservation before reporting prepared", () => {
391 // update-downloaded fires from inside downloadUpdate. If the flow
392 // reported "installing" right then, install would be refused for the
393 // held reservation after the irrevocable terminal already went out.
394 const releaseDownload = Deferred.makeUnsafe<void>();
395 const harness = makeHarness({ downloadUpdate: Deferred.await(releaseDownload) });
396
397 return runRemoteUpdatesTest(harness, ({ reports, requests }) =>
398 Effect.gen(function* () {
399 yield* Queue.offer(requests, request("req-10"));
400 yield* settle;
401 harness.emit("update-available", { version: "1.2.4" });
402 yield* settle;
403 harness.emit("update-downloaded", { version: "1.2.4" });
404 yield* settle;
405 assert.deepEqual(terminalReports(reports), []);
406 assert.equal(harness.quitAndInstalls(), 0);
407
408 yield* Deferred.succeed(releaseDownload, undefined);
409 yield* settle;
410 yield* TestClock.adjust(Duration.millis(300));
411 yield* settle;
412 assert.deepEqual(
413 terminalReports(reports).map((report) => report.outcome),
414 ["ready-to-install"],
415 );
416 assert.equal(harness.quitAndInstalls(), 0);
417 }),
418 );
419 });
420
421 it.effect("joins an install that is already tearing the app down", () => {
422 const harness = makeHarness();
423
424 return runRemoteUpdatesTest(harness, ({ reports, requests }) =>
425 Effect.gen(function* () {
426 const desktopState = yield* DesktopState.DesktopState;
427 harness.emit("update-downloaded", { version: "1.2.4" });
428 yield* settle;
429 // Another install already owns the shutdown.
430 yield* Ref.set(desktopState.quitting, true);
431
432 yield* Queue.offer(requests, request("req-9"));
433 yield* settle;
434
435 // Report "installing" once, no "failed" after the refusal: that
436 // install will relaunch the app and this request rides along.
437 assert.deepEqual(
438 terminalReports(reports).map((report) => report.outcome),
439 ["ready-to-install"],
440 );
441 assert.equal(harness.quitAndInstalls(), 0);
442 }),
443 );
444 });
445
446 it.effect("does not run the installer twice for a repeated commit", () => {
447 const harness = makeHarness();
448
449 return runRemoteUpdatesTest(harness, ({ requests, commits }) =>
450 Effect.gen(function* () {
451 yield* Queue.offer(requests, request("req-repeat"));
452 yield* settle;
453 harness.emit("update-available", { version: "1.2.4" });
454 yield* settle;
455 harness.emit("update-downloaded", { version: "1.2.4" });
456 yield* settle;
457
458 const commit = {
459 version: 1,
460 type: "commitDesktopUpdate",
461 requestId: "req-repeat",
462 } as const;
463 yield* Queue.offer(commits, commit);
464 yield* settle;
465 yield* Queue.offer(commits, commit);
466 yield* settle;
467
468 assert.equal(harness.quitAndInstalls(), 1);
469 }),
470 );
471 });
472
473 it.effect("waits for a background check before installing a prepared update", () => {
474 const backgroundCheckStarted = Deferred.makeUnsafe<void>();
475 const releaseBackgroundCheck = Deferred.makeUnsafe<void>();
476 let checks = 0;
477 const harness = makeHarness({
478 checkForUpdates: Effect.suspend(() => {
479 checks += 1;
480 return checks === 2
481 ? Deferred.succeed(backgroundCheckStarted, undefined).pipe(
482 Effect.andThen(Deferred.await(releaseBackgroundCheck)),
483 )
484 : Effect.void;
485 }),
486 });
487
488 return runRemoteUpdatesTest(harness, ({ requests, commits }) =>
489 Effect.gen(function* () {
490 const updates = yield* DesktopUpdates.DesktopUpdates;
491 yield* Queue.offer(requests, request("req-background-check"));
492 yield* settle;
493 harness.emit("update-available", { version: "1.2.4" });
494 yield* settle;
495 harness.emit("update-downloaded", { version: "1.2.4" });
496 yield* settle;
497
498 const backgroundCheck = yield* updates.check("poll").pipe(Effect.forkChild);
499 yield* Deferred.await(backgroundCheckStarted);
500 assert.equal((yield* updates.getState).status, "checking");
501
502 yield* Queue.offer(commits, {
503 version: 1,
504 type: "commitDesktopUpdate",
505 requestId: "req-background-check",
506 });
507 yield* settle;
508 assert.equal(harness.quitAndInstalls(), 0);
509
510 harness.emit("update-downloaded", { version: "1.2.4" });
511 yield* Deferred.succeed(releaseBackgroundCheck, undefined);
512 yield* Fiber.join(backgroundCheck);
513 yield* settle;
514 assert.equal(harness.quitAndInstalls(), 1);
515 }),
516 );
517 });
518
519 it.effect("fails a prepared install when its background check stays blocked", () => {
520 const backgroundCheckStarted = Deferred.makeUnsafe<void>();
521 const releaseBackgroundCheck = Deferred.makeUnsafe<void>();
522 let checks = 0;
523 const harness = makeHarness({
524 checkForUpdates: Effect.suspend(() => {
525 checks += 1;
526 return checks === 2
527 ? Deferred.succeed(backgroundCheckStarted, undefined).pipe(
528 Effect.andThen(Deferred.await(releaseBackgroundCheck)),
529 )
530 : Effect.void;
531 }),
532 });
533
534 return runRemoteUpdatesTest(harness, ({ reports, requests, commits }) =>
535 Effect.gen(function* () {
536 const updates = yield* DesktopUpdates.DesktopUpdates;
537 yield* Queue.offer(requests, request("req-blocked-background-check"));
538 yield* settle;
539 harness.emit("update-available", { version: "1.2.4" });
540 yield* settle;
541 harness.emit("update-downloaded", { version: "1.2.4" });
542 yield* settle;
543
544 const backgroundCheck = yield* updates.check("poll").pipe(Effect.forkChild);
545 yield* Deferred.await(backgroundCheckStarted);
546 const commit = {
547 version: 1,
548 type: "commitDesktopUpdate",
549 requestId: "req-blocked-background-check",
550 } as const;
551 yield* Queue.offer(commits, commit);
552 yield* settle;
553 yield* TestClock.adjust(Duration.seconds(91));
554 yield* settle;
555
556 assert.equal(harness.quitAndInstalls(), 0);
557 assert.deepEqual(
558 terminalReports(reports)
559 .filter((report) => report.requestId === "req-blocked-background-check")
560 .map((report) => report.outcome),
561 ["ready-to-install", "failed"],
562 );
563
564 harness.emit("update-downloaded", { version: "1.2.4" });
565 yield* Deferred.succeed(releaseBackgroundCheck, undefined);
566 yield* Fiber.join(backgroundCheck);
567 yield* settle;
568 assert.equal(harness.quitAndInstalls(), 0);
569
570 yield* Queue.offer(commits, commit);
571 yield* settle;
572 assert.deepEqual(
573 terminalReports(reports)
574 .filter((report) => report.requestId === "req-blocked-background-check")
575 .map((report) => report.outcome),
576 ["ready-to-install", "failed", "failed"],
577 );
578 }),
579 );
580 });
581
582 it.effect("rejects a new preparation while an install commit is active", () => {
583 const installStarted = Deferred.makeUnsafe<void>();
584 const releaseInstall = Deferred.makeUnsafe<void>();
585 const harness = makeHarness({
586 quitAndInstall: Deferred.succeed(installStarted, undefined).pipe(
587 Effect.andThen(Deferred.await(releaseInstall)),
588 Effect.andThen(
589 Effect.fail(
590 new ElectronUpdater.ElectronUpdaterQuitAndInstallError({
591 channel: "latest",
592 isSilent: true,
593 isForceRunAfter: true,
594 cause: new Error("installer refused"),
595 }),
596 ),
597 ),
598 ),
599 });
600
601 return runRemoteUpdatesTest(harness, ({ reports, requests, commits }) =>
602 Effect.gen(function* () {
603 yield* Queue.offer(requests, request("req-active"));
604 yield* settle;
605 harness.emit("update-available", { version: "1.2.4" });
606 yield* settle;
607 harness.emit("update-downloaded", { version: "1.2.4" });
608 yield* settle;
609 yield* Queue.offer(commits, {
610 version: 1,
611 type: "commitDesktopUpdate",
612 requestId: "req-active",
613 });
614 yield* Deferred.await(installStarted);
615
616 yield* Queue.offer(requests, request("req-overlap"));
617 yield* settle;
618 const overlap = terminalReports(reports).find(
619 (report) => report.requestId === "req-overlap",
620 );
621 assert.equal(overlap?.outcome, "failed");
622 assert.equal(overlap?.reason, "A prepared desktop update is already in progress.");
623
624 yield* Deferred.succeed(releaseInstall, undefined);
625 yield* settle;
626 }),
627 );
628 });
629
630 it.effect("cancels an active preparation so the next request can run", () => {
631 const releaseCheck = Deferred.makeUnsafe<void>();
632 const harness = makeHarness({ checkForUpdates: Deferred.await(releaseCheck) });
633
634 return runRemoteUpdatesTest(harness, ({ requests, cancellations }) =>
635 Effect.gen(function* () {
636 yield* Queue.offer(requests, request("req-cancel"));
637 yield* settle;
638 yield* Queue.offer(cancellations, {
639 version: 1,
640 type: "cancelDesktopUpdate",
641 requestId: "req-cancel",
642 });
643 yield* settle;
644 yield* Deferred.succeed(releaseCheck, undefined);
645 yield* Queue.offer(requests, request("req-next"));
646 yield* settle;
647
648 assert.equal(harness.checkCount(), 2);
649 }),
650 );
651 });
652
653 it.effect("remembers a cancellation that arrives before its request starts", () => {
654 const harness = makeHarness();
655
656 return runRemoteUpdatesTest(harness, ({ requests, cancellations }) =>
657 Effect.gen(function* () {
658 yield* Queue.offer(cancellations, {
659 version: 1,
660 type: "cancelDesktopUpdate",
661 requestId: "req-early-cancel",
662 });
663 yield* Queue.offer(requests, request("req-early-cancel"));
664 yield* settle;
665
666 assert.equal(harness.checkCount(), 0);
667
668 yield* Queue.offer(requests, request("req-after-early-cancel"));
669 yield* settle;
670 assert.equal(harness.checkCount(), 1);
671 }),
672 );
673 });
674
675 it.effect("keeps every queued cancellation until its request starts", () => {
676 const harness = makeHarness();
677
678 return runRemoteUpdatesTest(harness, ({ requests, cancellations }) =>
679 Effect.gen(function* () {
680 for (let index = 0; index < 40; index += 1) {
681 yield* Queue.offer(cancellations, {
682 version: 1,
683 type: "cancelDesktopUpdate",
684 requestId: `req-queued-cancel-${index}`,
685 });
686 }
687 yield* settle;
688 for (let index = 0; index < 40; index += 1) {
689 yield* Queue.offer(requests, request(`req-queued-cancel-${index}`));
690 }
691 yield* settle;
692 assert.equal(harness.checkCount(), 0);
693
694 yield* Queue.offer(requests, request("req-after-queued-cancels"));
695 yield* settle;
696 assert.equal(harness.checkCount(), 1);
697 }),
698 );
699 });
700
701 it.effect("does not install after cancellation wins the commit claim", () => {
702 const harness = makeHarness();
703
704 return runRemoteUpdatesTest(harness, ({ reports, requests, commits, cancellations }) =>
705 Effect.gen(function* () {
706 yield* Queue.offer(requests, request("req-cancel-before-commit"));
707 yield* settle;
708 harness.emit("update-available", { version: "1.2.4" });
709 yield* settle;
710 harness.emit("update-downloaded", { version: "1.2.4" });
711 yield* settle;
712
713 yield* Queue.offer(cancellations, {
714 version: 1,
715 type: "cancelDesktopUpdate",
716 requestId: "req-cancel-before-commit",
717 });
718 yield* settle;
719 yield* Queue.offer(commits, {
720 version: 1,
721 type: "commitDesktopUpdate",
722 requestId: "req-cancel-before-commit",
723 });
724 yield* settle;
725
726 assert.equal(harness.quitAndInstalls(), 0);
727 assert.deepEqual(
728 terminalReports(reports)
729 .filter((report) => report.requestId === "req-cancel-before-commit")
730 .map((report) => report.outcome),
731 ["ready-to-install", "failed"],
732 );
733 }),
734 );
735 });
736
737 it.effect("retains an install failure after cancellation loses the commit claim", () => {
738 const installStarted = Deferred.makeUnsafe<void>();
739 const releaseInstall = Deferred.makeUnsafe<void>();
740 const harness = makeHarness({
741 quitAndInstall: Deferred.succeed(installStarted, undefined).pipe(
742 Effect.andThen(Deferred.await(releaseInstall)),
743 Effect.andThen(
744 Effect.fail(
745 new ElectronUpdater.ElectronUpdaterQuitAndInstallError({
746 channel: "latest",
747 isSilent: true,
748 isForceRunAfter: true,
749 cause: new Error("installer refused"),
750 }),
751 ),
752 ),
753 ),
754 });
755
756 return runRemoteUpdatesTest(harness, ({ reports, requests, commits, cancellations }) =>
757 Effect.gen(function* () {
758 yield* Queue.offer(requests, request("req-cancel-after-commit"));
759 yield* settle;
760 harness.emit("update-available", { version: "1.2.4" });
761 yield* settle;
762 harness.emit("update-downloaded", { version: "1.2.4" });
763 yield* settle;
764 const commit = {
765 version: 1,
766 type: "commitDesktopUpdate",
767 requestId: "req-cancel-after-commit",
768 } as const;
769 yield* Queue.offer(commits, commit);
770 yield* Deferred.await(installStarted);
771 yield* Queue.offer(cancellations, {
772 version: 1,
773 type: "cancelDesktopUpdate",
774 requestId: "req-cancel-after-commit",
775 });
776 yield* settle;
777 yield* Deferred.succeed(releaseInstall, undefined);
778 yield* settle;
779 yield* Queue.offer(commits, commit);
780 yield* settle;
781
782 assert.deepEqual(
783 terminalReports(reports)
784 .filter((report) => report.requestId === "req-cancel-after-commit")
785 .map((report) => report.outcome),
786 ["ready-to-install", "failed", "failed"],
787 );
788 }),
789 );
790 });
791
792 it.effect("expires an uncommitted preparation", () => {
793 const harness = makeHarness();
794
795 return runRemoteUpdatesTest(harness, ({ reports, requests, commits }) =>
796 Effect.gen(function* () {
797 yield* Queue.offer(requests, request("req-stale"));
798 yield* settle;
799 harness.emit("update-available", { version: "1.2.4" });
800 yield* settle;
801 harness.emit("update-downloaded", { version: "1.2.4" });
802 yield* settle;
803
804 yield* TestClock.setTime(Duration.toMillis(Duration.minutes(6)));
805 yield* Queue.offer(commits, {
806 version: 1,
807 type: "commitDesktopUpdate",
808 requestId: "req-stale",
809 });
810 yield* settle;
811
812 const outcomes = terminalReports(reports)
813 .filter((report) => report.requestId === "req-stale")
814 .map((report) => report.outcome);
815 assert.deepEqual(outcomes, ["ready-to-install", "failed"]);
816 }),
817 );
818 });
819
820 it.effect("reports up-to-date without installing when there is no update", () => {
821 const harness = makeHarness();
822
823 return runRemoteUpdatesTest(harness, ({ reports, requests }) =>
824 Effect.gen(function* () {
825 yield* Queue.offer(requests, request("req-2"));
826 yield* settle;
827 harness.emit("update-not-available");
828 yield* settle;
829
830 const terminals = terminalReports(reports);
831 assert.equal(terminals.length, 1);
832 assert.equal(terminals[0]?.outcome, "up-to-date");
833 assert.equal(terminals[0]?.requestId, "req-2");
834 assert.equal(harness.quitAndInstalls(), 0);
835 }),
836 );
837 });
838
839 it.effect("fails fast with the disabled reason when updates are off", () => {
840 const harness = makeHarness({ env: { T3CODE_DISABLE_AUTO_UPDATE: "true" } });
841
842 return runRemoteUpdatesTest(harness, ({ reports, requests }) =>
843 Effect.gen(function* () {
844 yield* Queue.offer(requests, request("req-3"));
845 yield* settle;
846
847 const terminals = terminalReports(reports);
848 assert.equal(terminals.length, 1);
849 assert.equal(terminals[0]?.outcome, "failed");
850 assert.equal(
851 terminals[0]?.reason,
852 "Automatic updates are disabled by the T3CODE_DISABLE_AUTO_UPDATE setting.",
853 );
854 assert.equal(harness.quitAndInstalls(), 0);
855 }),
856 );
857 });
858});