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