1import { EnvironmentId, type ServerSelfUpdateCapability } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";
3
4import {
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
16function 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
47describe("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
88describe("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
133describe("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
177describe("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
212describe("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
267describe("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});