Submatter/apps/web/components/ai/ai-workspace.test.tsx

156 lines
5 KiB
TypeScript
Raw Permalink Normal View History

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(<AIWorkspace workspaceSlug="acme" />);
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(<AIWorkspace workspaceSlug="acme" />);
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(<AIWorkspace workspaceSlug="acme" />);
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(
<WorkspaceComposer
mode="agents"
input=" "
onInputChange={vi.fn()}
onSubmit={vi.fn()}
/>,
);
expect(html).toContain('aria-label="Agent instructions"');
expect(html).toMatch(/<button[^>]*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(<AgentLibraryState state={state} />)).toContain(
text,
);
});
});