diff --git a/canvas/src/components/tabs/ChatTab.tsx b/canvas/src/components/tabs/ChatTab.tsx index 156f87e8..7b0ee0d2 100644 --- a/canvas/src/components/tabs/ChatTab.tsx +++ b/canvas/src/components/tabs/ChatTab.tsx @@ -67,7 +67,7 @@ interface A2AResponse { // Server-side counterpart in workspace-server/internal/channels/ // manager.go has the same single-part bug; fix that too if/when a // channel-delivered reply (Slack, Lark, etc.) gets truncated. -function extractReplyText(resp: A2AResponse): string { +export function extractReplyText(resp: A2AResponse): string { const collect = (parts: A2APart[] | undefined): string => { if (!parts) return ""; return parts diff --git a/canvas/src/components/tabs/ConfigTab.tsx b/canvas/src/components/tabs/ConfigTab.tsx index 50ae227b..ab6ff6e6 100644 --- a/canvas/src/components/tabs/ConfigTab.tsx +++ b/canvas/src/components/tabs/ConfigTab.tsx @@ -143,7 +143,7 @@ interface RuntimeOption { // haven't migrated to the explicit `providers:` field yet, AND // continues to be a useful fallback for any future runtime whose // derive-provider semantics happen to match the slug prefix. -function deriveProvidersFromModels(models: ModelSpec[]): string[] { +export function deriveProvidersFromModels(models: ModelSpec[]): string[] { const seen = new Set(); const out: string[] = []; for (const m of models) { diff --git a/canvas/src/components/tabs/FilesTab/__tests__/FilesToolbar.test.tsx b/canvas/src/components/tabs/FilesTab/__tests__/FilesToolbar.test.tsx new file mode 100644 index 00000000..cb23c95d --- /dev/null +++ b/canvas/src/components/tabs/FilesTab/__tests__/FilesToolbar.test.tsx @@ -0,0 +1,349 @@ +// @vitest-environment jsdom +/** + * Tests for FilesToolbar — the top-of-panel bar for the Files tab. + * Covers: directory select, file count, New/Upload/Clear (configs-only), + * Export, Refresh, and aria-labels. + */ +import React from "react"; +import { render, screen, fireEvent, cleanup } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FilesToolbar } from "../FilesToolbar"; + +afterEach(cleanup); + +describe("FilesToolbar", () => { + describe("renders base toolbar", () => { + it("renders the directory select with aria-label", () => { + render( + + ); + expect( + screen.getByRole("combobox", { name: /file root directory/i }) + ).toBeTruthy(); + }); + + it("renders the file count", () => { + render( + + ); + expect(screen.getByText("7 files")).toBeTruthy(); + }); + + it("renders Export button", () => { + render( + + ); + expect( + screen.getByRole("button", { name: /download all files/i }) + ).toBeTruthy(); + }); + + it("renders Refresh button", () => { + render( + + ); + expect(screen.getByRole("button", { name: /refresh file list/i })).toBeTruthy(); + }); + + it("renders 0 files when count is 0", () => { + render( + + ); + expect(screen.getByText("0 files")).toBeTruthy(); + }); + }); + + describe("configs-only buttons", () => { + it("shows New and Upload buttons when root is /configs", () => { + render( + + ); + expect( + screen.getByRole("button", { name: /create new file/i }) + ).toBeTruthy(); + expect( + screen.getByRole("button", { name: /upload folder/i }) + ).toBeTruthy(); + expect(screen.getByRole("button", { name: /delete all files/i })).toBeTruthy(); + }); + + it("hides New and Upload when root is /workspace", () => { + render( + + ); + expect( + screen.queryByRole("button", { name: /create new file/i }) + ).toBeNull(); + expect( + screen.queryByRole("button", { name: /upload folder/i }) + ).toBeNull(); + expect( + screen.queryByRole("button", { name: /delete all files/i }) + ).toBeNull(); + // Export and Refresh are still present + expect( + screen.getByRole("button", { name: /download all files/i }) + ).toBeTruthy(); + }); + + it("hides New and Upload when root is /home", () => { + render( + + ); + expect( + screen.queryByRole("button", { name: /create new file/i }) + ).toBeNull(); + expect( + screen.queryByRole("button", { name: /upload folder/i }) + ).toBeNull(); + }); + + it("hides New and Upload when root is /plugins", () => { + render( + + ); + expect( + screen.queryByRole("button", { name: /create new file/i }) + ).toBeNull(); + expect( + screen.queryByRole("button", { name: /upload folder/i }) + ).toBeNull(); + }); + }); + + describe("callbacks", () => { + it("calls setRoot when directory is changed", () => { + const setRoot = vi.fn(); + render( + + ); + fireEvent.change(screen.getByRole("combobox"), { + target: { value: "/workspace" }, + }); + expect(setRoot).toHaveBeenCalledWith("/workspace"); + }); + + it("calls onNewFile when New button is clicked", () => { + const onNewFile = vi.fn(); + render( + + ); + fireEvent.click(screen.getByRole("button", { name: /create new file/i })); + expect(onNewFile).toHaveBeenCalledTimes(1); + }); + + it("calls onDownloadAll when Export button is clicked", () => { + const onDownloadAll = vi.fn(); + render( + + ); + fireEvent.click(screen.getByRole("button", { name: /download all files/i })); + expect(onDownloadAll).toHaveBeenCalledTimes(1); + }); + + it("calls onClearAll when Clear button is clicked", () => { + const onClearAll = vi.fn(); + render( + + ); + fireEvent.click(screen.getByRole("button", { name: /delete all files/i })); + expect(onClearAll).toHaveBeenCalledTimes(1); + }); + + it("calls onRefresh when Refresh button is clicked", () => { + const onRefresh = vi.fn(); + render( + + ); + fireEvent.click(screen.getByRole("button", { name: /refresh file list/i })); + expect(onRefresh).toHaveBeenCalledTimes(1); + }); + + it("calls onUpload when the hidden file input changes", () => { + const onUpload = vi.fn(); + render( + + ); + // Find the hidden file input + const fileInput = document.querySelector( + 'input[type="file"]' + ) as HTMLInputElement; + expect(fileInput).toBeTruthy(); + expect(fileInput?.getAttribute("aria-label")).toBe("Upload folder files"); + }); + }); + + describe("a11y", () => { + it("all buttons have aria-label or accessible name", () => { + render( + + ); + // All buttons should be findable by role + const buttons = screen.getAllByRole("button"); + for (const btn of buttons) { + expect(btn.getAttribute("aria-label") ?? btn.textContent).toBeTruthy(); + } + }); + + it("directory select has aria-label", () => { + render( + + ); + const select = screen.getByRole("combobox"); + expect(select.getAttribute("aria-label")).toBe("File root directory"); + }); + }); +}); diff --git a/canvas/src/components/tabs/FilesTab/__tests__/NotAvailablePanel.test.tsx b/canvas/src/components/tabs/FilesTab/__tests__/NotAvailablePanel.test.tsx new file mode 100644 index 00000000..c670bb50 --- /dev/null +++ b/canvas/src/components/tabs/FilesTab/__tests__/NotAvailablePanel.test.tsx @@ -0,0 +1,101 @@ +// @vitest-environment jsdom +/** + * Tests for NotAvailablePanel — the full-tab placeholder shown when a + * workspace's runtime doesn't own a platform-managed filesystem (today: + * runtime === "external"). Covers rendering, a11y, and runtime prop + * display. + */ +import React from "react"; +import { render, screen, cleanup } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; +import { NotAvailablePanel } from "../NotAvailablePanel"; + +afterEach(cleanup); + +describe("NotAvailablePanel", () => { + describe("renders", () => { + it("renders the heading", () => { + render(); + expect(screen.getByText("Files not available")).toBeTruthy(); + }); + + it("renders the description text", () => { + render(); + expect( + screen.getByText(/whose filesystem isn't owned by the platform/i) + ).toBeTruthy(); + }); + + it("displays the runtime name in the description", () => { + render(); + // The runtime name appears inside the paragraph + const para = screen.getByText(/whose filesystem isn't owned/i); + expect(para.textContent).toContain("aws-lambda"); + }); + + it("renders the SVG folder icon with aria-hidden", () => { + render(); + const svg = document.querySelector("svg"); + expect(svg).toBeTruthy(); + expect(svg?.getAttribute("aria-hidden")).toBe("true"); + }); + + it("uses the provided runtime prop verbatim", () => { + render(); + const monoRuntime = document.querySelector(".font-mono"); + expect(monoRuntime?.textContent).toBe("cloud-run"); + }); + + it("renders the 'Use the Chat tab' guidance text", () => { + render(); + expect(screen.getByText(/Use the Chat tab/i)).toBeTruthy(); + }); + + it("is contained in a full-height flex column", () => { + render(); + const container = screen.getByText("Files not available").closest("div"); + expect(container?.className).toContain("flex"); + expect(container?.className).toContain("flex-col"); + expect(container?.className).toContain("items-center"); + expect(container?.className).toContain("justify-center"); + expect(container?.className).toContain("h-full"); + }); + }); + + describe("a11y", () => { + it("heading is an h3", () => { + render(); + expect(screen.getByRole("heading", { level: 3 })).toBeTruthy(); + }); + + it("SVG icon has aria-hidden so screen readers skip it", () => { + render(); + const svg = document.querySelector("svg"); + expect(svg?.getAttribute("aria-hidden")).toBe("true"); + }); + + it("description paragraph is present with descriptive text", () => { + render(); + const paras = document.querySelectorAll("p"); + expect(paras.length).toBeGreaterThan(0); + const text = Array.from(paras) + .map((p) => p.textContent) + .join(" "); + expect(text.toLowerCase()).toContain("runtime"); + }); + }); + + describe("props", () => { + it("renders with a short runtime name", () => { + render(); + const monoRuntime = document.querySelector(".font-mono"); + expect(monoRuntime?.textContent).toBe("ext"); + }); + + it("renders with a complex runtime name", () => { + render(); + const monoRuntime = document.querySelector(".font-mono"); + expect(monoRuntime?.textContent).toBe("gcp-cloud-functions-v2"); + }); + }); +}); diff --git a/canvas/src/components/tabs/__tests__/deriveProvidersFromModels.test.ts b/canvas/src/components/tabs/__tests__/deriveProvidersFromModels.test.ts new file mode 100644 index 00000000..4c1bd3ec --- /dev/null +++ b/canvas/src/components/tabs/__tests__/deriveProvidersFromModels.test.ts @@ -0,0 +1,100 @@ +// @vitest-environment jsdom +/** + * Tests for deriveProvidersFromModels — pure vendor-slug extractor from + * a model list used in ConfigTab.tsx. + * + * Takes ModelSpec[] and returns a deduplicated array of vendor strings. + * Vendor is derived by splitting on ":" (anthropic:claude-opus-4-7) or + * "/" (nousresearch/hermes-4-70b). Order is preserved from input. + */ +import { describe, expect, it } from "vitest"; +import { deriveProvidersFromModels } from "../ConfigTab"; + +// Local type mirror (not exported from ConfigTab) +interface ModelSpec { + id?: string; +} + +describe("deriveProvidersFromModels", () => { + it("returns empty array for empty input", () => { + expect(deriveProvidersFromModels([])).toEqual([]); + }); + + it("extracts vendor from colon-separated id", () => { + const models: ModelSpec[] = [{ id: "anthropic:claude-sonnet-4-5" }]; + expect(deriveProvidersFromModels(models)).toEqual(["anthropic"]); + }); + + it("extracts vendor from slash-separated id", () => { + const models: ModelSpec[] = [{ id: "nousresearch/hermes-4-70b" }]; + expect(deriveProvidersFromModels(models)).toEqual(["nousresearch"]); + }); + + it("deduplicates repeated vendors", () => { + const models: ModelSpec[] = [ + { id: "anthropic:claude-opus-4-7" }, + { id: "anthropic:claude-sonnet-4-5" }, + { id: "openai:gpt-4o" }, + ]; + expect(deriveProvidersFromModels(models)).toEqual(["anthropic", "openai"]); + }); + + it("skips models with no id", () => { + const models: ModelSpec[] = [ + { id: "anthropic:claude-sonnet-4-5" }, + {}, + { id: undefined }, + { id: "" }, + ]; + expect(deriveProvidersFromModels(models)).toEqual(["anthropic"]); + }); + + it("skips ids with no vendor separator", () => { + const models: ModelSpec[] = [ + { id: "claude-sonnet-4-5" }, + { id: "unknown/runtime" }, + ]; + expect(deriveProvidersFromModels(models)).toEqual(["unknown"]); + }); + + it("skips empty string id", () => { + const models: ModelSpec[] = [{ id: "" }]; + expect(deriveProvidersFromModels(models)).toEqual([]); + }); + + it("preserves first-occurrence order", () => { + const models: ModelSpec[] = [ + { id: "openai:gpt-4o" }, + { id: "anthropic:claude-opus-4-7" }, + { id: "anthropic:claude-sonnet-4-5" }, + { id: "google:gemini-2-5-flash" }, + ]; + expect(deriveProvidersFromModels(models)).toEqual([ + "openai", + "anthropic", + "google", + ]); + }); + + it("handles mix of valid and invalid ids", () => { + const models: ModelSpec[] = [ + {}, + { id: "openai:gpt-4o-mini" }, + { id: "" }, + { id: "no-separator" }, + { id: "anthropic:claude-opus-4-7" }, + ]; + expect(deriveProvidersFromModels(models)).toEqual(["openai", "anthropic"]); + }); + + it("is pure — same input always returns same output", () => { + const models: ModelSpec[] = [ + { id: "anthropic:claude-sonnet-4-5" }, + { id: "openai:gpt-4o" }, + { id: "google:gemini-2-5-flash" }, + ]; + for (let i = 0; i < 3; i++) { + expect(deriveProvidersFromModels(models)).toEqual(["anthropic", "openai", "google"]); + } + }); +}); diff --git a/canvas/src/components/tabs/__tests__/extractReplyText.test.ts b/canvas/src/components/tabs/__tests__/extractReplyText.test.ts new file mode 100644 index 00000000..cb69d9bc --- /dev/null +++ b/canvas/src/components/tabs/__tests__/extractReplyText.test.ts @@ -0,0 +1,135 @@ +// @vitest-environment jsdom +/** + * Tests for extractReplyText — the A2A result-path text extractor used + * in ChatTab.tsx. + * + * extractReplyText pulls the agent's text reply out of an A2A response. + * Concatenates ALL text parts (joined with "\n") rather than returning + * just the first. Claude Code and other runtimes commonly emit multi- + * part text replies for long content (markdown tables, code blocks), + * and the prior "first part wins" implementation silently truncated + * the rest. Mirrors extractTextsFromParts in message-parser.ts. + * + * Note: extractReplyText is scoped to the result.parts + result.artifacts + * path — unlike extractResponseText which also handles body.task / body.text / + * body.response_preview. It is the correct extractor for live A2A + * responses where the text lives on result. + */ +import { describe, expect, it } from "vitest"; +import { extractReplyText } from "../ChatTab"; + +describe("extractReplyText — A2A result path", () => { + it("returns empty string for undefined response", () => { + expect(extractReplyText(undefined as never)).toBe(""); + }); + + it("returns empty string for null result", () => { + expect(extractReplyText({ result: null as never })).toBe(""); + }); + + it("returns empty string when result has no parts or artifacts", () => { + expect(extractReplyText({ result: {} })).toBe(""); + }); + + it("returns empty string when parts array is empty", () => { + expect(extractReplyText({ result: { parts: [] } })).toBe(""); + }); + + it("extracts text from a single text part", () => { + expect( + extractReplyText({ result: { parts: [{ kind: "text", text: "Hello world" }] } }) + ).toBe("Hello world"); + }); + + it("concatenates multiple text parts with newlines (no truncation)", () => { + expect( + extractReplyText({ + result: { + parts: [ + { kind: "text", text: "# Header" }, + { kind: "text", text: "| Col |" }, + { kind: "text", text: "| --- |" }, + { kind: "text", text: "| Row |" }, + ], + }, + }) + ).toBe("# Header\n| Col |\n| --- |\n| Row |"); + }); + + it("skips non-text parts", () => { + expect( + extractReplyText({ + result: { + parts: [ + { kind: "image", text: "should be ignored" }, + { kind: "text", text: "visible" }, + { kind: "file", text: "also ignored" }, + ], + }, + }) + ).toBe("visible"); + }); + + it("skips text parts with empty string", () => { + expect(extractReplyText({ result: { parts: [{ kind: "text", text: "" }] } })).toBe(""); + }); + + it("skips parts with missing text field", () => { + expect(extractReplyText({ result: { parts: [{ kind: "text" }] } })).toBe(""); + }); + + it("walks artifacts and collects their text parts", () => { + expect( + extractReplyText({ + result: { + artifacts: [ + { parts: [{ kind: "text", text: "Artifact one" }] }, + { parts: [{ kind: "text", text: "Artifact two" }] }, + ], + }, + }) + ).toBe("Artifact one\nArtifact two"); + }); + + it("combines result.parts AND result.artifacts text (both sources)", () => { + expect( + extractReplyText({ + result: { + parts: [{ kind: "text", text: "Summary" }], + artifacts: [ + { parts: [{ kind: "text", text: "Detail block one" }] }, + { parts: [{ kind: "text", text: "Detail block two" }] }, + ], + }, + }) + ).toBe("Summary\nDetail block one\nDetail block two"); + }); + + it("artifacts are processed even when parts are empty", () => { + expect( + extractReplyText({ + result: { + parts: [], + artifacts: [{ parts: [{ kind: "text", text: "Only artifact" }] }], + }, + }) + ).toBe("Only artifact"); + }); + + it("artifacts with empty parts array contribute nothing", () => { + expect(extractReplyText({ result: { artifacts: [{ parts: [] }] } })).toBe(""); + }); + + it("multiple artifacts each contribute their text", () => { + expect( + extractReplyText({ + result: { + artifacts: [ + { parts: [{ kind: "text", text: "A" }, { kind: "text", text: "B" }] }, + { parts: [{ kind: "text", text: "C" }] }, + ], + }, + }) + ).toBe("A\nB\nC"); + }); +});