diff --git a/platform/configurator-mcp/test/http.test.ts b/platform/configurator-mcp/test/http.test.ts index d7eba0459..2df65de10 100644 --- a/platform/configurator-mcp/test/http.test.ts +++ b/platform/configurator-mcp/test/http.test.ts @@ -180,6 +180,10 @@ describe("Streamable HTTP configurator", () => { }); it("aborts an in-flight upstream request when the MCP client disconnects", async () => { + let upstreamStarted!: () => void; + const started = new Promise((resolve) => { + upstreamStarted = resolve; + }); let observedAbort!: () => void; const aborted = new Promise((resolve) => { observedAbort = resolve; @@ -194,6 +198,7 @@ describe("Streamable HTTP configurator", () => { }, { once: true }, ); + upstreamStarted(); }); }); const controller = new AbortController(); @@ -203,9 +208,10 @@ describe("Streamable HTTP configurator", () => { body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "ping" }), signal: controller.signal, }); - await new Promise((resolve) => setTimeout(resolve, 20)); + const rejected = expect(request).rejects.toThrow(); + await started; controller.abort(); - await expect(request).rejects.toThrow(); + await rejected; await expect(aborted).resolves.toBeUndefined(); }); diff --git a/platform/web/src/components/session/composer.dictation.test.tsx b/platform/web/src/components/session/composer.dictation.test.tsx index 813a99361..39fbed97d 100644 --- a/platform/web/src/components/session/composer.dictation.test.tsx +++ b/platform/web/src/components/session/composer.dictation.test.tsx @@ -2,6 +2,7 @@ import { act, type ComponentProps } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { waitForUi } from "@/test/wait-for-ui"; import { SessionComposer } from "./composer"; const mocks = vi.hoisted(() => ({ capture: vi.fn(), transcribe: vi.fn(), cancel: vi.fn(), api: vi.fn() })); @@ -230,8 +231,7 @@ it.each(["transcript", "upload"])("waits for both the transcript and attachments const input = container.querySelector('input[type="file"]')!; Object.defineProperty(input, "files", { value: [new File(["pdf"], "notes.pdf", { type: "application/pdf" })] }); await act(async () => input.dispatchEvent(new Event("change", { bubbles: true }))); - await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); }); - expect(mocks.api).toHaveBeenCalled(); + await waitForUi(() => expect(mocks.api).toHaveBeenCalled()); await record(); await click("Send message"); const completeTranscript = async () => { await act(async () => finish("Spoken text")); }; @@ -250,8 +250,7 @@ it("keeps the completed transcript for review when an attachment upload fails", const input = container.querySelector('input[type="file"]')!; Object.defineProperty(input, "files", { value: [new File(["pdf"], "notes.pdf", { type: "application/pdf" })] }); await act(async () => input.dispatchEvent(new Event("change", { bubbles: true }))); - await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); }); - expect(container.textContent).toContain("Upload failed"); + await waitForUi(() => expect(container.textContent).toContain("Upload failed")); await record(); await click("Send message"); await act(async () => finish("Spoken text")); diff --git a/platform/web/src/components/session/composer.test.tsx b/platform/web/src/components/session/composer.test.tsx index ea3d3f404..00eb72b62 100644 --- a/platform/web/src/components/session/composer.test.tsx +++ b/platform/web/src/components/session/composer.test.tsx @@ -3,6 +3,7 @@ import { act } from "react"; import { createRoot } from "react-dom/client"; import { afterEach, beforeEach, expect, it, vi } from "vitest"; import type { ModelOption } from "@/api"; +import { waitForUi } from "@/test/wait-for-ui"; import { SessionComposer } from "./composer"; const mocks = vi.hoisted(() => ({ api: vi.fn() })); @@ -57,8 +58,6 @@ async function attach(...files: File[]) { const input = container.querySelector('input[type="file"]')!; Object.defineProperty(input, "files", { value: files, configurable: true }); await act(async () => input.dispatchEvent(new Event("change", { bubbles: true }))); - // The base64 read is asynchronous; let it reach the upload call. - await act(async () => { await new Promise((resolve) => setTimeout(resolve, 20)); }); } const png = (name = "screen.png", size = 3) => new File([new Uint8Array(size)], name, { type: "image/png" }); @@ -91,6 +90,7 @@ it("keeps the draft and explains when nothing can be steered", async () => { it("uploads attachments on pick and sends them with the message", async () => { const { onSend } = await setup({ runActive: false }); await attach(png()); + await waitForUi(() => expect(uploads).toHaveLength(1)); expect(mocks.api).toHaveBeenCalledWith("POST", "/api/v1/universes/u1/attachments", { bytesBase64: btoa("\0\0\0") }, expect.any(AbortSignal)); expect(container.textContent).toContain("Uploading…"); await act(async () => uploads[0]!(`sha256:${"a".repeat(64)}`)); @@ -108,6 +108,7 @@ it("waits for uploads when Enter is pressed early, then sends", async () => { await attach(png()); await press("Enter"); expect(onSend).not.toHaveBeenCalled(); + await waitForUi(() => expect(uploads).toHaveLength(1)); await act(async () => uploads[0]!(`sha256:${"b".repeat(64)}`)); expect(onSend).toHaveBeenCalledWith(expect.objectContaining({ text: "", attachments: [expect.objectContaining({ name: "screen.png" })] }), null); }); @@ -128,7 +129,7 @@ it("blocks sending while an upload has failed until it is removed", async () => mocks.api.mockRejectedValueOnce(new Error("Gateway unavailable")); const { onSend } = await setup({ runActive: false }); await attach(png()); - expect(container.textContent).toContain("Upload failed"); + await waitForUi(() => expect(container.textContent).toContain("Upload failed")); await act(async () => button("Send message").click()); expect(onSend).not.toHaveBeenCalled(); expect(container.textContent).toContain("Remove or retry the attachments that failed to upload."); diff --git a/platform/web/src/components/session/file-links.test.tsx b/platform/web/src/components/session/file-links.test.tsx index c3b2d6660..947850d4f 100644 --- a/platform/web/src/components/session/file-links.test.tsx +++ b/platform/web/src/components/session/file-links.test.tsx @@ -3,6 +3,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { waitForUi } from "@/test/wait-for-ui"; import { appHref, blobHref } from "@/lib/blob-view"; import { ToolGroupTrace } from "./tool-trace"; import { MarkdownContent } from "./markdown-content"; @@ -19,14 +20,17 @@ const file: FileReference = { }; let root: Root; let container: HTMLDivElement; +let client: QueryClient; beforeEach(() => { vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: Infinity } } }); container = document.createElement("div"); document.body.append(container); root = createRoot(container); }); afterEach(async () => { await act(async () => root.unmount()); + client.clear(); container.remove(); vi.unstubAllGlobals(); }); @@ -137,9 +141,6 @@ it("continues to sanitize unsafe links", async () => { }); it.each([false, true])("resolves a reference outside the loaded history and scopes the lookup to its session (image: %s)", async (showImage) => { - const client = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); const resolved = showImage ? { ...file, type: "image/jpeg" } : file; const load = vi.fn(async () => resolved); vi.stubGlobal("URL", class extends URL { @@ -162,8 +163,9 @@ it.each([false, true])("resolves a reference outside the loaded history and scop , ), ); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 20)); + await waitForUi(() => { + expect(container.querySelector("a")?.href).toContain("session=s1"); + expect(container.querySelector("img")?.getAttribute("src")).toBe(showImage ? "blob:http://localhost/historical-image" : undefined); }); expect(load).toHaveBeenCalledWith(file.handle, expect.any(AbortSignal)); expect(container.querySelector("a")!.href).toContain("session=s1"); @@ -178,7 +180,6 @@ it.each([false, true])("resolves a reference outside the loaded history and scop it("checks history even for a visible reference and refreshes when new metadata arrives", async () => { - const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); const load = vi.fn().mockResolvedValueOnce(null).mockResolvedValueOnce(file); const show = async (known: boolean) => { await act(async () => root.render( @@ -192,7 +193,11 @@ it("checks history even for a visible reference and refreshes when new metadata , )); - await act(async () => { await new Promise((resolve) => setTimeout(resolve, 10)); }); + await waitForUi(() => { + expect(load).toHaveBeenCalledTimes(known ? 2 : 1); + expect(client.isFetching()).toBe(0); + expect(client.getQueryCache().getAll().at(-1)?.state.status).toBe("success"); + }); }; await show(false); expect(container.querySelector("a")).toBeNull(); diff --git a/platform/web/src/components/ui/dropdown-menu.keyboard.test.tsx b/platform/web/src/components/ui/dropdown-menu.keyboard.test.tsx index 711b5b1a3..c1129471b 100644 --- a/platform/web/src/components/ui/dropdown-menu.keyboard.test.tsx +++ b/platform/web/src/components/ui/dropdown-menu.keyboard.test.tsx @@ -13,6 +13,7 @@ import { Button } from "./button"; let root: Root; let container: HTMLDivElement; beforeEach(() => { + vi.useFakeTimers(); vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); vi.stubGlobal("PointerEvent", MouseEvent); vi.stubGlobal( @@ -48,17 +49,18 @@ beforeEach(() => { afterEach(async () => { await act(async () => root.unmount()); container.remove(); + vi.clearAllTimers(); + vi.useRealTimers(); vi.unstubAllGlobals(); vi.restoreAllMocks(); }); -const settle = () => new Promise((resolve) => setTimeout(resolve, 30)); async function key(target: Element, key: string) { await act(async () => { target.dispatchEvent( new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }), ); - await settle(); }); + await act(async () => { await vi.runAllTimersAsync(); }); } it("opens with the keyboard, moves between actions and restores focus on Escape", async () => { const download = vi.fn(); @@ -89,8 +91,9 @@ it("opens with the keyboard, moves between actions and restores focus on Escape" trigger.dispatchEvent( new MouseEvent("click", { bubbles: true, detail: 1 }), ); - await settle(); }); + await act(async () => { await vi.runAllTimersAsync(); }); + expect(document.activeElement?.getAttribute("role")).toBe("menu"); await key(document.activeElement!, "ArrowDown"); expect(document.activeElement?.textContent).toBe("Upload replacement"); await key(document.activeElement!, "ArrowDown"); diff --git a/platform/web/src/components/universe-appearance-card.test.tsx b/platform/web/src/components/universe-appearance-card.test.tsx index 81501d56d..e1a0b9a12 100644 --- a/platform/web/src/components/universe-appearance-card.test.tsx +++ b/platform/web/src/components/universe-appearance-card.test.tsx @@ -9,6 +9,7 @@ import { UNIVERSE_ICONS } from "@lightspeed/platform-shared"; import { PermissionIdentityProvider } from "@/lib/permissions"; import { GeneralSettingsPage } from "@/pages/GeneralSettingsPage"; import { UniverseAppearanceCard } from "./universe-appearance-card"; +import { waitForUi } from "@/test/wait-for-ui"; import { UniverseIcon } from "./universe-icon"; const mocks = vi.hoisted(() => ({ api: vi.fn(), universe: {} as Universe })); @@ -64,7 +65,7 @@ it.each(UNIVERSE_ICONS)("previews the %s icon and saves it to the shared univers expect(preview.querySelector("span[style]")?.getAttribute("style")).toContain("oklch(0.58 0.11 255)"); expect(mocks.api).not.toHaveBeenCalled(); await click("Save appearance"); - await vi.waitFor(() => expect(client.getQueryData(["universes"])![0]).toMatchObject({ icon, iconColor: "blue" })); + await waitForUi(() => expect(client.getQueryData(["universes"])![0]).toMatchObject({ icon, iconColor: "blue" })); expect(mocks.api).toHaveBeenCalledWith("PATCH", "/api/v1/universes/universe", { icon, iconColor: "blue" }); await render(); expect(container.querySelector(`.lucide-${icon}`)).not.toBeNull(); @@ -75,11 +76,11 @@ it("keeps the saved appearance when a save fails and allows retry", async () => mocks.api.mockRejectedValueOnce(new Error("Unable to save")); await render(); await click("Star icon"); await click("Save appearance"); - await vi.waitFor(() => expect(container.querySelector('[role="alert"]')?.textContent).toBe("Unable to save")); + await waitForUi(() => expect(container.querySelector('[role="alert"]')?.textContent).toBe("Unable to save")); expect(client.getQueryData(["universes"])![0]).toMatchObject({ icon: "orbit", iconColor: "default" }); expect(button("Star icon").getAttribute("aria-pressed")).toBe("true"); await click("Save appearance"); - await vi.waitFor(() => expect(client.getQueryData(["universes"])![0]).toMatchObject({ icon: "star" })); + await waitForUi(() => expect(client.getQueryData(["universes"])![0]).toMatchObject({ icon: "star" })); }); it("restores defaults only after saving", async () => { diff --git a/platform/web/src/components/workspace-transfers.test.tsx b/platform/web/src/components/workspace-transfers.test.tsx index 67f710e02..678bb82f7 100644 --- a/platform/web/src/components/workspace-transfers.test.tsx +++ b/platform/web/src/components/workspace-transfers.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { act, type ReactNode, type ReactElement } from "react"; import { createRoot, type Root } from "react-dom/client"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { QueryClient, QueryClientProvider, notifyManager } from "@tanstack/react-query"; import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom"; import { WorkspacesPage } from "@/pages/WorkspacesPage"; import { afterEach, beforeEach, expect, it, vi } from "vitest"; @@ -12,6 +12,7 @@ import { WorkspaceDropArea, } from "./workspace-transfers"; import { WorkspaceFileTree } from "./workspace-file-tree"; +import { waitForUi } from "@/test/wait-for-ui"; const mocks = vi.hoisted(() => ({ api: vi.fn(), @@ -96,6 +97,13 @@ vi.mock("@/components/ui/dropdown-menu", async () => { let root: Root; let container: HTMLDivElement; let entries: Record; +let transferClient: QueryClient; +const clients: QueryClient[] = []; +function createQueryClient() { + const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: Infinity } } }); + clients.push(client); + return client; +} const originalPdfSupport = Object.getOwnPropertyDescriptor( navigator, "pdfViewerEnabled", @@ -112,6 +120,8 @@ const tree = (revision = 3) => ({ }); beforeEach(() => { vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + notifyManager.setNotifyFunction((notify) => act(notify)); + transferClient = createQueryClient(); mocks.editable = true; mocks.configurable = true; mocks.workspaceRemoved.mockReset(); @@ -127,25 +137,23 @@ beforeEach(() => { afterEach(async () => { await act(async () => root.unmount()); container.remove(); + clients.splice(0).forEach((client) => client.clear()); + notifyManager.setNotifyFunction((notify) => notify()); vi.unstubAllGlobals(); vi.restoreAllMocks(); if (originalPdfSupport) Object.defineProperty(navigator, "pdfViewerEnabled", originalPdfSupport); else Reflect.deleteProperty(navigator, "pdfViewerEnabled"); }); -const settle = () => new Promise((resolve) => setTimeout(resolve, 40)); async function waitForEditor() { - return vi.waitFor(async () => { - await act(settle); + return waitForUi(() => { const editor = container.querySelector('textarea[aria-label="File contents"]'); expect(editor).toBeInstanceOf(HTMLTextAreaElement); return editor as HTMLTextAreaElement; }); } async function render(withTree = false) { - const client = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); + const client = transferClient; await act(async () => root.render( @@ -181,13 +189,12 @@ async function render(withTree = false) { , ), ); - await act(settle); + await waitForUi(() => expect(client.getQueryState(["workspace-tree", "u", "ws"])?.status).toBe("success")); } async function click(element: Element | null | undefined) { if (!element) throw new Error("Expected a clickable element"); await act(async () => { (element as HTMLElement).click(); - await settle(); }); } const button = (text: string) => @@ -208,7 +215,6 @@ async function select(files: File[], replacement = false) { Object.defineProperty(input, "files", { configurable: true, value: files }); await act(async () => { input.dispatchEvent(new Event("change", { bubbles: true })); - await settle(); }); } const writes = () => @@ -224,7 +230,6 @@ async function drag(type: string, target: Element, types = ["Files"]) { Object.defineProperty(event, "dataTransfer", { value: transfer }); await act(async () => { target.dispatchEvent(event); - await settle(); }); return transfer; } @@ -309,12 +314,11 @@ it("does not advertise a drop destination for viewers or text drags", async () = ).toBeNull(); }); async function waitForWrites(count = 1) { - await act(async () => { - await vi.waitFor(() => expect(writes()).toHaveLength(count)); - }); + await waitForUi(() => expect(writes()).toHaveLength(count)); } async function folderName(name: string) { + await waitForUi(() => expect(document.querySelector('[role="dialog"] input')).not.toBeNull()); const input = document.querySelector( '[role="dialog"] input', )!; @@ -388,7 +392,7 @@ function WorkspaceLocation() { return {useLocation().pathname}; } -it.each([0, 100])("renames a workspace in the picker while keeping the open file and unsaved edits (file delay: %i ms)", async (fileDelay) => { +it("renames a workspace in the picker while keeping the open file and unsaved edits", async () => { let workspace = { workspaceId: "ws", displayName: "Documents", @@ -407,16 +411,11 @@ it.each([0, 100])("renames a workspace in the picker while keeping the open file } if (path.endsWith("/workspaces")) return [workspace]; if (path.endsWith("/tree")) return { ...tree(), workspace }; - if (path.includes("/files/")) { - await new Promise((resolve) => setTimeout(resolve, fileDelay)); - return { bytesBase64: "eA==", bytes: 1 }; - } + if (path.includes("/files/")) return { bytesBase64: "eA==", bytes: 1 }; throw new Error(`Unexpected request: ${path}`); }, ); - const client = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); + const client = createQueryClient(); await act(async () => root.render( @@ -533,9 +532,7 @@ it.each([true, false])( if (path.includes("/files/")) return { bytesBase64: "eA==", bytes: 1 }; throw new Error(`Unexpected request: ${path}`); }); - const client = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); + const client = createQueryClient(); await act(async () => root.render( @@ -557,15 +554,14 @@ it.each([true, false])( , ), ); - await act(settle); + await waitForEditor(); await menu("Workspace actions"); await click(item("Delete workspace…")); expect(document.querySelector('[role="dialog"]')?.textContent).toContain( "Documents", ); await click(button("Delete workspace")); - await vi.waitFor(async () => { - await act(settle); + await waitForUi(() => { expect(container.querySelector("[data-location]")?.textContent).toBe( `/u/test/workspaces${hasOther ? "/other" : ""}`, ); @@ -596,12 +592,12 @@ it.each([ )!; expect(input.value).toBe(path.split("/").at(-1)); expect((button("Rename") as HTMLButtonElement).disabled).toBe(true); - expect(input.selectionStart).toBe(0); - expect(input.selectionEnd).toBe( - label.startsWith("File") - ? input.value.lastIndexOf(".") - : input.value.length, - ); + await waitForUi(() => { + expect(input.selectionStart).toBe(0); + expect(input.selectionEnd).toBe( + label.startsWith("File") ? input.value.lastIndexOf(".") : input.value.length, + ); + }); await folderName("renamed"); await click(button("Rename")); expect(writes()).toEqual([ @@ -790,7 +786,6 @@ it("uploads dropped files into the folder under the pointer without asking", asy }); await act(async () => { container.querySelector("[data-workspace-folder]")!.dispatchEvent(event); - await settle(); }); expect(event.defaultPrevented).toBe(true); await waitForWrites(); @@ -809,6 +804,7 @@ it("keeps progress inside the action menu and adds no status strip", async () => return tree(); }); await select([new File(["new"], "new.txt")]); + await waitForWrites(); const statuses = [...container.querySelectorAll('[role="status"]')]; expect(statuses.length).toBeGreaterThan(0); for (const status of statuses) { @@ -818,9 +814,8 @@ it("keeps progress inside the action menu and adds no status strip", async () => expect(document.querySelector('[role="dialog"]')).toBeNull(); await act(async () => { finish(); - await settle(); }); - expect(container.querySelector('[role="status"]')).toBeNull(); + await waitForUi(() => expect(container.querySelector('[role="status"]')).toBeNull()); expect(container.textContent).not.toContain("Upload complete"); }); @@ -848,7 +843,7 @@ it("replaces the selected file at its original path after confirmation", async ( await click(item("Upload replacement…")); await select([new File(["new"], "different-name.txt")], true); expect(writes()).toHaveLength(0); - expect(document.body.textContent).toContain("Replace existing files?"); + await waitForUi(() => expect(document.body.textContent).toContain("Replace existing files?")); expect(document.body.textContent).toContain("docs/a #?.txt"); await click(button("Replace and upload")); await waitForWrites(); @@ -867,6 +862,7 @@ it("can skip existing files and upload the rest", async () => { new File(["replacement"], "a #?.txt"), new File(["new"], "new.txt"), ]); + await waitForUi(() => expect(button("Skip existing")).toBeDefined()); expect(writes()).toHaveLength(0); await click(button("Skip existing")); await waitForWrites(); @@ -889,13 +885,15 @@ it("rechecks collisions after a concurrent edit instead of silently overwriting" return tree(failed ? 4 : 3); }); await select([new File(["new"], "new.txt")]); + await waitForUi(() => expect(document.querySelector('[role="alert"]')?.textContent).toContain("Workspace changed")); expect(document.querySelector('[role="alert"]')?.textContent).toContain( "Workspace changed", ); await click(button("Retry")); expect(writes()).toHaveLength(1); - expect(document.body.textContent).toContain("Replace existing files?"); + await waitForUi(() => expect(document.body.textContent).toContain("Replace existing files?")); await click(button("Replace and upload")); + await waitForWrites(2); expect(writes()[1]?.[2]).toMatchObject({ replace: true, expectedRevision: 4, @@ -952,10 +950,12 @@ it("downloads from the file menu with the original filename", async () => { "/api/v1/universes/u/workspaces/ws/download?path=docs%2Fa+%23%3F.txt", { credentials: "same-origin" }, ); - expect(names).toEqual(["a #?.txt"]); + await waitForUi(() => expect(names).toEqual(["a #?.txt"])); }); it("places the open file’s actions in the tree, not its detail header", async () => { + const snapshot = deferred>(); + const content = deferred<{ bytesBase64: string; bytes: number }>(); const workspace = { workspaceId: "ws", displayName: "Documents", @@ -964,13 +964,11 @@ it("places the open file’s actions in the tree, not its detail header", async }; mocks.api.mockImplementation(async (_method: string, path: string) => { if (path.endsWith("/workspaces")) return [workspace]; - if (path.endsWith("/tree")) return { ...tree(), workspace }; - if (path.includes("/files/")) return { bytesBase64: "eA==", bytes: 1 }; + if (path.endsWith("/tree")) return snapshot.promise; + if (path.includes("/files/")) return content.promise; throw new Error(`Unexpected request: ${path}`); }); - const client = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); + const client = createQueryClient(); await act(async () => root.render( @@ -987,7 +985,20 @@ it("places the open file’s actions in the tree, not its detail header", async , ), ); - await act(settle); + await waitForUi(() => expect(mocks.api).toHaveBeenCalledWith( + "GET", "/api/v1/universes/u/workspaces/ws/tree", + )); + expect(container.querySelector("textarea")).toBeNull(); + await act(async () => snapshot.resolve({ ...tree(), workspace })); + await waitForUi(() => { + expect(mocks.api).toHaveBeenCalledWith( + "GET", + "/api/v1/universes/u/workspaces/ws/files/docs/a%20%23%3F.txt", + ); + }); + expect(container.querySelector('textarea[aria-label="File contents"]')).toBeNull(); + await act(async () => content.resolve({ bytesBase64: "eA==", bytes: 1 })); + await waitForEditor(); const menus = container.querySelectorAll( '[aria-label="File actions: docs/a #?.txt"]', ); @@ -1056,9 +1067,7 @@ it.each(["", "docs"])( throw new Error(`Unexpected request: ${path}`); }, ); - const client = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); + const client = createQueryClient(); await act(async () => root.render( @@ -1075,7 +1084,7 @@ it.each(["", "docs"])( , ), ); - await act(settle); + await waitForEditor(); await menu(parent ? `Folder actions: ${parent}` : "Workspace actions"); await click(item("New file")); expect(document.querySelector('[role="dialog"]')?.textContent).toContain( @@ -1104,8 +1113,7 @@ it.each(["", "docs"])( }, ], ]); - await vi.waitFor(async () => { - await act(settle); + await waitForUi(() => { expect( container .querySelector('[role="treeitem"][aria-selected="true"]') @@ -1151,9 +1159,7 @@ it.each(["file", "folder"])( throw new Error(`Unexpected request: ${path}`); }, ); - const client = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); + const client = createQueryClient(); await act(async () => root.render( @@ -1214,8 +1220,7 @@ it.each(["file", "folder"])( 'a[href="/u/test/workspaces/ws/files/other.txt"]', ), ); - await vi.waitFor(async () => { - await act(settle); + await waitForUi(() => { expect(container.querySelector("textarea")?.value).toBe("x"); }); }, @@ -1236,9 +1241,7 @@ it.each(["metaKey", "ctrlKey"] as const)( if (path.includes("/files/")) return { bytesBase64: "eA==", bytes: 1 }; throw new Error(`Unexpected request: ${path}`); }); - const client = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); + const client = createQueryClient(); await act(async () => root.render( @@ -1282,7 +1285,6 @@ it.each(["metaKey", "ctrlKey"] as const)( }); await act(async () => { expect(pressSave().defaultPrevented).toBe(true); - await settle(); }); expect(mocks.api).toHaveBeenCalledWith( "PUT", @@ -1316,9 +1318,7 @@ it.each([ return { bytesBase64: btoa(content), bytes: content.length }; throw new Error(`Unexpected request: ${path}`); }); - const client = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); + const client = createQueryClient(); await act(async () => root.render( @@ -1335,8 +1335,7 @@ it.each([ , ), ); - await vi.waitFor(async () => { - await act(settle); + await waitForUi(() => { expect(container.querySelector("iframe")?.getAttribute("src")).toBe( "blob:workspace-pdf", ); @@ -1386,9 +1385,7 @@ it.each([ : { blobRef: "old", bytesBase64: "eA==", bytes: 1 }; throw new Error(`Unexpected request: ${path}`); }); - const client = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); + const client = createQueryClient(); await act(async () => root.render( @@ -1429,9 +1426,8 @@ it.each([ cancelable: true, }), ); - await settle(); }); - expect(button("Saving…")).toBeDefined(); + await waitForUi(() => expect(button("Saving…")).toBeDefined()); if (keepTyping) await edit("newer unsaved content"); const stable = () => { expect(container.querySelector("textarea")).toBe(editor); @@ -1448,7 +1444,6 @@ it.each([ await act(async () => { saved = true; write.resolve({ workspace: { ...workspace, revision: 4 } }); - await settle(); }); stable(); await act(async () => { @@ -1465,9 +1460,10 @@ it.each([ ...tree(4), workspace: { ...workspace, revision: 4 }, }); - await settle(); }); - await act(settle); + await waitForUi(() => expect( + mocks.api.mock.calls.filter(([method, path]) => method === "GET" && path.includes("/files/")), + ).toHaveLength(2)); stable(); expect( mocks.api.mock.calls.filter( @@ -1480,10 +1476,12 @@ it.each([ bytesBase64: btoa("saved content"), bytes: 13, }); - await settle(); + }); + await waitForUi(() => { + expect(client.isFetching()).toBe(0); + expect(button(keepTyping ? "Save" : "Saved")).toBeDefined(); }); stable(); - expect(button(keepTyping ? "Save" : "Saved")).toBeDefined(); expect( (button(keepTyping ? "Save" : "Saved") as HTMLButtonElement).disabled, ).toBe(!keepTyping); diff --git a/platform/web/src/demo/router.test.ts b/platform/web/src/demo/router.test.ts index b248022cc..f1bd676b9 100644 --- a/platform/web/src/demo/router.test.ts +++ b/platform/web/src/demo/router.test.ts @@ -1,5 +1,5 @@ import { defaultEnvironmentAttachment, environmentAttachments } from "@/lib/sessions/resource-features"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { createDemoStore } from "./fixtures"; import { createDemoRouter } from "./router"; import type { @@ -709,7 +709,12 @@ describe("demo router", () => { expect((overriddenToKeep.json as SessionView).retention.deleteAfterCloseMs).toBeNull(); }); - it("admits a manual bot event and resolves its outcome", async () => { + it("admits a manual bot event and resolves its outcome", async ({ onTestFinished }) => { + vi.useFakeTimers(); + onTestFinished(() => { + vi.clearAllTimers(); + vi.useRealTimers(); + }); const { call } = await boot(); const [universe] = (await call("GET", "/api/v1/universes")).json as Universe[]; const [bot] = ((await call("GET", `/api/v1/universes/${universe!.id}/bots`)).json as { bots: BotListItem[] }).bots; @@ -721,14 +726,14 @@ describe("demo router", () => { }); expect(admitted.status).toBe(202); expect((admitted.json as { duplicate: boolean }).duplicate).toBe(false); - await new Promise((resolve) => setTimeout(resolve, 6_000)); + await vi.runAllTimersAsync(); const after = (await call("GET", `/api/v1/universes/${universe!.id}/bots/${bot!.botId}/events`)).json as { events: Array<{ seq: number; outcome: string | null }>; }; expect(after.events.length).toBe(before.events.length + 1); const newest = after.events.reduce((a, b) => (a.seq > b.seq ? a : b)); expect(newest.outcome).not.toBeNull(); - }, 20_000); + }); it("names the missing stub instead of hanging", async () => { const { call } = await boot(); diff --git a/platform/web/src/pages/BlobPage.test.tsx b/platform/web/src/pages/BlobPage.test.tsx index 9533de081..f93a43d78 100644 --- a/platform/web/src/pages/BlobPage.test.tsx +++ b/platform/web/src/pages/BlobPage.test.tsx @@ -5,6 +5,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { MemoryRouter, Route, Routes } from "react-router-dom"; import { afterEach, beforeEach, expect, it, vi } from "vitest"; import { PermissionIdentityProvider } from "@/lib/permissions"; +import { waitForUi } from "@/test/wait-for-ui"; import { BlobPage } from "./BlobPage"; const mocks = vi.hoisted(() => ({ api: vi.fn() })); @@ -16,8 +17,10 @@ const filePath = "docs/résumé #?.txt"; let root: Root; let container: HTMLDivElement; let stored: string; +let client: QueryClient; beforeEach(() => { vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: Infinity } } }); stored = "# Instructions\n\n"; mocks.api.mockReset().mockImplementation(async (_method: string, path: string) => { if (path.endsWith("/access")) return { actions: ["read"], resources: [] }; @@ -46,12 +49,12 @@ beforeEach(() => { }); afterEach(async () => { await act(async () => root.unmount()); + client.clear(); container.remove(); vi.unstubAllGlobals(); }); async function open(url: string) { - const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); await act(async () => root.render( @@ -61,10 +64,10 @@ async function open(url: string) { , )); - for (let attempt = 0; attempt < 20 && !container.querySelector("pre, img, iframe"); attempt++) { - await act(async () => { await new Promise((resolve) => setTimeout(resolve, 20)); }); - } - expect(container.querySelector("pre, img, iframe")).not.toBeNull(); + await waitForUi(() => { + expect(container.querySelector("pre, img, iframe")).not.toBeNull(); + expect(client.isFetching()).toBe(0); + }); } it("shows stored text exactly as stored, named from the link, with its source session", async () => { @@ -73,7 +76,7 @@ it("shows stored text exactly as stored, named from the link, with its source se expect(container.textContent).toContain("Markdown · "); expect(container.querySelector("pre")!.textContent).toBe(stored); expect(container.querySelector("script")).toBeNull(); - await vi.waitFor(() => expect(container.textContent).toContain("Linked from session: HIT login debugging")); + await waitForUi(() => expect(container.textContent).toContain("Linked from session: HIT login debugging")); expect(container.querySelector('header a[href="/u/acme/sessions/s1"]')!.textContent).toBe("HIT login debugging"); expect(document.title).toBe("Profile instructions"); }); @@ -91,7 +94,7 @@ it("hands images to the browser and names unnamed blobs by digest", async () => it("names a source session the viewer cannot read by its id, without a link", async () => { await open(`/u/acme/blobs/${digest}?session=private`); - await vi.waitFor(() => expect(container.textContent).toContain("Linked from session: private")); + await waitForUi(() => expect(container.textContent).toContain("Linked from session: private")); expect(container.querySelector(`header a[href*="/sessions/"]`)).toBeNull(); }); @@ -127,7 +130,6 @@ it("does not look up workspaces when the link has no workspace source", async () }); it("refuses addresses that are not digests without reading anything", async () => { - const client = new QueryClient(); await act(async () => root.render( @@ -137,6 +139,6 @@ it("refuses addresses that are not digests without reading anything", async () = , )); - await vi.waitFor(() => expect(container.textContent).toContain("This is not a blob address.")); + await waitForUi(() => expect(container.textContent).toContain("This is not a blob address.")); expect(mocks.api.mock.calls.some(([, path]) => String(path).includes("/blobs/"))).toBe(false); }); diff --git a/platform/web/src/pages/WorkspacesPage.blob-view.test.tsx b/platform/web/src/pages/WorkspacesPage.blob-view.test.tsx index d7dbe29dd..3765790aa 100644 --- a/platform/web/src/pages/WorkspacesPage.blob-view.test.tsx +++ b/platform/web/src/pages/WorkspacesPage.blob-view.test.tsx @@ -1,9 +1,10 @@ // @vitest-environment jsdom import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { QueryClient, QueryClientProvider, notifyManager } from "@tanstack/react-query"; import { MemoryRouter, Route, Routes } from "react-router-dom"; import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { waitForUi } from "@/test/wait-for-ui"; import { WorkspacesPage } from "./WorkspacesPage"; const mocks = vi.hoisted(() => ({ api: vi.fn(), editable: true })); @@ -59,6 +60,7 @@ const tree = () => ({ }); beforeEach(() => { vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + notifyManager.setNotifyFunction((notify) => act(notify)); mocks.editable = true; blobRef = `sha256:${digest}`; mocks.api @@ -77,6 +79,8 @@ beforeEach(() => { }); afterEach(async () => { await act(async () => root.unmount()); + client.clear(); + notifyManager.setNotifyFunction((notify) => notify()); container.remove(); vi.unstubAllGlobals(); }); @@ -99,16 +103,7 @@ async function render() { , ), ); - for ( - let attempt = 0; - attempt < 20 && !container.querySelector("textarea"); - attempt++ - ) { - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 20)); - }); - } - expect(container.querySelector("textarea")).not.toBeNull(); + await waitForUi(() => expect(container.querySelector("textarea")).not.toBeNull()); } const link = () => container.querySelector( @@ -153,12 +148,8 @@ it("keeps unsaved edits local and updates the link when the saved blob changes", await act(async () => { blobRef = `sha256:${nextDigest}`; client.setQueryData(["workspace-tree", "u", "ws"], tree()); - await new Promise((resolve) => setTimeout(resolve, 20)); }); - await vi.waitFor(async () => { - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 20)); - }); + await waitForUi(() => { expect(link()!.href).toContain(nextDigest); }); expect(editor.value).toBe("unsaved edit"); diff --git a/platform/web/src/test/wait-for-ui.ts b/platform/web/src/test/wait-for-ui.ts new file mode 100644 index 000000000..432245686 --- /dev/null +++ b/platform/web/src/test/wait-for-ui.ts @@ -0,0 +1,15 @@ +import { act } from "react"; +import { vi } from "vitest"; + +/** Retry an assertion after flushing React and queued query notifications. + * The assertion determines readiness; yielding a task is not a loading budget. + * Keep interactions outside this callback so retries cannot repeat a mutation. + */ +export async function waitForUi(assertion: () => T): Promise { + return vi.waitFor(async () => { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + return assertion(); + }); +}