Skip to content
Source
Sign inSign up

DesktopAppUpdate.test.ts

219 lines7.5 KB

1import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { expect, it } from "@effect/vitest";
4import type { DesktopUpdateState, DesktopUpdateStatusReport } from "@t3tools/contracts";
5import * as Deferred from "effect/Deferred";
6import * as Effect from "effect/Effect";
7import * as FileSystem from "effect/FileSystem";
8import * as Fiber from "effect/Fiber";
9import * as Layer from "effect/Layer";
10import * as Option from "effect/Option";
11import * as Stream from "effect/Stream";
12​
13import * as ServerConfig from "../config.ts";
14import * as DesktopTelemetryReceiver from "../resourceTelemetry/DesktopTelemetryReceiver.ts";
15import * as DesktopAppUpdate from "./DesktopAppUpdate.ts";
16​
17function makeState(overrides: Partial<DesktopUpdateState> = {}): DesktopUpdateState {
18 return {
19 enabled: true,
20 status: "idle",
21 channel: "latest",
22 currentVersion: "1.2.3",
23 hostArch: "arm64",
24 appArch: "arm64",
25 runningUnderArm64Translation: false,
26 availableVersion: null,
27 downloadedVersion: null,
28 releaseNotes: [],
29 downloadPercent: null,
30 checkedAt: null,
31 message: null,
32 errorContext: null,
33 canRetry: false,
34 omittedReleaseCount: 0,
35 ...overrides,
36 };
38​
39function report(
40 requestId: string,
41 state: DesktopUpdateState,
42 terminal?: {
43 readonly outcome: DesktopUpdateStatusReport["outcome"];
44 readonly reason?: string;
45 },
46): DesktopUpdateStatusReport {
47 return {
48 version: 1,
49 type: "desktopUpdateStatus",
50 requestId,
51 ...(terminal?.outcome === undefined ? {} : { outcome: terminal.outcome }),
52 ...(terminal?.reason === undefined ? {} : { reason: terminal.reason }),
53 state,
54 };
56​
57interface HarnessOptions {
58 readonly mode?: "web" | "desktop";
59 readonly controlFd?: number | undefined;
60 readonly reports?: (requestId: string) => readonly DesktopUpdateStatusReport[];
61 readonly keepOpen?: boolean;
63​
64const makeHarness = Effect.fn("test.make_desktop_app_update_harness")(function* (
65 options: HarnessOptions = {},
66) {
67 const fs = yield* FileSystem.FileSystem;
68 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-desktop-app-update-test-" });
69 const requestIdDeferred = yield* Deferred.make<string>();
70 const baseConfig = yield* ServerConfig.ServerConfig.pipe(
71 Effect.provide(ServerConfig.layerTest(process.cwd(), baseDir)),
72 );
73 const config: ServerConfig.ServerConfig["Service"] = {
74 ...baseConfig,
75 mode: options.mode ?? "desktop",
76 ...("controlFd" in options
77 ? { desktopTelemetryControlFd: options.controlFd }
78 : { desktopTelemetryControlFd: 5 }),
79 };
80 const reportsForRun = options.reports ?? (() => []);
81 const changes = Stream.unwrap(
82 Deferred.await(requestIdDeferred).pipe(
83 Effect.map((requestId) => {
84 const emitted = Stream.fromIterable(reportsForRun(requestId));
85 return options.keepOpen ? Stream.concat(emitted, Stream.never) : emitted;
86 }),
87 ),
88 );
89​
90 const service = yield* DesktopAppUpdate.make().pipe(
91 Effect.provide(
92 Layer.mergeAll(
93 DesktopTelemetryReceiver.layerTest({
94 requestDesktopUpdate: (requestId) =>
95 Deferred.succeed(requestIdDeferred, requestId).pipe(Effect.asVoid),
96 desktopUpdates: Effect.succeed({
97 latest: Option.none<DesktopUpdateStatusReport>(),
98 changes,
99 }),
100 }),
101 ServerConfig.layer(config),
102 ),
103 ),
104 );
105 return { service };
106});
107​
108it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("desktop app update", (it) => {
109 it.effect("collapses state reports into progress stages and succeeds on installing", () =>
110 Effect.gen(function* () {
111 const { service } = yield* makeHarness({
112 reports: (requestId) => [
113 report(requestId, makeState({ status: "checking" })),
114 report(requestId, makeState({ status: "available", availableVersion: "1.2.4" })),
115 report(requestId, makeState({ status: "downloading", downloadPercent: 40 })),
116 report("other-run", makeState({ status: "error", message: "unrelated" })),
117 report(requestId, makeState({ status: "downloaded", downloadedVersion: "1.2.4" }), {
118 outcome: "ready-to-install",
119 }),
120 ],
121 });
122 const stages: string[] = [];
123 const result = yield* service.run((stage) => Effect.sync(() => void stages.push(stage)));
124 expect(result).toEqual({
125 targetVersion: "1.2.4",
126 method: "desktop-app",
127 desktopUpdateToken: expect.any(String),
128 });
129 expect(stages).toEqual(["downloading", "installing"]);
130​
131 const retry = yield* service.run(() => Effect.void).pipe(Effect.flip);
132 expect(retry.reason).not.toBe("A desktop app update is already in progress.");
133 }),
134 );
135​
136 it.effect("succeeds without an install when a track switch finds the host current", () =>
137 Effect.gen(function* () {
138 const { service } = yield* makeHarness({
139 reports: (requestId) => [
140 report(requestId, makeState({ status: "up-to-date", currentVersion: "1.2.3" }), {
141 outcome: "up-to-date",
142 }),
143 ],
144 });
145 const trackSwitch = { channel: "latest" as const, targetVersion: "1.2.3" };
146​
147 expect(yield* service.run(() => Effect.void, trackSwitch)).toEqual({
148 targetVersion: "1.2.3",
149 method: "desktop-app",
150 });
151 }),
152 );
153​
154 it.effect("replays a retained commit failure for the preparation token", () =>
155 Effect.gen(function* () {
156 const { service } = yield* makeHarness({
157 reports: (requestId) => [
158 report(requestId, makeState({ status: "downloaded", downloadedVersion: "1.2.4" }), {
159 outcome: "ready-to-install",
160 }),
161 report(
162 requestId,
163 makeState({
164 status: "downloaded",
165 downloadedVersion: "1.2.4",
166 errorContext: "install",
167 message: "installer refused",
168 }),
169 { outcome: "failed", reason: "installer refused" },
170 ),
171 ],
172 });
173 const prepared = yield* service.run(() => Effect.void);
174​
175 expect(
176 (yield* service.commit(prepared.desktopUpdateToken ?? "missing").pipe(Effect.flip)).reason,
177 ).toBe("installer refused");
178 }),
179 );
180​
181 it.effect("fails when the desktop stops reporting before a terminal outcome", () =>
182 Effect.gen(function* () {
183 const { service } = yield* makeHarness({
184 reports: (requestId) => [report(requestId, makeState({ status: "checking" }))],
185 });
186 expect((yield* service.run(() => Effect.void).pipe(Effect.flip)).reason).toBe(
187 "The desktop app stopped reporting its update.",
188 );
189 }),
190 );
191​
192 it.effect("allows only one desktop update at a time", () =>
193 Effect.gen(function* () {
194 const { service } = yield* makeHarness({ reports: () => [], keepOpen: true });
195 const first = yield* Effect.forkChild(
196 service.run(() => Effect.void),
197 {
198 startImmediately: true,
199 },
200 );
201 expect((yield* service.run(() => Effect.void).pipe(Effect.flip)).reason).toBe(
202 "A desktop app update is already in progress.",
203 );
204 yield* Fiber.interrupt(first);
205​
206 const retry = yield* Effect.forkChild(
207 service.run(() => Effect.void),
208 {
209 startImmediately: true,
210 },
211 );
212 yield* Effect.yieldNow;
213 expect((yield* service.run(() => Effect.void).pipe(Effect.flip)).reason).toBe(
214 "A desktop app update is already in progress.",
215 );
216 yield* Fiber.interrupt(retry);
217 }),
218 );
219});