| 1 | import { EnvironmentId, ProviderDriverKind, type AcpRegistrySearchAgent } from "@t3tools/contracts"; |
| 2 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 3 | |
| 4 | import { visitElements } from "../../test/reactElementTree"; |
| 5 | import { reactHookHarness as hooks } from "../../test/reactHookHarness"; |
| 6 | |
| 7 | const actions = vi.hoisted(() => ({ |
| 8 | update: vi.fn(), |
| 9 | toast: vi.fn(), |
| 10 | onOpenChange: vi.fn(), |
| 11 | canManageProviders: true, |
| 12 | })); |
| 13 | |
| 14 | const settingsHooks = vi.hoisted(() => ({ |
| 15 | read: vi.fn(() => ({ providerInstances: {} })), |
| 16 | mutate: vi.fn(), |
| 17 | useMutation: vi.fn(), |
| 18 | })); |
| 19 | |
| 20 | vi.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 | |
| 30 | vi.mock("react/compiler-runtime", async () => { |
| 31 | const { reactHookHarness } = await import("../../test/reactHookHarness"); |
| 32 | return { c: reactHookHarness.useMemoCache }; |
| 33 | }); |
| 34 | |
| 35 | vi.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 | |
| 40 | vi.mock("../../hooks/useSettings", () => ({ |
| 41 | useEnvironmentSettings: settingsHooks.read, |
| 42 | usePersistEnvironmentProviderInstanceMutation: settingsHooks.useMutation, |
| 43 | })); |
| 44 | |
| 45 | vi.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 | |
| 52 | vi.mock("../ui/toast", () => ({ toastManager: { add: actions.toast } })); |
| 53 | |
| 54 | import { AddProviderInstanceDialog } from "./AddProviderInstanceDialog"; |
| 55 | |
| 56 | const remoteEnvironmentId = EnvironmentId.make("remote-device"); |
| 57 | const 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 | |
| 71 | function 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 | |
| 81 | function 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 | |
| 87 | async 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 | |
| 100 | function renderDialog() { |
| 101 | hooks.beginRender(); |
| 102 | return AddProviderInstanceDialog({ |
| 103 | open: true, |
| 104 | environmentId: remoteEnvironmentId, |
| 105 | environmentLabel: "Remote device", |
| 106 | onOpenChange: actions.onOpenChange, |
| 107 | }); |
| 108 | } |
| 109 | |
| 110 | function 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 | |
| 119 | function 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 | |
| 133 | describe("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 | }); |