// @vitest-environment jsdom import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { HarnessEvent } from "@agent-studio/shared"; import { App } from "../src/App"; import { HarnessRuntime } from "../src/assistant/HarnessRuntime"; import { HarnessClientStore } from "../src/assistant/store"; import { MockAdsMetricsUI, SaveReportDraftUI, SwadsReportUI } from "../src/components/ToolUIs"; import { AppErrorBoundary } from "../src/components/AppErrorBoundary"; afterEach(() => { localStorage.clear(); sessionStorage.clear(); vi.restoreAllMocks(); vi.unstubAllGlobals(); }); describe("web UI", () => { it("renders the Skills, Chat, and Timeline operating areas", () => { const store = new HarnessClientStore(); render(); expect(screen.getByLabelText("Skills Catalog")).toBeInTheDocument(); expect(screen.getByLabelText("Streaming Chat")).toBeInTheDocument(); expect(screen.getByLabelText("Event Timeline")).toBeInTheDocument(); expect(screen.getByRole("status")).toHaveTextContent("正在读取服务器模型配置"); expect(screen.queryByText(/尚未配置模型|重新输入 API Key|Model missing/)).not.toBeInTheDocument(); expect(screen.getByRole("button", { name: "发送消息" })).toBeDisabled(); }); it("keeps Composer outside the scrolling message viewport and inside the Thread", async () => { const store = new HarnessClientStore(); store.setMessages(Array.from({ length: 30 }, (_, index) => ({ id: `message-${index}`, role: "assistant", content: [{ type: "text", text: `回复 ${index}` }] }))); render(); const input = screen.getByLabelText("消息输入"); expect(input.closest(".thread-viewport")).toBeNull(); const root = input.closest(".thread-root")!; expect(root.querySelector(".thread-viewport")).toContainElement(screen.getByText("回复 29")); expect(root.lastElementChild).toHaveClass("composer-footer"); expect(root.lastElementChild).toContainElement(input); await userEvent.setup().click(screen.getByRole("button", { name: "chat" })); expect(input.closest(".mobile-panel")).toHaveClass("shown"); }); it("enables sending after an environment model loads, and preserves send and stop controls", async () => { const store = new HarnessClientStore(); Object.assign(store.getSnapshot(), { sessionId: "ses_1", connection: "online" }); let release!: () => void; const gate = new Promise((resolve) => { release = resolve; }); const fetcher = vi.fn(async (url: string) => { if (url.endsWith("/model-config")) { await gate; return { ok: true, json: async () => ({ configured: true, source: "environment", keyConfigured: true }) }; } return { ok: true, json: async () => ({}) }; }); vi.stubGlobal("fetch", fetcher); render(); const user = userEvent.setup(); await user.type(screen.getByLabelText("消息输入"), "请分析账户"); expect(screen.getByRole("button", { name: "发送消息" })).toBeDisabled(); let refresh!: Promise; act(() => { refresh = store.refreshModel(); }); expect(screen.queryByText(/尚未配置模型|重新输入 API Key/)).not.toBeInTheDocument(); await act(async () => { release(); await refresh; }); expect(screen.getByRole("button", { name: "发送消息" })).toBeEnabled(); await user.click(screen.getByRole("button", { name: "发送消息" })); expect(fetcher).toHaveBeenCalledWith("/api/sessions/ses_1/messages", expect.objectContaining({ method: "POST", body: JSON.stringify({ text: "请分析账户", attachmentIds: [] }) })); act(() => store.reduce(harnessEvent(1, "run.started", { modelId: "env-model" }))); await user.click(screen.getByRole("button", { name: "停止运行" })); expect(fetcher).toHaveBeenCalledWith("/api/sessions/ses_1/cancel", expect.objectContaining({ method: "POST" })); }); it("preserves attachment drag-and-drop upload and removal in the fixed Composer", async () => { const store = new HarnessClientStore(); Object.assign(store.getSnapshot(), { sessionId: "ses_1", connection: "online", modelLoadState: "loaded", model: { configured: true, source: "environment", keyConfigured: true, supportsImages: true } }); const fetcher = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ attachmentId: "att_1", name: "photo.png", mediaType: "image/png" }) }); vi.stubGlobal("fetch", fetcher); Object.defineProperty(URL, "createObjectURL", { configurable: true, value: vi.fn(() => "blob:attachment") }); Object.defineProperty(URL, "revokeObjectURL", { configurable: true, value: vi.fn() }); const { container } = render(); const user = userEvent.setup(); expect(screen.getByRole("button", { name: "添加图片" })).toBeEnabled(); const dropzone = container.querySelector(".dropzone")!; const dataTransfer = { types: ["Files"], files: [new File(["png"], "photo.png", { type: "image/png" })] }; fireEvent.dragEnter(dropzone, { dataTransfer }); fireEvent.drop(dropzone, { dataTransfer }); await waitFor(() => expect(screen.getByText("photo.png")).toBeInTheDocument()); expect(screen.getByText("photo.png").closest(".composer-footer")).not.toBeNull(); expect(fetcher).toHaveBeenCalledWith("/api/sessions/ses_1/attachments", expect.objectContaining({ method: "POST", body: expect.any(FormData) })); await user.click(screen.getByRole("button", { name: "移除图片" })); await waitFor(() => expect(screen.queryByText("photo.png")).not.toBeInTheDocument()); expect(fetcher).toHaveBeenCalledWith("/api/sessions/ses_1/attachments/att_1", expect.objectContaining({ method: "DELETE" })); expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:attachment"); }); it("shows a recoverable read error without claiming missing credentials", async () => { const store = new HarnessClientStore(); Object.assign(store.getSnapshot(), { sessionId: "ses_1", connection: "online", model: { configured: true, source: "environment", keyConfigured: true } }); vi.stubGlobal("fetch", vi.fn().mockRejectedValueOnce(new Error("HTTP 503")) .mockResolvedValueOnce({ ok: true, json: async () => ({ configured: true, source: "environment", keyConfigured: true }) })); render(); const user = userEvent.setup(); await user.type(screen.getByLabelText("消息输入"), "任务"); expect(screen.getByRole("button", { name: "发送消息" })).toBeDisabled(); await act(() => store.refreshModel()); expect(screen.getByRole("alert")).toHaveTextContent("服务器模型配置读取失败:HTTP 503"); expect(screen.queryByText(/尚未配置模型|重新输入 API Key/)).not.toBeInTheDocument(); expect(screen.getByRole("button", { name: "发送消息" })).toBeDisabled(); await user.click(screen.getByRole("button", { name: "重试读取模型配置" })); await waitFor(() => expect(screen.getByRole("button", { name: "发送消息" })).toBeEnabled()); expect(screen.queryByRole("alert")).not.toBeInTheDocument(); }); it("only offers missing-model Settings after the server confirms it is unconfigured", async () => { const store = new HarnessClientStore(); store.getSnapshot().modelLoadState = "loaded"; render(); expect(screen.getByRole("button", { name: "发送消息" })).toBeDisabled(); await userEvent.setup().click(screen.getByRole("button", { name: /尚未配置模型/ })); expect(screen.getByLabelText("API Key")).toHaveAttribute("type", "password"); }); it("renders the mock metrics Tool UI", () => { render(); expect(screen.getByText("demo-account", { exact: false })).toBeInTheDocument(); expect(screen.getByText("ROAS")).toBeInTheDocument(); }); it("renders approval actions inside save_report_draft", () => { const store = new HarnessClientStore(); render(); expect(screen.getByRole("button", { name: "批准一次" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "拒绝" })).toBeInTheDocument(); }); it("opens settings with password input and does not use Web Storage", async () => { const user = userEvent.setup(); const store = new HarnessClientStore(); render(); await user.click(screen.getByRole("button", { name: "Settings" })); expect(screen.getByLabelText("API Key")).toHaveAttribute("type", "password"); expect(localStorage.length).toBe(0); expect(sessionStorage.length).toBe(0); }); it("prompts for a new key when local model metadata was restored", () => { const store = new HarnessClientStore(); store.getSnapshot().modelLoadState = "loaded"; store.getSnapshot().model = { configured: true, source: "session", providerId: "saved", api: "openai-completions", modelId: "saved-model", supportsImages: false, keyConfigured: false }; render(); expect(screen.getByRole("button", { name: /模型已配置,但缺少 API Key/ })).toBeInTheDocument(); expect(screen.getByLabelText("消息输入")).toHaveAttribute("placeholder", "请重新输入 API Key"); }); it("omits empty optional model fields when saving settings", async () => { const user = userEvent.setup(); const store = new HarnessClientStore(); const save = vi.spyOn(store, "saveModel").mockResolvedValue(); render(); await user.click(screen.getByRole("button", { name: "Settings" })); await user.type(screen.getByLabelText("API Key"), "sk-fake"); await user.click(screen.getByRole("button", { name: "保存配置" })); expect(save).toHaveBeenCalledWith({ providerId: "openai", api: "openai-responses", baseUrl: "https://api.openai.com/v1", apiKey: "sk-fake", modelId: "gpt-5.4", supportsImages: true }); }); it("fills the classroom demo prompt without sending it", async () => { const user = userEvent.setup(); const store = new HarnessClientStore(); render(); await user.click(screen.getByRole("button", { name: "填入 Demo Prompt" })); expect((screen.getByLabelText("消息输入") as HTMLTextAreaElement).value).toContain("ads-analysis"); expect(store.getSnapshot().events).toHaveLength(0); }); it("shows a recoverable fallback instead of a black screen for render failures", () => { vi.spyOn(console, "error").mockImplementation(() => undefined); const Broken = () => { throw new Error("tool renderer failed"); }; render(); expect(screen.getByRole("alert")).toHaveTextContent("界面渲染出现异常"); expect(screen.getByRole("button", { name: "重新加载页面" })).toBeInTheDocument(); }); it("renders a duplicated Pi Tool sequence and approval without crashing", () => { const store = new HarnessClientStore(); store.reduce(harnessEvent(1, "user.message", { messageId: "user_1", text: "demo", attachmentIds: [] })); store.reduce(harnessEvent(2, "run.started", { modelId: "fake" })); store.reduce(harnessEvent(3, "assistant.completed", { messageId: "assistant_1" })); store.reduce(harnessEvent(4, "tool.requested", { toolCallId: "read_1", toolName: "read", arguments: { path: "" } })); store.reduce(harnessEvent(5, "tool.requested", { toolCallId: "read_1", toolName: "read", arguments: { path: ".agents/skills/ads-analysis/SKILL.md" } })); store.reduce(harnessEvent(6, "tool.completed", { toolCallId: "read_1", toolName: "read", result: { status: "success" } })); store.reduce(harnessEvent(7, "tool.requested", { toolCallId: "metrics_1", toolName: "mock_ads_metrics", arguments: { accountId: "demo-account", days: 7 } })); store.reduce(harnessEvent(8, "tool.completed", { toolCallId: "metrics_1", toolName: "mock_ads_metrics", result: { details: { spend: 100, ctr: 2, cpa: 4, roas: 5 } } })); store.reduce(harnessEvent(9, "tool.requested", { toolCallId: "save_1", toolName: "save_report_draft", arguments: { content: "报告正文" } })); store.reduce(harnessEvent(10, "approval.required", { approvalId: "apr_1", sessionId: "ses_1", runId: "run_1", toolCallId: "save_1", toolName: "save_report_draft", arguments: { content: "报告正文" }, riskLevel: "medium", createdAt: new Date().toISOString(), expiresAt: new Date(Date.now() + 60_000).toISOString(), status: "pending" })); render(); expect(screen.queryByText("界面渲染出现异常")).not.toBeInTheDocument(); expect(screen.getByText("ROAS")).toBeInTheDocument(); expect(screen.getByRole("heading", { name: "允许保存报告草稿?" })).toBeInTheDocument(); }); it("shows Markdown, generated PNG preview and downloads for a text-only model", async () => { const store = new HarnessClientStore(); store.getSnapshot().model.supportsImages = false; vi.stubGlobal("fetch", vi.fn(async () => new Response(new Blob(["png"], { type: "image/png" }), { headers: { "content-type": "image/png" } }))); Object.defineProperty(URL, "createObjectURL", { configurable: true, value: vi.fn(() => "blob:report-image") }); const artifact = reportArtifact(); store.reduce(harnessEvent(1, "tool.requested", { toolCallId: "render", toolName: "render_swads_daily_report", arguments: {} })); store.reduce(harnessEvent(2, "assistant.delta", { messageId: "assistant-real", delta: "**账户结论**\n\n- 建议一\n- 建议二\n- 建议三" })); store.reduce(harnessEvent(3, "tool.completed", { toolCallId: "render", toolName: "render_swads_daily_report", result: artifact })); store.reduce(harnessEvent(4, "report.generated", { assistantMessageId: "assistant-real", artifact })); store.reduce(harnessEvent(5, "run.completed", {})); await waitFor(() => expect(store.getSnapshot().messages[0]?.content).toEqual(expect.arrayContaining([expect.objectContaining({ type: "image" })]))); render(); expect(screen.getByText("账户结论").tagName).toBe("STRONG"); expect(screen.getAllByRole("listitem")).toHaveLength(3); expect(screen.getByRole("link", { name: "下载 JSON" })).toHaveAttribute("href", artifact.downloads.json); await userEvent.setup().click(screen.getByRole("button", { name: "预览 SW Ads 2026-09-06" })); expect(screen.getByRole("dialog")).toBeInTheDocument(); expect(store.getSnapshot().messages.filter((message) => message.role === "assistant")).toHaveLength(1); }); it("shows a no-PNG warning and only available downloads", () => { const artifact = reportArtifact(); const result = { ...artifact, previewUrl: undefined, downloads: { json: artifact.downloads.json, html: artifact.downloads.html }, warning: { code: "CHROME_UNAVAILABLE", message: "Chrome unavailable; HTML/JSON available" } }; render(); expect(screen.getByRole("status")).toHaveTextContent("Chrome unavailable"); expect(screen.queryByRole("link", { name: "下载 PNG" })).not.toBeInTheDocument(); expect(screen.queryByRole("img")).not.toBeInTheDocument(); }); }); function toolProps(toolName: string, args: Record, result?: unknown, approval?: { id: string }) { return { type: "tool-call" as const, toolCallId: "t1", toolName, args, argsText: JSON.stringify(args), result, isError: false, status: { type: "complete" as const }, addResult: vi.fn(), resume: vi.fn(), respondToApproval: vi.fn(), ...(approval ? { approval } : {}) }; } function harnessEvent(sequence: number, type: T, payload: Extract["payload"]): HarnessEvent { return { eventId: `evt_${sequence}`, sessionId: "ses_1", runId: "run_1", timestamp: new Date().toISOString(), sequence, type, payload } as HarnessEvent; } function reportArtifact() { const accountKey = "acct_0123456789abcdef01234567", reportDate = "2026-09-06", reportId = "12345678-1234-4234-8234-123456789abc"; const base = `/api/reports/${accountKey}/${reportDate}/${reportId}`; return { accountKey, reportDate, reportId, previewUrl: `${base}/png`, downloads: { png: `${base}/png`, html: `${base}/html`, json: `${base}/json` } }; }