1import { describe, expect, it } from "vite-plus/test";
2import {
3 EnvironmentId,
4 ProviderDriverKind,
5 ProviderInstanceId,
6 type ServerProvider,
7} from "@t3tools/contracts";
8import * as Cause from "effect/Cause";
9import { AsyncResult } from "effect/reactivity";
10
11import {
12 canOneClickUpdateProviderCandidate,
13 getProviderUpdateRunToastView,
14 getProviderUpdateSidebarPillView,
15 hasOneClickUpdateProviderCandidate,
16 isProviderUpdateCandidate,
17 isProviderSettingsUpdateCandidate,
18 type ProviderUpdateCandidate,
19} from "./ProviderUpdateLaunchNotification.logic";
20
21const checkedAt = "2026-04-23T10:00:00.000Z";
22const sessionStartedAt = "2026-04-23T09:59:00.000Z";
23const laterCheckedAt = "2026-04-23T10:01:00.000Z";
24
25const driver = (value: string) => ProviderDriverKind.make(value);
26const instanceId = (value: string) => ProviderInstanceId.make(value);
27
28function provider(input: {
29 readonly driver: ReturnType<typeof ProviderDriverKind.make>;
30 readonly instanceId?: ReturnType<typeof ProviderInstanceId.make>;
31 readonly enabled?: boolean;
32 readonly version?: string | null;
33 readonly latestVersion?: string | null;
34 readonly canUpdate?: boolean;
35 readonly updateCommand?: string | null;
36 readonly updateState?: ServerProvider["updateState"];
37 readonly advisoryStatus?: NonNullable<ServerProvider["versionAdvisory"]>["status"];
38}): ServerProvider {
39 const result: ServerProvider = {
40 instanceId: input.instanceId ?? instanceId(String(input.driver)),
41 driver: input.driver,
42 enabled: input.enabled ?? true,
43 installed: true,
44 version: input.version ?? "1.0.0",
45 status: "ready",
46 auth: { status: "authenticated" },
47 checkedAt,
48 models: [],
49 slashCommands: [],
50 skills: [],
51 versionAdvisory: {
52 status: input.advisoryStatus ?? "behind_latest",
53 currentVersion: input.version ?? "1.0.0",
54 latestVersion: "latestVersion" in input ? input.latestVersion : "1.1.0",
55 updateCommand: "updateCommand" in input ? input.updateCommand : "npm install -g provider",
56 canUpdate: input.canUpdate ?? true,
57 checkedAt,
58 message: "Update available.",
59 },
60 };
61
62 if (input.updateState) {
63 return { ...result, updateState: input.updateState };
64 }
65
66 return result;
67}
68
69function updateCandidate(input: Parameters<typeof provider>[0]): ProviderUpdateCandidate {
70 return provider(input) as ProviderUpdateCandidate;
71}
72
73describe("provider update launch notification logic", () => {
74 it("disables one-click updates when provider instances disagree on the update command", () => {
75 const candidate = updateCandidate({
76 driver: driver("claudeAgent"),
77 instanceId: instanceId("claude_personal"),
78 latestVersion: "2.1.123",
79 });
80
81 expect(
82 canOneClickUpdateProviderCandidate(candidate, [
83 candidate,
84 provider({
85 driver: driver("claudeAgent"),
86 instanceId: instanceId("claude_work"),
87 latestVersion: "2.1.123",
88 canUpdate: true,
89 updateCommand: "bun add -g @anthropic-ai/claude-code@latest",
90 }),
91 ]),
92 ).toBe(false);
93 });
94
95 it("keeps one-click updates enabled when sibling instances are already current", () => {
96 const candidate = updateCandidate({
97 driver: driver("claudeAgent"),
98 instanceId: instanceId("claude_personal"),
99 latestVersion: "2.1.123",
100 updateCommand: "npm install -g @anthropic-ai/claude-code@latest",
101 });
102
103 expect(
104 hasOneClickUpdateProviderCandidate(candidate, [
105 candidate,
106 provider({
107 driver: driver("claudeAgent"),
108 instanceId: instanceId("claude_work"),
109 version: "2.1.123",
110 latestVersion: "2.1.123",
111 advisoryStatus: "current",
112 canUpdate: false,
113 updateCommand: null,
114 }),
115 ]),
116 ).toBe(true);
117 expect(
118 canOneClickUpdateProviderCandidate(candidate, [
119 candidate,
120 provider({
121 driver: driver("claudeAgent"),
122 instanceId: instanceId("claude_work"),
123 version: "2.1.123",
124 latestVersion: "2.1.123",
125 advisoryStatus: "current",
126 canUpdate: false,
127 updateCommand: null,
128 }),
129 ]),
130 ).toBe(true);
131 });
132
133 it("keeps the inline update action available while a provider update is already running", () => {
134 const candidate = updateCandidate({
135 driver: driver("codex"),
136 updateState: {
137 status: "running",
138 startedAt: checkedAt,
139 finishedAt: null,
140 message: "Updating provider.",
141 output: null,
142 },
143 });
144
145 expect(hasOneClickUpdateProviderCandidate(candidate, [candidate])).toBe(true);
146 expect(canOneClickUpdateProviderCandidate(candidate, [candidate])).toBe(false);
147 });
148
149 it("shows the active sidebar pill for an update on a non-default instance", () => {
150 const view = getProviderUpdateSidebarPillView([
151 provider({ driver: driver("claudeAgent") }),
152 provider({
153 driver: driver("claudeAgent"),
154 instanceId: instanceId("claude_work"),
155 updateState: {
156 status: "running",
157 startedAt: checkedAt,
158 finishedAt: null,
159 message: "Updating provider.",
160 output: null,
161 },
162 }),
163 ]);
164
165 expect(view).toMatchObject({
166 key: "loading:claudeAgent:running",
167 tone: "loading",
168 title: "Updating Claude",
169 description: "Claude update in progress.",
170 });
171 });
172
173 it("shows the latest terminal sidebar pill across instances of a driver", () => {
174 const view = getProviderUpdateSidebarPillView(
175 [
176 provider({
177 driver: driver("claudeAgent"),
178 updateState: {
179 status: "failed",
180 startedAt: checkedAt,
181 finishedAt: checkedAt,
182 message: "Update command exited with code 1.",
183 output: null,
184 },
185 }),
186 provider({
187 driver: driver("claudeAgent"),
188 instanceId: instanceId("claude_work"),
189 version: "1.1.0",
190 latestVersion: "1.1.0",
191 advisoryStatus: "current",
192 updateState: {
193 status: "succeeded",
194 startedAt: laterCheckedAt,
195 finishedAt: laterCheckedAt,
196 message: "Provider updated.",
197 output: null,
198 },
199 }),
200 ],
201 { visibleAfterIso: sessionStartedAt },
202 );
203
204 expect(view).toMatchObject({
205 key: "succeeded:claudeAgent:2026-04-23T10:01:00.000Z:Provider updated.",
206 tone: "success",
207 title: "Claude updated: v1.1.0",
208 });
209 });
210
211 it("does not show sidebar terminal states from before the current app session", () => {
212 expect(
213 getProviderUpdateSidebarPillView(
214 [
215 provider({
216 driver: driver("codex"),
217 updateState: {
218 status: "failed",
219 startedAt: checkedAt,
220 finishedAt: checkedAt,
221 message: "command failed",
222 output: "stderr",
223 },
224 }),
225 ],
226 { visibleAfterIso: "2026-04-23T10:00:01.000Z" },
227 ),
228 ).toBeNull();
229 });
230
231 it("shows a newer success before falling back to an older failure", () => {
232 const providers = [
233 provider({
234 driver: driver("claudeAgent"),
235 updateState: {
236 status: "failed",
237 startedAt: checkedAt,
238 finishedAt: checkedAt,
239 message: "Update command exited with code 1.",
240 output: null,
241 },
242 }),
243 provider({
244 driver: driver("codex"),
245 version: "1.2.0",
246 latestVersion: "1.2.0",
247 advisoryStatus: "current",
248 updateState: {
249 status: "succeeded",
250 startedAt: laterCheckedAt,
251 finishedAt: laterCheckedAt,
252 message: "Provider updated.",
253 output: null,
254 },
255 }),
256 ] satisfies ReadonlyArray<ServerProvider>;
257
258 const successView = getProviderUpdateSidebarPillView(providers, {
259 visibleAfterIso: sessionStartedAt,
260 });
261 expect(successView).toMatchObject({
262 key: "succeeded:codex:2026-04-23T10:01:00.000Z:Provider updated.",
263 tone: "success",
264 title: "Codex updated: v1.2.0",
265 });
266
267 const failureView = getProviderUpdateSidebarPillView(providers, {
268 visibleAfterIso: sessionStartedAt,
269 dismissedKeys: new Set(["succeeded:codex:2026-04-23T10:01:00.000Z:Provider updated."]),
270 });
271 expect(failureView).toMatchObject({
272 key: "failed:claudeAgent:2026-04-23T10:00:00.000Z:Update command exited with code 1.",
273 tone: "error",
274 title: "Claude v1.1.0 update failed",
275 });
276 });
277});
278
279it("does not offer incompatible latest versions and restores suggestions after policy relaxation", () => {
280 const installed = provider({ driver: driver("codex") });
281 for (const latestVersionStatus of ["broken", "unsupported", "supported", "unknown"] as const) {
282 const snapshot: ServerProvider = {
283 ...installed,
284 compatibilityAdvisory: {
285 status: "supported",
286 latestVersionStatus,
287 message: null,
288 recommendedRange: null,
289 recommendedVersion: null,
290 },
291 };
292 const expected = latestVersionStatus === "supported" || latestVersionStatus === "unknown";
293 expect(isProviderUpdateCandidate(snapshot)).toBe(expected);
294 expect(isProviderSettingsUpdateCandidate(snapshot)).toBe(expected);
295 }
296});
297
298describe("getProviderUpdateRunToastView", () => {
299 const updateState = (
300 status: "succeeded" | "failed",
301 message: string,
302 ): ServerProvider["updateState"] => ({
303 status,
304 startedAt: checkedAt,
305 finishedAt: laterCheckedAt,
306 message,
307 output: null,
308 });
309 const run = (
310 machineLabel: string,
311 providerDriver: string,
312 result: Parameters<typeof getProviderUpdateRunToastView>[0][number]["result"],
313 ) => ({
314 environmentId: EnvironmentId.make(machineLabel),
315 machineLabel,
316 driver: driver(providerDriver),
317 instanceId: instanceId(providerDriver),
318 result,
319 });
320
321 it("preserves the local server restart instruction after a successful update", () => {
322 const message =
323 "OpenCode CLI updated. Restart the configured local OpenCode server to use the new version.";
324 const updated = {
325 ...provider({
326 driver: driver("opencode"),
327 version: "2.1.2",
328 advisoryStatus: "current",
329 updateState: updateState("succeeded", message),
330 }),
331 installedVersion: "2.2.5",
332 };
333 expect(
334 getProviderUpdateRunToastView([
335 run("Linux", "opencode", AsyncResult.success({ providers: [updated] })),
336 run("Laptop", "opencode", AsyncResult.success({ providers: [updated] })),
337 ]),
338 ).toMatchObject({
339 type: "success",
340 description: `Linux · OpenCode: ${message}\nLaptop · OpenCode: ${message}`,
341 });
342 expect(
343 getProviderUpdateSidebarPillView([updated], {
344 visibleAfterIso: sessionStartedAt,
345 }),
346 ).toMatchObject({ title: "OpenCode updated: v2.2.5", description: message });
347
348 const restarted = { ...updated, version: "2.2.5" };
349 expect(
350 getProviderUpdateRunToastView([
351 run("Linux", "opencode", AsyncResult.success({ providers: [restarted] })),
352 ]),
353 ).toMatchObject({ description: "New sessions will use the updated provider." });
354 });
355
356 it("lists every failed update and ignores interrupted ones", () => {
357 const view = getProviderUpdateRunToastView([
358 run(
359 "Mac Studio",
360 "codex",
361 AsyncResult.success({
362 providers: [
363 provider({
364 driver: driver("codex"),
365 updateState: updateState("succeeded", "Provider updated."),
366 }),
367 ],
368 }),
369 ),
370 run(
371 "Mac Studio",
372 "claudeAgent",
373 AsyncResult.success({
374 providers: [
375 provider({
376 driver: driver("claudeAgent"),
377 updateState: updateState("failed", "npm exited with code 1."),
378 }),
379 ],
380 }),
381 ),
382 run("Laptop", "codex", AsyncResult.failure(Cause.die(new Error("WebSocket closed")))),
383 run("Server", "codex", AsyncResult.failure(Cause.interrupt())),
384 ]);
385
386 expect(view).toEqual({
387 type: "error",
388 title: "2 of 3 provider updates failed",
389 description: "Mac Studio · Claude: npm exited with code 1.\nLaptop · Codex: WebSocket closed",
390 });
391 });
392});