1import { EnvironmentId, ProviderDriverKind, type AcpRegistrySearchAgent } from "@t3tools/contracts";
2import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
3
4import { visitElements } from "../../test/reactElementTree";
5import { reactHookHarness as hooks } from "../../test/reactHookHarness";
6
7const actions = vi.hoisted(() => ({
8 update: vi.fn(),
9 toast: vi.fn(),
10 onOpenChange: vi.fn(),
11 canManageProviders: true,
12}));
13
14const settingsHooks = vi.hoisted(() => ({
15 read: vi.fn(() => ({ providerInstances: {} })),
16 mutate: vi.fn(),
17 useMutation: vi.fn(),
18}));
19
20vi.mock("react", async (importOriginal) => {
21 const actual = await importOriginal<typeof import("react")>();
22 const { reactHookHarness } = await import("../../test/reactHookHarness");
23 return {
24 ...actual,
25 useMemo: reactHookHarness.useMemo,
26 useState: reactHookHarness.useState,
27 };
28});
29
30vi.mock("react/compiler-runtime", async () => {
31 const { reactHookHarness } = await import("../../test/reactHookHarness");
32 return { c: reactHookHarness.useMemoCache };
33});
34
35vi.mock("@t3tools/client-runtime/state/runtime", async (importOriginal) => ({
36 ...(await importOriginal<typeof import("@t3tools/client-runtime/state/runtime")>()),
37 squashAtomCommandFailure: () => new Error("The settings update failed."),
38}));
39
40vi.mock("../../hooks/useSettings", () => ({
41 useEnvironmentSettings: settingsHooks.read,
42 usePersistEnvironmentProviderInstanceMutation: settingsHooks.useMutation,
43}));
44
45vi.mock("../../state/session", async (importOriginal) => {
46 const actual = await importOriginal<typeof import("../../state/session")>();
47 const hasScope = (environmentId: EnvironmentId, scope: string) =>
48 environmentId === "remote-device" && scope === "providers:manage" && actions.canManageProviders;
49 return { ...actual, useEnvironmentScope: hasScope, readEnvironmentScope: hasScope };
50});
51
52vi.mock("../ui/toast", () => ({ toastManager: { add: actions.toast } }));
53
54import { AddProviderInstanceDialog } from "./AddProviderInstanceDialog";
55
56const remoteEnvironmentId = EnvironmentId.make("remote-device");
57const preparedAgent: AcpRegistrySearchAgent = {
58 id: "kilo",
59 name: "Kilo Code",
60 version: "4.2.0",
61 description: "Kilo ACP agent",
62 authors: ["Kilo"],
63 license: "Apache-2.0",
64 website: null,
65 repository: null,
66 icon: "https://cdn.agentclientprotocol.com/registry/v1/latest/kilo.svg",
67 distribution: "binary",
68 integrity: "sha256",
69};
70
71function render(onOpenChange = vi.fn()) {
72 hooks.beginRender();
73 return AddProviderInstanceDialog({
74 open: true,
75 environmentId: remoteEnvironmentId,
76 environmentLabel: "Remote device",
77 onOpenChange,
78 });
79}
80
81function findByChildren(tree: ReturnType<typeof render>, children: string) {
82 const result = visitElements(tree, (element) => element.props.children === children);
83 expect(result).not.toBeNull();
84 return result!;
85}
86
87async function selectPreparedAcp() {
88 const searchStep = render();
89 const search = visitElements(
90 searchStep,
91 (element) =>
92 typeof element.type === "function" && element.type.name === "AcpRegistrySearchStep",
93 );
94 expect(search).not.toBeNull();
95 (search?.props.onPrepared as ((agent: AcpRegistrySearchAgent) => void) | undefined)?.(
96 preparedAgent,
97 );
98}
99
100function renderDialog() {
101 hooks.beginRender();
102 return AddProviderInstanceDialog({
103 open: true,
104 environmentId: remoteEnvironmentId,
105 environmentLabel: "Remote device",
106 onOpenChange: actions.onOpenChange,
107 });
108}
109
110function button(dialog: unknown, label: string) {
111 const element = visitElements(
112 dialog,
113 (entry) => entry.props.children === label && typeof entry.props.onClick === "function",
114 );
115 if (!element) throw new Error(`Missing button: ${label}`);
116 return element;
117}
118
119function prepareInstance() {
120 let dialog = renderDialog();
121 (button(dialog, "Configure manually").props.onClick as () => void)();
122 dialog = renderDialog();
123 const label = visitElements(dialog, (entry) => entry.props.placeholder === "e.g. Work");
124 if (!label) throw new Error("Missing instance label input.");
125 (label.props.onChange as (event: { target: { value: string } }) => void)({
126 target: { value: "Work" },
127 });
128 dialog = renderDialog();
129 (button(dialog, "Next").props.onClick as () => void)();
130 return renderDialog();
131}
132
133describe("AddProviderInstanceDialog environment routing", () => {
134 beforeEach(() => {
135 hooks.reset();
136 actions.canManageProviders = true;
137 actions.toast.mockReset();
138 actions.onOpenChange.mockReset();
139 settingsHooks.read.mockReset().mockReturnValue({ providerInstances: {} });
140 settingsHooks.mutate.mockReset().mockResolvedValue({ _tag: "Success", value: {} });
141 settingsHooks.useMutation.mockReset().mockReturnValue(settingsHooks.mutate);
142 });
143
144 it("creates a provider with its default identity without typing", async () => {
145 let tree = render();
146 const group = visitElements(
147 tree,
148 (element) => element.props["aria-labelledby"] === "add-instance-driver-label",
149 );
150 (group!.props.onValueChange as (value: string) => void)("grok");
151 tree = render();
152 (findByChildren(tree, "Next").props.onClick as () => void)();
153 tree = render();
154 (findByChildren(tree, "Next").props.onClick as () => void)();
155 tree = render();
156 (findByChildren(tree, "Add instance").props.onClick as () => void)();
157 await Promise.resolve();
158 expect(settingsHooks.mutate).toHaveBeenCalledWith({
159 operation: "create",
160 instanceId: "grok",
161 instance: { driver: "grok", enabled: true, displayName: "Grok" },
162 });
163 });
164
165 it("chooses an unused identity for another account without replacing configured instances", async () => {
166 settingsHooks.read.mockReturnValue({
167 providerInstances: {
168 codex_2: { driver: "codex", enabled: false },
169 },
170 });
171 let tree = render();
172 // Codex offers ChatGPT sign-in first; manual setup keeps the existing CLI flow.
173 (findByChildren(tree, "Configure manually").props.onClick as () => void)();
174 tree = render();
175 (findByChildren(tree, "Next").props.onClick as () => void)();
176 tree = render();
177 (findByChildren(tree, "Add instance").props.onClick as () => void)();
178 await Promise.resolve();
179 expect(settingsHooks.mutate).toHaveBeenCalledWith({
180 operation: "create",
181 instanceId: "codex_3",
182 instance: {
183 driver: "codex",
184 enabled: true,
185 displayName: "Codex",
186 config: { setupMode: "existing" },
187 },
188 });
189 });
190
191 it("reads and writes settings through the supplied environment", () => {
192 render();
193
194 expect(settingsHooks.read).toHaveBeenCalledWith(remoteEnvironmentId);
195 expect(settingsHooks.useMutation).toHaveBeenCalledWith(remoteEnvironmentId);
196 });
197
198 it("awaits an atomic create before closing and retains prepared ACP metadata", async () => {
199 const onOpenChange = vi.fn();
200 let resolveMutation!: (value: { readonly _tag: "Success"; readonly value: unknown }) => void;
201 settingsHooks.mutate.mockReturnValueOnce(
202 new Promise((resolve) => {
203 resolveMutation = resolve;
204 }),
205 );
206 await selectPreparedAcp();
207
208 const identityStep = render(onOpenChange);
209 expect(
210 visitElements(identityStep, (element) => {
211 const content = JSON.stringify(element.props.children);
212 return content?.includes("4.2.0") === true && content.includes("binary");
213 }),
214 ).not.toBeNull();
215 (
216 findByChildren(identityStep, "Continue to sign-in").props.onClick as (() => void) | undefined
217 )?.();
218 expect(settingsHooks.mutate).toHaveBeenCalledWith({
219 operation: "create",
220 instanceId: "acpRegistry_kilo_code",
221 instance: {
222 driver: ProviderDriverKind.make("acpRegistry"),
223 enabled: true,
224 displayName: "Kilo Code",
225 config: {
226 agentId: "kilo",
227 distribution: "auto",
228 registryIconUrl: "https://cdn.agentclientprotocol.com/registry/v1/latest/kilo.svg",
229 },
230 },
231 });
232 expect(onOpenChange).not.toHaveBeenCalled();
233
234 resolveMutation({ _tag: "Success", value: {} });
235 await Promise.resolve();
236 await Promise.resolve();
237 expect(onOpenChange).not.toHaveBeenCalled();
238 const signInStep = render(onOpenChange);
239 const authentication = visitElements(
240 signInStep,
241 (element) =>
242 typeof element.type === "function" &&
243 element.type.name === "ProviderWizardAuthenticationStep",
244 );
245 expect(authentication?.props.instanceId).toBe("acpRegistry_kilo_code");
246 expect(authentication?.props.environmentId).toBe(remoteEnvironmentId);
247 expect(settingsHooks.mutate).toHaveBeenCalledTimes(1);
248 (authentication!.props.onFinish as () => void)();
249 expect(onOpenChange).toHaveBeenCalledWith(false);
250 });
251
252 it("configures a manually entered registry agent from the first provider screen", async () => {
253 let tree = render();
254 const search = visitElements(
255 tree,
256 (element) =>
257 typeof element.type === "function" && element.type.name === "AcpRegistrySearchStep",
258 );
259 (search!.props.onManualConfiguration as () => void)();
260 tree = render();
261 const configuration = visitElements(
262 tree,
263 (element) => element.props.idPrefix === "add-provider-acpRegistry-manual",
264 );
265 (configuration!.props.onChange as (value: Record<string, unknown>) => void)({
266 agentId: "devin",
267 });
268 tree = render();
269 (findByChildren(tree, "Next").props.onClick as () => void)();
270 tree = render();
271 (findByChildren(tree, "Continue to sign-in").props.onClick as () => void)();
272 await Promise.resolve();
273 await Promise.resolve();
274 expect(settingsHooks.mutate).toHaveBeenCalledWith({
275 operation: "create",
276 instanceId: "acpRegistry_custom",
277 instance: {
278 driver: "acpRegistry",
279 enabled: true,
280 config: { agentId: "devin" },
281 },
282 });
283 tree = render();
284 expect(
285 visitElements(
286 tree,
287 (element) =>
288 typeof element.type === "function" &&
289 element.type.name === "ProviderWizardAuthenticationStep",
290 ),
291 ).not.toBeNull();
292 });
293
294 it("creates a local command in the selected environment without a sign-in step", async () => {
295 const onOpenChange = vi.fn();
296 let tree = render(onOpenChange);
297 const search = visitElements(
298 tree,
299 (element) =>
300 typeof element.type === "function" && element.type.name === "AcpRegistrySearchStep",
301 );
302 (search!.props.onLocalConfiguration as () => void)();
303 tree = render(onOpenChange);
304 (findByChildren(tree, "Next").props.onClick as () => void)();
305 tree = render(onOpenChange);
306 expect(findByChildren(tree, "Executable is required.")).not.toBeNull();
307 expect(settingsHooks.mutate).not.toHaveBeenCalled();
308
309 const configuration = visitElements(
310 tree,
311 (element) => element.props.idPrefix === "add-provider-acpRegistry-manual",
312 );
313 const commandArgs = ["--profile", "acp", " literal $(value) ; ", ""];
314 (configuration!.props.onChange as (value: Record<string, unknown>) => void)({
315 source: "local",
316 commandPath: "dsh",
317 commandArgs,
318 });
319 const environmentEditor = visitElements(
320 tree,
321 (element) =>
322 typeof element.type === "function" && element.type.name === "ProviderEnvironmentSection",
323 );
324 const environment = [{ name: "DSH_PROFILE", value: "work", sensitive: false }];
325 (environmentEditor!.props.onChange as (value: typeof environment) => void)(environment);
326 tree = render(onOpenChange);
327 (findByChildren(tree, "Next").props.onClick as () => void)();
328 tree = render(onOpenChange);
329 const label = visitElements(tree, (element) => element.props.id === "add-provider-label");
330 (label!.props.onChange as (event: { target: { value: string } }) => void)({
331 target: { value: "Deepseek Harness" },
332 });
333 tree = render(onOpenChange);
334 (findByChildren(tree, "Add instance").props.onClick as () => void)();
335 await Promise.resolve();
336 await Promise.resolve();
337
338 expect(settingsHooks.useMutation).toHaveBeenCalledWith(remoteEnvironmentId);
339 expect(settingsHooks.mutate).toHaveBeenCalledWith({
340 operation: "create",
341 instanceId: "acpRegistry_deepseek_harness",
342 instance: {
343 driver: "acpRegistry",
344 enabled: true,
345 displayName: "Deepseek Harness",
346 config: { source: "local", commandPath: "dsh", commandArgs },
347 environment,
348 },
349 });
350 expect(onOpenChange).toHaveBeenCalledWith(false);
351 tree = render(onOpenChange);
352 expect(
353 visitElements(
354 tree,
355 (element) =>
356 typeof element.type === "function" &&
357 element.type.name === "ProviderWizardAuthenticationStep",
358 ),
359 ).toBeNull();
360 });
361
362 it("keeps the dialog open when the atomic upsert fails", async () => {
363 settingsHooks.mutate.mockResolvedValueOnce({ _tag: "Failure", cause: new Error("Conflict") });
364 const onOpenChange = vi.fn();
365 await selectPreparedAcp();
366
367 const identityStep = render(onOpenChange);
368 (
369 findByChildren(identityStep, "Continue to sign-in").props.onClick as (() => void) | undefined
370 )?.();
371 await Promise.resolve();
372 await Promise.resolve();
373
374 expect(onOpenChange).not.toHaveBeenCalled();
375 });
376
377 it("adds an instance with the selected environment's provider grant alone", async () => {
378 const dialog = prepareInstance();
379 (button(dialog, "Add instance").props.onClick as () => void)();
380
381 await Promise.resolve();
382 expect(settingsHooks.mutate).toHaveBeenCalledWith({
383 operation: "create",
384 instanceId: "codex_work",
385 instance: {
386 driver: "codex",
387 enabled: true,
388 displayName: "Work",
389 config: { setupMode: "existing" },
390 },
391 });
392 expect(actions.toast).toHaveBeenCalledWith(
393 expect.objectContaining({ type: "success", title: "Provider instance added" }),
394 );
395 expect(actions.onOpenChange).toHaveBeenCalledWith(false);
396 });
397
398 it("rejects a queued save after the provider grant is revoked", () => {
399 const dialog = prepareInstance();
400 const save = button(dialog, "Add instance").props.onClick as () => void;
401 actions.canManageProviders = false;
402 save();
403
404 expect(settingsHooks.mutate).not.toHaveBeenCalled();
405 expect(actions.toast).not.toHaveBeenCalled();
406 expect(actions.onOpenChange).not.toHaveBeenCalled();
407 expect(button(renderDialog(), "Add instance").props.disabled).toBe(true);
408 });
409
410 it("keeps a denied draft available when the provider grant arrives", async () => {
411 actions.canManageProviders = false;
412 let dialog = prepareInstance();
413 (button(dialog, "Add instance").props.onClick as () => void)();
414 expect(settingsHooks.mutate).not.toHaveBeenCalled();
415 expect(actions.toast).not.toHaveBeenCalled();
416
417 actions.canManageProviders = true;
418 dialog = renderDialog();
419 expect(button(dialog, "Add instance").props.disabled).toBe(false);
420 (button(dialog, "Add instance").props.onClick as () => void)();
421 await Promise.resolve();
422 expect(settingsHooks.mutate).toHaveBeenCalledOnce();
423 expect(actions.onOpenChange).toHaveBeenCalledWith(false);
424 });
425});