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 buildLocalEnvironmentUpdateGroups,
13 canOneClickUpdateProviderCandidate,
14 collectProviderUpdateCandidates,
15 collectProviderUpdateOutcomeSnapshots,
16 collectUpdatedProviderSnapshots,
17 deriveEnvironmentDisplayLabel,
18 environmentGroupsWithUpdates,
19 firstFailedProviderUpdateMessage,
20 firstRejectedProviderUpdateMessage,
21 getProviderUpdateInitialToastView,
22 getProviderUpdateProgressToastView,
23 getProviderUpdateRejectedToastView,
24 getProviderUpdateRunToastView,
25 getProviderUpdateSidebarPillView,
26 hasOneClickUpdateProviderCandidate,
27 isProviderUpdateCandidate,
28 isProviderSettingsUpdateCandidate,
29 isTerminalProviderUpdatePhase,
30 localEnvironmentUpdateNotificationKey,
31 providerUpdateNotificationKey,
32 resolveEnvironmentUpdateRowStatus,
33 shouldShowPrimaryProviderUpdateToast,
34 type LocalEnvironmentProvidersInput,
35 type LocalEnvironmentUpdateGroup,
36 type LocalProviderUpdateOutcome,
37 type ProviderUpdateCandidate,
38 type ProviderUpdateSidebarPillView,
39 type ProviderUpdateToastView,
40} from "./ProviderUpdateLaunchNotification.logic";
41
42const checkedAt = "2026-04-23T10:00:00.000Z";
43const sessionStartedAt = "2026-04-23T09:59:00.000Z";
44const laterCheckedAt = "2026-04-23T10:01:00.000Z";
45
46const driver = (value: string) => ProviderDriverKind.make(value);
47const instanceId = (value: string) => ProviderInstanceId.make(value);
48
49function provider(input: {
50 readonly driver: ReturnType<typeof ProviderDriverKind.make>;
51 readonly instanceId?: ReturnType<typeof ProviderInstanceId.make>;
52 readonly enabled?: boolean;
53 readonly version?: string | null;
54 readonly latestVersion?: string | null;
55 readonly canUpdate?: boolean;
56 readonly updateCommand?: string | null;
57 readonly updateState?: ServerProvider["updateState"];
58 readonly advisoryStatus?: NonNullable<ServerProvider["versionAdvisory"]>["status"];
59}): ServerProvider {
60 const result: ServerProvider = {
61 instanceId: input.instanceId ?? instanceId(String(input.driver)),
62 driver: input.driver,
63 enabled: input.enabled ?? true,
64 installed: true,
65 version: input.version ?? "1.0.0",
66 status: "ready",
67 auth: { status: "authenticated" },
68 checkedAt,
69 models: [],
70 slashCommands: [],
71 skills: [],
72 versionAdvisory: {
73 status: input.advisoryStatus ?? "behind_latest",
74 currentVersion: input.version ?? "1.0.0",
75 latestVersion: "latestVersion" in input ? input.latestVersion : "1.1.0",
76 updateCommand: "updateCommand" in input ? input.updateCommand : "npm install -g provider",
77 canUpdate: input.canUpdate ?? true,
78 checkedAt,
79 message: "Update available.",
80 },
81 };
82
83 if (input.updateState) {
84 return { ...result, updateState: input.updateState };
85 }
86
87 return result;
88}
89
90function updateCandidate(input: Parameters<typeof provider>[0]): ProviderUpdateCandidate {
91 return provider(input) as ProviderUpdateCandidate;
92}
93
94describe("provider update launch notification logic", () => {
95 it("detects enabled providers with a latest-version advisory", () => {
96 expect(isProviderUpdateCandidate(provider({ driver: driver("codex") }))).toBe(true);
97 expect(isProviderUpdateCandidate(provider({ driver: driver("codex"), enabled: false }))).toBe(
98 false,
99 );
100 expect(
101 isProviderUpdateCandidate(
102 provider({ driver: driver("codex"), advisoryStatus: "current", latestVersion: null }),
103 ),
104 ).toBe(false);
105 expect(
106 isProviderUpdateCandidate(provider({ driver: driver("codex"), latestVersion: null })),
107 ).toBe(false);
108 });
109
110 it("deduplicates multi-instance provider candidates by driver", () => {
111 expect(
112 collectProviderUpdateCandidates([
113 provider({
114 driver: driver("codex"),
115 instanceId: instanceId("codex_personal"),
116 latestVersion: "1.1.0",
117 }),
118 provider({
119 driver: driver("codex"),
120 instanceId: instanceId("codex"),
121 latestVersion: "1.1.0",
122 }),
123 provider({ driver: driver("cursor"), latestVersion: "0.3.0" }),
124 ]),
125 ).toHaveLength(2);
126 });
127
128 it("disables one-click updates when provider instances disagree on the update command", () => {
129 const candidate = updateCandidate({
130 driver: driver("claudeAgent"),
131 instanceId: instanceId("claude_personal"),
132 latestVersion: "2.1.123",
133 });
134
135 expect(
136 canOneClickUpdateProviderCandidate(candidate, [
137 candidate,
138 provider({
139 driver: driver("claudeAgent"),
140 instanceId: instanceId("claude_work"),
141 latestVersion: "2.1.123",
142 canUpdate: true,
143 updateCommand: "bun add -g @anthropic-ai/claude-code@latest",
144 }),
145 ]),
146 ).toBe(false);
147 });
148
149 it("keeps one-click updates enabled when sibling instances are already current", () => {
150 const candidate = updateCandidate({
151 driver: driver("claudeAgent"),
152 instanceId: instanceId("claude_personal"),
153 latestVersion: "2.1.123",
154 updateCommand: "npm install -g @anthropic-ai/claude-code@latest",
155 });
156
157 expect(
158 hasOneClickUpdateProviderCandidate(candidate, [
159 candidate,
160 provider({
161 driver: driver("claudeAgent"),
162 instanceId: instanceId("claude_work"),
163 version: "2.1.123",
164 latestVersion: "2.1.123",
165 advisoryStatus: "current",
166 canUpdate: false,
167 updateCommand: null,
168 }),
169 ]),
170 ).toBe(true);
171 expect(
172 canOneClickUpdateProviderCandidate(candidate, [
173 candidate,
174 provider({
175 driver: driver("claudeAgent"),
176 instanceId: instanceId("claude_work"),
177 version: "2.1.123",
178 latestVersion: "2.1.123",
179 advisoryStatus: "current",
180 canUpdate: false,
181 updateCommand: null,
182 }),
183 ]),
184 ).toBe(true);
185 });
186
187 it("keeps the inline update action available while a provider update is already running", () => {
188 const candidate = updateCandidate({
189 driver: driver("codex"),
190 updateState: {
191 status: "running",
192 startedAt: checkedAt,
193 finishedAt: null,
194 message: "Updating provider.",
195 output: null,
196 },
197 });
198
199 expect(hasOneClickUpdateProviderCandidate(candidate, [candidate])).toBe(true);
200 expect(canOneClickUpdateProviderCandidate(candidate, [candidate])).toBe(false);
201 });
202
203 it("builds a notification key from provider latest versions", () => {
204 const codex = updateCandidate({
205 driver: driver("codex"),
206 version: "1.0.0",
207 latestVersion: "1.1.0",
208 });
209 const cursor = updateCandidate({
210 driver: driver("cursor"),
211 version: "0.2.0",
212 latestVersion: "0.3.0",
213 });
214
215 expect(providerUpdateNotificationKey([codex, cursor])).toBe("codex:1.1.0|cursor:0.3.0");
216 expect(providerUpdateNotificationKey([])).toBeNull();
217 });
218
219 it("keeps the same notification key while the published update version is unchanged", () => {
220 const first = updateCandidate({
221 driver: driver("codex"),
222 version: "1.0.0",
223 latestVersion: "1.2.0",
224 });
225 const second = updateCandidate({
226 driver: driver("codex"),
227 version: "1.1.0",
228 latestVersion: "1.2.0",
229 });
230 const nextPublishedVersion = updateCandidate({
231 driver: driver("codex"),
232 version: "1.1.0",
233 latestVersion: "1.3.0",
234 });
235
236 expect(providerUpdateNotificationKey([first])).toBe(providerUpdateNotificationKey([second]));
237 expect(providerUpdateNotificationKey([nextPublishedVersion])).not.toBe(
238 providerUpdateNotificationKey([first]),
239 );
240 });
241
242 it("tracks updated provider snapshots by instance instead of collapsing to a sibling driver", () => {
243 const targetInstanceId = instanceId("codex_personal");
244 const siblingInstanceId = instanceId("codex");
245 const updatedPersonal = provider({
246 driver: driver("codex"),
247 instanceId: targetInstanceId,
248 version: "1.1.0",
249 latestVersion: "1.1.0",
250 advisoryStatus: "current",
251 updateState: {
252 status: "succeeded",
253 startedAt: checkedAt,
254 finishedAt: checkedAt,
255 message: "Provider updated.",
256 output: null,
257 },
258 });
259 const currentDefaultSibling = provider({
260 driver: driver("codex"),
261 instanceId: siblingInstanceId,
262 version: "1.1.0",
263 latestVersion: "1.1.0",
264 advisoryStatus: "current",
265 updateState: undefined,
266 });
267
268 expect(
269 collectUpdatedProviderSnapshots({
270 results: [
271 AsyncResult.success({
272 providers: [updatedPersonal, currentDefaultSibling],
273 }),
274 ],
275 providerInstanceIds: new Set([targetInstanceId]),
276 }),
277 ).toEqual([updatedPersonal]);
278 });
279
280 it("describes a single one-click update", () => {
281 const view = getProviderUpdateInitialToastView({
282 updateProviders: [updateCandidate({ driver: driver("codex"), latestVersion: "1.1.0" })],
283 oneClickProviders: [updateCandidate({ driver: driver("codex"), latestVersion: "1.1.0" })],
284 });
285
286 expect(view).toMatchObject({
287 phase: "initial",
288 type: "warning",
289 title: "Update Available: Codex v1.1.0",
290 description: "Install the update now or review provider settings.",
291 });
292 });
293
294 it("describes settings-only updates without one-click support", () => {
295 const view = getProviderUpdateInitialToastView({
296 updateProviders: [
297 updateCandidate({ driver: driver("codex"), canUpdate: false }),
298 updateCandidate({ driver: driver("cursor"), canUpdate: false }),
299 ],
300 oneClickProviders: [],
301 });
302
303 expect(view.description).toBe("Codex and Cursor can be updated from provider settings.");
304 });
305
306 it("uses server update state for running progress", () => {
307 const view = getProviderUpdateProgressToastView({
308 providers: [
309 provider({
310 driver: driver("codex"),
311 updateState: {
312 status: "running",
313 startedAt: checkedAt,
314 finishedAt: null,
315 message: "Updating provider.",
316 output: null,
317 },
318 }),
319 ],
320 providerCount: 1,
321 });
322
323 expect(view).toMatchObject({
324 phase: "running",
325 type: "loading",
326 title: "Updating provider",
327 });
328 expect(shouldShowPrimaryProviderUpdateToast(view)).toBe(false);
329 });
330
331 it("keeps the initial prompt and terminal outcomes visible as toasts", () => {
332 expect(
333 shouldShowPrimaryProviderUpdateToast(
334 getProviderUpdateInitialToastView({
335 updateProviders: [updateCandidate({ driver: driver("codex") })],
336 oneClickProviders: [updateCandidate({ driver: driver("codex") })],
337 }),
338 ),
339 ).toBe(true);
340 expect(
341 shouldShowPrimaryProviderUpdateToast(getProviderUpdateRejectedToastView(1, "boom")),
342 ).toBe(true);
343 });
344
345 it("uses server failure state for failed progress", () => {
346 const view = getProviderUpdateProgressToastView({
347 providers: [
348 provider({
349 driver: driver("codex"),
350 updateState: {
351 status: "failed",
352 startedAt: checkedAt,
353 finishedAt: checkedAt,
354 message: "command failed",
355 output: "stderr",
356 },
357 }),
358 ],
359 providerCount: 1,
360 });
361
362 expect(view).toMatchObject({
363 phase: "failed",
364 type: "error",
365 title: "Provider update failed",
366 description: "command failed",
367 });
368 });
369
370 it("keeps unchanged providers actionable from settings", () => {
371 const view = getProviderUpdateProgressToastView({
372 providers: [
373 provider({
374 driver: driver("cursor"),
375 updateState: {
376 status: "unchanged",
377 startedAt: checkedAt,
378 finishedAt: checkedAt,
379 message: "still old",
380 output: null,
381 },
382 }),
383 ],
384 providerCount: 1,
385 });
386
387 expect(view).toMatchObject({
388 phase: "unchanged",
389 type: "warning",
390 title: "Provider still needs an update",
391 description: "Cursor still appears outdated. Check provider settings for details.",
392 });
393 });
394
395 it("marks progress succeeded once every attempted provider is no longer outdated", () => {
396 const view = getProviderUpdateProgressToastView({
397 providers: [
398 provider({
399 driver: driver("codex"),
400 version: "1.1.0",
401 latestVersion: "1.1.0",
402 advisoryStatus: "current",
403 updateState: {
404 status: "succeeded",
405 startedAt: checkedAt,
406 finishedAt: checkedAt,
407 message: "Provider updated.",
408 output: null,
409 },
410 }),
411 ],
412 providerCount: 1,
413 });
414
415 expect(view).toMatchObject({
416 phase: "succeeded",
417 type: "success",
418 title: "Provider updated",
419 description: "New sessions will use the updated provider.",
420 dismissAfterVisibleMs: 3_000,
421 });
422 });
423
424 it("falls back to a rejected RPC message for transport-level failures", () => {
425 const results = [AsyncResult.failure(Cause.die(new Error("WebSocket closed")))];
426
427 expect(firstFailedProviderUpdateMessage(results)).toBe("WebSocket closed");
428 expect(getProviderUpdateRejectedToastView(2, "WebSocket closed")).toMatchObject({
429 phase: "failed",
430 title: "Provider updates failed",
431 description: "WebSocket closed",
432 });
433 });
434
435 it("collects only attempted provider snapshots from update responses", () => {
436 const codex = provider({ driver: driver("codex") });
437 const cursor = provider({ driver: driver("cursor") });
438 const results = [AsyncResult.success({ providers: [codex, cursor] })];
439
440 expect(
441 collectUpdatedProviderSnapshots({
442 results,
443 providerInstanceIds: new Set([cursor.instanceId]),
444 }),
445 ).toEqual([cursor]);
446 });
447
448 it("summarizes active provider updates for the sidebar pill", () => {
449 const view = getProviderUpdateSidebarPillView([
450 provider({
451 driver: driver("codex"),
452 updateState: {
453 status: "running",
454 startedAt: checkedAt,
455 finishedAt: null,
456 message: "Updating provider.",
457 output: null,
458 },
459 }),
460 provider({
461 driver: driver("cursor"),
462 updateState: {
463 status: "queued",
464 startedAt: null,
465 finishedAt: null,
466 message: "Waiting for another provider update to finish.",
467 output: null,
468 },
469 }),
470 ]);
471
472 expect(view).toMatchObject({
473 tone: "loading",
474 title: "Updating 2 providers",
475 description: "Codex and Cursor updates are in progress.",
476 });
477 });
478
479 it("uses the provider name for single active sidebar pill updates", () => {
480 const view = getProviderUpdateSidebarPillView([
481 provider({
482 driver: driver("codex"),
483 updateState: {
484 status: "running",
485 startedAt: checkedAt,
486 finishedAt: null,
487 message: "Updating provider.",
488 output: null,
489 },
490 }),
491 ]);
492
493 expect(view).toMatchObject({
494 key: "loading:codex:running",
495 tone: "loading",
496 title: "Updating Codex",
497 description: "Codex update in progress.",
498 });
499 });
500
501 it("uses the provider name for single failed sidebar pill updates", () => {
502 const view = getProviderUpdateSidebarPillView(
503 [
504 provider({
505 driver: driver("claudeAgent"),
506 updateState: {
507 status: "failed",
508 startedAt: checkedAt,
509 finishedAt: checkedAt,
510 message: "Update command exited with code 1.",
511 output: null,
512 },
513 }),
514 ],
515 { visibleAfterIso: sessionStartedAt },
516 );
517
518 expect(view).toMatchObject({
519 key: "failed:claudeAgent:2026-04-23T10:00:00.000Z:Update command exited with code 1.",
520 tone: "error",
521 title: "Claude v1.1.0 update failed",
522 description: "Update command exited with code 1.",
523 dismissible: true,
524 });
525 });
526
527 it("shows a short-lived success sidebar pill after a single provider update succeeds", () => {
528 const view = getProviderUpdateSidebarPillView(
529 [
530 provider({
531 driver: driver("codex"),
532 version: "1.1.0",
533 latestVersion: "1.1.0",
534 advisoryStatus: "current",
535 updateState: {
536 status: "succeeded",
537 startedAt: checkedAt,
538 finishedAt: checkedAt,
539 message: "Provider updated.",
540 output: null,
541 },
542 }),
543 ],
544 { visibleAfterIso: sessionStartedAt },
545 );
546
547 expect(view).toMatchObject({
548 key: "succeeded:codex:2026-04-23T10:00:00.000Z:Provider updated.",
549 tone: "success",
550 title: "Codex updated: v1.1.0",
551 description: "New sessions will use the updated provider.",
552 dismissAfterVisibleMs: 3_000,
553 });
554 });
555
556 it("keeps unchanged sidebar pill states dismissible", () => {
557 const view = getProviderUpdateSidebarPillView(
558 [
559 provider({
560 driver: driver("cursor"),
561 updateState: {
562 status: "unchanged",
563 startedAt: checkedAt,
564 finishedAt: checkedAt,
565 message: "still old",
566 output: null,
567 },
568 }),
569 ],
570 { visibleAfterIso: sessionStartedAt },
571 );
572
573 expect(view).toMatchObject({
574 key: "unchanged:cursor:2026-04-23T10:00:00.000Z:still old",
575 tone: "warning",
576 title: "Cursor still needs an update",
577 dismissible: true,
578 });
579 });
580
581 it("does not show sidebar terminal states from before the current app session", () => {
582 expect(
583 getProviderUpdateSidebarPillView(
584 [
585 provider({
586 driver: driver("codex"),
587 updateState: {
588 status: "failed",
589 startedAt: checkedAt,
590 finishedAt: checkedAt,
591 message: "command failed",
592 output: "stderr",
593 },
594 }),
595 ],
596 { visibleAfterIso: "2026-04-23T10:00:01.000Z" },
597 ),
598 ).toBeNull();
599 });
600
601 it("shows a newer success before falling back to an older failure", () => {
602 const providers = [
603 provider({
604 driver: driver("claudeAgent"),
605 updateState: {
606 status: "failed",
607 startedAt: checkedAt,
608 finishedAt: checkedAt,
609 message: "Update command exited with code 1.",
610 output: null,
611 },
612 }),
613 provider({
614 driver: driver("codex"),
615 version: "1.2.0",
616 latestVersion: "1.2.0",
617 advisoryStatus: "current",
618 updateState: {
619 status: "succeeded",
620 startedAt: laterCheckedAt,
621 finishedAt: laterCheckedAt,
622 message: "Provider updated.",
623 output: null,
624 },
625 }),
626 ] satisfies ReadonlyArray<ServerProvider>;
627
628 const successView = getProviderUpdateSidebarPillView(providers, {
629 visibleAfterIso: sessionStartedAt,
630 });
631 expect(successView).toMatchObject({
632 key: "succeeded:codex:2026-04-23T10:01:00.000Z:Provider updated.",
633 tone: "success",
634 title: "Codex updated: v1.2.0",
635 });
636
637 const failureView = getProviderUpdateSidebarPillView(providers, {
638 visibleAfterIso: sessionStartedAt,
639 dismissedKeys: new Set(["succeeded:codex:2026-04-23T10:01:00.000Z:Provider updated."]),
640 });
641 expect(failureView).toMatchObject({
642 key: "failed:claudeAgent:2026-04-23T10:00:00.000Z:Update command exited with code 1.",
643 tone: "error",
644 title: "Claude v1.1.0 update failed",
645 });
646 });
647
648 it("does not show a sidebar pill for passive update availability", () => {
649 expect(
650 getProviderUpdateSidebarPillView([
651 provider({ driver: driver("codex"), canUpdate: true }),
652 provider({ driver: driver("cursor"), canUpdate: false }),
653 ]),
654 ).toBeNull();
655 });
656
657 describe("multi-backend update outcomes", () => {
658 const terminalState = (
659 status: "succeeded" | "failed" | "unchanged",
660 message: string,
661 ): NonNullable<ServerProvider["updateState"]> => ({
662 status,
663 startedAt: checkedAt,
664 finishedAt: checkedAt,
665 message,
666 output: null,
667 });
668
669 const fulfilledOutcome = (
670 isPrimary: boolean,
671 snapshot: ServerProvider | null,
672 environment = "env",
673 ): PromiseSettledResult<LocalProviderUpdateOutcome> => ({
674 status: "fulfilled",
675 value: {
676 environmentId: environment as LocalProviderUpdateOutcome["environmentId"],
677 isPrimary,
678 driver: snapshot?.driver ?? driver("codex"),
679 instanceId: snapshot?.instanceId ?? instanceId("codex"),
680 provider: snapshot,
681 },
682 });
683
684 it("surfaces a secondary backend's failed update over the primary's success", () => {
685 const snapshots = collectProviderUpdateOutcomeSnapshots([
686 fulfilledOutcome(
687 true,
688 provider({
689 driver: driver("codex"),
690 updateState: terminalState("succeeded", "Provider updated."),
691 }),
692 ),
693 fulfilledOutcome(
694 false,
695 provider({
696 driver: driver("codex"),
697 updateState: terminalState("failed", "npm: NotFound"),
698 }),
699 ),
700 ]);
701
702 expect(snapshots).toHaveLength(1);
703 expect(snapshots[0]?.updateState?.status).toBe("failed");
704 expect(
705 getProviderUpdateProgressToastView({ providers: snapshots, providerCount: 1 }),
706 ).toMatchObject({ phase: "failed" });
707 });
708
709 it("surfaces a secondary backend that stayed outdated over the primary's success", () => {
710 const snapshots = collectProviderUpdateOutcomeSnapshots([
711 fulfilledOutcome(
712 true,
713 provider({
714 driver: driver("codex"),
715 updateState: terminalState("succeeded", "Provider updated."),
716 }),
717 ),
718 fulfilledOutcome(
719 false,
720 provider({
721 driver: driver("codex"),
722 updateState: terminalState("unchanged", "still outdated"),
723 }),
724 ),
725 ]);
726
727 expect(snapshots[0]?.updateState?.status).toBe("unchanged");
728 expect(
729 getProviderUpdateProgressToastView({ providers: snapshots, providerCount: 1 }),
730 ).toMatchObject({ phase: "unchanged" });
731 });
732
733 it("reports success only when every backend succeeded", () => {
734 const snapshots = collectProviderUpdateOutcomeSnapshots([
735 fulfilledOutcome(
736 true,
737 provider({
738 driver: driver("codex"),
739 updateState: terminalState("succeeded", "Provider updated."),
740 }),
741 ),
742 fulfilledOutcome(
743 false,
744 provider({
745 driver: driver("codex"),
746 updateState: terminalState("succeeded", "Provider updated."),
747 }),
748 ),
749 ]);
750
751 expect(
752 getProviderUpdateProgressToastView({ providers: snapshots, providerCount: 1 }),
753 ).toMatchObject({ phase: "succeeded" });
754 });
755
756 it("ignores backends that did not return the targeted instance", () => {
757 const primary = provider({
758 driver: driver("codex"),
759 updateState: terminalState("succeeded", "Provider updated."),
760 });
761 const snapshots = collectProviderUpdateOutcomeSnapshots([
762 fulfilledOutcome(true, primary),
763 fulfilledOutcome(false, null),
764 ]);
765
766 expect(snapshots).toEqual([primary]);
767 });
768
769 it("treats a rejected dispatch as not contributing a snapshot", () => {
770 const primary = provider({
771 driver: driver("codex"),
772 updateState: terminalState("succeeded", "Provider updated."),
773 });
774 const results: PromiseSettledResult<LocalProviderUpdateOutcome>[] = [
775 fulfilledOutcome(true, primary),
776 { status: "rejected", reason: new Error("WebSocket closed") },
777 ];
778
779 expect(collectProviderUpdateOutcomeSnapshots(results)).toEqual([primary]);
780 expect(firstRejectedProviderUpdateMessage(results)).toBe("WebSocket closed");
781 });
782 });
783
784 describe("per-environment update grouping", () => {
785 const environment = (
786 input: {
787 readonly environmentId: string;
788 readonly providers: ReadonlyArray<ServerProvider>;
789 } & Partial<Omit<LocalEnvironmentProvidersInput, "environmentId" | "providers">>,
790 ): LocalEnvironmentProvidersInput => ({
791 environmentId: input.environmentId as EnvironmentId,
792 label: input.label ?? input.environmentId,
793 isPrimary: input.isPrimary ?? false,
794 connectionState: input.connectionState ?? "ready",
795 providers: input.providers,
796 });
797
798 it("groups each environment's outdated one-click candidates", () => {
799 const result = buildLocalEnvironmentUpdateGroups([
800 environment({
801 environmentId: "env-windows",
802 label: "Windows",
803 isPrimary: true,
804 providers: [provider({ driver: driver("codex"), latestVersion: "1.1.0" })],
805 }),
806 environment({
807 environmentId: "env-wsl",
808 label: "WSL",
809 providers: [provider({ driver: driver("codex"), latestVersion: "1.1.0" })],
810 }),
811 ]);
812
813 expect(result.isAnySettling).toBe(false);
814 expect(result.groups.map((group) => group.label)).toEqual(["Windows", "WSL"]);
815 expect(result.groups.every((group) => group.candidates.length === 1)).toBe(true);
816 });
817
818 it("flags settling while a secondary backend is still connecting", () => {
819 const result = buildLocalEnvironmentUpdateGroups([
820 environment({
821 environmentId: "env-windows",
822 isPrimary: true,
823 providers: [provider({ driver: driver("codex") })],
824 }),
825 environment({ environmentId: "env-wsl", connectionState: "connecting", providers: [] }),
826 ]);
827
828 expect(result.isAnySettling).toBe(true);
829 expect(
830 result.groups.find((group) => group.environmentId === ("env-wsl" as EnvironmentId))
831 ?.isSettling,
832 ).toBe(true);
833 });
834
835 it("keeps only environments that have a one-click update on offer", () => {
836 const { groups } = buildLocalEnvironmentUpdateGroups([
837 environment({
838 environmentId: "env-windows",
839 isPrimary: true,
840 providers: [provider({ driver: driver("codex") })],
841 }),
842 environment({
843 environmentId: "env-wsl",
844 providers: [
845 provider({ driver: driver("codex"), advisoryStatus: "current", latestVersion: null }),
846 ],
847 }),
848 ]);
849
850 expect(environmentGroupsWithUpdates(groups).map((group) => group.environmentId)).toEqual([
851 "env-windows",
852 ]);
853 });
854
855 it("keys the notification by environment, driver and latest version", () => {
856 const noUpdates = buildLocalEnvironmentUpdateGroups([
857 environment({
858 environmentId: "env-windows",
859 isPrimary: true,
860 providers: [
861 provider({ driver: driver("codex"), advisoryStatus: "current", latestVersion: null }),
862 ],
863 }),
864 ]);
865 expect(localEnvironmentUpdateNotificationKey(noUpdates.groups)).toBeNull();
866
867 const both = buildLocalEnvironmentUpdateGroups([
868 environment({
869 environmentId: "env-windows",
870 isPrimary: true,
871 providers: [provider({ driver: driver("codex"), latestVersion: "1.1.0" })],
872 }),
873 environment({
874 environmentId: "env-wsl",
875 providers: [provider({ driver: driver("codex"), latestVersion: "1.1.0" })],
876 }),
877 ]);
878 const key = localEnvironmentUpdateNotificationKey(both.groups);
879 expect(key).toContain("env-windows=codex:1.1.0");
880 expect(key).toContain("env-wsl=codex:1.1.0");
881 });
882
883 it("labels environments by platform so they are distinguishable", () => {
884 expect(
885 deriveEnvironmentDisplayLabel({
886 isWsl: false,
887 wslDistro: null,
888 platformOs: "windows",
889 fallbackLabel: "Jgratton24",
890 }),
891 ).toBe("Windows");
892 expect(
893 deriveEnvironmentDisplayLabel({
894 isWsl: true,
895 wslDistro: null,
896 platformOs: "linux",
897 fallbackLabel: "Jgratton24",
898 }),
899 ).toBe("WSL");
900 expect(
901 deriveEnvironmentDisplayLabel({
902 isWsl: true,
903 wslDistro: "ubuntu",
904 platformOs: "linux",
905 fallbackLabel: "Jgratton24",
906 }),
907 ).toBe("WSL · ubuntu");
908 expect(
909 deriveEnvironmentDisplayLabel({
910 isWsl: false,
911 wslDistro: null,
912 platformOs: undefined,
913 fallbackLabel: "My Device",
914 }),
915 ).toBe("My Device");
916 });
917 });
918
919 describe("isTerminalProviderUpdatePhase", () => {
920 it("treats succeeded/failed/unchanged as terminal", () => {
921 expect(isTerminalProviderUpdatePhase("succeeded")).toBe(true);
922 expect(isTerminalProviderUpdatePhase("failed")).toBe(true);
923 expect(isTerminalProviderUpdatePhase("unchanged")).toBe(true);
924 });
925
926 it("treats running/initial as non-terminal so they are not persisted", () => {
927 // The per-environment update row uses this to decide what to store. A
928 // "running"/"initial" snapshot never re-polls, so persisting it would pin
929 // the row's spinner forever once pending clears (see the
930 // resolveEnvironmentUpdateRowStatus "keeps a non-terminal result on
931 // loading even after pending clears" case). Dropping these lets the live
932 // per-environment provider state drive the row so it self-heals.
933 expect(isTerminalProviderUpdatePhase("running")).toBe(false);
934 expect(isTerminalProviderUpdatePhase("initial")).toBe(false);
935 });
936 });
937
938 describe("resolveEnvironmentUpdateRowStatus", () => {
939 const group: LocalEnvironmentUpdateGroup = {
940 environmentId: "env-wsl" as EnvironmentId,
941 label: "WSL",
942 isPrimary: false,
943 isSettling: false,
944 candidates: [updateCandidate({ driver: driver("codex"), latestVersion: "1.1.0" })],
945 providers: [],
946 };
947 const runningResult: ProviderUpdateToastView = {
948 phase: "running",
949 type: "loading",
950 title: "Updating providers",
951 description: "Running provider update command.",
952 };
953 const succeededResult: ProviderUpdateToastView = {
954 phase: "succeeded",
955 type: "success",
956 title: "Provider updated",
957 description: "New sessions will use the updated provider.",
958 };
959 const successPill: ProviderUpdateSidebarPillView = {
960 key: "succeeded:codex",
961 tone: "success",
962 title: "Codex updated",
963 description: "New sessions will use the updated provider.",
964 };
965
966 it("prefers a transport error", () => {
967 expect(
968 resolveEnvironmentUpdateRowStatus({
969 group,
970 error: "boom",
971 result: succeededResult,
972 pill: successPill,
973 isPending: true,
974 }),
975 ).toMatchObject({ kind: "failed", text: "boom" });
976 });
977
978 it("uses a terminal result snapshot", () => {
979 expect(
980 resolveEnvironmentUpdateRowStatus({
981 group,
982 error: undefined,
983 result: succeededResult,
984 pill: null,
985 isPending: false,
986 }),
987 ).toMatchObject({ kind: "success" });
988 });
989
990 it("falls through a non-terminal result to live server state", () => {
991 // The dispatch snapshot is still "running", but server state already
992 // reports success — the row must not stay pinned on "Updating…".
993 expect(
994 resolveEnvironmentUpdateRowStatus({
995 group,
996 error: undefined,
997 result: runningResult,
998 pill: successPill,
999 isPending: true,
1000 }),
1001 ).toMatchObject({ kind: "success" });
1002 });
1003
1004 it("shows the pending spinner before any signal arrives", () => {
1005 expect(
1006 resolveEnvironmentUpdateRowStatus({
1007 group,
1008 error: undefined,
1009 result: runningResult,
1010 pill: null,
1011 isPending: true,
1012 }),
1013 ).toMatchObject({ kind: "loading" });
1014 });
1015
1016 it("keeps a non-terminal result on loading even after pending clears", () => {
1017 // The dispatch returned an incomplete ("running") snapshot and pending was
1018 // cleared in finally — the row must not revert to the idle Update button.
1019 expect(
1020 resolveEnvironmentUpdateRowStatus({
1021 group,
1022 error: undefined,
1023 result: runningResult,
1024 pill: null,
1025 isPending: false,
1026 }),
1027 ).toMatchObject({ kind: "loading" });
1028 });
1029
1030 it("lists the providers when idle", () => {
1031 expect(
1032 resolveEnvironmentUpdateRowStatus({
1033 group,
1034 error: undefined,
1035 result: undefined,
1036 pill: null,
1037 isPending: false,
1038 }),
1039 ).toMatchObject({ kind: "idle", text: "Codex" });
1040 });
1041 });
1042});
1043
1044it("does not offer incompatible latest versions and restores suggestions after policy relaxation", () => {
1045 const installed = provider({ driver: driver("codex") });
1046 for (const latestVersionStatus of ["broken", "unsupported", "supported", "unknown"] as const) {
1047 const snapshot: ServerProvider = {
1048 ...installed,
1049 compatibilityAdvisory: {
1050 status: "supported",
1051 latestVersionStatus,
1052 message: null,
1053 recommendedRange: null,
1054 recommendedVersion: null,
1055 },
1056 };
1057 const expected = latestVersionStatus === "supported" || latestVersionStatus === "unknown";
1058 expect(isProviderUpdateCandidate(snapshot)).toBe(expected);
1059 expect(isProviderSettingsUpdateCandidate(snapshot)).toBe(expected);
1060 }
1061});
1062
1063describe("getProviderUpdateRunToastView", () => {
1064 const updateState = (
1065 status: "succeeded" | "failed",
1066 message: string,
1067 ): ServerProvider["updateState"] => ({
1068 status,
1069 startedAt: checkedAt,
1070 finishedAt: laterCheckedAt,
1071 message,
1072 output: null,
1073 });
1074 const run = (
1075 machineLabel: string,
1076 providerDriver: string,
1077 result: Parameters<typeof getProviderUpdateRunToastView>[0][number]["result"],
1078 ) => ({
1079 environmentId: EnvironmentId.make(machineLabel),
1080 machineLabel,
1081 driver: driver(providerDriver),
1082 instanceId: instanceId(providerDriver),
1083 result,
1084 });
1085
1086 it("lists every failed update and ignores interrupted ones", () => {
1087 const view = getProviderUpdateRunToastView([
1088 run(
1089 "Mac Studio",
1090 "codex",
1091 AsyncResult.success({
1092 providers: [
1093 provider({
1094 driver: driver("codex"),
1095 updateState: updateState("succeeded", "Provider updated."),
1096 }),
1097 ],
1098 }),
1099 ),
1100 run(
1101 "Mac Studio",
1102 "claudeAgent",
1103 AsyncResult.success({
1104 providers: [
1105 provider({
1106 driver: driver("claudeAgent"),
1107 updateState: updateState("failed", "npm exited with code 1."),
1108 }),
1109 ],
1110 }),
1111 ),
1112 run("Laptop", "codex", AsyncResult.failure(Cause.die(new Error("WebSocket closed")))),
1113 run("Server", "codex", AsyncResult.failure(Cause.interrupt())),
1114 ]);
1115
1116 expect(view).toEqual({
1117 type: "error",
1118 title: "2 of 3 provider updates failed",
1119 description: "Mac Studio · Claude: npm exited with code 1.\nLaptop · Codex: WebSocket closed",
1120 });
1121 });
1122
1123 it("reports success when every update succeeded", () => {
1124 const succeeded = AsyncResult.success({
1125 providers: [
1126 provider({
1127 driver: driver("codex"),
1128 updateState: updateState("succeeded", "Provider updated."),
1129 }),
1130 ],
1131 });
1132
1133 expect(
1134 getProviderUpdateRunToastView([
1135 run("Mac Studio", "codex", succeeded),
1136 run("Laptop", "codex", succeeded),
1137 ]),
1138 ).toEqual({
1139 type: "success",
1140 title: "2 providers updated",
1141 description: "New sessions will use the updated providers.",
1142 });
1143 expect(
1144 getProviderUpdateRunToastView([
1145 run("Server", "codex", AsyncResult.failure(Cause.interrupt())),
1146 ]),
1147 ).toBeNull();
1148 });
1149});