diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index de6408c0..e761bb37 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -1,8 +1,14 @@ -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { api } from "./api/client"; +vi.mock("./api/gateway", () => ({ + gatewayApi: { + getServerMode: vi.fn().mockResolvedValue({ mode: "standard" }), + }, +})); + vi.mock("./api/client", () => { const api = { getCurrentProject: vi.fn(), @@ -76,7 +82,11 @@ describe("App", () => { async function renderApp() { const { default: App } = await import("./App"); - return render(); + let result!: ReturnType; + await act(async () => { + result = render(); + }); + return result; } it("calls getCurrentProject() on mount", async () => { diff --git a/frontend/src/components/Chat.test.tsx b/frontend/src/components/Chat.test.tsx index 1e7b4fe4..579c0c85 100644 --- a/frontend/src/components/Chat.test.tsx +++ b/frontend/src/components/Chat.test.tsx @@ -165,7 +165,7 @@ describe("Chat message rendering — unified tool call UI", () => { }, ]; - act(() => { + await act(async () => { capturedWsHandlers?.onUpdate(messages); }); @@ -199,7 +199,7 @@ describe("Chat message rendering — unified tool call UI", () => { { role: "assistant", content: "The file contains a main function." }, ]; - act(() => { + await act(async () => { capturedWsHandlers?.onUpdate(messages); }); @@ -219,7 +219,7 @@ describe("Chat message rendering — unified tool call UI", () => { { role: "assistant", content: "Hi there! How can I help?" }, ]; - act(() => { + await act(async () => { capturedWsHandlers?.onUpdate(messages); }); @@ -254,7 +254,7 @@ describe("Chat message rendering — unified tool call UI", () => { }, ]; - act(() => { + await act(async () => { capturedWsHandlers?.onUpdate(messages); }); @@ -396,7 +396,7 @@ describe("Chat reconciliation banner", () => { await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); - act(() => { + await act(async () => { capturedWsHandlers?.onReconciliationProgress( "42_story_test", "checking", @@ -417,7 +417,7 @@ describe("Chat reconciliation banner", () => { await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); - act(() => { + await act(async () => { capturedWsHandlers?.onReconciliationProgress( "42_story_test", "gates_running", @@ -435,7 +435,7 @@ describe("Chat reconciliation banner", () => { await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); - act(() => { + await act(async () => { capturedWsHandlers?.onReconciliationProgress( "42_story_test", "checking", @@ -447,7 +447,7 @@ describe("Chat reconciliation banner", () => { await screen.findByTestId("reconciliation-banner"), ).toBeInTheDocument(); - act(() => { + await act(async () => { capturedWsHandlers?.onReconciliationProgress( "", "done", @@ -504,7 +504,7 @@ describe("Chat localStorage persistence (Story 145)", () => { { role: "assistant", content: "Hi there!" }, ]; - act(() => { + await act(async () => { capturedWsHandlers?.onUpdate(history); }); @@ -555,7 +555,7 @@ describe("Chat localStorage persistence (Story 145)", () => { { role: "assistant", content: "I should survive a reload" }, ]; - act(() => { + await act(async () => { capturedWsHandlers?.onUpdate(history); }); @@ -604,7 +604,7 @@ describe("Chat localStorage persistence (Story 145)", () => { { role: "user", content: "What is Rust?" }, { role: "assistant", content: "Rust is a systems programming language." }, ]; - act(() => { + await act(async () => { capturedWsHandlers?.onUpdate(priorHistory); }); @@ -692,12 +692,12 @@ describe("Chat activity status indicator (Bug 140)", () => { }); // Simulate tokens arriving (streamingContent becomes non-empty) - act(() => { + await act(async () => { capturedWsHandlers?.onToken("I'll read that file for you."); }); // Now simulate a tool activity event while streamingContent is non-empty - act(() => { + await act(async () => { capturedWsHandlers?.onActivity("read_file"); }); @@ -742,7 +742,7 @@ describe("Chat activity status indicator (Bug 140)", () => { }); // Tokens arrive — streamingContent is non-empty, no activity - act(() => { + await act(async () => { capturedWsHandlers?.onToken("Here is my response..."); }); @@ -765,12 +765,12 @@ describe("Chat activity status indicator (Bug 140)", () => { }); // Simulate tokens arriving - act(() => { + await act(async () => { capturedWsHandlers?.onToken("Let me read that."); }); // Claude Code sends tool name "Read" (not "read_file") - act(() => { + await act(async () => { capturedWsHandlers?.onActivity("Read"); }); @@ -792,11 +792,11 @@ describe("Chat activity status indicator (Bug 140)", () => { fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); }); - act(() => { + await act(async () => { capturedWsHandlers?.onToken("Running tests now."); }); - act(() => { + await act(async () => { capturedWsHandlers?.onActivity("Bash"); }); @@ -818,11 +818,11 @@ describe("Chat activity status indicator (Bug 140)", () => { fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); }); - act(() => { + await act(async () => { capturedWsHandlers?.onToken("Working on it."); }); - act(() => { + await act(async () => { capturedWsHandlers?.onActivity("SomeCustomTool"); }); @@ -899,7 +899,7 @@ describe("Chat message queue (Story 155)", () => { ).toBeInTheDocument(); // Simulate agent response completing (loading → false) - act(() => { + await act(async () => { capturedWsHandlers?.onUpdate([ { role: "user", content: "First" }, { role: "assistant", content: "Done." }, @@ -1066,7 +1066,7 @@ describe("Chat message queue (Story 155)", () => { expect(indicators[1]).toHaveTextContent("Third"); // Simulate first response completing — both "Second" and "Third" are drained at once - act(() => { + await act(async () => { capturedWsHandlers?.onUpdate([ { role: "user", content: "First" }, { role: "assistant", content: "Response 1." }, @@ -1145,7 +1145,7 @@ describe("Remove bubble styling from streaming messages (Story 163)", () => { }); // Simulate streaming tokens arriving - act(() => { + await act(async () => { capturedWsHandlers?.onToken("Streaming response text"); }); @@ -1176,7 +1176,7 @@ describe("Remove bubble styling from streaming messages (Story 163)", () => { fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); }); - act(() => { + await act(async () => { capturedWsHandlers?.onToken("Some markdown content"); }); @@ -1200,7 +1200,7 @@ describe("Remove bubble styling from streaming messages (Story 163)", () => { }); // Simulate streaming tokens - act(() => { + await act(async () => { capturedWsHandlers?.onToken("Final response"); }); @@ -1211,7 +1211,7 @@ describe("Remove bubble styling from streaming messages (Story 163)", () => { const streamingStyleAttr = streamingStyledDiv.getAttribute("style") ?? ""; // Transition: onUpdate completes the message - act(() => { + await act(async () => { capturedWsHandlers?.onUpdate([ { role: "user", content: "Hello" }, { role: "assistant", content: "Final response" }, @@ -1244,7 +1244,7 @@ describe("Remove bubble styling from streaming messages (Story 163)", () => { await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); - act(() => { + await act(async () => { capturedWsHandlers?.onUpdate([ { role: "user", content: "Hi" }, { role: "assistant", content: "Hello there!" }, @@ -1268,7 +1268,7 @@ describe("Remove bubble styling from streaming messages (Story 163)", () => { await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); - act(() => { + await act(async () => { capturedWsHandlers?.onUpdate([ { role: "user", content: "I am a user message" }, { role: "assistant", content: "I am a response" }, @@ -1310,7 +1310,7 @@ describe("Bug 264: Claude Code session ID persisted across browser refresh", () await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); - act(() => { + await act(async () => { capturedWsHandlers?.onSessionId("test-session-abc"); }); @@ -1394,7 +1394,7 @@ describe("Bug 264: Claude Code session ID persisted across browser refresh", () render(); await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); - act(() => { + await act(async () => { capturedWsHandlers?.onSessionId("my-session"); }); @@ -1595,7 +1595,7 @@ describe("Slash command handling (Story 374)", () => { await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); // First add a message so there is history to clear - act(() => { + await act(async () => { capturedWsHandlers?.onUpdate([ { role: "user", content: "hello" }, { role: "assistant", content: "world" }, @@ -1701,7 +1701,7 @@ describe("Bug 450: WebSocket error messages displayed in chat", () => { await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); - act(() => { + await act(async () => { capturedWsHandlers?.onError("Something went wrong on the server."); }); @@ -1715,7 +1715,7 @@ describe("Bug 450: WebSocket error messages displayed in chat", () => { await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); - act(() => { + await act(async () => { capturedWsHandlers?.onError( "OAuth login required. Please visit: https://example.com/oauth/login", ); diff --git a/frontend/src/components/selection/usePathCompletion.test.ts b/frontend/src/components/selection/usePathCompletion.test.ts index 378edbea..0685b41c 100644 --- a/frontend/src/components/selection/usePathCompletion.test.ts +++ b/frontend/src/components/selection/usePathCompletion.test.ts @@ -138,7 +138,7 @@ describe("usePathCompletion hook", () => { expect(result.current.matchList[0].name).toBe("Documents"); }); - it("calls setPathInput when acceptMatch is invoked", () => { + it("calls setPathInput when acceptMatch is invoked", async () => { const setPathInput = vi.fn(); const { result } = renderHook(() => @@ -151,7 +151,7 @@ describe("usePathCompletion hook", () => { }), ); - act(() => { + await act(async () => { result.current.acceptMatch("/home/user/Documents/"); }); @@ -308,14 +308,14 @@ describe("usePathCompletion hook", () => { expect(result.current.matchList.length).toBe(2); }); - act(() => { + await act(async () => { result.current.acceptSelectedMatch(); }); expect(setPathInput).toHaveBeenCalledWith("/home/user/Documents/"); }); - it("acceptSelectedMatch does nothing when matchList is empty", () => { + it("acceptSelectedMatch does nothing when matchList is empty", async () => { const setPathInput = vi.fn(); const { result } = renderHook(() => @@ -328,7 +328,7 @@ describe("usePathCompletion hook", () => { }), ); - act(() => { + await act(async () => { result.current.acceptSelectedMatch(); }); @@ -352,7 +352,7 @@ describe("usePathCompletion hook", () => { expect(result.current.matchList.length).toBe(1); }); - act(() => { + await act(async () => { result.current.closeSuggestions(); }); @@ -450,7 +450,7 @@ describe("usePathCompletion hook", () => { expect(result.current.matchList.length).toBe(2); }); - act(() => { + await act(async () => { result.current.setSelectedMatch(1); }); diff --git a/frontend/src/hooks/useChatHistory.test.ts b/frontend/src/hooks/useChatHistory.test.ts index 13537038..529569d4 100644 --- a/frontend/src/hooks/useChatHistory.test.ts +++ b/frontend/src/hooks/useChatHistory.test.ts @@ -19,7 +19,7 @@ function makeMessages(count: number): Message[] { })); } -describe("useChatHistory", () => { +describe("useChatHistory", async () => { beforeEach(() => { localStorage.clear(); }); @@ -28,7 +28,7 @@ describe("useChatHistory", () => { localStorage.clear(); }); - it("AC1: restores messages from localStorage on mount", () => { + it("AC1: restores messages from localStorage on mount", async () => { localStorage.setItem(STORAGE_KEY, JSON.stringify(sampleMessages)); const { result } = renderHook(() => useChatHistory(PROJECT)); @@ -36,13 +36,13 @@ describe("useChatHistory", () => { expect(result.current.messages).toEqual(sampleMessages); }); - it("AC1: returns empty array when localStorage has no data", () => { + it("AC1: returns empty array when localStorage has no data", async () => { const { result } = renderHook(() => useChatHistory(PROJECT)); expect(result.current.messages).toEqual([]); }); - it("AC1: returns empty array when localStorage contains invalid JSON", () => { + it("AC1: returns empty array when localStorage contains invalid JSON", async () => { localStorage.setItem(STORAGE_KEY, "not-json{{{"); const { result } = renderHook(() => useChatHistory(PROJECT)); @@ -50,7 +50,7 @@ describe("useChatHistory", () => { expect(result.current.messages).toEqual([]); }); - it("AC1: returns empty array when localStorage contains a non-array", () => { + it("AC1: returns empty array when localStorage contains a non-array", async () => { localStorage.setItem(STORAGE_KEY, JSON.stringify({ not: "array" })); const { result } = renderHook(() => useChatHistory(PROJECT)); @@ -58,10 +58,10 @@ describe("useChatHistory", () => { expect(result.current.messages).toEqual([]); }); - it("AC2: saves messages to localStorage when setMessages is called with an array", () => { + it("AC2: saves messages to localStorage when setMessages is called with an array", async () => { const { result } = renderHook(() => useChatHistory(PROJECT)); - act(() => { + await act(async () => { result.current.setMessages(sampleMessages); }); @@ -69,10 +69,10 @@ describe("useChatHistory", () => { expect(stored).toEqual(sampleMessages); }); - it("AC2: saves messages to localStorage when setMessages is called with updater function", () => { + it("AC2: saves messages to localStorage when setMessages is called with updater function", async () => { const { result } = renderHook(() => useChatHistory(PROJECT)); - act(() => { + await act(async () => { result.current.setMessages(() => sampleMessages); }); @@ -80,14 +80,14 @@ describe("useChatHistory", () => { expect(stored).toEqual(sampleMessages); }); - it("AC3: clearMessages removes messages from state and localStorage", () => { + it("AC3: clearMessages removes messages from state and localStorage", async () => { localStorage.setItem(STORAGE_KEY, JSON.stringify(sampleMessages)); const { result } = renderHook(() => useChatHistory(PROJECT)); expect(result.current.messages).toEqual(sampleMessages); - act(() => { + await act(async () => { result.current.clearMessages(); }); @@ -95,7 +95,7 @@ describe("useChatHistory", () => { expect(localStorage.getItem(STORAGE_KEY)).toBeNull(); }); - it("AC4: handles localStorage quota errors gracefully", () => { + it("AC4: handles localStorage quota errors gracefully", async () => { const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); const setItemSpy = vi .spyOn(Storage.prototype, "setItem") @@ -106,7 +106,7 @@ describe("useChatHistory", () => { const { result } = renderHook(() => useChatHistory(PROJECT)); // Should not throw - act(() => { + await act(async () => { result.current.setMessages(sampleMessages); }); @@ -121,7 +121,7 @@ describe("useChatHistory", () => { setItemSpy.mockRestore(); }); - it("AC5: scopes storage key to project path", () => { + it("AC5: scopes storage key to project path", async () => { const projectA = "/projects/a"; const projectB = "/projects/b"; const keyA = `storykit-chat-history:${projectA}`; @@ -140,12 +140,12 @@ describe("useChatHistory", () => { expect(resultB.current.messages).toEqual(messagesB); }); - it("AC2: removes localStorage key when messages are set to empty array", () => { + it("AC2: removes localStorage key when messages are set to empty array", async () => { localStorage.setItem(STORAGE_KEY, JSON.stringify(sampleMessages)); const { result } = renderHook(() => useChatHistory(PROJECT)); - act(() => { + await act(async () => { result.current.setMessages([]); }); @@ -154,20 +154,20 @@ describe("useChatHistory", () => { // --- Story 179: Chat history pruning tests --- - it("S179: default limit of 200 is applied when saving to localStorage", () => { + it("S179: default limit of 200 is applied when saving to localStorage", async () => { const { result } = renderHook(() => useChatHistory(PROJECT)); expect(result.current.maxMessages).toBe(200); }); - it("S179: messages are pruned from the front when exceeding the limit", () => { + it("S179: messages are pruned from the front when exceeding the limit", async () => { // Set a small limit to make testing practical localStorage.setItem(LIMIT_KEY, "3"); const { result } = renderHook(() => useChatHistory(PROJECT)); const fiveMessages = makeMessages(5); - act(() => { + await act(async () => { result.current.setMessages(fiveMessages); }); @@ -180,13 +180,13 @@ describe("useChatHistory", () => { expect(stored[0].content).toBe("Message 3"); }); - it("S179: messages under the limit are not pruned", () => { + it("S179: messages under the limit are not pruned", async () => { localStorage.setItem(LIMIT_KEY, "10"); const { result } = renderHook(() => useChatHistory(PROJECT)); const threeMessages = makeMessages(3); - act(() => { + await act(async () => { result.current.setMessages(threeMessages); }); @@ -197,7 +197,7 @@ describe("useChatHistory", () => { expect(stored).toHaveLength(3); }); - it("S179: limit is configurable via localStorage key", () => { + it("S179: limit is configurable via localStorage key", async () => { localStorage.setItem(LIMIT_KEY, "5"); const { result } = renderHook(() => useChatHistory(PROJECT)); @@ -205,10 +205,10 @@ describe("useChatHistory", () => { expect(result.current.maxMessages).toBe(5); }); - it("S179: setMaxMessages updates the limit and persists it", () => { + it("S179: setMaxMessages updates the limit and persists it", async () => { const { result } = renderHook(() => useChatHistory(PROJECT)); - act(() => { + await act(async () => { result.current.setMaxMessages(50); }); @@ -216,13 +216,13 @@ describe("useChatHistory", () => { expect(localStorage.getItem(LIMIT_KEY)).toBe("50"); }); - it("S179: a limit of 0 means unlimited (no pruning)", () => { + it("S179: a limit of 0 means unlimited (no pruning)", async () => { localStorage.setItem(LIMIT_KEY, "0"); const { result } = renderHook(() => useChatHistory(PROJECT)); const manyMessages = makeMessages(500); - act(() => { + await act(async () => { result.current.setMessages(manyMessages); }); @@ -233,11 +233,11 @@ describe("useChatHistory", () => { expect(stored).toEqual(manyMessages); }); - it("S179: changing the limit re-prunes messages on next save", () => { + it("S179: changing the limit re-prunes messages on next save", async () => { const { result } = renderHook(() => useChatHistory(PROJECT)); const tenMessages = makeMessages(10); - act(() => { + await act(async () => { result.current.setMessages(tenMessages); }); @@ -248,7 +248,7 @@ describe("useChatHistory", () => { expect(stored).toHaveLength(10); // Now lower the limit — the effect re-runs and prunes - act(() => { + await act(async () => { result.current.setMaxMessages(3); }); @@ -257,7 +257,7 @@ describe("useChatHistory", () => { expect(stored[0].content).toBe("Message 8"); }); - it("S179: invalid limit in localStorage falls back to default", () => { + it("S179: invalid limit in localStorage falls back to default", async () => { localStorage.setItem(LIMIT_KEY, "not-a-number"); const { result } = renderHook(() => useChatHistory(PROJECT)); @@ -265,7 +265,7 @@ describe("useChatHistory", () => { expect(result.current.maxMessages).toBe(200); }); - it("S179: negative limit in localStorage falls back to default", () => { + it("S179: negative limit in localStorage falls back to default", async () => { localStorage.setItem(LIMIT_KEY, "-5"); const { result } = renderHook(() => useChatHistory(PROJECT));