import { renderToStaticMarkup } from "react-dom/server"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { AIWorkspace } from "./ai-workspace"; import { AgentLibraryState } from "./agents-mode"; import { agentBuilderStub } from "./agent-builder"; import { aiModeHref, parseAIMode } from "./workspace-mode"; import { WorkspaceComposer, shouldSubmitOnEnter } from "./workspace-composer"; const mocks = vi.hoisted(() => ({ search: "", chat: vi.fn(), })); vi.mock("next/navigation", () => ({ usePathname: () => "/acme/ai", useSearchParams: () => new URLSearchParams(mocks.search), })); vi.mock("@ai-sdk/react", () => ({ useChat: mocks.chat })); beforeEach(() => { mocks.search = ""; mocks.chat.mockReset().mockReturnValue({ messages: [], input: "Chat draft", handleSubmit: vi.fn(), status: "ready", setInput: vi.fn(), stop: vi.fn(), }); }); describe("AI mode URLs", () => { it.each([null, "", "unknown", "chat"])("defaults %s to Chat", (mode) => { expect(parseAIMode(mode)).toBe("chat"); }); it("recognizes an Agents deep link", () => { expect(parseAIMode("agents")).toBe("agents"); }); it("preserves unrelated search parameters and the fragment", () => { expect( aiModeHref("/acme/ai", "filter=mine&tab=chat", "agents", "#library"), ).toBe("/acme/ai?filter=mine&tab=agents#library"); expect(aiModeHref("/acme/ai", "tab=agents", "chat")).toBe( "/acme/ai?tab=chat", ); expect( aiModeHref("/acme/ai", "filter=mine&tab=agents", "chat", "#library"), ).toBe("/acme/ai?filter=mine&tab=chat#library"); }); }); describe("workspace shell", () => { it("keeps legacy URLs in Chat with both mode trees mounted and labelled tabs", () => { const html = renderToStaticMarkup(); expect(html).toContain('role="tablist"'); expect(html).toMatch(/aria-selected="true"[^>]*>Chat/); expect(html).toMatch(/role="tabpanel"[^>]*hidden=""/); expect(html).toContain("Chat draft"); expect(html).toContain("Agent library"); expect(html).toContain('aria-label="Chat message"'); expect(html).toContain('aria-label="Agent instructions"'); }); it("renders the URL-selected mode and only one workspace-scoped Chat adapter", () => { mocks.search = "tab=agents"; const html = renderToStaticMarkup(); expect(html).toMatch(/aria-selected="true"[^>]*>Agents/); expect(mocks.chat).toHaveBeenCalledTimes(1); expect(mocks.chat).toHaveBeenCalledWith({ api: "/api/chat", body: { workspace: "acme" }, }); expect(html).toContain("Builder is not connected yet"); }); it("retains streaming messages, stop control, and errors", () => { mocks.chat.mockReturnValue({ messages: [{ id: "m1", role: "assistant", content: "Partial answer" }], input: "Next question", handleSubmit: vi.fn(), setInput: vi.fn(), stop: vi.fn(), status: "streaming", error: new Error("Provider unavailable"), }); const html = renderToStaticMarkup(); expect(html).toContain("Partial answer"); expect(html).toContain('aria-label="Stop generating"'); expect(html).toContain("Provider unavailable"); expect(html).toContain('role="alert"'); }); }); describe("shared composer", () => { it.each([ ["Enter", false, false, true], ["Enter", true, false, false], ["Enter", false, true, false], ["a", false, false, false], ])( "handles key %s, shift %s, composing %s", (key, shiftKey, isComposing, expected) => { expect(shouldSubmitOnEnter({ key, shiftKey, isComposing })).toBe( expected, ); }, ); it("disables blank submissions and exposes the input and keyboard hint", () => { const html = renderToStaticMarkup( , ); expect(html).toContain('aria-label="Agent instructions"'); expect(html).toMatch(/]*disabled=""/); expect(html).toContain("Shift+Enter"); }); }); describe("Agents scaffold", () => { it("returns an explicit unavailable result without making a network request", async () => { const fetchSpy = vi.spyOn(globalThis, "fetch"); try { const result = await agentBuilderStub({ workspaceSlug: "acme", prompt: "Triage tasks", }); expect(result.status).toBe("unavailable"); expect(result.message).toContain("not connected"); expect(fetchSpy).not.toHaveBeenCalled(); } finally { fetchSpy.mockRestore(); } }); it.each([ [{ status: "loading" } as const, "Loading agents"], [{ status: "empty" } as const, "No reusable agents yet"], [ { status: "error", message: "Library unavailable" } as const, "Library unavailable", ], ])("renders the library state %j", (state, text) => { expect(renderToStaticMarkup()).toContain( text, ); }); });