1import type { Dispatch, ReactElement, SetStateAction } from "react";
2import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
3import {
4 AuthProvidersManageScope,
5 type AuthEnvironmentScope,
6 type EnvironmentId,
7 ProviderDriverKind,
8 ProviderInstanceId,
9 type ServerProvider,
10} from "@t3tools/contracts";
11import { AsyncResult } from "effect/reactivity";
12
13import type {
14 LocalEnvironmentUpdateGroup,
15 ProviderUpdateCandidate,
16 ProviderUpdateRowStatus,
17} from "./ProviderUpdateLaunchNotification.logic";
18
19const testState = vi.hoisted(() => ({
20 groups: [] as LocalEnvironmentUpdateGroup[],
21 permittedEnvironmentIds: new Set<EnvironmentId>(),
22 updateProvider: vi.fn(),
23}));
24
25const hooks = vi.hoisted(() => {
26 let cursor = 0;
27 let slots: unknown[] = [];
28
29 const nextIndex = () => cursor++;
30
31 return {
32 beginRender() {
33 cursor = 0;
34 },
35 reset() {
36 cursor = 0;
37 slots = [];
38 },
39 useCallback<T>(callback: T): T {
40 nextIndex();
41 return callback;
42 },
43 useMemo<T>(factory: () => T): T {
44 nextIndex();
45 return factory();
46 },
47 useMemoCache(size: number): unknown[] {
48 const index = nextIndex();
49 if (!slots[index]) {
50 slots[index] = Array.from({ length: size }, () => Symbol.for("react.memo_cache_sentinel"));
51 }
52 return slots[index] as unknown[];
53 },
54 useRef<T>(initialValue: T): { current: T } {
55 const index = nextIndex();
56 if (!slots[index]) {
57 slots[index] = { current: initialValue };
58 }
59 return slots[index] as { current: T };
60 },
61 useState<T>(initialValue: T | (() => T)): [T, Dispatch<SetStateAction<T>>] {
62 const index = nextIndex();
63 if (index >= slots.length) {
64 slots[index] =
65 typeof initialValue === "function" ? (initialValue as () => T)() : initialValue;
66 }
67 const setValue: Dispatch<SetStateAction<T>> = (nextValue) => {
68 const previous = slots[index] as T;
69 slots[index] =
70 typeof nextValue === "function" ? (nextValue as (value: T) => T)(previous) : nextValue;
71 };
72 return [slots[index] as T, setValue];
73 },
74 };
75});
76
77vi.mock("react", async (importOriginal) => {
78 const actual = await importOriginal<typeof import("react")>();
79 return {
80 ...actual,
81 useCallback: hooks.useCallback,
82 useMemo: hooks.useMemo,
83 useRef: hooks.useRef,
84 useState: hooks.useState,
85 };
86});
87
88vi.mock("react/compiler-runtime", () => ({
89 c: hooks.useMemoCache,
90}));
91
92vi.mock("~/state/server", () => ({
93 serverEnvironment: { updateProvider: Symbol("updateProvider") },
94}));
95
96vi.mock("~/state/session", () => ({
97 readEnvironmentScope: (environmentId: EnvironmentId, scope: AuthEnvironmentScope) =>
98 scope === AuthProvidersManageScope && testState.permittedEnvironmentIds.has(environmentId),
99 useEnvironmentScope: (environmentId: EnvironmentId | null, scope: AuthEnvironmentScope) =>
100 environmentId !== null &&
101 scope === AuthProvidersManageScope &&
102 testState.permittedEnvironmentIds.has(environmentId),
103}));
104
105vi.mock("~/state/use-atom-command", () => ({
106 useAtomCommand: () => testState.updateProvider,
107}));
108
109vi.mock("./ProviderUpdateLaunchNotification.environments", () => ({
110 useLocalEnvironmentUpdateGroups: () => ({
111 groups: testState.groups,
112 isAnySettling: false,
113 }),
114}));
115
116import { ProviderUpdateEnvironmentRows } from "./ProviderUpdateEnvironmentRows";
117
118const environmentId = "env-wsl" as EnvironmentId;
119const pendingExpiryMs = 6 * 60_000;
120
121function provider(updateStatus?: "succeeded"): ServerProvider {
122 const result: ServerProvider = {
123 instanceId: ProviderInstanceId.make("codex-wsl"),
124 driver: ProviderDriverKind.make("codex"),
125 enabled: true,
126 installed: true,
127 version: updateStatus ? "1.1.0" : "1.0.0",
128 status: "ready",
129 auth: { status: "authenticated" },
130 checkedAt: "2026-06-26T12:00:00.000Z",
131 models: [],
132 slashCommands: [],
133 skills: [],
134 versionAdvisory: {
135 status: updateStatus ? "current" : "behind_latest",
136 currentVersion: updateStatus ? "1.1.0" : "1.0.0",
137 latestVersion: "1.1.0",
138 updateCommand: "npm install -g @openai/codex@latest",
139 canUpdate: true,
140 checkedAt: "2026-06-26T12:00:00.000Z",
141 message: updateStatus ? "Up to date." : "Update available.",
142 },
143 };
144
145 return updateStatus
146 ? {
147 ...result,
148 updateState: {
149 status: updateStatus,
150 startedAt: "2026-06-26T12:00:00.000Z",
151 finishedAt: "2026-06-26T12:00:01.000Z",
152 message: "Provider updated.",
153 output: null,
154 },
155 }
156 : result;
157}
158
159function deferred<T>() {
160 let resolve!: (value: T) => void;
161 const promise = new Promise<T>((complete) => {
162 resolve = complete;
163 });
164 return { promise, resolve };
165}
166
167type RowElement = ReactElement<{
168 readonly status: ProviderUpdateRowStatus;
169 readonly onUpdate: () => void;
170}>;
171
172function renderRow(): RowElement {
173 hooks.beginRender();
174 const output = ProviderUpdateEnvironmentRows({}) as ReactElement<{
175 readonly children: RowElement | RowElement[];
176 }>;
177 const children = output.props.children;
178 return Array.isArray(children) ? children[0]! : children;
179}
180
181async function flushPromises(): Promise<void> {
182 await Promise.resolve();
183 await Promise.resolve();
184 await Promise.resolve();
185}
186
187describe("ProviderUpdateEnvironmentRows", () => {
188 beforeEach(() => {
189 vi.useFakeTimers();
190 hooks.reset();
191 testState.permittedEnvironmentIds.clear();
192 testState.permittedEnvironmentIds.add(environmentId);
193 testState.updateProvider.mockReset();
194 const candidate = provider() as ProviderUpdateCandidate;
195 testState.groups = [
196 {
197 environmentId,
198 label: "WSL",
199 isPrimary: false,
200 isSettling: false,
201 candidates: [candidate],
202 providers: [candidate],
203 },
204 ];
205 });
206
207 afterEach(() => {
208 vi.useRealTimers();
209 });
210
211 it("keeps a successor pending when an expired request resolves late, then shows its success", async () => {
212 const firstRequest =
213 deferred<ReturnType<typeof AsyncResult.success<{ providers: ServerProvider[] }>>>();
214 const successorRequest =
215 deferred<ReturnType<typeof AsyncResult.success<{ providers: ServerProvider[] }>>>();
216 testState.updateProvider
217 .mockReturnValueOnce(firstRequest.promise)
218 .mockReturnValueOnce(successorRequest.promise);
219
220 renderRow().props.onUpdate();
221 expect(renderRow().props.status.kind).toBe("loading");
222
223 await vi.advanceTimersByTimeAsync(pendingExpiryMs);
224 expect(renderRow().props.status.kind).toBe("failed");
225
226 renderRow().props.onUpdate();
227 expect(testState.updateProvider).toHaveBeenCalledTimes(2);
228 expect(renderRow().props.status.kind).toBe("loading");
229
230 firstRequest.resolve(AsyncResult.success({ providers: [provider("succeeded")] }));
231 await flushPromises();
232
233 expect(renderRow().props.status.kind).toBe("loading");
234
235 successorRequest.resolve(AsyncResult.success({ providers: [provider("succeeded")] }));
236 await flushPromises();
237
238 expect(renderRow().props.status.kind).toBe("success");
239 });
240});