| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | import { expect, it } from "@effect/vitest"; |
| 3 | import type { DesktopUpdateState, DesktopUpdateStatusReport } from "@t3tools/contracts"; |
| 4 | import * as Deferred from "effect/Deferred"; |
| 5 | import * as Effect from "effect/Effect"; |
| 6 | import * as FileSystem from "effect/FileSystem"; |
| 7 | import * as Fiber from "effect/Fiber"; |
| 8 | import * as Layer from "effect/Layer"; |
| 9 | import * as Option from "effect/Option"; |
| 10 | import * as Stream from "effect/Stream"; |
| 11 | |
| 12 | import * as ServerConfig from "../config.ts"; |
| 13 | import * as DesktopTelemetryReceiver from "../resourceTelemetry/DesktopTelemetryReceiver.ts"; |
| 14 | import * as DesktopAppUpdate from "./DesktopAppUpdate.ts"; |
| 15 | |
| 16 | function 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 | |
| 38 | function 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 | |
| 56 | interface 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 | |
| 65 | const 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 | |
| 109 | it.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 | }); |