mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-06 20:05:57 +02:00
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 <noreply@paperclip.ing>
This commit is contained in:
1 parent
cab4263dc9
commit
55e0c895ef
1 file changed
+52
-39
@@ -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<void>) {
|
||||
let result: void | Promise<void> = undefined;
|
||||
flushSync(() => {
|
||||
result = callback();
|
||||
});
|
||||
await result;
|
||||
}
|
||||
|
||||
function makeAgent(overrides: Partial<Agent>): Agent {
|
||||
return {
|
||||
id: "agent-1",
|
||||
@@ -168,7 +159,7 @@ function makeAgent(overrides: Partial<Agent>): 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(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<SidebarAgents streamlined />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
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<Agent>((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([]);
|
||||
|
||||
Reference in new issue
Block a user