diff --git a/__tests__/components/backends/add-backend-modal.test.tsx b/__tests__/components/backends/add-backend-modal.test.tsx index 99bbb1a061..d318c829a9 100644 --- a/__tests__/components/backends/add-backend-modal.test.tsx +++ b/__tests__/components/backends/add-backend-modal.test.tsx @@ -118,7 +118,7 @@ describe("AddBackendModal – two-column layout", () => { expect(submit).not.toBeDisabled(); }); - it("saves the backend and closes WITHOUT switching the active selection", async () => { + it("saves the backend, switches to it, and closes", async () => { const onClose = vi.fn(); renderWithProviders(); @@ -146,8 +146,11 @@ describe("AddBackendModal – two-column layout", () => { kind: "local", }); - // Adding a backend must NOT change the active selection. - expect(window.localStorage.getItem("openhands-active-backend")).toBeNull(); + // Active selection must point at the newly added backend. + const active = JSON.parse( + window.localStorage.getItem("openhands-active-backend") ?? "null", + ); + expect(active).toEqual({ backendId: added.id, orgId: null }); }); it("shows the close button", () => { diff --git a/__tests__/components/backends/backend-selector.test.tsx b/__tests__/components/backends/backend-selector.test.tsx index 986b2f8c44..a455af97bf 100644 --- a/__tests__/components/backends/backend-selector.test.tsx +++ b/__tests__/components/backends/backend-selector.test.tsx @@ -39,6 +39,21 @@ vi.mock("@openhands/typescript-client/clients", () => ({ ServerClient: vi.fn(), })); +// Shared seed configs reused across tests. +const SEED_LOCAL_1 = { + name: "Local 1", + host: "http://localhost:9000", + apiKey: "k", + kind: "local" as const, +}; + +const SEED_CLOUD_PRODUCTION = { + name: "Production", + host: "https://app.all-hands.dev", + apiKey: "bearer-key", + kind: "cloud" as const, +}; + function renderWithProviders(ui: React.ReactElement) { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } }, @@ -141,12 +156,7 @@ describe("BackendSelector", () => { renderWithProviders( { - ctx.addBackend({ - name: "Local 1", - host: "http://localhost:9000", - apiKey: "k", - kind: "local", - }); + ctx.addBackend(SEED_LOCAL_1); ctx.addBackend({ name: "Production", host: "https://app.all-hands.dev", @@ -181,12 +191,11 @@ describe("BackendSelector", () => { renderWithProviders( { - cloudId = ctx.addBackend({ - name: "Production", - host: "https://app.all-hands.dev", - apiKey: "bearer-key", - kind: "cloud", - }).id; + cloudId = ctx.addBackend(SEED_CLOUD_PRODUCTION).id; + // Add a second backend so auto-switch lands here, leaving the + // cloud backend unselected (the dropdown only expands org rows + // for non-active cloud backends). + ctx.addBackend(SEED_LOCAL_1); }} > @@ -245,6 +254,9 @@ describe("BackendSelector", () => { apiKey: "key-acme", kind: "cloud", }); + // Land on a local backend so both cloud backends are unselected + // and their org rows render in the dropdown. + ctx.addBackend(SEED_LOCAL_1); }} > @@ -289,12 +301,10 @@ describe("BackendSelector", () => { renderWithProviders( { - ctx.addBackend({ - name: "Production", - host: "https://app.all-hands.dev", - apiKey: "bearer-key", - kind: "cloud", - }); + ctx.addBackend(SEED_CLOUD_PRODUCTION); + // Land on a local backend so the cloud backend is unselected + // and its org rows render in the dropdown. + ctx.addBackend(SEED_LOCAL_1); }} > @@ -333,12 +343,7 @@ describe("BackendSelector", () => { renderWithProviders( { - cloudId = ctx.addBackend({ - name: "Production", - host: "https://app.all-hands.dev", - apiKey: "bearer-key", - kind: "cloud", - }).id; + cloudId = ctx.addBackend(SEED_CLOUD_PRODUCTION).id; // Simulate the post-refresh malformed state: active backend is // the cloud one but no orgId is set yet. ctx.setActive(cloudId, null); @@ -364,24 +369,20 @@ describe("BackendSelector", () => { renderWithProviders( { - ctx.addBackend({ - name: "Local 1", - host: "http://localhost:9000", - apiKey: "k", - kind: "local", - }); + ctx.addBackend(SEED_LOCAL_1); }} > , ); + // Auto-switch lands on "Local 1"; click the seeded default to switch. const user = await openDropdown(); - await user.click(screen.getByText("Local 1")); + await user.click(screen.getByText("Local")); const wrapper = screen.getByTestId("backend-selector"); const input = wrapper.querySelector("input") as HTMLInputElement; - expect(input.value).toBe("Local 1"); + expect(input.value).toBe("Local"); }); it("redirects to home when switching backends from a conversation route", async () => { @@ -389,12 +390,7 @@ describe("BackendSelector", () => { return ( { - ctx.addBackend({ - name: "Local 1", - host: "http://localhost:9000", - apiKey: "k", - kind: "local", - }); + ctx.addBackend(SEED_LOCAL_1); }} > @@ -419,8 +415,9 @@ describe("BackendSelector", () => { , ); + // Auto-switch lands on "Local 1"; click the seeded default to switch. const user = await openDropdown(); - await user.click(screen.getByText("Local 1")); + await user.click(screen.getByText("Local")); expect(await screen.findByTestId("home")).toBeInTheDocument(); }); @@ -430,12 +427,7 @@ describe("BackendSelector", () => { return ( { - ctx.addBackend({ - name: "Local 1", - host: "http://localhost:9000", - apiKey: "k", - kind: "local", - }); + ctx.addBackend(SEED_LOCAL_1); }} >
@@ -461,8 +453,9 @@ describe("BackendSelector", () => { , ); + // Auto-switch lands on "Local 1"; click the seeded default to switch. const user = await openDropdown(); - await user.click(screen.getByText("Local 1")); + await user.click(screen.getByText("Local")); // The settings route is still in the DOM after the switch — no // redirect to /conversations or anywhere else. @@ -495,17 +488,17 @@ describe("BackendSelector", () => { ); render(); - // Act — select a different backend, then immediately unmount the - // selector (the click itself would do this in production via the - // outside-click handler). + // Act — auto-switch lands on "Acme Local"; click the seeded default + // to trigger a switch, then immediately unmount the selector (the + // click itself would do this in production via the outside-click handler). const user = await openDropdown(); - await user.click(screen.getByText("Acme Local")); + await user.click(screen.getByText("Local")); selectorRender.unmount(); // Assert — the overlay is still in the DOM with the chosen target expect(screen.getByTestId("environment-switch-overlay")).toHaveAttribute( "data-target", - "Acme Local", + "Local", ); }); @@ -560,12 +553,7 @@ describe("BackendSelector", () => { renderWithProviders( { - ctx.addBackend({ - name: "Local 1", - host: "http://localhost:9000", - apiKey: "k", - kind: "local", - }); + ctx.addBackend(SEED_LOCAL_1); }} > @@ -661,12 +649,7 @@ describe("BackendSelector", () => { return ( { - ctx.addBackend({ - name: "Local 1", - host: "http://localhost:9000", - apiKey: "k", - kind: "local", - }); + ctx.addBackend(SEED_LOCAL_1); }} > @@ -691,8 +674,9 @@ describe("BackendSelector", () => { , ); + // Auto-switch lands on "Local 1"; click the seeded default to switch. const user = await openDropdown(); - await user.click(screen.getByText("Local 1")); + await user.click(screen.getByText("Local")); expect(await screen.findByTestId("automations-list")).toBeInTheDocument(); }); @@ -702,12 +686,7 @@ describe("BackendSelector", () => { return ( { - ctx.addBackend({ - name: "Local 1", - host: "http://localhost:9000", - apiKey: "k", - kind: "local", - }); + ctx.addBackend(SEED_LOCAL_1); }} > @@ -735,12 +714,13 @@ describe("BackendSelector", () => { const settingsButton = screen.getByTestId("backend-selector-settings-link"); expect(settingsButton).toHaveAttribute("data-active", "true"); + // Auto-switch lands on "Local 1"; click the seeded default to switch. const user = await openDropdown(); - await user.click(screen.getByText("Local 1")); + await user.click(screen.getByText("Local")); const wrapper = screen.getByTestId("backend-selector"); const input = wrapper.querySelector("input") as HTMLInputElement; - expect(input.value).toBe("Local 1"); + expect(input.value).toBe("Local"); expect(screen.queryByTestId("home")).not.toBeInTheDocument(); }); @@ -755,12 +735,7 @@ describe("BackendSelector", () => { renderWithProviders( { - ctx.addBackend({ - name: "Local 1", - host: "http://localhost:9000", - apiKey: "k", - kind: "local", - }); + ctx.addBackend(SEED_LOCAL_1); }} > diff --git a/__tests__/contexts/active-backend-context.test.tsx b/__tests__/contexts/active-backend-context.test.tsx index 4f3fc11c3b..2b93e5232d 100644 --- a/__tests__/contexts/active-backend-context.test.tsx +++ b/__tests__/contexts/active-backend-context.test.tsx @@ -76,6 +76,30 @@ describe("ActiveBackendProvider", () => { }); }); + // @spec BM-001 — Auto-switch to newly connected backend + it("addBackend automatically switches the active backend to the newly added one", () => { + const { result } = renderHook(() => useActiveBackendContext(), { + wrapper: makeWrapper(), + }); + + expect(result.current.active.backend.id).toBe(DEFAULT_LOCAL_BACKEND_ID); + + let added: { id: string } | null = null; + act(() => { + added = result.current.addBackend({ + name: "OpenHands Cloud", + host: "https://app.all-hands.dev", + apiKey: "bearer-token", + kind: "cloud", + }); + }); + + expect(result.current.active.backend.id).toBe(added!.id); + // Previous backends remain in the registry. + expect(result.current.backends).toHaveLength(2); + expect(result.current.backends.find((b) => b.id === DEFAULT_LOCAL_BACKEND_ID)).toBeDefined(); + }); + it("setActive switches the active backend without touching unrelated React Query cache entries", () => { const queryClient = new QueryClient(); queryClient.setQueryData(["dummy"], { value: 1 }); diff --git a/specs/backend-management.md b/specs/backend-management.md new file mode 100644 index 0000000000..98e41b079b --- /dev/null +++ b/specs/backend-management.md @@ -0,0 +1,4 @@ +# Backend Management Specs + +## BM-001: Auto-switch on connect +- [x] Adding a backend shall automatically switch the active selection to it. diff --git a/src/contexts/active-backend-context.tsx b/src/contexts/active-backend-context.tsx index 26077df427..bfee8baf69 100644 --- a/src/contexts/active-backend-context.tsx +++ b/src/contexts/active-backend-context.tsx @@ -73,11 +73,13 @@ export function ActiveBackendProvider({ [], ); + // @spec BM-001 — Auto-switch to newly connected backend const addBackend = React.useCallback( (backend: Omit): Backend => { const next: Backend = { ...backend, id: generateId() }; const list = [...getRegisteredBackends(), next]; setRegisteredBackends(list); + setActiveSelection({ backendId: next.id }); return next; }, [],