| 1 | import { EnvironmentId, type ServerSelfUpdateCapability } from "@t3tools/contracts"; |
| 2 | import { describe, expect, it } from "vite-plus/test"; |
| 3 | |
| 4 | import { |
| 5 | collectRestartingMachines, |
| 6 | collectServerUpdateTargets, |
| 7 | collectUpdateEverythingResult, |
| 8 | describeUpdateEverything, |
| 9 | getUpdateEverythingToastView, |
| 10 | resolveUpdateRunMachineStatus, |
| 11 | runUpdateEverything, |
| 12 | type UpdateEverythingMachine, |
| 13 | type UpdateRunMachine, |
| 14 | } from "./updateEverything.logic"; |
| 15 | |
| 16 | function machine( |
| 17 | id: string, |
| 18 | serverVersion: string, |
| 19 | selfUpdate: ServerSelfUpdateCapability | undefined, |
| 20 | overrides: Partial<UpdateEverythingMachine> = {}, |
| 21 | ): UpdateEverythingMachine { |
| 22 | return { |
| 23 | environmentId: EnvironmentId.make(id), |
| 24 | label: id, |
| 25 | serverConfig: { |
| 26 | environment: { |
| 27 | environmentId: EnvironmentId.make(id), |
| 28 | label: id, |
| 29 | platform: { os: "linux", arch: "x64" }, |
| 30 | serverVersion, |
| 31 | capabilities: { |
| 32 | repositoryIdentity: true, |
| 33 | ...(selfUpdate ? { serverSelfUpdate: selfUpdate } : {}), |
| 34 | ...(selfUpdate === "desktop-managed" ? { desktopAppUpdate: true } : {}), |
| 35 | }, |
| 36 | }, |
| 37 | settings: { continueThreadsAfterServerUpdate: false }, |
| 38 | }, |
| 39 | connected: true, |
| 40 | hostedByThisApp: false, |
| 41 | canMaintain: true, |
| 42 | updateState: { status: "idle" }, |
| 43 | ...overrides, |
| 44 | }; |
| 45 | } |
| 46 | |
| 47 | describe("collectServerUpdateTargets", () => { |
| 48 | it("updates remote servers that can move to the target and names every skipped one", () => { |
| 49 | const { targets, skipped } = collectServerUpdateTargets( |
| 50 | [ |
| 51 | machine("this-app", "0.0.40", "desktop-managed", { hostedByThisApp: true }), |
| 52 | machine("service", "0.0.40", "boot-service"), |
| 53 | machine("nightly-service", "0.0.49-nightly.20261001.3", "boot-service"), |
| 54 | machine("other-desktop", "0.0.49-nightly.20261001.3", "desktop-managed"), |
| 55 | machine("offline", "0.0.40", "boot-service", { connected: false }), |
| 56 | machine("never-connected", "0.0.40", "boot-service", { serverConfig: null }), |
| 57 | machine("no-scope", "0.0.40", "boot-service", { canMaintain: false }), |
| 58 | machine("busy", "0.0.40", "boot-service", { |
| 59 | updateState: { |
| 60 | status: "running", |
| 61 | stage: "downloading", |
| 62 | fromVersion: "0.0.40", |
| 63 | targetVersion: "0.0.50", |
| 64 | }, |
| 65 | }), |
| 66 | machine("current", "0.0.50", "boot-service"), |
| 67 | machine("current-offline", "0.0.50", "boot-service", { connected: false }), |
| 68 | machine("manual", "0.0.40", undefined), |
| 69 | ], |
| 70 | "0.0.50", |
| 71 | ); |
| 72 | |
| 73 | expect(targets.map((target) => [target.serverLabel, target.targetVersion])).toEqual([ |
| 74 | ["service", "0.0.50"], |
| 75 | ["other-desktop", "0.0.50"], |
| 76 | ]); |
| 77 | expect(skipped.map((entry) => [entry.label, entry.reason])).toEqual([ |
| 78 | ["nightly-service", "on the nightly channel"], |
| 79 | ["offline", "not connected"], |
| 80 | ["never-connected", "not connected"], |
| 81 | ["no-scope", "no permission"], |
| 82 | ["busy", "already updating"], |
| 83 | ["manual", "needs a manual update"], |
| 84 | ]); |
| 85 | }); |
| 86 | }); |
| 87 | |
| 88 | describe("collectRestartingMachines", () => { |
| 89 | it("lists updated servers, and this app's servers only when it installs", () => { |
| 90 | const withContinuation = ( |
| 91 | id: string, |
| 92 | overrides: Partial<UpdateEverythingMachine> = {}, |
| 93 | enabled = false, |
| 94 | ) => { |
| 95 | const base = machine(id, "0.0.40", "boot-service", overrides); |
| 96 | return { |
| 97 | ...base, |
| 98 | serverConfig: { |
| 99 | environment: { |
| 100 | ...base.serverConfig!.environment, |
| 101 | capabilities: { |
| 102 | ...base.serverConfig!.environment.capabilities, |
| 103 | threadRestartContinuation: true, |
| 104 | }, |
| 105 | }, |
| 106 | settings: { continueThreadsAfterServerUpdate: enabled }, |
| 107 | }, |
| 108 | }; |
| 109 | }; |
| 110 | const machines = [ |
| 111 | withContinuation("this-app", { hostedByThisApp: true }, true), |
| 112 | withContinuation("alvin", {}, true), |
| 113 | withContinuation("opted-out", {}, false), |
| 114 | machine("old", "0.0.40", "boot-service"), |
| 115 | withContinuation("providers-only"), |
| 116 | ]; |
| 117 | const servers = ["alvin", "opted-out", "old"].map((id) => ({ |
| 118 | environmentId: EnvironmentId.make(id), |
| 119 | })); |
| 120 | |
| 121 | expect(collectRestartingMachines(machines, servers, true)).toEqual([ |
| 122 | { environmentId: "this-app", continuesThreads: true }, |
| 123 | { environmentId: "alvin", continuesThreads: true }, |
| 124 | { environmentId: "opted-out", continuesThreads: false }, |
| 125 | { environmentId: "old", continuesThreads: false }, |
| 126 | ]); |
| 127 | expect( |
| 128 | collectRestartingMachines(machines, servers, false).map((entry) => entry.environmentId), |
| 129 | ).toEqual(["alvin", "opted-out", "old"]); |
| 130 | }); |
| 131 | }); |
| 132 | |
| 133 | describe("runUpdateEverything", () => { |
| 134 | it("restarts servers after providers finish and returns every result", async () => { |
| 135 | const calls: string[] = []; |
| 136 | let finishProviders = (_value: string) => {}; |
| 137 | const run = runUpdateEverything({ |
| 138 | downloadLocal: async () => { |
| 139 | calls.push("download"); |
| 140 | return true; |
| 141 | }, |
| 142 | updateProviders: () => |
| 143 | new Promise<string>((resolve) => { |
| 144 | calls.push("providers"); |
| 145 | finishProviders = resolve; |
| 146 | }), |
| 147 | updateServers: async () => { |
| 148 | calls.push("servers"); |
| 149 | return 2; |
| 150 | }, |
| 151 | }); |
| 152 | |
| 153 | await new Promise((resolve) => setTimeout(resolve, 0)); |
| 154 | expect(calls).toEqual(["download", "providers"]); |
| 155 | finishProviders("provider results"); |
| 156 | expect(await run).toEqual({ localReady: true, providers: "provider results", servers: 2 }); |
| 157 | expect(calls).toEqual(["download", "providers", "servers"]); |
| 158 | }); |
| 159 | |
| 160 | it("still updates remote machines when the local download fails", async () => { |
| 161 | const calls: string[] = []; |
| 162 | const result = await runUpdateEverything({ |
| 163 | downloadLocal: async () => false, |
| 164 | updateProviders: async () => { |
| 165 | calls.push("providers"); |
| 166 | }, |
| 167 | updateServers: async () => { |
| 168 | calls.push("servers"); |
| 169 | }, |
| 170 | }); |
| 171 | |
| 172 | expect(calls).toEqual(["providers", "servers"]); |
| 173 | expect(result.localReady).toBe(false); |
| 174 | }); |
| 175 | }); |
| 176 | |
| 177 | describe("resolveUpdateRunMachineStatus", () => { |
| 178 | const runMachine = (overrides: Partial<UpdateRunMachine> = {}): UpdateRunMachine => ({ |
| 179 | environmentId: EnvironmentId.make("alvin"), |
| 180 | label: "alvin", |
| 181 | updatesProviders: true, |
| 182 | updatesServer: true, |
| 183 | result: null, |
| 184 | ...overrides, |
| 185 | }); |
| 186 | const running = (stage: "downloading" | "installing" | "resuming") => |
| 187 | ({ status: "running", stage, fromVersion: "0.0.40", targetVersion: "0.0.50" }) as const; |
| 188 | |
| 189 | it("follows the run phase, then the server's own update stage", () => { |
| 190 | const idle = { status: "idle" } as const; |
| 191 | expect(resolveUpdateRunMachineStatus(runMachine(), "providers", idle)).toBe("providers"); |
| 192 | expect(resolveUpdateRunMachineStatus(runMachine(), "servers", idle)).toBe("waiting"); |
| 193 | expect(resolveUpdateRunMachineStatus(runMachine(), "servers", running("installing"))).toBe( |
| 194 | "downloading", |
| 195 | ); |
| 196 | expect(resolveUpdateRunMachineStatus(runMachine(), "servers", running("resuming"))).toBe( |
| 197 | "restarting", |
| 198 | ); |
| 199 | expect( |
| 200 | resolveUpdateRunMachineStatus( |
| 201 | runMachine({ updatesProviders: false }), |
| 202 | "providers", |
| 203 | running("downloading"), |
| 204 | ), |
| 205 | ).toBe("downloading"); |
| 206 | expect(resolveUpdateRunMachineStatus(runMachine({ result: "failed" }), "servers", idle)).toBe( |
| 207 | "failed", |
| 208 | ); |
| 209 | }); |
| 210 | }); |
| 211 | |
| 212 | describe("update everything summary", () => { |
| 213 | const result = collectUpdateEverythingResult({ |
| 214 | localVersion: "0.0.50", |
| 215 | localFailures: ["Could not download update: Network error."], |
| 216 | servers: [ |
| 217 | { label: "alvin", outcome: { status: "failed", message: "Download failed." } }, |
| 218 | { label: "box", outcome: { status: "updated" } }, |
| 219 | { label: "studio", outcome: { status: "updated" } }, |
| 220 | { label: "busy", outcome: { status: "skipped" } }, |
| 221 | ], |
| 222 | providers: [ |
| 223 | { status: "updated" }, |
| 224 | { status: "interrupted" }, |
| 225 | { status: "failed", line: "box · Codex: npm exited with code 1." }, |
| 226 | ], |
| 227 | }); |
| 228 | |
| 229 | it("counts updates and names the machine of each failure", () => { |
| 230 | expect(result).toEqual({ |
| 231 | localVersion: "0.0.50", |
| 232 | updatedServers: 2, |
| 233 | updatedProviders: 1, |
| 234 | failures: [ |
| 235 | "This app: Could not download update: Network error.", |
| 236 | "alvin: Download failed.", |
| 237 | "box · Codex: npm exited with code 1.", |
| 238 | ], |
| 239 | }); |
| 240 | }); |
| 241 | |
| 242 | it("includes this app only when the relaunch landed on its version", () => { |
| 243 | expect(getUpdateEverythingToastView(result, "0.0.50")).toEqual({ |
| 244 | type: "error", |
| 245 | title: "Updated this app, 2 servers, and 1 provider", |
| 246 | description: [ |
| 247 | "This app: Could not download update: Network error.", |
| 248 | "alvin: Download failed.", |
| 249 | "box · Codex: npm exited with code 1.", |
| 250 | ].join("\n"), |
| 251 | }); |
| 252 | expect(getUpdateEverythingToastView({ ...result, failures: [] }, "0.0.40")).toEqual({ |
| 253 | type: "success", |
| 254 | title: "Updated 2 servers and 1 provider", |
| 255 | }); |
| 256 | }); |
| 257 | |
| 258 | it("reports a failure when nothing updated, and nothing when nothing finished", () => { |
| 259 | const empty = { localVersion: null, updatedServers: 0, updatedProviders: 0 }; |
| 260 | expect( |
| 261 | getUpdateEverythingToastView({ ...empty, failures: ["alvin: offline"] }, "0.0.50"), |
| 262 | ).toEqual({ type: "error", title: "Update failed", description: "alvin: offline" }); |
| 263 | expect(getUpdateEverythingToastView({ ...empty, failures: [] }, "0.0.50")).toBeNull(); |
| 264 | }); |
| 265 | }); |
| 266 | |
| 267 | describe("describeUpdateEverything", () => { |
| 268 | it("summarizes the run and lists each machine once", () => { |
| 269 | const alvin = EnvironmentId.make("alvin"); |
| 270 | expect( |
| 271 | describeUpdateEverything({ |
| 272 | localVersion: "0.0.50", |
| 273 | servers: [ |
| 274 | { |
| 275 | environmentId: alvin, |
| 276 | serverLabel: "alvin", |
| 277 | selfUpdate: "boot-service", |
| 278 | targetVersion: "0.0.50", |
| 279 | }, |
| 280 | { |
| 281 | environmentId: EnvironmentId.make("studio"), |
| 282 | serverLabel: "studio", |
| 283 | selfUpdate: "desktop-managed", |
| 284 | targetVersion: "0.0.50", |
| 285 | }, |
| 286 | ], |
| 287 | providerMachines: [ |
| 288 | { environmentId: alvin, label: "alvin", providers: ["Codex", "Claude"] }, |
| 289 | { environmentId: EnvironmentId.make("box"), label: "box", providers: ["Cursor"] }, |
| 290 | ], |
| 291 | }), |
| 292 | ).toEqual({ |
| 293 | summary: "Update this app, 2 servers, and 3 providers", |
| 294 | lines: [ |
| 295 | "This app: 0.0.50, restarts last", |
| 296 | "alvin: server to 0.0.50, Codex, Claude", |
| 297 | "studio: desktop app update", |
| 298 | "box: Cursor", |
| 299 | ], |
| 300 | }); |
| 301 | }); |
| 302 | |
| 303 | it("reads naturally with one kind of update", () => { |
| 304 | expect( |
| 305 | describeUpdateEverything({ |
| 306 | localVersion: null, |
| 307 | servers: [], |
| 308 | providerMachines: [ |
| 309 | { environmentId: EnvironmentId.make("alvin"), label: "alvin", providers: ["Codex"] }, |
| 310 | ], |
| 311 | }).summary, |
| 312 | ).toBe("Update 1 provider"); |
| 313 | }); |
| 314 | }); |