From 55e0c895efd7cd39f24fb72dfac138d0a34a29b8 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Mon, 5 Oct 2026 12:11:32 -0700 Subject: [PATCH] test(ui): finish sidebar focus cleanup before JSDOM teardown (#15255) Finish deferred Radix focus cleanup before the sidebar test environment closes. Use React act and controlled timers, check the real unmount focus events for both menus, and assert no timer work remains. Validation: all 7,368 UI tests, repository typecheck and build passed. Hosted CI passed, including server tests, browser tests and canary packaging. Greptile 5/5 on the exact reviewed head; no unresolved comments. Local full-suite limitations and clean-base comparisons are recorded in the PR. Co-Authored-By: Paperclip --- ui/src/components/SidebarAgents.test.tsx | 91 ++++++++++++++---------- 1 file changed, 52 insertions(+), 39 deletions(-) diff --git a/ui/src/components/SidebarAgents.test.tsx b/ui/src/components/SidebarAgents.test.tsx index 63b0fa1c94..3ce2523f1a 100644 --- a/ui/src/components/SidebarAgents.test.tsx +++ b/ui/src/components/SidebarAgents.test.tsx @@ -1,7 +1,6 @@ // @vitest-environment jsdom -import type { ReactNode } from "react"; -import { flushSync } from "react-dom"; +import { act, type ReactNode } from "react"; import { createRoot } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { Agent, ResourceMemberships } from "@paperclipai/shared"; @@ -129,14 +128,6 @@ if (!globalThis.PointerEvent) { (globalThis as any).PointerEvent = MouseEvent; } -async function act(callback: () => void | Promise) { - let result: void | Promise = undefined; - flushSync(() => { - result = callback(); - }); - await result; -} - function makeAgent(overrides: Partial): Agent { return { id: "agent-1", @@ -168,7 +159,7 @@ function makeAgent(overrides: Partial): Agent { async function flushReact() { await act(async () => { await Promise.resolve(); - await new Promise((resolve) => window.setTimeout(resolve, 0)); + await vi.advanceTimersByTimeAsync(0); }); } @@ -227,6 +218,7 @@ describe("SidebarAgents", () => { let memberships: ResourceMemberships; beforeEach(() => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] }); mockSidebarState.collapsed = false; mockSidebarState.peeking = false; container = document.createElement("div"); @@ -281,19 +273,33 @@ describe("SidebarAgents", () => { localStorage.clear(); }); - afterEach(async () => { + async function unmountSidebarAgents() { const currentRoot = root; + root = null; if (currentRoot) { await act(async () => { currentRoot.unmount(); }); } - vi.useRealTimers(); queryClient.clear(); - container.remove(); - document.body.innerHTML = ""; - localStorage.clear(); - vi.clearAllMocks(); + // Radix defers focus restoration until after unmount. Run it while the + // JSDOM event constructors still belong to this document. + await act(async () => { + await vi.runOnlyPendingTimersAsync(); + }); + expect(vi.getTimerCount()).toBe(0); + } + + afterEach(async () => { + try { + await unmountSidebarAgents(); + } finally { + vi.useRealTimers(); + container.remove(); + document.body.innerHTML = ""; + localStorage.clear(); + vi.clearAllMocks(); + } }); async function renderSidebarAgents(streamlined = true) { @@ -324,23 +330,6 @@ describe("SidebarAgents", () => { await flushReact(); } - async function renderSidebarAgentsWithFakeTimers() { - const currentRoot = createRoot(container); - root = currentRoot; - - await act(async () => { - currentRoot.render( - - - , - ); - }); - await act(async () => { - await Promise.resolve(); - await vi.advanceTimersByTimeAsync(0); - }); - } - async function renderRailSidebarAgents() { mockSidebarState.collapsed = true; const currentRoot = createRoot(container); @@ -358,6 +347,22 @@ describe("SidebarAgents", () => { await flushReact(); } + it.each(["agent", "section"])("finishes deferred %s menu focus cleanup before DOM teardown", async (menu) => { + await renderSidebarAgents(); + if (menu === "agent") await openAgentMenu(); + else await openAgentsSectionMenu(); + + const menuContent = document.body.querySelector('[role="menu"]'); + expect(menuContent).not.toBeNull(); + const onUnmountAutoFocus = vi.fn(); + menuContent!.addEventListener("focusScope.autoFocusOnUnmount", onUnmountAutoFocus); + + await unmountSidebarAgents(); + + expect(onUnmountAutoFocus).toHaveBeenCalledTimes(1); + expect(container.isConnected).toBe(true); + }); + it("does not query built-in agents when the experimental feature is disabled", async () => { queryClient.setQueryData(queryKeys.builtInAgents.list("company-1"), [ { agentId: "agent-1", status: "needs_setup" }, @@ -675,7 +680,10 @@ describe("SidebarAgents", () => { makeAgent({ id: "agent-1", name: "Alpha", urlKey: "alpha" }), makeAgent({ id: "agent-2", name: "Beta", urlKey: "beta" }), ]); - mockAgentsApi.pause.mockImplementation(() => new Promise(() => {})); + let finishPause!: (agent: Agent) => void; + mockAgentsApi.pause.mockImplementation(() => new Promise((resolve) => { + finishPause = resolve; + })); await renderSidebarAgents(); await openAgentMenu(); @@ -696,6 +704,11 @@ describe("SidebarAgents", () => { .find((element) => element.textContent?.includes("Pause agent")); expect(betaPauseItem).toBeTruthy(); expect(document.body.textContent).not.toContain("Updating..."); + + await act(async () => { + finishPause(makeAgent({ status: "paused" })); + }); + await flushReact(); }); it("shows only active agents when any agent has a live run", async () => { @@ -719,7 +732,7 @@ describe("SidebarAgents", () => { }); it("keeps formerly live agents visible for the streamlined linger window", async () => { - vi.useFakeTimers({ now: new Date("2026-01-01T00:00:00Z") }); + vi.setSystemTime(new Date("2026-01-01T00:00:00Z")); mockAgentsApi.list.mockResolvedValue([ makeAgent({ id: "agent-a", name: "Alpha", urlKey: "alpha" }), makeAgent({ id: "agent-b", name: "Bravo", urlKey: "bravo" }), @@ -730,7 +743,7 @@ describe("SidebarAgents", () => { { id: "run-1", agentId: "agent-a", status: "running" }, ]); - await renderSidebarAgentsWithFakeTimers(); + await renderSidebarAgents(); let labels = agentLinkLabels(container); expect(labels).toHaveLength(1); @@ -763,7 +776,7 @@ describe("SidebarAgents", () => { }); it("expires staggered lingering agents without unrelated sidebar updates", async () => { - vi.useFakeTimers({ now: new Date("2026-01-01T00:00:00Z") }); + vi.setSystemTime(new Date("2026-01-01T00:00:00Z")); mockAgentsApi.list.mockResolvedValue([ makeAgent({ id: "agent-a", name: "Alpha", urlKey: "alpha" }), makeAgent({ id: "agent-b", name: "Bravo", urlKey: "bravo" }), @@ -774,7 +787,7 @@ describe("SidebarAgents", () => { { id: "run-1", agentId: "agent-a", status: "running" }, ]); - await renderSidebarAgentsWithFakeTimers(); + await renderSidebarAgents(); expect(agentLinkLabels(container)[0]).toContain("Alpha"); mockHeartbeatsApi.liveRunsForCompany.mockResolvedValue([]);