1// @vitest-environment jsdom
2import { act } from "react";
3import { createRoot, type Root } from "react-dom/client";
4import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts";
5import {
6 createMemoryHistory,
7 createRootRoute,
8 createRoute,
9 createRouter,
10 RouterContextProvider,
11 type AnyRouter,
12} from "@tanstack/react-router";
13import * as Cause from "effect/Cause";
14import { AsyncResult } from "effect/reactivity";
15import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
16
17const state = vi.hoisted(() => ({
18 environments: [] as Array<{
19 environmentId: string;
20 label: string;
21 connection: { phase: "connected" | "offline" };
22 serverConfig: { providers: ServerProvider[] };
23 }>,
24 permitted: new Set<string>(),
25 updateProvider: vi.fn(),
26}));
27
28vi.mock("~/state/environments", () => ({ useEnvironments: () => state }));
29vi.mock("~/state/server", () => ({
30 serverEnvironment: { updateProvider: { permissionAtom: (id: string) => id } },
31}));
32vi.mock("@effect/atom-react", () => ({
33 useAtomValue: (id: string) => state.permitted.has(id),
34}));
35vi.mock("~/state/use-atom-command", () => ({ useAtomCommand: () => state.updateProvider }));
36vi.mock("../chat/ProviderInstanceIcon", () => ({ ProviderInstanceIcon: () => null }));
37vi.mock("../ui/spinner", () => ({ Spinner: () => null }));
38vi.mock("../settings/providerDriverMeta", () => ({ providerClients: new Map() }));
39
40import { AppMenuProviderUpdates } from "./AppMenuProviderUpdates";
41
42let root: Root;
43let container: HTMLDivElement;
44let router: AnyRouter;
45
46function provider(overrides: Partial<ServerProvider> = {}): ServerProvider {
47 return {
48 driver: ProviderDriverKind.make("codex"),
49 instanceId: ProviderInstanceId.make("codex"),
50 enabled: true,
51 installed: true,
52 version: "1.0.0",
53 status: "ready",
54 auth: { status: "authenticated" },
55 checkedAt: "2026-10-15T12:00:00.000Z",
56 models: [],
57 slashCommands: [],
58 skills: [],
59 versionAdvisory: {
60 status: "behind_latest",
61 currentVersion: "1.0.0",
62 latestVersion: "1.1.0",
63 updateCommand: "npm install -g provider",
64 canUpdate: true,
65 checkedAt: "2026-10-15T12:00:00.000Z",
66 message: "Update available.",
67 },
68 ...overrides,
69 };
70}
71
72function connect(id: string, providers: ServerProvider[]) {
73 state.environments.push({
74 environmentId: id,
75 label: id,
76 connection: { phase: "connected" },
77 serverConfig: { providers },
78 });
79 state.permitted.add(id);
80}
81
82function button(name: string) {
83 const node = container.querySelector<HTMLButtonElement>(`button[aria-label="${name}"]`);
84 if (!node) throw new Error(`Missing button: ${name}`);
85 return node;
86}
87
88async function render() {
89 await act(() =>
90 root.render(
91 <RouterContextProvider router={router}>
92 <AppMenuProviderUpdates />
93 </RouterContextProvider>,
94 ),
95 );
96}
97
98beforeEach(async () => {
99 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
100 state.environments = [];
101 state.permitted.clear();
102 state.updateProvider.mockReset();
103 state.updateProvider.mockResolvedValue(AsyncResult.success({ providers: [] }));
104 container = document.createElement("div");
105 document.body.append(container);
106 root = createRoot(container);
107 const rootRoute = createRootRoute();
108 router = createRouter({
109 routeTree: rootRoute.addChildren([
110 createRoute({ getParentRoute: () => rootRoute, path: "/" }),
111 createRoute({ getParentRoute: () => rootRoute, path: "/settings/providers" }),
112 ]),
113 history: createMemoryHistory({ initialEntries: ["/"] }),
114 });
115 await router.load();
116});
117
118afterEach(async () => {
119 await act(() => root.unmount());
120 container.remove();
121 vi.unstubAllGlobals();
122});
123
124it("updates one representative per installation and routes each machine independently", async () => {
125 connect("laptop", [
126 provider({ instanceId: ProviderInstanceId.make("codex-personal") }),
127 provider(),
128 provider({ driver: ProviderDriverKind.make("claudeAgent"), enabled: false }),
129 provider({ driver: ProviderDriverKind.make("cursor"), installed: false }),
130 ]);
131 connect("desktop", [provider()]);
132 state.environments.push({
133 environmentId: "offline",
134 label: "offline",
135 connection: { phase: "offline" },
136 serverConfig: { providers: [provider()] },
137 });
138 await render();
139
140 await act(async () => button("Update Codex on laptop").click());
141 await act(async () => button("Update Codex on desktop").click());
142
143 expect(state.updateProvider.mock.calls.map(([target]) => target)).toEqual([
144 { environmentId: "laptop", input: { provider: "codex", instanceId: "codex" } },
145 { environmentId: "desktop", input: { provider: "codex", instanceId: "codex" } },
146 ]);
147 expect(container.querySelectorAll("button")).toHaveLength(2);
148});
149
150it("keeps an installation disabled until its direct update settles, including rapid clicks", async () => {
151 connect("laptop", [provider()]);
152 const run = Promise.withResolvers<unknown>();
153 state.updateProvider.mockReturnValue(run.promise);
154 await render();
155 const update = button("Update Codex");
156 await act(() => {
157 update.click();
158 update.click();
159 });
160 expect(state.updateProvider).toHaveBeenCalledOnce();
161 expect(button("Updating Codex").disabled).toBe(true);
162
163 await act(() => run.resolve(AsyncResult.success({ providers: [] })));
164 expect(button("Update Codex").disabled).toBe(false);
165});
166
167it("reports a transport failure inline, permits retry, and clears it once the installation is current", async () => {
168 const outdated = provider();
169 connect("laptop", [outdated]);
170 state.updateProvider.mockResolvedValueOnce(
171 AsyncResult.failure(Cause.fail(new Error("Host disconnected. Reconnect and retry."))),
172 );
173 await render();
174 await act(async () => button("Update Codex").click());
175 expect(container.querySelector('[role="alert"]')?.textContent).toBe(
176 "Host disconnected. Reconnect and retry.",
177 );
178
179 await act(async () => button("Retry Codex").click());
180 expect(state.updateProvider).toHaveBeenCalledTimes(2);
181 expect(container.querySelector('[role="alert"]')).toBeNull();
182
183 state.environments[0]!.serverConfig.providers = [
184 provider({
185 version: "1.1.0",
186 versionAdvisory: { ...outdated.versionAdvisory!, status: "current", currentVersion: "1.1.0" },
187 }),
188 ];
189 await render();
190 expect(container.querySelector('[role="img"][aria-label="Up to date"]')).not.toBeNull();
191 expect(container.querySelector("button")).toBeNull();
192});
193
194it("follows the guarded command permission and never dispatches through a disabled connection", async () => {
195 connect("laptop", [provider()]);
196 state.permitted.delete("laptop");
197 await render();
198 await act(async () => button("Update Codex").click());
199 expect(state.updateProvider).not.toHaveBeenCalled();
200 expect(container.textContent).toContain("This connection cannot update providers.");
201
202 state.permitted.add("laptop");
203 await render();
204 await act(async () => button("Update Codex").click());
205 expect(state.updateProvider).toHaveBeenCalledOnce();
206});
207
208it("requires provider settings when profiles disagree on their installer", async () => {
209 const first = provider();
210 connect("laptop", [
211 first,
212 provider({
213 instanceId: ProviderInstanceId.make("codex-personal"),
214 versionAdvisory: { ...first.versionAdvisory!, updateCommand: "brew upgrade provider" },
215 }),
216 ]);
217 await render();
218 expect(container.querySelector("button")).toBeNull();
219 const details = container.querySelector<HTMLAnchorElement>("a");
220 expect(details).not.toBeNull();
221 await act(async () => details!.click());
222 expect(router.state.location.pathname).toBe("/settings/providers");
223 expect(router.state.location.search).toEqual({ machine: "laptop" });
224 expect(state.updateProvider).not.toHaveBeenCalled();
225});
226
227it("blocks incompatible latest versions and explains the reason", async () => {
228 connect("laptop", [
229 provider({
230 compatibilityAdvisory: {
231 status: "supported",
232 latestVersionStatus: "broken",
233 message: "The latest CLI cannot start sessions.",
234 recommendedVersion: "1.0.0",
235 recommendedRange: null,
236 },
237 }),
238 ]);
239 await render();
240 expect(container.textContent).toContain("Update blocked: The latest CLI cannot start sessions.");
241 expect(container.querySelector("button")).toBeNull();
242 expect(container.querySelector("a")?.getAttribute("href")).toBe(
243 "/settings/providers?machine=laptop",
244 );
245 expect(state.updateProvider).not.toHaveBeenCalled();
246});
247
248it("honors a sibling profile's queued update and keeps failed server results retryable", async () => {
249 connect("laptop", [
250 provider(),
251 provider({
252 instanceId: ProviderInstanceId.make("codex-personal"),
253 updateState: {
254 status: "queued",
255 startedAt: "2026-10-15T12:01:00.000Z",
256 finishedAt: null,
257 message: null,
258 output: null,
259 },
260 }),
261 ]);
262 await render();
263 expect(button("Updating Codex").disabled).toBe(true);
264 expect(container.textContent).toContain("Queued");
265 expect(state.updateProvider).not.toHaveBeenCalled();
266
267 state.environments[0]!.serverConfig.providers = [
268 provider({
269 updateState: {
270 status: "failed",
271 startedAt: "2026-10-15T12:01:00.000Z",
272 finishedAt: "2026-10-15T12:02:00.000Z",
273 message: "Installer unavailable. Try again.",
274 output: null,
275 },
276 }),
277 ];
278 await render();
279 expect(container.querySelector('[role="alert"]')?.textContent).toBe(
280 "Installer unavailable. Try again.",
281 );
282 await act(async () => button("Retry Codex").click());
283 expect(state.updateProvider).toHaveBeenCalledOnce();
284});