156 lines
5 KiB
TypeScript
156 lines
5 KiB
TypeScript
|
|
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,
|
||
|
|
);
|
||
|
|
});
|
||
|
|
});
|