From e1cabbb3c82578d9a3846e9de70fb94d0b186e75 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Sat, 17 Oct 2026 07:07:26 -0700 Subject: [PATCH] test(web): provider, usage and relationship tests run in a real browser Converts the provider settings panel, source control writing, usage refresh and thread relationship agent tests to Vitest browser mode, and deletes the reactHookHarness and reactElementTree helpers, which no longer have users. Co-Authored-By: Claude Opus 5.5 --- ...ationshipsControl.agents.browser.test.tsx} | 492 ++++++------- ...SettingsPanel.environment.browser.test.tsx | 558 ++++++++++++++ ...ProviderSettingsPanel.environment.test.tsx | 688 ------------------ ...ceControlWritingSettings.browser.test.tsx} | 181 +++-- .../usage/UsagePage.refresh.browser.test.tsx | 249 +++++++ .../usage/UsagePage.refresh.test.tsx | 301 -------- apps/web/src/test/reactElementTree.ts | 21 - apps/web/src/test/reactHookHarness.ts | 62 -- 8 files changed, 1116 insertions(+), 1436 deletions(-) rename apps/web/src/components/chat/{ThreadRelationshipsControl.agents.test.tsx => ThreadRelationshipsControl.agents.browser.test.tsx} (73%) create mode 100644 apps/web/src/components/settings/ProviderSettingsPanel.environment.browser.test.tsx delete mode 100644 apps/web/src/components/settings/ProviderSettingsPanel.environment.test.tsx rename apps/web/src/components/settings/{SourceControlWritingSettings.test.tsx => SourceControlWritingSettings.browser.test.tsx} (53%) create mode 100644 apps/web/src/components/usage/UsagePage.refresh.browser.test.tsx delete mode 100644 apps/web/src/components/usage/UsagePage.refresh.test.tsx delete mode 100644 apps/web/src/test/reactElementTree.ts delete mode 100644 apps/web/src/test/reactHookHarness.ts diff --git a/apps/web/src/components/chat/ThreadRelationshipsControl.agents.test.tsx b/apps/web/src/components/chat/ThreadRelationshipsControl.agents.browser.test.tsx similarity index 73% rename from apps/web/src/components/chat/ThreadRelationshipsControl.agents.test.tsx rename to apps/web/src/components/chat/ThreadRelationshipsControl.agents.browser.test.tsx index a9885b3129a7..342fb968abe9 100644 --- a/apps/web/src/components/chat/ThreadRelationshipsControl.agents.test.tsx +++ b/apps/web/src/components/chat/ThreadRelationshipsControl.agents.browser.test.tsx @@ -1,5 +1,5 @@ import { act, cloneElement, type ReactElement, type ReactNode } from "react"; -import { create, type ReactTestInstance, type ReactTestRenderer } from "react-test-renderer"; +import { createRoot, type Root } from "react-dom/client"; import { EnvironmentId, ThreadId, @@ -8,9 +8,12 @@ import { type OrchestrationV2ContextTransfer, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; -import { afterEach, expect, it, vi } from "vite-plus/test"; +import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; + +import "~/index.css"; const state = vi.hoisted(() => ({ + stopWorkflowLabel: "environment-data:orchestration:stop-workflow", projection: null as unknown, navigate: vi.fn(), shells: [] as unknown[], @@ -22,56 +25,124 @@ const state = vi.hoisted(() => ({ toast: vi.fn(), })); -vi.mock("@tanstack/react-router", () => ({ useNavigate: () => state.navigate })); -vi.mock("../../state/entities", () => ({ - useThreadProjection: () => ({ projection: state.projection }), - useThreadRelationshipShells: (ref: { readonly environmentId: string }) => +vi.mock(import("@tanstack/react-router"), async (original) => ({ + ...(await original()), + useNavigate: (() => state.navigate) as never, +})); +vi.mock(import("../../state/entities"), async (original) => ({ + ...(await original()), + useThreadProjection: (() => ({ projection: state.projection })) as never, + useThreadRelationshipShells: ((ref: { readonly environmentId: string }) => (state.shells as ReadonlyArray<{ readonly environmentId: string; readonly source: unknown }>) .filter((shell) => shell.environmentId === ref.environmentId) - .map((shell) => shell.source), - useProjects: () => state.projects, + .map((shell) => shell.source)) as never, + useProjects: (() => state.projects) as never, })); -vi.mock("../../state/environments", () => ({ - useEnvironmentProviders: () => +vi.mock(import("../../state/environments"), async (original) => ({ + ...(await original()), + useEnvironmentProviders: (() => new Map( [...state.configs].map(([id, config]) => [id, (config as { providers: unknown }).providers]), - ), + )) as never, })); -vi.mock("../../lib/archivedThreadsState", () => ({ - useArchivedThreadSnapshots: () => ({ snapshots: [] }), +vi.mock(import("../../lib/archivedThreadsState"), async (original) => ({ + ...(await original()), + useArchivedThreadSnapshots: (() => ({ snapshots: [] })) as never, })); -vi.mock("../../state/use-atom-command", () => ({ - useAtomCommand: (command: { label: string }) => - command.label === "environment-data:orchestration:stop-workflow" - ? state.workflowCommand - : state.command, +vi.mock(import("../../state/use-atom-command"), async (original) => ({ + ...(await original()), + useAtomCommand: ((command: { label?: string }) => + command.label === state.stopWorkflowLabel ? state.workflowCommand : state.command) as never, })); -vi.mock("../ui/toast", () => ({ toastManager: { add: state.toast } })); -vi.mock("../../state/orchestration", async () => { +vi.mock(import("../ui/toast"), async (original) => { + const actual = await original(); + return { ...actual, toastManager: { ...actual.toastManager, add: state.toast } as never }; +}); +vi.mock(import("../../state/orchestration"), async (original) => { + const actual = await original(); const { Atom } = await import("effect/reactivity"); const granted = Atom.make(true); + const stopWorkflow = actual.orchestrationEnvironment.stopWorkflow; return { - orchestrationEnvironment: { - stopWorkflow: { - label: "environment-data:orchestration:stop-workflow", + ...actual, + orchestrationEnvironment: Object.assign(Object.create(actual.orchestrationEnvironment), { + stopWorkflow: Object.assign(Object.create(stopWorkflow), { + label: state.stopWorkflowLabel, permissionAtom: () => granted, - }, - }, + }), + }), + }; +}); +// Tooltip and hover card content renders inline so the suite can read every row's agent +// details across many states without hovering each row. +vi.mock(import("../ui/tooltip"), async () => { + const { cloneElement } = await import("react"); + return { + Tooltip: (({ children }: { children: ReactNode }) => children) as never, + TooltipTrigger: (({ render, children }: { render: ReactElement; children: ReactNode }) => + cloneElement(render, {}, children)) as never, + TooltipPopup: (({ children }: { children: ReactNode }) => + state.showTooltips ? children : null) as never, }; }); -vi.mock("../ui/tooltip", () => ({ - Tooltip: ({ children }: { children: ReactNode }) => children, - TooltipTrigger: ({ render, children }: { render: ReactElement; children: ReactNode }) => - cloneElement(render, {}, children), - TooltipPopup: ({ children }: { children: ReactNode }) => (state.showTooltips ? children : null), -})); import { ThreadRelationshipsPanel } from "./ThreadRelationshipsControl"; -let renderer: ReactTestRenderer; +let root: Root; +let container: HTMLDivElement; +let current: ReactElement; + +async function render(element: ReactElement) { + current = element; + await act(async () => root.render(element)); +} +// The mocked hooks read `state` while rendering, so a new element picks up changes. +const update = () => act(async () => root.render(cloneElement(current))); +const click = (element: HTMLElement) => act(async () => element.click()); +const buttons = () => [...container.querySelectorAll("button")]; +const allByLabel = (label: string) => + buttons().filter((button) => button.getAttribute("aria-label") === label); +function byLabel(label: string) { + const matches = allByLabel(label); + if (matches.length !== 1) + throw new Error(`Expected one "${label}" button, got ${matches.length}`); + return matches[0]!; +} +const sectionToggles = (expanded: boolean) => + buttons().filter( + (button) => + button.getAttribute("aria-expanded") === String(expanded) && + !button.hasAttribute("aria-label"), + ); +function sectionToggle(expanded: boolean) { + const [toggle] = sectionToggles(expanded); + if (!toggle) throw new Error(`No ${expanded ? "expanded" : "collapsed"} section toggle`); + return toggle; +} +const heading = () => container.querySelector("h3")?.textContent; +const buttonsWithLabel = (matches: (label: string) => boolean) => + buttons().filter((button) => matches(button.getAttribute("aria-label") ?? "")); +// Joins rendered text nodes; `visibleOnly` drops screen-reader-only text. +function textNodes(separator: string, visibleOnly = false) { + const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT); + const parts: string[] = []; + for (let node = walker.nextNode(); node; node = walker.nextNode()) { + if (visibleOnly && node.parentElement?.closest(".sr-only")) continue; + parts.push(node.textContent ?? ""); + } + return parts.join(separator); +} + +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); +}); afterEach(async () => { - await act(async () => renderer?.unmount()); + await act(async () => root.unmount()); + container.remove(); vi.unstubAllGlobals(); state.shells = []; state.projects = []; @@ -85,7 +156,6 @@ afterEach(async () => { }); it("stops the entire native workflow through its parent without changing state before acknowledgement", async () => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); const coordinator = { id: "workflow", origin: "provider_native", @@ -132,24 +202,15 @@ it("stops the entire native workflow through its parent without changing state b }, }, ]; - await act(async () => { - renderer = create( - , - ); - }); - await act(async () => - renderer.root.findByProps({ "aria-label": "Expand Checkout review" }).props.onClick(), + await render( + , ); - const stopButton = () => - renderer.root.findByProps({ "aria-label": "Stop workflow Checkout review" }); - expect( - renderer.root.findAll( - (node) => node.type === "button" && String(node.props["aria-label"]).startsWith("Stop "), - ), - ).toHaveLength(2); + await click(byLabel("Expand Checkout review")); + const stopButton = () => byLabel("Stop workflow Checkout review"); + expect(buttonsWithLabel((label) => label.startsWith("Stop "))).toHaveLength(2); let acknowledge!: (result: { _tag: string }) => void; state.workflowCommand.mockImplementationOnce( () => @@ -157,35 +218,29 @@ it("stops the entire native workflow through its parent without changing state b acknowledge = resolve; }), ); - await act(async () => { - stopButton().props.onClick(); - }); + await click(stopButton()); expect(state.workflowCommand).toHaveBeenCalledExactlyOnceWith({ environmentId: "remote", input: { threadId: "parent", subagentId: "workflow" }, }); expect(state.command).not.toHaveBeenCalled(); expect(state.navigate).not.toHaveBeenCalled(); - expect(stopButton().props.disabled).toBe(true); - expect(renderer.root.findByProps({ "aria-label": "Stop subagent Worker" }).props.disabled).toBe( - true, - ); - expect(renderer.root.findByProps({ "aria-label": "Open Checker chat" })).toBeDefined(); - expect(renderer.root.findByType("h3").children).toEqual(["Lineage · 2 running"]); + expect(stopButton().disabled).toBe(true); + expect(byLabel("Stop subagent Worker").disabled).toBe(true); + expect(byLabel("Open Checker chat")).toBeDefined(); + expect(heading()).toBe("Lineage · 2 running"); await act(async () => { acknowledge({ _tag: "Success" }); }); - expect(stopButton().props.disabled).toBe(false); - expect(renderer.root.findByType("h3").children).toEqual(["Lineage · 2 running"]); + expect(stopButton().disabled).toBe(false); + expect(heading()).toBe("Lineage · 2 running"); state.workflowCommand.mockResolvedValueOnce({ _tag: "Failure" }); - await act(async () => { - stopButton().props.onClick(); - }); + await click(stopButton()); expect(state.toast).toHaveBeenCalledExactlyOnceWith({ type: "error", title: "Could not stop workflow", }); - expect(stopButton().props.disabled).toBe(false); + expect(stopButton().disabled).toBe(false); expect(state.command).not.toHaveBeenCalled(); expect(state.navigate).not.toHaveBeenCalled(); }); @@ -198,7 +253,6 @@ it.each([ ["codex", "provider_native", "running"], ["claudeAgent", "app_owned", "running"], ])("does not offer workflow stop for %s %s %s", async (driver, origin, status) => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); state.projection = { thread: { id: "parent", lineage: { relationshipToParent: null } }, runs: [], @@ -221,28 +275,20 @@ it.each([ }, ], }; - await act(async () => { - renderer = create( - , - ); - }); - const previousAgents = renderer.root.findAll( - (node) => - node.type === "button" && node.props["aria-expanded"] === false && !node.props["aria-label"], + await render( + , ); - if (previousAgents.length > 0) await act(async () => previousAgents[0]!.props.onClick()); - expect(renderer.root.findByProps({ "aria-label": "Expand Checkout review" })).toBeDefined(); - expect( - renderer.root.findAllByProps({ "aria-label": "Stop workflow Checkout review" }), - ).toHaveLength(0); + const previousAgents = sectionToggles(false); + if (previousAgents.length > 0) await click(previousAgents[0]!); + expect(byLabel("Expand Checkout review")).toBeDefined(); + expect(allByLabel("Stop workflow Checkout review")).toHaveLength(0); expect(state.workflowCommand).not.toHaveBeenCalled(); }); it("opens the correct chat for every workflow phase and unphased member", async () => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); state.showTooltips = true; state.configs.set("remote", { providers: [ @@ -310,92 +356,70 @@ it("opens the correct chat for every workflow phase and unphased member", async threadId={ThreadId.make("parent")} /> ); - await act(async () => { - renderer = create(panel); - }); - await act(async () => - renderer.root.findByProps({ "aria-label": "Expand Checkout review" }).props.onClick(), - ); - const memberButtons = () => - renderer.root.findAll( - (node) => - node.type === "button" && String(node.props["aria-label"]).startsWith("Open Member"), - ); + await render(panel); + await click(byLabel("Expand Checkout review")); + const memberButtons = () => buttonsWithLabel((label) => label.startsWith("Open Member")); const phaseButtons = () => - renderer.root.findAll( - (node) => - node.type === "button" && - typeof node.props["aria-expanded"] === "boolean" && - String(node.props["aria-label"]).includes(" phase, "), + buttonsWithLabel((label) => label.includes(" phase, ")).filter((button) => + button.hasAttribute("aria-expanded"), ); expect(memberButtons()).toHaveLength(3); expect(phaseButtons()).toHaveLength(3); - expect(renderer.root.findByProps({ "aria-label": "Expand Inspect phase, done" })).toBeDefined(); - expect( - renderer.root.findByProps({ "aria-label": "Expand Improve phase, stopped" }), - ).toBeDefined(); - expect( - renderer.root.findByProps({ "aria-label": "Collapse Verify phase, running" }), - ).toBeDefined(); - for (const phase of phaseButtons().slice(0, 2)) await act(async () => phase.props.onClick()); + expect(byLabel("Expand Inspect phase, done")).toBeDefined(); + expect(byLabel("Expand Improve phase, stopped")).toBeDefined(); + expect(byLabel("Collapse Verify phase, running")).toBeDefined(); + for (const phase of phaseButtons().slice(0, 2)) await click(phase); expect(memberButtons()).toHaveLength(7); for (const button of memberButtons()) { - expect(button.props.disabled).toBe(true); - await act(async () => button.props.onClick()); + expect(button.disabled).toBe(true); + await click(button); } expect(state.navigate).not.toHaveBeenCalled(); state.shells = memberShells.map((source) => ({ environmentId: "remote", source })); - await act(async () => renderer.update(cloneElement(panel))); - for (const button of memberButtons()) expect(button.props.disabled).toBe(false); + await update(); + for (const button of memberButtons()) expect(button.disabled).toBe(false); for (const agent of agents) { - await act(async () => - renderer.root.findByProps({ "aria-label": `Open ${agent.label} chat` }).props.onClick(), - ); + await click(byLabel(`Open ${agent.label} chat`)); expect(state.navigate).toHaveBeenLastCalledWith({ to: "/$environmentId/$threadId", params: { environmentId: "remote", threadId: agent.childThreadId }, }); } - const rendered = JSON.stringify(renderer.toJSON()); + const rendered = textNodes("|"); expect(rendered.match(/Sonnet · Work account/g)).toHaveLength(7); expect(rendered).not.toContain("Personal account"); for (const phase of phases) expect(rendered).toContain(phase.title); expect(rendered).not.toContain(" phases"); - expect(rendered).toContain('"1","/","2"," ","agents"'); + expect(rendered).toContain("1|/|2| |agents"); expect(rendered).toContain("Running"); expect(rendered).toContain("running"); - expect(rendered).toContain("done"); - expect(rendered).toContain("stopped"); - expect(rendered).toContain('"6s"'); + expect(buttonsWithLabel((label) => label.endsWith("phase, done"))).toHaveLength(1); + expect(buttonsWithLabel((label) => label.endsWith("phase, stopped"))).toHaveLength(1); + expect(rendered).toContain("|6s|"); for (const phase of phaseButtons()) { - const label = String(phase.props["aria-label"]).slice("Collapse ".length); - await act(async () => phase.props.onClick()); + const label = String(phase.getAttribute("aria-label")).slice("Collapse ".length); + await click(phase); expect(memberButtons()).toHaveLength(5); - await act(async () => - renderer.root.findByProps({ "aria-label": `Expand ${label}` }).props.onClick(), - ); + await click(byLabel(`Expand ${label}`)); expect(memberButtons()).toHaveLength(7); } - await act(async () => phaseButtons()[0]!.props.onClick()); + await click(phaseButtons()[0]!); expect(memberButtons()).toHaveLength(5); const restarted = agents.map((agent) => agent.index === 0 ? { ...agent, state: "running" } : agent, ); state.projection = project(restarted); - await act(async () => renderer.update(cloneElement(panel))); + await update(); expect(memberButtons()).toHaveLength(5); state.projection = project(agents); - await act(async () => renderer.update(cloneElement(panel))); + await update(); expect(memberButtons()).toHaveLength(5); - await act(async () => - renderer.root.findByProps({ "aria-label": "Collapse Checkout review" }).props.onClick(), - ); + await click(byLabel("Collapse Checkout review")); expect(memberButtons()).toHaveLength(0); expect(state.navigate).toHaveBeenCalledTimes(7); }); it("groups and counts a workflow by its run status instead of its coordinator chat", async () => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); state.showTooltips = true; const coordinator = { id: "workflow", @@ -438,22 +462,14 @@ it("groups and counts a workflow by its run status instead of its coordinator ch threadId={ThreadId.make("parent")} /> ); - await act(async () => { - renderer = create(panel); - }); - const text = () => - renderer.root - .findAll((node) => typeof node.type === "string") - .flatMap((node) => node.children.filter((child) => typeof child === "string")) - .join(""); - expect(renderer.root.findByType("h3").children).toEqual(["Lineage · 1 running"]); - await act(async () => - renderer.root.findByProps({ "aria-label": "Expand Checkout review" }).props.onClick(), - ); - expect(renderer.root.findByProps({ "aria-label": "Open Checker chat" })).toBeDefined(); + await render(panel); + const text = () => textNodes(""); + expect(heading()).toBe("Lineage · 1 running"); + await click(byLabel("Expand Checkout review")); + expect(byLabel("Open Checker chat")).toBeDefined(); state.shells = [{ environmentId: "test", source: { ...child, activityRunStatus: "waiting" } }]; - await act(async () => renderer.update(cloneElement(panel))); - expect(renderer.root.findByType("h3").children).toEqual(["Lineage · 1 running"]); + await update(); + expect(heading()).toBe("Lineage · 1 running"); expect(text()).toContain("Inspecting the checkout workflow"); state.projection = { ...projection, @@ -465,14 +481,12 @@ it("groups and counts a workflow by its run status instead of its coordinator ch }, ], }; - await act(async () => renderer.update(cloneElement(panel))); - expect(renderer.root.findByType("h3").children).toEqual(["Lineage"]); + await update(); + expect(heading()).toBe("Lineage"); expect(text()).toContain("Previous agents"); - expect(renderer.root.findAllByProps({ "aria-label": "Open Checker chat" })).toHaveLength(0); - await act(async () => - renderer.root.findByProps({ type: "button", "aria-expanded": false }).props.onClick(), - ); - expect(renderer.root.findByProps({ "aria-label": "Expand Checkout review" })).toBeDefined(); + expect(allByLabel("Open Checker chat")).toHaveLength(0); + await click(sectionToggle(false)); + expect(byLabel("Expand Checkout review")).toBeDefined(); expect(text()).toContain("Workflow checks complete"); expect(text()).toContain("10m"); }); @@ -485,7 +499,6 @@ it.each([ ])( "offers individual Stop for supported $origin $driver subagents with run $runId", async ({ driver, origin, runId, canStop }) => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); const parent = { id: "parent", lineage: { relationshipToParent: null }, @@ -531,17 +544,13 @@ it.each([ threadId={ThreadId.make("parent")} /> ); - await act(async () => { - renderer = create(panel); - }); + await render(panel); if (!canStop) { - expect(renderer.root.findAllByProps({ "aria-label": "Stop subagent Worker" })).toHaveLength( - 0, - ); + expect(allByLabel("Stop subagent Worker")).toHaveLength(0); return; } - const stopButton = () => renderer.root.findByProps({ "aria-label": "Stop subagent Worker" }); - await act(async () => stopButton().props.onClick()); + const stopButton = () => byLabel("Stop subagent Worker"); + await click(stopButton()); expect(state.command).toHaveBeenCalledWith({ environmentId: "test", input: @@ -567,19 +576,15 @@ it.each([ ...projection, subagents: [{ ...agent, origin: "provider_native", status: "completed" }], }; - await act(async () => renderer.update(cloneElement(panel))); - expect(renderer.root.findAllByProps({ "aria-label": "Stop subagent Worker" })).toHaveLength( - 0, - ); + await update(); + expect(allByLabel("Stop subagent Worker")).toHaveLength(0); state.projection = { ...projection, subagents: [{ ...agent, status: "completed" }] }; - await act(async () => renderer.update(cloneElement(panel))); + await update(); if (origin === "provider_native") { - expect(renderer.root.findAllByProps({ "aria-label": "Stop subagent Worker" })).toHaveLength( - 0, - ); + expect(allByLabel("Stop subagent Worker")).toHaveLength(0); continue; } - await act(async () => stopButton().props.onClick()); + await click(stopButton()); expect(state.command).toHaveBeenCalledTimes(1); expect(state.command).toHaveBeenLastCalledWith({ environmentId: "test", @@ -589,25 +594,22 @@ it.each([ state.shells = [{ environmentId: "test", source: child }]; for (const status of ["completed", "failed", "interrupted"]) { state.projection = { ...projection, subagents: [{ ...agent, status }] }; - await act(async () => renderer.update(cloneElement(panel))); - expect(renderer.root.findAllByProps({ "aria-label": "Stop subagent Worker" })).toHaveLength( - 0, - ); + await update(); + expect(allByLabel("Stop subagent Worker")).toHaveLength(0); } state.projection = { ...projection, subagents: [{ ...agent, startedAt: null }] }; - await act(async () => renderer.update(cloneElement(panel))); - expect(renderer.root.findAllByProps({ "aria-label": "Stop subagent Worker" })).toHaveLength(0); + await update(); + expect(allByLabel("Stop subagent Worker")).toHaveLength(0); state.projection = { ...projection, subagents: [{ ...agent, origin: "provider_native", driver: "codex" }], }; - await act(async () => renderer.update(cloneElement(panel))); - expect(renderer.root.findAllByProps({ "aria-label": "Stop subagent Worker" })).toHaveLength(0); + await update(); + expect(allByLabel("Stop subagent Worker")).toHaveLength(0); }, ); it("shows the matching child agent details and refreshes them when the agent settles", async () => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); const agent = { id: "agent-1", driver: "codex", @@ -646,15 +648,8 @@ it("shows the matching child agent details and refreshes them when the agent set threadId={ThreadId.make("parent")} /> ); - await act(async () => { - renderer = create(panel); - }); - const text = () => - renderer.root - .findAll((node) => typeof node.type === "string") - .flatMap((node) => node.children.filter((child) => typeof child === "string")) - .join(" ") - .replace(/\s+/g, " "); + await render(panel); + const text = () => textNodes(" ").replace(/\s+/g, " "); expect(text()).toContain("Checker"); expect(text()).toContain("Lineage · 3 running"); expect(text()).toContain("running"); @@ -663,7 +658,7 @@ it("shows the matching child agent details and refreshes them when the agent set expect(text()).not.toContain("tok"); expect(text()).not.toContain("Unlinked agent"); expect(text()).not.toContain("Active agents"); - expect(renderer.root.findAllByProps({ type: "button", "aria-expanded": true })).toHaveLength(0); + expect(sectionToggles(true)).toHaveLength(0); state.projection = { ...projection, @@ -677,27 +672,21 @@ it("shows the matching child agent details and refreshes them when the agent set }, ], }; - await act(async () => renderer.update(cloneElement(panel))); - expect(renderer.root.findByType("h3").children).toEqual(["Lineage"]); + await update(); + expect(heading()).toBe("Lineage"); expect(text()).toContain("Previous agents (1)"); expect(text()).not.toContain("Checker"); - await act(async () => - renderer.root.findByProps({ type: "button", "aria-expanded": false }).props.onClick(), - ); + await click(sectionToggle(false)); expect(text()).toContain("Checker"); expect(text()).toContain("Checker 2m"); expect(text()).not.toContain("(1)"); expect(text()).not.toContain("Done"); expect(text()).not.toContain("running"); expect(text()).not.toContain("Worker"); - await act(async () => - renderer.root.findByProps({ type: "button", "aria-expanded": true }).props.onClick(), - ); + await click(sectionToggle(true)); expect(text()).not.toContain("Checker"); expect(text()).toContain("Previous agents (1)"); - await act(async () => - renderer.root.findByProps({ type: "button", "aria-expanded": false }).props.onClick(), - ); + await click(sectionToggle(false)); expect(text()).toContain("Checker"); state.projection = { @@ -708,7 +697,7 @@ it("shows the matching child agent details and refreshes them when the agent set childThreadId: `running-child-${index}`, })), }; - await act(async () => renderer.update(cloneElement(panel))); + await update(); expect(text()).toContain("Lineage · 8 running"); state.projection = { @@ -723,27 +712,21 @@ it("shows the matching child agent details and refreshes them when the agent set completedAt: DateTime.makeUnsafe("2026-09-16T12:02:15Z"), })), }; - await act(async () => renderer.update(cloneElement(panel))); + await update(); expect(text()).toContain("1 failed"); expect(text()).not.toContain("Old agent 7"); - await act(async () => - renderer.root - .findAllByType("button") - .find((button) => button.children.includes("Show "))! - .props.onClick(), - ); + await click(buttons().find((button) => button.textContent?.startsWith("Show "))!); expect(text()).toContain("Old agent 7"); state.projection = { ...projection, subagents: [{ ...agent, childThreadId: null }], }; - await act(async () => renderer.update(cloneElement(panel))); + await update(); expect(text()).toContain("Lineage · 1 running"); }); it("shows readable models and only differing workspace details in agent tooltips", async () => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); state.showTooltips = true; const parent = { id: "parent", @@ -817,20 +800,20 @@ it("shows readable models and only differing workspace details in agent tooltips threadId={ThreadId.make("parent")} /> ); - await act(async () => { - renderer = create(panel); - }); - const text = (visibleOnly = false) => { - const read = (node: ReactTestInstance | string): string => { - if (typeof node === "string") return node; - if (visibleOnly && node.props.className === "sr-only") return ""; - return node.children.map(read).join(""); - }; - return read(renderer.root); - }; + await render(panel); + const text = (visibleOnly = false) => textNodes("", visibleOnly); const rerender = async (source = child) => { - state.shells = [{ environmentId: "test", source: { ...source } }]; - await act(async () => renderer.update(cloneElement(panel))); + // Fresh objects, as real shells are: the compiled component memoizes on identity. + state.shells = [ + { + environmentId: "test", + source: { + ...source, + modelSelection: { ...source.modelSelection, options: source.modelSelection.options }, + }, + }, + ]; + await update(); }; const nativeProjection = { ...projection, @@ -838,11 +821,11 @@ it("shows readable models and only differing workspace details in agent tooltips }; expect(text()).toContain("My GPT · high"); state.projection = nativeProjection; - await act(async () => renderer.update(cloneElement(panel))); + await update(); expect(text()).toContain("My GPT"); expect(text()).not.toContain("My GPT · high"); state.projection = projection; - await act(async () => renderer.update(cloneElement(panel))); + await update(); expect(text()).not.toContain("Tokens"); expect(text()).not.toContain("Open subagent"); expect(text()).not.toContain("Project"); @@ -861,7 +844,7 @@ it("shows readable models and only differing workspace details in agent tooltips ...projection, subagents: [{ ...projection.subagents[0], model }], }; - await act(async () => renderer.update(cloneElement(panel))); + await update(); expect(text()).toContain(expected); if (!model?.trim() || model === "gpt-5.5" || model === "custom/model-v1") { expect(text()).not.toContain(" · high"); @@ -886,7 +869,7 @@ it("shows readable models and only differing workspace details in agent tooltips ...projection, subagents: [{ ...projection.subagents[0], driver, model: null }], }; - await act(async () => renderer.update(cloneElement(panel))); + await update(); expect(text()).toContain("Not reported"); expect(text()).not.toContain("My GPT"); } @@ -1012,7 +995,7 @@ it("shows readable models and only differing workspace details in agent tooltips expect(text()).toContain("My GPT · Work account · Fast mode onhigh"); expect(text()).not.toContain("Personal account"); state.projection = nativeProjection; - await act(async () => renderer.update(cloneElement(panel))); + await update(); expect(text()).not.toMatch(/Fast|Ultrafast|Normal|Standard|Flex| · high/); state.projection = projection; for (const modelSelection of [ @@ -1042,18 +1025,14 @@ it("shows readable models and only differing workspace details in agent tooltips }, ], }; - await act(async () => renderer.update(cloneElement(panel))); + await update(); if (status === "completed") { - await act(async () => - renderer.root.findByProps({ type: "button", "aria-expanded": false }).props.onClick(), - ); + await click(sectionToggle(false)); } expect(text()).toContain("My GPT · Work account · Ultrafast mode onlow"); expect(text()).not.toContain(" · high"); } - await act(async () => - renderer.root.findByProps({ type: "button", "aria-expanded": true }).props.onClick(), - ); + await click(sectionToggle(true)); state.projection = projection; state.configs.set("test", speedConfig); child.modelSelection.options = [{ id: "reasoningEffort", value: "high" }]; @@ -1061,7 +1040,7 @@ it("shows readable models and only differing workspace details in agent tooltips expect(text()).not.toContain("My GPT ·"); const config = state.configs.get("test"); state.configs.clear(); - await act(async () => renderer.update(cloneElement(panel))); + await update(); expect(text()).toContain("GPT-5.4"); expect(text()).not.toContain("GPT-5.4 ·"); await rerender(); @@ -1089,7 +1068,7 @@ it("shows readable models and only differing workspace details in agent tooltips }, ], }; - await act(async () => renderer.update(cloneElement(panel))); + await update(); expect(text()).toContain("Checking the latest changes"); expect(text()).not.toContain("Old intermediate result"); const result = "Final checks passed. " + "More detail. ".repeat(50) + "Hidden tail"; @@ -1099,10 +1078,8 @@ it("shows readable models and only differing workspace details in agent tooltips { ...projection.subagents[0], status: "failed", progress: "Stale progress", result }, ], }; - await act(async () => renderer.update(cloneElement(panel))); - await act(async () => - renderer.root.findByProps({ type: "button", "aria-expanded": false }).props.onClick(), - ); + await update(); + await click(sectionToggle(false)); expect(text()).toContain("Final checks passed."); expect(text()).not.toContain("Stale progress"); expect(text()).not.toContain("Hidden tail"); @@ -1136,7 +1113,7 @@ it("shows readable models and only differing workspace details in agent tooltips state.shells = []; state.configs.clear(); - await act(async () => renderer.update(cloneElement(panel))); + await update(); expect(text()).toContain("GPT-5.4"); expect(text()).not.toContain("GPT-5.4 ·"); expect(text()).not.toContain("gpt-5.4"); @@ -1158,13 +1135,12 @@ it("shows readable models and only differing workspace details in agent tooltips ...projection, subagents: [{ ...projection.subagents[0], driver, model }], }; - await act(async () => renderer.update(cloneElement(panel))); + await update(); expect(text()).toContain(expected); } }); it("shows the parent's own visible status as parent and child activity change", async () => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); const environmentId = EnvironmentId.make("test"); const parent = { id: "parent", @@ -1195,21 +1171,15 @@ it("shows the parent's own visible status as parent and child activity change", const panel = ( ); - await act(async () => { - renderer = create(panel); - }); - const visibleText = () => - renderer.root - .findAll((node) => typeof node.type === "string" && node.props.className !== "sr-only") - .flatMap((node) => node.children.filter((child) => typeof child === "string")) - .join(" "); + await render(panel); + const visibleText = () => textNodes(" ", true); expect(visibleText()).toContain("Parent conversation"); expect(visibleText()).toContain("Running"); state.shells = shells( { ...parent, activityRunStatus: "completed" }, { ...child, status: "running" }, ); - await act(async () => renderer.update(cloneElement(panel))); + await update(); expect(visibleText()).toContain("Done"); expect(visibleText()).not.toContain("Running"); }); @@ -1217,7 +1187,6 @@ it("shows the parent's own visible status as parent and child activity change", it.each(["source", "target"])( "shows transfer lifecycle states when viewing the %s thread", async (currentThreadId) => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); const environmentId = EnvironmentId.make("test"); const threads = ["source", "target"].map((id) => ({ id, @@ -1253,14 +1222,9 @@ it.each(["source", "target"])( subagents: [], contextTransfers: [{ sourceThreadId: "source", targetThreadId: "target", status }], }; - await act(async () => { - if (status === "pending") renderer = create(panel); - else renderer.update(cloneElement(panel)); - }); - const visibleText = renderer.root - .findAll((node) => typeof node.type === "string") - .flatMap((node) => node.children.filter((child) => typeof child === "string")) - .join(" "); + if (status === "pending") await render(panel); + else await update(); + const visibleText = textNodes(" "); expect(visibleText).toContain( currentThreadId === "source" ? "target conversation" : "source conversation", ); diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.environment.browser.test.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.environment.browser.test.tsx new file mode 100644 index 000000000000..f3c320c9214d --- /dev/null +++ b/apps/web/src/components/settings/ProviderSettingsPanel.environment.browser.test.tsx @@ -0,0 +1,558 @@ +import { RegistryContext } from "@effect/atom-react"; +import { + DEFAULT_UNIFIED_SETTINGS, + EnvironmentId, + ProviderDriverKind, + ProviderInstanceId, + type ServerProvider, + type UnifiedSettings, +} from "@t3tools/contracts"; +import { AsyncResult, type Atom } from "effect/reactivity"; +import { act, type ReactNode } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { page, userEvent } from "vite-plus/test/browser"; + +import "~/index.css"; + +const state = vi.hoisted(() => ({ + providers: null as unknown as Atom.Writable>, + settings: null as unknown as UnifiedSettings, + canManageProviders: true, + canWriteSettings: true, + searchTargetId: null as string | null, + readEnvironmentIds: [] as string[], + updateEnvironmentIds: [] as string[], + mutationEnvironmentIds: [] as string[], + refresh: vi.fn(), + updateProvider: vi.fn(), + uninstall: vi.fn(), + acceptUrlAuth: vi.fn(), + updateSettings: vi.fn(), + updateModelPreferences: vi.fn(), + toggleFavorite: vi.fn(), + mutateProviderInstance: vi.fn(), +})); + +vi.mock(import("../../state/server"), async (original) => { + const actual = await original(); + const { Atom } = await import("effect/reactivity"); + const providers = Atom.make>([]).pipe(Atom.keepAlive); + state.providers = providers; + const config = Atom.make(null); + const commandLabels = { + refreshProviders: "refresh", + updateProvider: "update", + uninstallAcpRegistryManagedBinary: "uninstall", + acceptAcpRegistryUrlAuth: "accept-url-auth", + } as const; + const labelled = Object.fromEntries( + Object.entries(commandLabels).map(([key, label]) => [ + key, + { ...actual.serverEnvironment[key as keyof typeof commandLabels], label }, + ]), + ); + return { + ...actual, + providersIgnoringCheckedAtAtom: (() => providers) as never, + serverEnvironment: { + ...actual.serverEnvironment, + ...labelled, + configValueAtom: (() => config) as never, + providersValueAtom: (() => providers) as never, + } as never, + }; +}); +vi.mock(import("../../state/use-atom-command"), async (original) => ({ + ...(await original()), + useAtomCommand: ((command: { label?: string }) => + ({ + refresh: state.refresh, + update: state.updateProvider, + uninstall: state.uninstall, + "accept-url-auth": state.acceptUrlAuth, + })[command.label ?? ""] ?? vi.fn()) as never, +})); +vi.mock(import("../../state/query"), async (original) => ({ + ...(await original()), + useEnvironmentQuery: (() => ({ + data: null, + error: null, + isPending: false, + refresh: vi.fn(), + })) as never, +})); +vi.mock(import("../../hooks/useSettings"), async (original) => ({ + ...(await original()), + useEnvironmentSettings: ((environmentId: string) => { + state.readEnvironmentIds.push(environmentId); + return state.settings; + }) as never, + useUpdateEnvironmentSettings: ((environmentId: string) => { + state.updateEnvironmentIds.push(environmentId); + return state.updateSettings; + }) as never, + useUpdateEnvironmentModelPreferences: (() => state.updateModelPreferences) as never, + useToggleEnvironmentModelFavorite: (() => state.toggleFavorite) as never, + usePersistEnvironmentProviderInstanceMutation: ((environmentId: string) => { + state.mutationEnvironmentIds.push(environmentId); + return state.mutateProviderInstance; + }) as never, +})); +vi.mock(import("../../state/session"), async (original) => { + const scope = (environmentId: string, requested: string) => + environmentId === "remote-device" && + (requested === "providers:manage" + ? state.canManageProviders + : requested === "settings:write" + ? state.canWriteSettings + : requested === "orchestration:read"); + return { + ...(await original()), + useEnvironmentScope: scope as never, + readEnvironmentScope: scope as never, + }; +}); +vi.mock(import("../../state/entities"), async (original) => ({ + ...(await original()), + useProjects: (() => []) as never, +})); +vi.mock(import("./settingsLayout"), async (original) => ({ + ...(await original()), + useSettingsSearchTargetId: () => state.searchTargetId, + // The real row reads write permission and overrides from live environment state. + SettingsRow: (({ + id, + title, + description, + status, + control, + children, + }: { + id?: string; + title: ReactNode; + description?: ReactNode; + status?: ReactNode; + control?: ReactNode; + children?: ReactNode; + }) => ( +
+

{title}

+ {description} + {status} + {control} + {children} +
+ )) as never, +})); +// Its own data flows are covered by its suite; this one is about the panel. +vi.mock(import("./UsageProviderSettings"), async (original) => ({ + ...(await original()), + UsageProviderSettings: (() => null) as never, +})); +vi.mock(import("../ProviderUpdatesAction"), async (original) => ({ + ...(await original()), + ProviderUpdatesAction: (() => null) as never, +})); + +import { appAtomRegistry } from "~/rpc/atomRegistry"; +import { EnvironmentProviderSettings } from "./ProviderSettingsPanel"; + +const environmentId = EnvironmentId.make("remote-device"); +const codexId = ProviderInstanceId.make("codex"); +const customId = ProviderInstanceId.make("codex_work"); + +function provider(): ServerProvider { + return { + instanceId: codexId, + driver: ProviderDriverKind.make("codex"), + enabled: true, + installed: true, + version: "1.0.0", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-07-24T12:00:00.000Z", + models: [{ slug: "chosen", name: "Chosen", isCustom: false, capabilities: null }], + slashCommands: [], + skills: [], + versionAdvisory: { + status: "behind_latest", + currentVersion: "1.0.0", + latestVersion: "1.1.0", + updateCommand: "pnpm add -g @openai/codex@latest", + canUpdate: true, + checkedAt: "2026-07-24T12:00:00.000Z", + message: "Update available.", + }, + }; +} + +let root: Root; +let container: HTMLDivElement; + +const setProviders = (providers: ReadonlyArray) => + act(async () => appAtomRegistry.set(state.providers, providers)); + +async function render(options?: { + readonly readOnly?: boolean; + readonly targetInstanceId?: ProviderInstanceId; +}) { + await act(async () => + root.render( + +
+ +
+
, + ), + ); +} + +const button = (name: string) => page.getByRole("button", { name, exact: true }); +const refreshButton = () => page.getByRole("button", { name: /^Refresh provider status/ }); +// The section header and the end of the provider list both offer Add provider. +const addProvider = () => page.getByRole("button", { name: "Add provider" }).first(); +const select = (name: string) => button(`Select ${name}`); +const deleteButton = (id: string) => button(`Delete instance ${id}`); + +beforeEach(async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + await page.viewport(1280, 900); + state.settings = DEFAULT_UNIFIED_SETTINGS; + state.canManageProviders = true; + state.canWriteSettings = true; + state.searchTargetId = null; + state.readEnvironmentIds = []; + state.updateEnvironmentIds = []; + state.mutationEnvironmentIds = []; + state.updateSettings.mockReset(); + state.updateModelPreferences.mockReset(); + state.toggleFavorite.mockReset(); + state.mutateProviderInstance.mockReset().mockResolvedValue(AsyncResult.success({})); + state.refresh.mockReset().mockResolvedValue(AsyncResult.success(undefined)); + state.updateProvider.mockReset().mockResolvedValue(AsyncResult.success(undefined)); + state.uninstall.mockReset().mockResolvedValue(AsyncResult.success({})); + state.acceptUrlAuth.mockReset().mockResolvedValue(AsyncResult.success({ accepted: true })); + await setProviders([]); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); +}); + +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); +}); + +describe("EnvironmentProviderSettings routing", () => { + it("shows Codex and Claude while hiding untouched disabled provider slots", async () => { + await render(); + await expect.element(select("Codex")).toBeVisible(); + await expect.element(select("Claude")).toBeVisible(); + for (const label of ["Cursor", "Grok", "Pi", "OpenCode", "Antigravity"]) { + await expect.element(select(label)).not.toBeInTheDocument(); + } + }); + + it("keeps explicitly configured providers visible when disabled", async () => { + state.settings = { + ...DEFAULT_UNIFIED_SETTINGS, + providerInstances: { + [ProviderInstanceId.make("grok")]: { + driver: ProviderDriverKind.make("grok"), + enabled: false, + }, + }, + }; + await render(); + await expect.element(select("Grok")).toBeVisible(); + }); + + it("keeps legacy provider configuration visible when disabled", async () => { + state.settings = { + ...DEFAULT_UNIFIED_SETTINGS, + providerInstances: { + [ProviderInstanceId.make("grok")]: { + driver: ProviderDriverKind.make("grok"), + enabled: false, + config: { binaryPath: "/custom/grok" }, + }, + }, + }; + await render(); + await expect.element(select("Grok")).toBeVisible(); + }); + + it("reads and writes settings on the selected environment", async () => { + await render(); + await expect.element(select("Codex")).toBeVisible(); + expect(new Set(state.readEnvironmentIds)).toEqual(new Set([environmentId])); + expect(new Set(state.updateEnvironmentIds)).toEqual(new Set([environmentId])); + expect(new Set(state.mutationEnvironmentIds)).toEqual(new Set([environmentId])); + }); + + it("routes refresh and provider update commands to the selected environment", async () => { + await setProviders([provider()]); + await render(); + + await userEvent.click(refreshButton()); + expect(state.refresh).toHaveBeenCalledWith({ + environmentId, + input: { refreshModels: true }, + }); + + await userEvent.click(page.getByRole("button", { name: /view details/ }).last()); + await userEvent.click(button("Update now")); + expect(state.updateProvider).toHaveBeenCalledWith({ + environmentId, + input: { provider: ProviderDriverKind.make("codex"), instanceId: codexId }, + }); + }); + + it("opens the requested provider instance instead of the first provider", async () => { + state.settings = { + ...DEFAULT_UNIFIED_SETTINGS, + providerInstances: { + [customId]: { + driver: ProviderDriverKind.make("codex"), + enabled: true, + displayName: "Work", + }, + }, + }; + await setProviders([provider()]); + await render({ targetInstanceId: customId }); + await expect.element(deleteButton(customId)).toBeVisible(); + }); + + it("saves model favorites and visibility on the selected environment", async () => { + await setProviders([provider()]); + await render(); + + await userEvent.click(button("Add Chosen to favorites")); + expect(state.toggleFavorite).toHaveBeenCalledExactlyOnceWith(codexId, "chosen"); + + await userEvent.click(page.getByRole("switch", { name: "Show Chosen in the model picker" })); + expect(state.updateModelPreferences).toHaveBeenCalledExactlyOnceWith({ + setModelsHidden: [{ provider: codexId, model: "chosen", hidden: true }], + }); + expect(state.updateSettings).not.toHaveBeenCalled(); + }); + + it("does not substitute another account when the requested instance was removed", async () => { + await setProviders([provider()]); + await render({ targetInstanceId: customId }); + await expect + .element(page.getByText("This provider instance is no longer available on this device.")) + .toBeVisible(); + expect(state.updateSettings).not.toHaveBeenCalled(); + }); + + it("keeps provider selection available while write controls are read only", async () => { + state.canWriteSettings = false; + state.settings = { + ...DEFAULT_UNIFIED_SETTINGS, + providerInstances: { + [customId]: { + driver: ProviderDriverKind.make("codex"), + enabled: true, + displayName: "Work", + }, + }, + }; + await setProviders([provider()]); + await render({ readOnly: true }); + + await expect.element(page.getByRole("heading", { name: "Limited permissions" })).toBeVisible(); + await userEvent.click(select("Work")); + await expect.element(deleteButton(customId)).toBeInTheDocument(); + expect(container.querySelector("[inert]")).not.toBeNull(); + await expect.element(refreshButton()).toBeVisible(); + await expect.element(addProvider()).not.toBeInTheDocument(); + }); + + it("keeps the editable layout interactive when not read only", async () => { + await setProviders([provider()]); + await render(); + await expect.element(page.getByText("Limited permissions")).not.toBeInTheDocument(); + await expect.element(refreshButton()).toBeVisible(); + await expect.element(addProvider()).toBeVisible(); + await expect.element(page.getByRole("switch", { name: "Enable Codex" }).first()).toBeEnabled(); + }); + + it("removes an open add-instance dialog when the provider grant is revoked", async () => { + await render(); + await userEvent.click(addProvider()); + await expect.element(page.getByRole("dialog")).toBeVisible(); + + state.canManageProviders = false; + await render({ readOnly: true }); + await vi.waitFor(() => expect(document.querySelector('[role="dialog"]')).toBeNull()); + expect(state.updateSettings).not.toHaveBeenCalled(); + }); + + it("keeps Advanced visible when search targets the provider health interval", async () => { + await render(); + await expect.element(page.getByText("Advanced", { exact: true })).toBeVisible(); + expect(container.querySelector("#provider-health-check-interval")).not.toBeNull(); + + state.searchTargetId = "provider-health-check-interval"; + await render(); + await expect.element(page.getByText("Advanced", { exact: true })).toBeVisible(); + expect(container.querySelector("#provider-health-check-interval")).not.toBeNull(); + }); + + it("deletes and resets provider configuration without erasing shared preferences", async () => { + state.settings = { + ...DEFAULT_UNIFIED_SETTINGS, + providerInstances: { + [codexId]: { driver: ProviderDriverKind.make("codex"), enabled: false }, + [customId]: { + driver: ProviderDriverKind.make("codex"), + enabled: true, + displayName: "Work", + }, + }, + providerModelPreferences: { + [customId]: { hiddenModels: ["hidden"], modelOrder: ["model"] }, + }, + favorites: [{ provider: customId, model: "favorite" }], + }; + await render(); + await userEvent.click(select("Work")); + await userEvent.click(deleteButton(customId)); + expect(state.mutateProviderInstance).toHaveBeenLastCalledWith({ + operation: "remove", + instanceId: customId, + }); + + state.mutateProviderInstance.mockClear(); + await userEvent.click(select("Codex")); + await userEvent.click(button("Reset Codex provider settings to default")); + const [resetMutation, resetPatch] = state.mutateProviderInstance.mock.lastCall ?? []; + expect(resetMutation).toEqual({ operation: "remove", instanceId: codexId }); + expect(resetPatch ?? {}).toEqual({}); + }); + + it("updates one provider instance without sending a stale whole map", async () => { + state.settings = { + ...DEFAULT_UNIFIED_SETTINGS, + providerInstances: { + [customId]: { + driver: ProviderDriverKind.make("codex"), + enabled: true, + displayName: "Work", + }, + }, + }; + await render(); + await userEvent.click(page.getByRole("switch", { name: "Enable Work" }).first()); + + expect(state.mutateProviderInstance).toHaveBeenCalledWith( + { + operation: "upsert", + instanceId: customId, + instance: { driver: ProviderDriverKind.make("codex"), enabled: false, displayName: "Work" }, + }, + {}, + ); + }); + + it("lets the server decide managed ACP cleanup after an atomic delete", async () => { + const firstId = ProviderInstanceId.make("acpRegistry_kilo_one"); + const secondId = ProviderInstanceId.make("acpRegistry_kilo_two"); + const registryInstance = (displayName: string) => ({ + driver: ProviderDriverKind.make("acpRegistry"), + enabled: true, + displayName, + config: { agentId: "kilo" }, + }); + state.settings = { + ...DEFAULT_UNIFIED_SETTINGS, + providerInstances: { + [firstId]: registryInstance("Kilo one"), + [secondId]: registryInstance("Kilo two"), + }, + }; + await render(); + await userEvent.click(select("Kilo one")); + await userEvent.click(deleteButton(firstId)); + + expect(state.mutateProviderInstance).toHaveBeenCalledWith({ + operation: "remove", + instanceId: firstId, + }); + await vi.waitFor(() => + expect(state.uninstall).toHaveBeenCalledWith({ environmentId, input: { agentId: "kilo" } }), + ); + }); + + it("keeps the signed-in ACP account visible when login methods are no longer advertised", async () => { + const instanceId = ProviderInstanceId.make("acpRegistry_devin"); + state.settings = { + ...DEFAULT_UNIFIED_SETTINGS, + providerInstances: { + [instanceId]: { + driver: ProviderDriverKind.make("acpRegistry"), + enabled: true, + config: { agentId: "devin" }, + }, + }, + }; + await setProviders([ + { + ...provider(), + instanceId, + driver: ProviderDriverKind.make("acpRegistry"), + auth: { status: "authenticated", canLogout: false }, + setup: { canAuthenticate: false, canInstall: false }, + }, + ]); + await render({ targetInstanceId: instanceId }); + await expect.element(page.getByRole("heading", { name: "Account" })).toBeVisible(); + }); + + it("routes explicit ACP browser authentication consent to the selected environment", async () => { + const instanceId = ProviderInstanceId.make("acpRegistry_antigravity"); + const action = { + elicitationId: "google-login-1", + url: "https://accounts.google.com/login", + message: "Continue with Google", + }; + state.settings = { + ...DEFAULT_UNIFIED_SETTINGS, + providerInstances: { + [instanceId]: { + driver: ProviderDriverKind.make("acpRegistry"), + enabled: true, + config: { agentId: "antigravity" }, + }, + }, + }; + await setProviders([ + { + ...provider(), + instanceId, + driver: ProviderDriverKind.make("acpRegistry"), + auth: { status: "unauthenticated", action }, + }, + ]); + await render({ targetInstanceId: instanceId }); + const link = page.getByRole("link", { name: "Continue authentication" }); + link.element().addEventListener("click", (event) => event.preventDefault()); + await userEvent.click(link); + + expect(state.acceptUrlAuth).toHaveBeenCalledWith({ + environmentId, + input: { instanceId, elicitationId: action.elicitationId }, + }); + }); +}); diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.environment.test.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.environment.test.tsx deleted file mode 100644 index 83e8ea9ef1b3..000000000000 --- a/apps/web/src/components/settings/ProviderSettingsPanel.environment.test.tsx +++ /dev/null @@ -1,688 +0,0 @@ -import { cloneElement, isValidElement, type ReactElement } from "react"; -import { - DEFAULT_UNIFIED_SETTINGS, - EnvironmentId, - ProviderDriverKind, - ProviderInstanceId, - type ServerProvider, - type UnifiedSettings, -} from "@t3tools/contracts"; -import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; - -import { visitElements } from "../../test/reactElementTree"; -import { reactHookHarness as hooks } from "../../test/reactHookHarness"; - -const atoms = vi.hoisted(() => ({ - providers: null as ReadonlyArray | null, - providersAtom: Symbol("providers"), - refreshProviders: Symbol("refreshProviders"), - updateProvider: Symbol("updateProvider"), - uninstallAcpRegistryManagedBinary: Symbol("uninstallAcpRegistryManagedBinary"), - acceptAcpRegistryUrlAuth: Symbol("acceptAcpRegistryUrlAuth"), -})); - -const commands = vi.hoisted(() => ({ - refresh: vi.fn(), - updateProvider: vi.fn(), - uninstall: vi.fn(), - acceptUrlAuth: vi.fn(), - canManageProviders: true, - canWriteSettings: true, -})); - -const settingsState = vi.hoisted(() => ({ - value: null as UnifiedSettings | null, - readEnvironmentIds: [] as EnvironmentId[], - updateEnvironmentIds: [] as EnvironmentId[], - mutationEnvironmentIds: [] as EnvironmentId[], - updateSettings: vi.fn(), - mutateProviderInstance: vi.fn(), - updateClientSettings: vi.fn(), -})); - -const settingsSearchState = vi.hoisted(() => ({ - targetId: null as string | null, - effects: [] as Array<() => void>, -})); - -vi.mock("react", async (importOriginal) => { - const actual = await importOriginal(); - const { reactHookHarness } = await import("../../test/reactHookHarness"); - return { - ...actual, - useCallback: reactHookHarness.useCallback, - useEffect: (effect: () => void) => settingsSearchState.effects.push(effect), - useMemo: reactHookHarness.useMemo, - useRef: reactHookHarness.useRef, - useState: reactHookHarness.useState, - }; -}); - -vi.mock("./settingsLayout", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - useSettingsSearchTargetId: () => settingsSearchState.targetId, - }; -}); - -vi.mock("../../hooks/useStableCallback", () => ({ - useStableCallback: (fn: F) => fn, -})); -vi.mock("./SettingsScopeSentence", () => ({ SettingsScopeSentence: () => null })); -vi.mock("react/compiler-runtime", async () => { - const { reactHookHarness } = await import("../../test/reactHookHarness"); - return { c: reactHookHarness.useMemoCache }; -}); - -vi.mock("@effect/atom-react", () => ({ - useAtomValue: (atom: unknown, select?: (value: null) => unknown) => - select ? select(null) : atom === atoms.providersAtom ? (atoms.providers ?? []) : null, -})); - -vi.mock("../../state/server", () => ({ - EMPTY_SERVER_PROVIDERS: [], - providersIgnoringCheckedAtAtom: () => atoms.providersAtom, - serverEnvironment: { - configValueAtom: () => atoms.providersAtom, - providersValueAtom: () => atoms.providersAtom, - refreshProviders: atoms.refreshProviders, - updateProvider: atoms.updateProvider, - uninstallAcpRegistryManagedBinary: atoms.uninstallAcpRegistryManagedBinary, - acceptAcpRegistryUrlAuth: atoms.acceptAcpRegistryUrlAuth, - }, -})); - -vi.mock("../../state/use-atom-command", () => ({ - useAtomCommand: (atom: symbol) => - atom === atoms.refreshProviders - ? commands.refresh - : atom === atoms.uninstallAcpRegistryManagedBinary - ? commands.uninstall - : atom === atoms.acceptAcpRegistryUrlAuth - ? commands.acceptUrlAuth - : commands.updateProvider, -})); - -vi.mock("../../hooks/useSettings", () => ({ - useUpdateClientSettings: () => settingsState.updateClientSettings, - useEnvironmentSettings: (environmentId: EnvironmentId) => { - settingsState.readEnvironmentIds.push(environmentId); - return settingsState.value; - }, - useUpdateEnvironmentSettings: (environmentId: EnvironmentId) => { - settingsState.updateEnvironmentIds.push(environmentId); - return settingsState.updateSettings; - }, - useUpdateEnvironmentModelPreferences: () => settingsState.updateSettings, - useToggleEnvironmentModelFavorite: () => () => undefined, - usePersistEnvironmentProviderInstanceMutation: (environmentId: EnvironmentId) => { - settingsState.mutationEnvironmentIds.push(environmentId); - return settingsState.mutateProviderInstance; - }, -})); - -vi.mock("../../environments/primary", () => ({ - usePrimarySessionState: () => ({ data: null, error: null, isPending: false, refresh: vi.fn() }), -})); - -vi.mock("../../state/session", () => ({ - useEnvironmentSessionState: () => ({ data: null, hasError: false, isPending: true }), - useEnvironmentScope: (environmentId: EnvironmentId, scope: string) => - environmentId === "remote-device" && - (scope === "providers:manage" - ? commands.canManageProviders - : scope === "settings:write" - ? commands.canWriteSettings - : scope === "orchestration:read"), - readEnvironmentScope: (environmentId: EnvironmentId, scope: string) => - environmentId === "remote-device" && - (scope === "providers:manage" - ? commands.canManageProviders - : scope === "settings:write" - ? commands.canWriteSettings - : scope === "orchestration:read"), -})); - -vi.mock("../../state/entities", () => ({ - useProjects: () => [], - useServerConfigs: () => new Map(), -})); - -import { EnvironmentProviderSettings } from "./ProviderSettingsPanel"; -import { AddProviderInstanceDialog } from "./AddProviderInstanceDialog"; - -const environmentId = EnvironmentId.make("remote-device"); -const codexId = ProviderInstanceId.make("codex"); -const customId = ProviderInstanceId.make("codex_work"); - -function provider(): ServerProvider { - return { - instanceId: codexId, - driver: ProviderDriverKind.make("codex"), - enabled: true, - installed: true, - version: "1.0.0", - status: "ready", - auth: { status: "authenticated" }, - checkedAt: "2026-07-24T12:00:00.000Z", - models: [], - slashCommands: [], - skills: [], - versionAdvisory: { - status: "behind_latest", - currentVersion: "1.0.0", - latestVersion: "1.1.0", - updateCommand: "pnpm add -g @openai/codex@latest", - canUpdate: true, - checkedAt: "2026-07-24T12:00:00.000Z", - message: "Update available.", - }, - }; -} - -function renderPanel(options?: { - readonly readOnly?: boolean; - readonly targetInstanceId?: ProviderInstanceId; -}): ReactElement> { - hooks.beginRender(); - return expandProviderRows( - EnvironmentProviderSettings({ - environmentId, - environmentLabel: "Remote device", - ...(options?.readOnly === undefined ? {} : { readOnly: options.readOnly }), - ...(options?.targetInstanceId === undefined - ? {} - : { targetInstanceId: options.targetInstanceId }), - }), - ) as ReactElement>; -} - -// Renders the row components inline so assertions see the cards they return. -function expandProviderRows(node: unknown): unknown { - if (Array.isArray(node)) return node.map(expandProviderRows); - if (!isValidElement>(node)) return node; - if (typeof node.type === "function" && node.type.name === "ProviderInstanceRow") { - return expandProviderRows((node.type as (props: unknown) => unknown)(node.props)); - } - const props: Record = {}; - let changed = false; - for (const [key, value] of Object.entries(node.props)) { - const expanded = expandProviderRows(value); - if (expanded !== value) changed = true; - props[key] = expanded; - } - return changed ? cloneElement(node, props) : node; -} - -function isRefreshButton(element: ReactElement>): boolean { - const children = element.props.children; - return ( - Array.isArray(children) && - children.some( - (child) => - typeof child === "object" && - child !== null && - (child as ReactElement>).props?.className === "sr-only" && - (child as ReactElement>).props?.children === - "Refresh provider status", - ) - ); -} - -function isAddProviderButton(element: ReactElement>): boolean { - return element.props["aria-label"] === "Add provider"; -} - -async function flushPromises(): Promise { - await Promise.resolve(); - await Promise.resolve(); -} - -describe("EnvironmentProviderSettings routing", () => { - beforeEach(() => { - hooks.reset(); - atoms.providers = null; - settingsState.value = DEFAULT_UNIFIED_SETTINGS; - settingsState.readEnvironmentIds = []; - settingsState.updateEnvironmentIds = []; - settingsState.mutationEnvironmentIds = []; - settingsState.updateSettings.mockReset(); - settingsState.updateClientSettings.mockReset(); - settingsSearchState.targetId = null; - settingsSearchState.effects = []; - settingsState.mutateProviderInstance - .mockReset() - .mockResolvedValue({ _tag: "Success", value: {} }); - commands.canManageProviders = true; - commands.canWriteSettings = true; - commands.refresh.mockReset().mockResolvedValue({ _tag: "Success" }); - commands.updateProvider.mockReset().mockResolvedValue({ _tag: "Success" }); - commands.uninstall.mockReset().mockResolvedValue({ _tag: "Success", value: {} }); - commands.acceptUrlAuth - .mockReset() - .mockResolvedValue({ _tag: "Success", value: { accepted: true } }); - }); - - it("shows Codex and Claude while hiding untouched disabled provider slots", () => { - const panel = renderPanel(); - for (const driver of ["codex", "claudeAgent"] as const) { - expect( - visitElements( - panel, - (element) => element.props.instanceId === driver && element.props.mode === "list", - ), - ).not.toBeNull(); - } - for (const driver of ["cursor", "grok", "pi", "opencode", "antigravity"] as const) { - expect( - visitElements( - panel, - (element) => element.props.instanceId === driver && element.props.mode === "list", - ), - ).toBeNull(); - } - }); - - it("keeps explicitly configured providers visible when disabled", () => { - const grokId = ProviderInstanceId.make("grok"); - settingsState.value = { - ...DEFAULT_UNIFIED_SETTINGS, - providerInstances: { - [grokId]: { driver: ProviderDriverKind.make("grok"), enabled: false }, - }, - }; - const panel = renderPanel(); - expect( - visitElements( - panel, - (element) => element.props.instanceId === grokId && element.props.mode === "list", - ), - ).not.toBeNull(); - }); - - it("keeps legacy provider configuration visible when disabled", () => { - settingsState.value = { - ...DEFAULT_UNIFIED_SETTINGS, - providerInstances: { - [ProviderInstanceId.make("grok")]: { - driver: ProviderDriverKind.make("grok"), - enabled: false, - config: { - binaryPath: "/custom/grok", - }, - }, - }, - }; - const panel = renderPanel(); - expect( - visitElements( - panel, - (element) => element.props.instanceId === "grok" && element.props.mode === "list", - ), - ).not.toBeNull(); - }); - - it("coalesces a nullable provider snapshot before rendering array-backed UI", () => { - expect(() => renderPanel()).not.toThrow(); - expect(settingsState.readEnvironmentIds).toEqual([environmentId]); - expect(settingsState.updateEnvironmentIds).toEqual([environmentId]); - expect(settingsState.mutationEnvironmentIds).toEqual([environmentId]); - }); - - it("routes refresh and provider update commands to the selected environment", async () => { - atoms.providers = [provider()]; - const panel = renderPanel(); - const refreshButton = visitElements(panel, isRefreshButton); - expect(refreshButton).not.toBeNull(); - (refreshButton?.props.onClick as (() => void) | undefined)?.(); - await flushPromises(); - - expect(commands.refresh).toHaveBeenCalledWith({ - environmentId, - input: { refreshModels: true }, - }); - - const providerCard = visitElements( - panel, - (element) => - element.props.instanceId === codexId && typeof element.props.onRunUpdate === "function", - ); - expect(providerCard).not.toBeNull(); - (providerCard?.props.onRunUpdate as (() => void) | undefined)?.(); - await flushPromises(); - - expect(commands.updateProvider).toHaveBeenCalledWith({ - environmentId, - input: { provider: ProviderDriverKind.make("codex"), instanceId: codexId }, - }); - }); - - it("opens the requested provider instance instead of the first provider", () => { - settingsState.value = { - ...DEFAULT_UNIFIED_SETTINGS, - providerInstances: { - [customId]: { driver: ProviderDriverKind.make("codex"), enabled: true }, - }, - }; - atoms.providers = [provider()]; - const panel = renderPanel({ targetInstanceId: customId }); - const editor = visitElements(panel, (element) => element.props.mode === "editor"); - expect(editor?.props.instanceId).toBe(customId); - }); - - it.each([ - [ - "onFavoriteModelsChange", - { setModelFavorites: [{ provider: codexId, model: "chosen", favorite: true }] }, - ], - [ - "onHiddenModelsChange", - { setModelsHidden: [{ provider: codexId, model: "chosen", hidden: true }] }, - ], - [ - "onModelOrderChange", - { setProviderModelOrder: { provider: codexId, modelOrder: ["chosen"] } }, - ], - ])("saves %s on the selected environment for every connected client", (action, expected) => { - atoms.providers = [provider()]; - const panel = renderPanel(); - const editor = visitElements( - panel, - (element) => element.props.instanceId === codexId && element.props.mode === "editor", - ); - expect(editor).not.toBeNull(); - if (!editor) throw new Error("Provider editor was not rendered"); - (editor.props[action] as (models: string[]) => void)(["chosen"]); - expect(settingsState.updateSettings).toHaveBeenCalledExactlyOnceWith(expected); - expect(settingsState.updateClientSettings).not.toHaveBeenCalled(); - }); - - it("does not substitute another account when the requested instance was removed", () => { - atoms.providers = [provider()]; - const panel = renderPanel({ targetInstanceId: customId }); - expect(visitElements(panel, (element) => element.props.mode === "editor")).toBeNull(); - expect(settingsState.updateSettings).not.toHaveBeenCalled(); - }); - - it("keeps provider selection available while write controls are read only", () => { - commands.canWriteSettings = false; - settingsState.value = { - ...DEFAULT_UNIFIED_SETTINGS, - providerInstances: { - [customId]: { - driver: ProviderDriverKind.make("codex"), - enabled: true, - }, - }, - }; - atoms.providers = [provider()]; - let panel = renderPanel({ readOnly: true }); - - const inertWrapper = visitElements(panel, (element) => element.props.inert === true); - expect(inertWrapper).not.toBeNull(); - - const customRow = visitElements( - panel, - (element) => element.props.instanceId === customId && element.props.mode === "list", - ); - expect(customRow?.props.readOnly).toBe(true); - expect(customRow?.props.onSelect).toBeTypeOf("function"); - (customRow?.props.onSelect as (() => void) | undefined)?.(); - - panel = renderPanel({ readOnly: true }); - const customEditor = visitElements( - panel, - (element) => element.props.instanceId === customId && element.props.mode === "editor", - ); - expect(customEditor).not.toBeNull(); - - const notice = visitElements(panel, (element) => element.props.title === "Limited permissions"); - expect(notice).not.toBeNull(); - - expect(visitElements(panel, isRefreshButton)).not.toBeNull(); - expect(visitElements(panel, isAddProviderButton)).toBeNull(); - }); - - it("keeps the editable layout interactive when not read only", () => { - atoms.providers = [provider()]; - const panel = renderPanel(); - expect(visitElements(panel, (element) => element.props.inert === true)).toBeNull(); - expect( - visitElements(panel, (element) => element.props.title === "Limited permissions"), - ).toBeNull(); - expect(visitElements(panel, isRefreshButton)).not.toBeNull(); - expect(visitElements(panel, isAddProviderButton)).not.toBeNull(); - }); - - it("removes an open add-instance dialog when the provider grant is revoked", () => { - let panel = renderPanel(); - const add = visitElements(panel, isAddProviderButton); - if (!add) throw new Error("Missing Add provider action."); - (add.props.onClick as () => void)(); - panel = renderPanel(); - expect( - visitElements(panel, (element) => element.type === AddProviderInstanceDialog), - ).not.toBeNull(); - - commands.canManageProviders = false; - panel = renderPanel({ readOnly: true }); - expect( - visitElements(panel, (element) => element.type === AddProviderInstanceDialog), - ).toBeNull(); - expect(settingsState.updateSettings).not.toHaveBeenCalled(); - }); - - it("keeps Advanced visible when search targets the provider health interval", () => { - let panel = renderPanel(); - expect(visitElements(panel, (element) => element.props.title === "Advanced")).not.toBeNull(); - expect( - visitElements(panel, (element) => element.props.id === "provider-health-check-interval"), - ).not.toBeNull(); - - settingsSearchState.targetId = "provider-health-check-interval"; - panel = renderPanel(); - expect(visitElements(panel, (element) => element.props.title === "Advanced")).not.toBeNull(); - expect( - visitElements(panel, (element) => element.props.id === "provider-health-check-interval"), - ).not.toBeNull(); - }); - - it("deletes and resets provider configuration without erasing shared preferences", async () => { - settingsState.value = { - ...DEFAULT_UNIFIED_SETTINGS, - providerInstances: { - [codexId]: { - driver: ProviderDriverKind.make("codex"), - enabled: false, - }, - [customId]: { - driver: ProviderDriverKind.make("codex"), - enabled: true, - }, - }, - providerModelPreferences: { - [customId]: { hiddenModels: ["hidden"], modelOrder: ["model"] }, - }, - favorites: [{ provider: customId, model: "favorite" }], - }; - let panel = renderPanel(); - const customRow = visitElements( - panel, - (element) => element.props.instanceId === customId && element.props.mode === "list", - ); - (customRow?.props.onSelect as (() => void) | undefined)?.(); - panel = renderPanel(); - const customCard = visitElements( - panel, - (element) => element.props.instanceId === customId && element.props.mode === "editor", - ); - expect(customCard).not.toBeNull(); - (customCard?.props.onDelete as (() => void) | undefined)?.(); - await flushPromises(); - - expect(settingsState.mutateProviderInstance).toHaveBeenLastCalledWith({ - operation: "remove", - instanceId: customId, - }); - - settingsState.mutateProviderInstance.mockClear(); - const defaultRow = visitElements( - panel, - (element) => element.props.instanceId === codexId && element.props.mode === "list", - ); - (defaultRow?.props.onSelect as (() => void) | undefined)?.(); - panel = renderPanel(); - const defaultCard = visitElements( - panel, - (element) => element.props.instanceId === codexId && element.props.mode === "editor", - ); - const resetAction = defaultCard?.props.headerAction; - const resetButton = visitElements( - resetAction, - (element) => typeof element.props.onClick === "function", - ); - expect(resetButton).not.toBeNull(); - (resetButton?.props.onClick as (() => void) | undefined)?.(); - await flushPromises(); - - const [resetMutation, resetPatch] = settingsState.mutateProviderInstance.mock.lastCall ?? []; - expect(resetMutation).toEqual({ operation: "remove", instanceId: codexId }); - expect(resetPatch ?? {}).toEqual({}); - }); - - it("updates one provider instance without sending a stale whole map", async () => { - settingsState.value = { - ...DEFAULT_UNIFIED_SETTINGS, - providerInstances: { - [customId]: { - driver: ProviderDriverKind.make("codex"), - enabled: true, - displayName: "Work", - }, - }, - }; - const panel = renderPanel(); - const card = visitElements(panel, (element) => element.props.instanceId === customId); - const next = { - driver: ProviderDriverKind.make("codex"), - enabled: false, - displayName: "Work", - }; - (card?.props.onUpdate as ((instance: typeof next) => void) | undefined)?.(next); - await flushPromises(); - - expect(settingsState.mutateProviderInstance).toHaveBeenCalledWith( - { operation: "upsert", instanceId: customId, instance: next }, - {}, - ); - }); - - it("lets the server decide managed ACP cleanup after an atomic delete", async () => { - const firstId = ProviderInstanceId.make("acpRegistry_kilo_one"); - const secondId = ProviderInstanceId.make("acpRegistry_kilo_two"); - const registryInstance = { - driver: ProviderDriverKind.make("acpRegistry"), - enabled: true, - config: { agentId: "kilo" }, - }; - settingsState.value = { - ...DEFAULT_UNIFIED_SETTINGS, - providerInstances: { - [firstId]: registryInstance, - [secondId]: registryInstance, - }, - }; - let panel = renderPanel(); - const row = visitElements( - panel, - (element) => element.props.instanceId === firstId && element.props.mode === "list", - ); - (row?.props.onSelect as (() => void) | undefined)?.(); - panel = renderPanel(); - const card = visitElements( - panel, - (element) => element.props.instanceId === firstId && element.props.mode === "editor", - ); - (card?.props.onDelete as (() => void) | undefined)?.(); - await flushPromises(); - - expect(settingsState.mutateProviderInstance).toHaveBeenCalledWith({ - operation: "remove", - instanceId: firstId, - }); - expect(commands.uninstall).toHaveBeenCalledWith({ - environmentId, - input: { agentId: "kilo" }, - }); - }); - - it("keeps the signed-in ACP account visible when login methods are no longer advertised", () => { - const instanceId = ProviderInstanceId.make("acpRegistry_devin"); - settingsState.value = { - ...DEFAULT_UNIFIED_SETTINGS, - providerInstances: { - [instanceId]: { - driver: ProviderDriverKind.make("acpRegistry"), - enabled: true, - config: { agentId: "devin" }, - }, - }, - }; - atoms.providers = [ - { - ...provider(), - instanceId, - driver: ProviderDriverKind.make("acpRegistry"), - auth: { status: "authenticated", canLogout: false }, - setup: { canAuthenticate: false, canInstall: false }, - }, - ]; - const panel = renderPanel({ targetInstanceId: instanceId }); - expect( - visitElements( - panel, - (element) => - typeof element.type === "function" && - element.type.name === "ProviderAuthenticationSection", - ), - ).not.toBeNull(); - }); - - it("routes explicit ACP browser authentication consent to the selected environment", async () => { - const instanceId = ProviderInstanceId.make("acpRegistry_antigravity"); - const action = { - elicitationId: "google-login-1", - url: "https://accounts.google.com/login", - message: "Continue with Google", - }; - settingsState.value = { - ...DEFAULT_UNIFIED_SETTINGS, - providerInstances: { - [instanceId]: { - driver: ProviderDriverKind.make("acpRegistry"), - enabled: true, - config: { agentId: "antigravity" }, - }, - }, - }; - atoms.providers = [ - { - ...provider(), - instanceId, - driver: ProviderDriverKind.make("acpRegistry"), - auth: { status: "unauthenticated", action }, - }, - ]; - - const panel = renderPanel(); - const card = visitElements(panel, (element) => element.props.instanceId === instanceId); - (card?.props.onAcceptUrlAuth as ((candidate: typeof action) => void) | undefined)?.(action); - await flushPromises(); - - expect(commands.acceptUrlAuth).toHaveBeenCalledWith({ - environmentId, - input: { instanceId, elicitationId: action.elicitationId }, - }); - }); -}); diff --git a/apps/web/src/components/settings/SourceControlWritingSettings.test.tsx b/apps/web/src/components/settings/SourceControlWritingSettings.browser.test.tsx similarity index 53% rename from apps/web/src/components/settings/SourceControlWritingSettings.test.tsx rename to apps/web/src/components/settings/SourceControlWritingSettings.browser.test.tsx index e628af122a03..326a1d4c2f8e 100644 --- a/apps/web/src/components/settings/SourceControlWritingSettings.test.tsx +++ b/apps/web/src/components/settings/SourceControlWritingSettings.browser.test.tsx @@ -1,8 +1,10 @@ import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; import { act, StrictMode, type ReactNode } from "react"; -import { create, type ReactTestRenderer } from "react-test-renderer"; +import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { page, userEvent } from "vite-plus/test/browser"; +import "../../index.css"; import type { ScopedSettingsPatch } from "./scopedSettings"; type WritingStyle = typeof DEFAULT_UNIFIED_SETTINGS.sourceControlWritingStyle; @@ -11,90 +13,77 @@ const state = vi.hoisted(() => ({ updateSettings: vi.fn<(patch: ScopedSettingsPatch) => void>(), })); -vi.mock("@tanstack/react-router", () => ({ useNavigate: () => vi.fn() })); -vi.mock("./useScopedSettings", () => ({ - useScopedSettings: () => ({ +vi.mock(import("@tanstack/react-router"), async (original) => ({ + ...(await original()), + useNavigate: (() => vi.fn()) as never, +})); +vi.mock(import("./useScopedSettings"), async (original) => ({ + ...(await original()), + useScopedSettings: (() => ({ ...DEFAULT_UNIFIED_SETTINGS, sourceControlWritingStyle: state.styles[0], - }), - useScopedSettingsMixed: (keys: ReadonlyArray) => + })) as never, + useScopedSettingsMixed: (keys) => !keys.includes("reviewGuidesEnabled") && JSON.stringify(state.styles[0]) !== JSON.stringify(state.styles[1]), useUpdateScopedSettings: () => state.updateSettings, })); -vi.mock("./SettingsScopeContext", () => ({ - useSettingsScope: () => ({ +vi.mock(import("./SettingsScopeContext"), async (original) => ({ + ...(await original()), + useSettingsScope: (() => ({ scope: { kind: "all", environmentIds: [] }, environment: null, connectedEnvironments: [], targets: state.styles.map((style) => ({ settings: { ...DEFAULT_UNIFIED_SETTINGS, sourceControlWritingStyle: style }, })), - }), + target: null, + })) as never, })); -vi.mock("./useScopedModelAvailability", () => ({ - useScopedModelDisabledReason: () => () => null, +vi.mock(import("../../state/environments"), async (original) => ({ + ...(await original()), + useEnvironmentProviders: () => new Map(), })); -vi.mock("../ui/toast", () => ({ toastManager: { add: vi.fn() } })); -vi.mock("../../state/server", () => ({ EMPTY_SERVER_PROVIDERS: [] })); -vi.mock("../../state/environments", () => ({ useEnvironmentProviders: () => new Map() })); -vi.mock("../chat/ProviderModelPicker", () => ({ ProviderModelPicker: () => null })); -vi.mock("./settingsSearch", () => ({ searchableSetting: (id: string) => ({ id, title: id }) })); -vi.mock("./settingsLayout", () => ({ - SETTINGS_PICKER_TRIGGER_CLASSNAME: "", - SettingResetButton: ({ label, onClick }: { label: string; onClick: () => void }) => ( - - ), - SettingsSection: ({ children }: { children: ReactNode }) => children, - SettingsRow: ({ - children, - control, - resetAction, - }: { - children: ReactNode; - control: ReactNode; - resetAction: ReactNode; - }) => ( +// The real row reads write permission and overrides from live environment state; this +// suite is about how the section writes mixed writing styles. +vi.mock(import("./settingsLayout"), async (original) => ({ + ...(await original()), + SettingsRow: ({ title, control, resetAction, children }) => (
+

{title}

{control} {resetAction} {children}
), + SettingsSection: (({ children }: { children: ReactNode }) => children) as never, })); -vi.mock("../ui/select", () => ({ - Select: ({ children }: { children: ReactNode }) => children, - SelectItem: "span", - SelectPopup: "div", - SelectTrigger: "div", - SelectValue: "span", -})); -vi.mock("../ui/switch", () => ({ Switch: "input" })); -vi.mock("../ui/textarea", () => ({ Textarea: "textarea" })); -vi.mock("../ui/button", () => ({ Button: "button" })); import { SourceControlWritingSettingsSection } from "./SourceControlWritingSettings"; -let renderer: ReactTestRenderer | null; - -function button(label: string) { - return renderer!.root.findAllByType("button").find((item) => item.children.includes(label))!; -} +let root: Root; +let container: HTMLDivElement; -function openEditor() { - act(() => button("Write custom instructions for all").props.onClick()); -} - -function editInstructions(value: string) { - act(() => renderer!.root.findByType("textarea").props.onChange({ target: { value } })); -} +const resetButton = (label: string) => + page.getByRole("button", { name: `Reset ${label} to default` }); +const writeForAll = () => page.getByRole("button", { name: "Write custom instructions for all" }); +const applyToAll = () => page.getByRole("button", { name: "Apply instructions to all" }); +const bulkEditor = () => + page.getByRole("textbox", { + name: "Custom source control instructions for all selected environments", + }); -function applyInstructions() { - act(() => button("Apply instructions to all").props.onClick()); +async function render() { + await act(async () => + root.render( + + + , + ), + ); } -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); +beforeEach(async () => { state.styles = [ { ...DEFAULT_UNIFIED_SETTINGS.sourceControlWritingStyle, @@ -111,30 +100,27 @@ beforeEach(() => { ]; state.updateSettings.mockReset().mockImplementation((patch) => { state.styles = state.styles.map((style) => ({ ...style, ...patch.sourceControlWritingStyle })); + void render(); }); - act(() => { - renderer = create( - - - , - ); - }); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + await render(); }); afterEach(async () => { - await act(async () => renderer?.unmount()); - renderer = null; - vi.unstubAllGlobals(); + await act(async () => root.unmount()); + container.remove(); }); describe("mixed source control instructions", () => { - it("resets mixed template preferences without replacing each environment's instructions", () => { + it("resets mixed template preferences without replacing each environment's instructions", async () => { const initialInstructions = state.styles.map(({ mode, customInstructions }) => ({ mode, customInstructions, })); - act(() => button("Reset change request templates").props.onClick()); + await userEvent.click(resetButton("change request templates")); expect(state.updateSettings).toHaveBeenCalledTimes(1); expect(state.styles.map((style) => style.followChangeRequestTemplates)).toEqual([ @@ -146,17 +132,11 @@ describe("mixed source control instructions", () => { ).toEqual(initialInstructions); }); - it("resets every environment even when the representative already has default instructions", () => { + it("resets every environment even when the representative already has default instructions", async () => { state.styles[0] = { ...DEFAULT_UNIFIED_SETTINGS.sourceControlWritingStyle }; - act(() => { - renderer!.update( - - - , - ); - }); + await render(); - act(() => button("Reset source control writing style").props.onClick()); + await userEvent.click(resetButton("source control writing style")); expect(state.updateSettings).toHaveBeenCalledTimes(1); expect( @@ -170,22 +150,21 @@ describe("mixed source control instructions", () => { expect(state.styles[1]!.followChangeRequestTemplates).toBe(false); }); - it("does not write an untouched bulk draft", () => { + it("does not write an untouched bulk draft", async () => { const initialStyles = state.styles; - openEditor(); - expect(renderer!.root.findByType("textarea").props.value).toBe(""); - expect(button("Apply instructions to all").props.disabled).toBe(true); + await userEvent.click(writeForAll()); + await expect.element(bulkEditor()).toHaveValue(""); + await expect.element(applyToAll()).toBeDisabled(); - applyInstructions(); expect(state.updateSettings).not.toHaveBeenCalled(); expect(state.styles).toEqual(initialStyles); }); - it("applies edited instructions to every selected environment", () => { - openEditor(); - editInstructions(" Keep titles concise. "); - expect(button("Apply instructions to all").props.disabled).toBe(false); - applyInstructions(); + it("applies edited instructions to every selected environment", async () => { + await userEvent.click(writeForAll()); + await userEvent.fill(bulkEditor(), " Keep titles concise. "); + await expect.element(applyToAll()).toBeEnabled(); + await userEvent.click(applyToAll()); expect(state.updateSettings).toHaveBeenCalledTimes(1); expect(state.styles.map((style) => style.customInstructions)).toEqual([ @@ -195,28 +174,30 @@ describe("mixed source control instructions", () => { expect(state.styles.map((style) => style.followChangeRequestTemplates)).toEqual([true, false]); }); - it("allows an intentional clear after editing", () => { - openEditor(); - editInstructions("Temporary instructions"); - editInstructions(""); - expect(button("Apply instructions to all").props.disabled).toBe(false); - applyInstructions(); + it("allows an intentional clear after editing", async () => { + await userEvent.click(writeForAll()); + await userEvent.fill(bulkEditor(), "Temporary instructions"); + await userEvent.clear(bulkEditor()); + await expect.element(applyToAll()).toBeEnabled(); + await userEvent.click(applyToAll()); expect(state.updateSettings).toHaveBeenCalledTimes(1); expect(state.styles.map((style) => style.customInstructions)).toEqual(["", ""]); }); - it("shows the plain editor once instructions agree, even while templates differ", () => { - openEditor(); - editInstructions("Shared instructions"); - applyInstructions(); + it("shows the plain editor once instructions agree, even while templates differ", async () => { + await userEvent.click(writeForAll()); + await userEvent.fill(bulkEditor(), "Shared instructions"); + await userEvent.click(applyToAll()); expect(state.updateSettings).toHaveBeenCalledTimes(1); expect(state.styles.map((style) => style.customInstructions)).toEqual([ "Shared instructions", "Shared instructions", ]); - expect(button("Write custom instructions for all")).toBeUndefined(); - expect(renderer!.root.findByType("textarea").props.defaultValue).toBe("Shared instructions"); + await expect.element(writeForAll()).not.toBeInTheDocument(); + await expect + .element(page.getByRole("textbox", { name: "Custom source control writing instructions" })) + .toHaveValue("Shared instructions"); }); }); diff --git a/apps/web/src/components/usage/UsagePage.refresh.browser.test.tsx b/apps/web/src/components/usage/UsagePage.refresh.browser.test.tsx new file mode 100644 index 000000000000..e174b702d005 --- /dev/null +++ b/apps/web/src/components/usage/UsagePage.refresh.browser.test.tsx @@ -0,0 +1,249 @@ +import { RegistryContext } from "@effect/atom-react"; +import { EnvironmentId, ProviderInstanceId, USAGE_CONTRACT_VERSION } from "@t3tools/contracts"; +import { mergeUsage } from "@t3tools/shared/usageMerge"; +import type { Atom } from "effect/reactivity"; +import { act, StrictMode } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; +import { page, userEvent } from "vite-plus/test/browser"; + +import "~/index.css"; + +type Presentations = ReadonlyMap>; +const state = vi.hoisted(() => ({ + presentations: null as unknown as Atom.Writable, + refreshProviders: vi.fn<(input: unknown) => Promise>(), +})); + +vi.mock(import("@tanstack/react-router"), async (original) => ({ + ...(await original()), + useNavigate: (() => vi.fn()) as never, + useCanGoBack: () => false, +})); +// Real presentations come from live connections; these tests drive them directly. +vi.mock(import("../../state/presentation"), async (original) => { + const actual = await original(); + const { Atom } = await import("effect/reactivity"); + const presentations = Atom.make(new Map()).pipe(Atom.keepAlive); + state.presentations = presentations; + const providers = Atom.make( + (get) => + new Map( + [...get(presentations)].map(([id, presentation]) => [ + id, + (presentation.serverConfig as { providers: unknown }).providers, + ]), + ), + ); + return { + ...actual, + environmentPresentations: { + ...actual.environmentPresentations, + presentationsAtom: presentations as never, + presentationsWithProvidersAtom: presentations as never, + providersByEnvironmentAtom: providers as never, + }, + }; +}); +vi.mock(import("../../state/use-atom-command"), async (original) => ({ + ...(await original()), + useAtomCommand: (() => state.refreshProviders) as never, +})); +vi.mock(import("../../state/usage"), async (original) => { + const environment = { + environmentId: EnvironmentId.make("test"), + label: "Test", + isPending: false, + canReadDiagnostics: true, + error: null, + summary: null, + }; + return { + ...(await original()), + useUsage: (() => ({ + merged: mergeUsage([], USAGE_CONTRACT_VERSION), + environments: [environment], + selectedEnvironments: [environment], + isPending: false, + shown: null, + isPartial: false, + refresh: async () => undefined, + })) as never, + }; +}); + +import { appAtomRegistry } from "~/rpc/atomRegistry"; +import { UsagePage } from "./UsagePage"; + +const PREFERENCES_KEY = "t3code:usage-page-preferences:v1"; +let root: Root; +let container: HTMLDivElement; +// Limits refreshes are throttled per environment for the whole session, so each test +// gets fresh environment ids. +let environmentNumber = 0; +let environmentId: EnvironmentId; + +function presentation(phase = "connected") { + return { + entry: { target: { label: "Test" } }, + connection: { phase, error: null, traceId: null }, + serverConfig: { + providers: [ + { + instanceId: ProviderInstanceId.make("codex"), + driver: "codex", + enabled: true, + installed: true, + version: null, + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-09-11T12:00:00Z", + models: [], + slashCommands: [], + skills: [], + usageLimits: { + checkedAt: "2026-09-11T12:00:00Z", + windows: [ + { + id: "five_hour", + kind: "session", + label: "Session", + usedPercent: 40, + windowDurationMins: 300, + resetsAt: "2026-09-11T14:00:00Z", + }, + ], + }, + }, + ], + }, + }; +} + +const setPresentations = (entries: ReadonlyArray) => + act(async () => appAtomRegistry.set(state.presentations, new Map(entries) as Presentations)); +const setNow = (iso: string) => vi.mocked(Date.now).mockReturnValue(Date.parse(iso)); +const resetsIn = (countdown: string) => + page.getByRole("button", { name: `codex: 60% left, resets in ${countdown}` }); +const refreshButton = () => page.getByRole("button", { name: "Refresh limits" }); +const selectMetric = (label: string) => userEvent.click(page.getByRole("button", { name: label })); + +async function render({ strict = false } = {}) { + const content = ( + + + + ); + await act(async () => root.render(strict ? {content} : content)); +} + +beforeEach(async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + // The metric toggle and its refresh button only show on wide screens. + await page.viewport(1440, 900); + vi.spyOn(Date, "now").mockReturnValue(Date.parse("2026-09-11T12:00:00Z")); + environmentNumber += 1; + environmentId = EnvironmentId.make(`test-${environmentNumber}`); + localStorage.setItem(PREFERENCES_KEY, JSON.stringify({ metric: "limits", windowDays: 30 })); + state.refreshProviders.mockReset().mockResolvedValue(undefined); + await setPresentations([[environmentId, presentation()]]); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); +}); + +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + localStorage.clear(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +it.each([ + ["wide", 1440], + ["compact", 800], +])( + "refreshes the visible limits countdown from the %s toolbar without switching tabs, even when quota is unchanged", + async (_layout, width) => { + await page.viewport(width, 900); + await render(); + await expect.element(resetsIn("2h 0m")).toBeVisible(); + setNow("2026-09-11T12:30:00Z"); + + await userEvent.click(refreshButton()); + + expect(state.refreshProviders).toHaveBeenCalledWith({ environmentId, input: {} }); + await expect.element(resetsIn("1h 30m")).toBeVisible(); + await expect.element(resetsIn("2h 0m")).not.toBeInTheDocument(); + }, +); + +it("uses the current time when returning to limits from tokens", async () => { + await render(); + await selectMetric("Tokens"); + setNow("2026-09-11T13:00:00Z"); + await selectMetric("Limits"); + + await expect.element(resetsIn("1h 0m")).toBeVisible(); + expect(state.refreshProviders).toHaveBeenCalledTimes(2); +}); + +it("refreshes once on opening Limits and suppresses rapid returns and remounts", async () => { + localStorage.setItem(PREFERENCES_KEY, JSON.stringify({ metric: "tokens", windowDays: 30 })); + await render({ strict: true }); + expect(state.refreshProviders).not.toHaveBeenCalled(); + + await selectMetric("Limits"); + await vi.waitFor(() => expect(state.refreshProviders).toHaveBeenCalledTimes(1)); + await selectMetric("Tokens"); + await selectMetric("Limits"); + await act(async () => root.unmount()); + root = createRoot(container); + await render({ strict: true }); + expect(state.refreshProviders).toHaveBeenCalledTimes(1); + + await selectMetric("Tokens"); + setNow("2026-09-11T12:05:00Z"); + await selectMetric("Limits"); + await vi.waitFor(() => expect(state.refreshProviders).toHaveBeenCalledTimes(2)); +}); + +it("waits for connection and refreshes new environments during a slow refresh", async () => { + await setPresentations([[environmentId, presentation("disconnected")]]); + await render(); + expect(state.refreshProviders).not.toHaveBeenCalled(); + + let finishRefresh!: () => void; + state.refreshProviders.mockImplementationOnce( + () => new Promise((resolve) => (finishRefresh = () => resolve(undefined))), + ); + await setPresentations([[environmentId, presentation()]]); + await vi.waitFor(() => expect(state.refreshProviders).toHaveBeenCalledTimes(1)); + + const nextId = EnvironmentId.make(`${environmentId}-next`); + await setPresentations([ + [environmentId, presentation()], + [nextId, presentation()], + ]); + await vi.waitFor(() => expect(state.refreshProviders).toHaveBeenCalledTimes(2)); + expect(state.refreshProviders).toHaveBeenLastCalledWith({ environmentId: nextId, input: {} }); + await act(async () => finishRefresh()); +}); + +it("keeps manual refresh busy until the already-running automatic check settles", async () => { + let finishRefresh!: () => void; + state.refreshProviders.mockImplementationOnce( + () => new Promise((resolve) => (finishRefresh = () => resolve(undefined))), + ); + await render(); + await vi.waitFor(() => expect(state.refreshProviders).toHaveBeenCalledTimes(1)); + + await userEvent.click(refreshButton()); + await expect.element(refreshButton()).toHaveAttribute("aria-busy", "true"); + await expect.element(refreshButton()).toBeDisabled(); + expect(state.refreshProviders).toHaveBeenCalledTimes(1); + + await act(async () => finishRefresh()); + await expect.element(refreshButton()).toHaveAttribute("aria-busy", "false"); +}); diff --git a/apps/web/src/components/usage/UsagePage.refresh.test.tsx b/apps/web/src/components/usage/UsagePage.refresh.test.tsx deleted file mode 100644 index 69df78f366ee..000000000000 --- a/apps/web/src/components/usage/UsagePage.refresh.test.tsx +++ /dev/null @@ -1,301 +0,0 @@ -import { EnvironmentId, ProviderInstanceId, USAGE_CONTRACT_VERSION } from "@t3tools/contracts"; -import { mergeUsage } from "@t3tools/shared/usageMerge"; -import { StrictMode, act } from "react"; -import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; -import { create, type ReactTestRenderer } from "react-test-renderer"; -import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; - -const state = vi.hoisted(() => ({ - presentations: new Map(), - refreshProviders: vi.fn(async () => undefined), - metric: "limits", -})); -vi.mock("@effect/atom-react", () => ({ - useAtomValue: (atom: unknown) => - atom === "keybindings" - ? DEFAULT_RESOLVED_KEYBINDINGS - : atom === "providers" - ? new Map( - [...state.presentations].map(([id, presentation]) => [ - id, - presentation.serverConfig.providers, - ]), - ) - : state.presentations, -})); -vi.mock("@tanstack/react-router", () => ({ - useNavigate: () => vi.fn(), - useCanGoBack: () => false, -})); -vi.mock("../../state/presentation", () => ({ - environmentPresentations: { - presentationsAtom: null, - presentationsWithProvidersAtom: null, - providersByEnvironmentAtom: "providers", - }, -})); -vi.mock("../../state/server", () => ({ - serverEnvironment: { refreshProviders: null }, - primaryServerKeybindingsAtom: "keybindings", -})); -vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: () => state.refreshProviders })); -vi.mock("../../env", () => ({ isElectron: false })); -vi.mock("../../hooks/useSettings", () => ({ usePrimarySettings: () => "24h" })); -vi.mock("../../state/usage", () => ({ - useUsage: () => ({ - merged: mergeUsage([], USAGE_CONTRACT_VERSION), - environments: [ - { - environmentId: EnvironmentId.make("test"), - label: "Test", - isPending: false, - error: null, - summary: null, - }, - ], - selectedEnvironments: [ - { - environmentId: EnvironmentId.make("test"), - label: "Test", - isPending: false, - error: null, - summary: null, - }, - ], - isPending: false, - shown: null, - isPartial: false, - refresh: async () => undefined, - }), -})); -vi.mock("./usagePagePreferences", () => ({ - readUsagePagePreferences: () => ({ metric: state.metric, windowDays: 30 }), - saveUsagePagePreferences: vi.fn(), -})); -vi.mock("../ui/button", () => ({ Button: "button", InlineButton: "button" })); -vi.mock("../ui/scroll-area", () => ({ ScrollArea: "div" })); -vi.mock("../ui/select", () => ({ - Select: "select", - SelectItem: "option", - SelectPopup: "div", - SelectTrigger: "div", - SelectValue: "span", -})); -vi.mock("../ui/sidebar", () => ({ SidebarInset: "div" })); -vi.mock("../ui/toggle-group", () => ({ Toggle: "button", ToggleGroup: "div" })); -vi.mock("../ui/tooltip", () => ({ Tooltip: "div", TooltipPopup: "div", TooltipTrigger: "div" })); -vi.mock("../ui/popover", () => ({ Popover: "div", PopoverPopup: "div", PopoverTrigger: "div" })); -vi.mock("../ui/menu", () => ({ - Menu: "div", - MenuCheckboxItem: "div", - MenuItem: "div", - MenuPopup: "div", - MenuSeparator: "hr", - MenuTrigger: "div", -})); -vi.mock("../WorkspaceBreadcrumb", () => ({ - WorkspaceBreadcrumb: "div", - WorkspaceBreadcrumbItem: "div", - WorkspaceBreadcrumbSeparator: "span", - WorkspaceBreadcrumbText: "span", -})); -vi.mock("../WorkspacePageContainer", () => ({ WorkspacePageContainer: "main" })); -vi.mock("../WorkspacePageHeader", () => ({ WorkspacePageHeader: "header" })); -vi.mock("./UsageProviderChart", () => ({ UsageProviderChart: "div" })); -vi.mock("./UsagePriceOverrides", () => ({ UsagePriceOverrides: () => null })); -vi.mock("../chat/ProviderInstanceIcon", () => ({ ProviderInstanceIcon: () => null })); -vi.mock("../settings/RedactedSensitiveText", () => ({ RedactedSensitiveText: "span" })); -vi.mock("../settings/providerDriverMeta", () => ({ - providerClients: { get: () => ({ label: "Codex" }) }, -})); - -import { UsagePage } from "./UsagePage"; - -let renderer: ReactTestRenderer; -let environmentNumber = 0; -beforeEach(() => { - vi.stubGlobal("window", new EventTarget()); - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - vi.spyOn(Date, "now").mockReturnValue(Date.parse("2026-09-11T12:00:00Z")); - environmentNumber += 1; - state.metric = "limits"; - state.refreshProviders.mockClear(); - state.presentations = new Map([ - [ - EnvironmentId.make(`test-${environmentNumber}`), - { - entry: { target: { label: "Test" } }, - connection: { phase: "connected" }, - serverConfig: { - providers: [ - { - instanceId: ProviderInstanceId.make("codex"), - driver: "codex", - enabled: true, - installed: true, - version: null, - status: "ready", - auth: { status: "authenticated" }, - checkedAt: "2026-09-11T12:00:00Z", - models: [], - slashCommands: [], - skills: [], - usageLimits: { - checkedAt: "2026-09-11T12:00:00Z", - windows: [ - { - id: "five_hour", - kind: "session", - label: "Session", - usedPercent: 40, - windowDurationMins: 300, - resetsAt: "2026-09-11T14:00:00Z", - }, - ], - }, - }, - ], - }, - }, - ], - ]); -}); -afterEach(async () => { - await act(() => renderer?.unmount()); - vi.restoreAllMocks(); - vi.unstubAllGlobals(); -}); - -it.each([0, 1])( - "refreshes the visible limits countdown with refresh button %i without switching tabs, even when quota is unchanged", - async (buttonIndex) => { - await act(() => { - renderer = create(); - }); - expect( - JSON.stringify(renderer.toJSON(), (key, value) => (key === "props" ? undefined : value)), - ).toContain("in 2h 0m"); - vi.mocked(Date.now).mockReturnValue(Date.parse("2026-09-11T12:30:00Z")); - await act(async () => { - renderer.root - .findAllByProps({ "aria-label": "Refresh limits" }) - .filter((node) => node.type === "button") - .at(buttonIndex)! - .props.onClick(); - }); - expect(state.refreshProviders).toHaveBeenCalledWith({ - environmentId: `test-${environmentNumber}`, - input: {}, - }); - expect( - JSON.stringify(renderer.toJSON(), (key, value) => (key === "props" ? undefined : value)), - ).toContain("in 1h 30m"); - expect( - JSON.stringify(renderer.toJSON(), (key, value) => (key === "props" ? undefined : value)), - ).not.toContain("in 2h 0m"); - }, -); - -it("uses the current time when returning to limits from tokens", async () => { - await act(() => { - renderer = create(); - }); - const selectMetric = (metric: string) => { - renderer.root - .findAll((node) => node.type === "div" && node.props["aria-label"] === "Usage metric")[0]! - .props.onValueChange([metric]); - }; - await act(() => selectMetric("tokens")); - vi.mocked(Date.now).mockReturnValue(Date.parse("2026-09-11T13:00:00Z")); - await act(() => selectMetric("limits")); - expect( - JSON.stringify(renderer.toJSON(), (key, value) => (key === "props" ? undefined : value)), - ).toContain("in 1h 0m"); - expect(state.refreshProviders).toHaveBeenCalledTimes(2); -}); - -it("refreshes once on opening Limits and suppresses rapid returns and remounts", async () => { - state.metric = "tokens"; - await act(() => { - renderer = create( - - - , - ); - }); - expect(state.refreshProviders).not.toHaveBeenCalled(); - const selectMetric = (metric: string) => - renderer.root - .findAll((node) => node.type === "div" && node.props["aria-label"] === "Usage metric")[0]! - .props.onValueChange([metric]); - await act(() => selectMetric("limits")); - expect(state.refreshProviders).toHaveBeenCalledTimes(1); - await act(() => selectMetric("tokens")); - await act(() => selectMetric("limits")); - await act(() => renderer.unmount()); - state.metric = "limits"; - await act(() => { - renderer = create( - - - , - ); - }); - expect(state.refreshProviders).toHaveBeenCalledTimes(1); - await act(() => selectMetric("tokens")); - vi.mocked(Date.now).mockReturnValue(Date.parse("2026-09-11T12:05:00Z")); - await act(() => selectMetric("limits")); - expect(state.refreshProviders).toHaveBeenCalledTimes(2); -}); - -it("waits for connection and refreshes new environments during a slow refresh", async () => { - const [id, presentation] = [...state.presentations][0]!; - state.presentations = new Map([[id, { ...presentation, connection: { phase: "disconnected" } }]]); - await act(() => { - renderer = create(); - }); - expect(state.refreshProviders).not.toHaveBeenCalled(); - let finishRefresh!: () => void; - state.refreshProviders.mockImplementationOnce( - () => - new Promise((resolve) => { - finishRefresh = () => resolve(undefined); - }), - ); - state.presentations = new Map([[id, presentation]]); - await act(() => renderer.update()); - expect(state.refreshProviders).toHaveBeenCalledTimes(1); - const nextId = EnvironmentId.make(`${id}-next`); - state.presentations = new Map([...state.presentations, [nextId, presentation]]); - await act(() => renderer.update()); - expect(state.refreshProviders).toHaveBeenCalledTimes(2); - expect(state.refreshProviders).toHaveBeenLastCalledWith({ environmentId: nextId, input: {} }); - await act(() => finishRefresh()); -}); - -it("keeps manual refresh busy until the already-running automatic check settles", async () => { - let finishRefresh!: () => void; - const pending = new Promise((resolve) => { - finishRefresh = () => resolve(undefined); - }); - state.refreshProviders.mockImplementationOnce(() => pending); - await act(() => { - renderer = create(); - }); - const button = () => - renderer.root.findAll( - (node) => node.type === "button" && node.props["aria-label"] === "Refresh limits", - )[0]!; - expect(state.refreshProviders).toHaveBeenCalledTimes(1); - await act(() => button().props.onClick()); - try { - expect(button().props["aria-busy"]).toBe(true); - expect(state.refreshProviders).toHaveBeenCalledTimes(1); - } finally { - await act(async () => { - finishRefresh(); - await pending; - }); - } - expect(button().props["aria-busy"]).toBe(false); -}); diff --git a/apps/web/src/test/reactElementTree.ts b/apps/web/src/test/reactElementTree.ts deleted file mode 100644 index a1ba37261913..000000000000 --- a/apps/web/src/test/reactElementTree.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { isValidElement, type ReactElement } from "react"; - -export function visitElements( - node: unknown, - visitor: (element: ReactElement>) => boolean, -): ReactElement> | null { - if (Array.isArray(node)) { - for (const child of node) { - const found = visitElements(child, visitor); - if (found) return found; - } - return null; - } - if (!isValidElement>(node)) return null; - if (visitor(node)) return node; - for (const value of Object.values(node.props)) { - const found = visitElements(value, visitor); - if (found) return found; - } - return null; -} diff --git a/apps/web/src/test/reactHookHarness.ts b/apps/web/src/test/reactHookHarness.ts deleted file mode 100644 index 55978e1a83a0..000000000000 --- a/apps/web/src/test/reactHookHarness.ts +++ /dev/null @@ -1,62 +0,0 @@ -import type { Dispatch, SetStateAction } from "react"; - -/** - * This module must stay free of runtime `react` imports: it is loaded from - * inside `vi.mock("react", ...)` factories, and a value import would recurse - * into the in-progress mock. - * - * Call `beginRender()` before each component invocation and `reset()` in - * `beforeEach` to drop persisted state between tests. - */ -function createReactHookHarness() { - let cursor = 0; - let slots: unknown[] = []; - const nextIndex = () => cursor++; - - return { - beginRender() { - cursor = 0; - }, - reset() { - cursor = 0; - slots = []; - }, - useCallback(callback: T): T { - nextIndex(); - return callback; - }, - useMemo(factory: () => T): T { - nextIndex(); - return factory(); - }, - useMemoCache(size: number): unknown[] { - const index = nextIndex(); - if (!slots[index]) { - slots[index] = Array.from({ length: size }, () => Symbol.for("react.memo_cache_sentinel")); - } - return slots[index] as unknown[]; - }, - useRef(initialValue: T): { current: T } { - const index = nextIndex(); - if (!slots[index]) { - slots[index] = { current: initialValue }; - } - return slots[index] as { current: T }; - }, - useState(initialValue: T | (() => T)): [T, Dispatch>] { - const index = nextIndex(); - if (index >= slots.length) { - slots[index] = - typeof initialValue === "function" ? (initialValue as () => T)() : initialValue; - } - const setValue: Dispatch> = (nextValue) => { - const previous = slots[index] as T; - slots[index] = - typeof nextValue === "function" ? (nextValue as (value: T) => T)(previous) : nextValue; - }; - return [slots[index] as T, setValue]; - }, - }; -} - -export const reactHookHarness = createReactHookHarness();