1import type { ReactElement } from "react";
2import { EnvironmentId, ProviderDriverKind, type AcpRegistrySearchAgent } from "@t3tools/contracts";
3import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
4
5import { visitElements } from "../../test/reactElementTree";
6import { reactHookHarness as hooks } from "../../test/reactHookHarness";
7
8const atoms = vi.hoisted(() => ({
9 search: Symbol("acp-search"),
10 prepare: Symbol("acp-prepare"),
11}));
12
13const state = vi.hoisted(() => ({
14 result: null as { readonly agents: ReadonlyArray<AcpRegistrySearchAgent> } | null,
15 error: null as string | null,
16 isPending: false,
17 canManage: true,
18 refresh: vi.fn(),
19 search: vi.fn(() => atoms.search),
20 prepare: vi.fn(),
21}));
22
23const lifecycle = vi.hoisted(() => ({
24 cleanups: [] as Array<() => void>,
25}));
26
27vi.mock("react", async (importOriginal) => {
28 const actual = await importOriginal<typeof import("react")>();
29 const { reactHookHarness } = await import("../../test/reactHookHarness");
30 return {
31 ...actual,
32 useEffect: (effect: () => void | (() => void)) => {
33 const cleanup = effect();
34 if (cleanup) lifecycle.cleanups.push(cleanup);
35 },
36 useRef: reactHookHarness.useRef,
37 useLayoutEffect: (effect: () => void | (() => void)) => {
38 const cleanup = effect();
39 if (cleanup) lifecycle.cleanups.push(cleanup);
40 },
41 useState: reactHookHarness.useState,
42 };
43});
44
45vi.mock("react/compiler-runtime", async () => {
46 const { reactHookHarness } = await import("../../test/reactHookHarness");
47 return { c: reactHookHarness.useMemoCache };
48});
49
50vi.mock("../../state/server", () => ({
51 serverEnvironment: {
52 searchAcpRegistry: state.search,
53 prepareAcpRegistryAgent: atoms.prepare,
54 },
55}));
56
57vi.mock("../../state/session", () => ({
58 useEnvironmentScope: () => state.canManage,
59 readEnvironmentScope: () => state.canManage,
60}));
61
62vi.mock("../../state/query", () => ({
63 useEnvironmentQuery: () => ({
64 data: state.result,
65 error: state.error,
66 isPending: state.isPending,
67 refresh: state.refresh,
68 }),
69}));
70
71vi.mock("../../state/use-atom-command", () => ({
72 useAtomCommand: () => state.prepare,
73}));
74
75vi.mock("@t3tools/client-runtime/state/runtime", () => ({
76 isAtomCommandInterrupted: () => false,
77 squashAtomCommandFailure: () => new Error("Prepare failed."),
78}));
79
80import { AcpRegistrySearchStep } from "./AcpRegistrySearchStep";
81
82const environmentId = EnvironmentId.make("remote-device");
83const gemini: AcpRegistrySearchAgent = {
84 id: "gemini",
85 name: "Gemini CLI",
86 version: "1.2.3",
87 description: "Google's agent",
88 authors: ["Google <gemini-cli@google.com>", "Contributor"],
89 license: "Apache-2.0",
90 website: "https://example.com/docs",
91 repository: "https://example.com/source",
92 icon: "https://example.com/icon.png",
93 distribution: "npx",
94 integrity: "registry",
95};
96
97function render(options?: {
98 readonly configured?: boolean;
99 readonly onPrepared?: (agent: AcpRegistrySearchAgent) => void;
100}): ReactElement<Record<string, unknown>> {
101 hooks.beginRender();
102 return AcpRegistrySearchStep({
103 environmentId,
104 providerInstances: options?.configured
105 ? {
106 acpRegistry_gemini: {
107 driver: ProviderDriverKind.make("acpRegistry"),
108 config: { agentId: "gemini" },
109 },
110 }
111 : {},
112 onPrepared: options?.onPrepared ?? vi.fn(),
113 onManualConfiguration: vi.fn(),
114 }) as ReactElement<Record<string, unknown>>;
115}
116
117function findByAriaLabel(
118 tree: ReactElement<Record<string, unknown>>,
119 label: string,
120): ReactElement<Record<string, unknown>> {
121 const found = visitElements(tree, (element) => element.props["aria-label"] === label);
122 expect(found).not.toBeNull();
123 return found!;
124}
125
126describe("AcpRegistrySearchStep", () => {
127 beforeEach(() => {
128 state.canManage = true;
129 vi.useFakeTimers();
130 hooks.reset();
131 state.result = null;
132 state.error = null;
133 state.isPending = false;
134 state.refresh.mockReset();
135 state.search.mockClear();
136 state.prepare.mockReset().mockResolvedValue({
137 _tag: "Success",
138 value: { agentId: "gemini", version: "1.2.3", distribution: "npx", prepared: true },
139 });
140 lifecycle.cleanups = [];
141 vi.stubGlobal(
142 "fetch",
143 vi.fn(() => new Promise<Response>(() => {})),
144 );
145 });
146 afterEach(() => {
147 for (const cleanup of lifecycle.cleanups) cleanup();
148 vi.useRealTimers();
149 });
150
151 it("loads the catalog immediately and searches only after typing settles", () => {
152 const initial = render();
153 expect(state.search).toHaveBeenLastCalledWith({
154 environmentId,
155 input: { query: "" },
156 });
157
158 const input = findByAriaLabel(initial, "Search ACP Registry");
159 expect(input.props.size).toBe("sm");
160 (input.props.onChange as ((event: { currentTarget: { value: string } }) => void) | undefined)?.(
161 { currentTarget: { value: " Gemini " } },
162 );
163 vi.advanceTimersByTime(200);
164 render();
165 expect(state.search).toHaveBeenLastCalledWith({ environmentId, input: { query: "" } });
166 (input.props.onChange as ((event: { currentTarget: { value: string } }) => void) | undefined)?.(
167 {
168 currentTarget: { value: " Gemini CLI " },
169 },
170 );
171 vi.advanceTimersByTime(299);
172 render();
173 expect(state.search).toHaveBeenLastCalledWith({ environmentId, input: { query: "" } });
174 vi.advanceTimersByTime(1);
175 render();
176 expect(state.search).toHaveBeenLastCalledWith({
177 environmentId,
178 input: { query: "Gemini CLI" },
179 });
180 (input.props.onChange as ((event: { currentTarget: { value: string } }) => void) | undefined)?.(
181 {
182 currentTarget: { value: "" },
183 },
184 );
185 vi.advanceTimersByTime(300);
186 render();
187 expect(state.search).toHaveBeenLastCalledWith({ environmentId, input: { query: "" } });
188 });
189
190 it("renders deterministic loading, error, and empty states", () => {
191 state.isPending = true;
192 expect(
193 visitElements(render(), (element) => element.props.children === "Searching the registry..."),
194 ).not.toBeNull();
195
196 state.isPending = false;
197 state.error = "Registry unavailable.";
198 expect(
199 visitElements(render(), (element) => element.props.children === "Registry unavailable."),
200 ).not.toBeNull();
201
202 state.error = null;
203 state.result = { agents: [] };
204 expect(
205 visitElements(render(), (element) => element.props.children === "No compatible agents found"),
206 ).not.toBeNull();
207 });
208
209 it("keeps same-query refreshes visible and announced while retaining results", () => {
210 const first = render();
211 const input = findByAriaLabel(first, "Search ACP Registry");
212 (input.props.onChange as ((event: { currentTarget: { value: string } }) => void) | undefined)?.(
213 {
214 currentTarget: { value: "Codex" },
215 },
216 );
217 const draft = render();
218 const searchForm = visitElements(draft, (element) => element.type === "form");
219 (searchForm?.props.onSubmit as ((event: { preventDefault: () => void }) => void) | undefined)?.(
220 {
221 preventDefault: vi.fn(),
222 },
223 );
224
225 state.result = { agents: [gemini] };
226 const resultTree = render();
227 expect(state.search).toHaveBeenCalledWith({
228 environmentId,
229 input: { query: "Codex" },
230 });
231 const form = visitElements(resultTree, (element) => element.type === "form");
232 (form?.props.onSubmit as ((event: { preventDefault: () => void }) => void) | undefined)?.({
233 preventDefault: vi.fn(),
234 });
235 expect(state.refresh).toHaveBeenCalledOnce();
236
237 state.isPending = true;
238 const refreshingTree = render();
239 expect(
240 visitElements(
241 refreshingTree,
242 (element) =>
243 element.props.role === "status" &&
244 element.props.children === "Refreshing ACP Registry results.",
245 ),
246 ).not.toBeNull();
247 expect(findByAriaLabel(refreshingTree, "Add Gemini CLI")).not.toBeNull();
248 });
249
250 it("prepares a result before handing it back to the wizard", async () => {
251 state.prepare.mockResolvedValueOnce({
252 _tag: "Success",
253 value: { agentId: "gemini", version: "2.0.0", distribution: "binary", prepared: true },
254 });
255 state.result = { agents: [gemini] };
256 const onPrepared = vi.fn();
257 const tree = render({ onPrepared });
258
259 const add = findByAriaLabel(tree, "Add Gemini CLI");
260 (add.props.onClick as (() => void) | undefined)?.();
261 await Promise.resolve();
262 await Promise.resolve();
263
264 expect(state.prepare).toHaveBeenCalledWith({
265 environmentId,
266 input: { agentId: "gemini" },
267 });
268 expect(onPrepared).toHaveBeenCalledWith({
269 ...gemini,
270 version: "2.0.0",
271 distribution: "binary",
272 });
273 });
274
275 it("rechecks the grant before preparing an agent", async () => {
276 state.result = { agents: [gemini] };
277 const onPrepared = vi.fn();
278 const add = findByAriaLabel(render({ onPrepared }), "Add Gemini CLI");
279 state.canManage = false;
280 await (add.props.onClick as () => void)();
281 expect(state.prepare).not.toHaveBeenCalled();
282 expect(onPrepared).not.toHaveBeenCalled();
283 });
284
285 it("ignores a stale prepare completion", async () => {
286 let resolveFirst!: (value: { readonly _tag: "Success"; readonly value: unknown }) => void;
287 let resolveSecond!: (value: { readonly _tag: "Success"; readonly value: unknown }) => void;
288 state.prepare
289 .mockImplementationOnce(
290 () =>
291 new Promise((resolve) => {
292 resolveFirst = resolve;
293 }),
294 )
295 .mockImplementationOnce(
296 () =>
297 new Promise((resolve) => {
298 resolveSecond = resolve;
299 }),
300 );
301 state.result = { agents: [gemini] };
302 const onPrepared = vi.fn();
303 const tree = render({ onPrepared });
304 const add = findByAriaLabel(tree, "Add Gemini CLI");
305
306 (add.props.onClick as (() => void) | undefined)?.();
307 (add.props.onClick as (() => void) | undefined)?.();
308 resolveFirst({ _tag: "Success", value: {} });
309 await Promise.resolve();
310 expect(onPrepared).not.toHaveBeenCalled();
311
312 resolveSecond({ _tag: "Success", value: {} });
313 await Promise.resolve();
314 expect(onPrepared).toHaveBeenCalledOnce();
315 });
316
317 it("ignores prepare completion after unmount", async () => {
318 let resolvePrepare!: (value: { readonly _tag: "Success"; readonly value: unknown }) => void;
319 state.prepare.mockImplementationOnce(
320 () =>
321 new Promise((resolve) => {
322 resolvePrepare = resolve;
323 }),
324 );
325 state.result = { agents: [gemini] };
326 const onPrepared = vi.fn();
327 const tree = render({ onPrepared });
328
329 const add = findByAriaLabel(tree, "Add Gemini CLI");
330 (add.props.onClick as (() => void) | undefined)?.();
331 for (const cleanup of lifecycle.cleanups) cleanup();
332 resolvePrepare({ _tag: "Success", value: {} });
333 await Promise.resolve();
334
335 expect(onPrepared).not.toHaveBeenCalled();
336 });
337
338 it("renders existing registry configuration as already added", () => {
339 state.result = { agents: [gemini] };
340 const tree = render({ configured: true });
341 const added = findByAriaLabel(tree, "Already added Gemini CLI");
342
343 expect(added.props.disabled).toBe(true);
344 });
345});