diff --git a/.gitea/workflows/status-reaper.yml b/.gitea/workflows/status-reaper.yml index ed8f9049..4d992b4b 100644 --- a/.gitea/workflows/status-reaper.yml +++ b/.gitea/workflows/status-reaper.yml @@ -67,12 +67,13 @@ on: permissions: contents: read -# Single-flight: two reaper ticks racing would POST duplicate -# compensations. Idempotent at the API (Gitea overwrites by context -# on POST /statuses/{sha}) but cleaner to serialise. -concurrency: - group: status-reaper - cancel-in-progress: false +# NOTE: NO `concurrency:` block is intentional. +# Gitea 1.22.6 doesn't honor `cancel-in-progress: false`: queued ticks +# of the same group get cancelled-with-started=0 instead of waiting +# (DB-verified 2026-05-12, runs 16053/16085 of status-reaper.yml). +# The reaper's POST /statuses/{sha} is idempotent — Gitea de-dups by +# context — so concurrent ticks are safe; accept them rather than +# serialise via the broken mechanism. jobs: reap: diff --git a/canvas/src/components/tabs/DetailsTab.tsx b/canvas/src/components/tabs/DetailsTab.tsx index 8d659797..36d57850 100644 --- a/canvas/src/components/tabs/DetailsTab.tsx +++ b/canvas/src/components/tabs/DetailsTab.tsx @@ -402,7 +402,7 @@ function Row({ label, value, mono }: { label: string; value: string; mono?: bool ); } -function getSkills(card: Record | null): { id: string; description?: string }[] { +export function getSkills(card: Record | null): { id: string; description?: string }[] { if (!card) return []; const skills = card.skills; if (!Array.isArray(skills)) return []; diff --git a/canvas/src/components/tabs/FilesTab/__tests__/FilesTab.test.tsx b/canvas/src/components/tabs/FilesTab/__tests__/FilesTab.test.tsx new file mode 100644 index 00000000..751954e2 --- /dev/null +++ b/canvas/src/components/tabs/FilesTab/__tests__/FilesTab.test.tsx @@ -0,0 +1,224 @@ +// @vitest-environment jsdom +/** + * FilesTab: NotAvailablePanel + FilesToolbar coverage. + * + * NotAvailablePanel: pure presentational component — renders a "feature not + * available" placeholder for external-runtime workspaces. + * FilesToolbar: pure props-driven component — directory selector, file count, + * action buttons (New, Upload, Export, Clear, Refresh) with correct aria-labels. + * + * No @testing-library/jest-dom import — use textContent / className / + * getAttribute checks to avoid "expect is not defined" errors. + */ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import React from "react"; + +import { FilesToolbar } from "../FilesToolbar"; +import { NotAvailablePanel } from "../NotAvailablePanel"; + +// ─── afterEach ───────────────────────────────────────────────────────────────── + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +// ─── NotAvailablePanel ───────────────────────────────────────────────────────── + +describe("NotAvailablePanel", () => { + it("renders heading 'Files not available'", () => { + const { container } = render(); + expect(container.textContent).toContain("Files not available"); + }); + + it("renders the runtime name in monospace", () => { + const { container } = render(); + expect(container.textContent).toContain("external"); + const spans = container.querySelectorAll("span"); + const monoSpans = Array.from(spans).filter( + (s) => s.className && s.className.includes("font-mono"), + ); + expect(monoSpans.length).toBeGreaterThan(0); + }); + + it("renders a Chat tab hint in description", () => { + const { container } = render(); + expect(container.textContent).toContain("Chat tab"); + }); + + it("SVG icon has aria-hidden=true", () => { + const { container } = render(); + const svg = container.querySelector("svg"); + expect(svg?.getAttribute("aria-hidden")).toBe("true"); + }); + + it("renders without crashing for any runtime string", () => { + const { container } = render(); + expect(container.textContent).toContain("unknown-runtime"); + }); + + it("applies the correct layout classes to root div", () => { + const { container } = render(); + const root = container.firstElementChild as HTMLElement; + expect(root.className).toContain("flex"); + expect(root.className).toContain("flex-col"); + expect(root.className).toContain("items-center"); + }); +}); + +// ─── FilesToolbar ─────────────────────────────────────────────────────────────── + +describe("FilesToolbar", () => { + const noop = vi.fn(); + + function renderToolbar(props: Partial> = {}) { + return render( + , + ); + } + + it("renders the directory selector with correct aria-label", () => { + const { container } = renderToolbar(); + const select = container.querySelector("select"); + expect(select?.getAttribute("aria-label")).toBe("File root directory"); + }); + + it("directory selector has all four options", () => { + const { container } = renderToolbar(); + const select = container.querySelector("select") as HTMLSelectElement; + const options = Array.from(select?.options ?? []); + const values = options.map((o) => o.value); + expect(values).toContain("/configs"); + expect(values).toContain("/home"); + expect(values).toContain("/workspace"); + expect(values).toContain("/plugins"); + }); + + it("calls setRoot when directory changes", () => { + const setRoot = vi.fn(); + const { container } = renderToolbar({ setRoot }); + const select = container.querySelector("select") as HTMLSelectElement; + select.value = "/home"; + select.dispatchEvent(new Event("change", { bubbles: true })); + expect(setRoot).toHaveBeenCalledWith("/home"); + }); + + it("displays the file count", () => { + const { container } = renderToolbar({ fileCount: 42 }); + expect(container.textContent).toContain("42 files"); + }); + + it("shows New + Upload + Clear buttons for /configs", () => { + const { container } = renderToolbar({ root: "/configs" }); + const texts = Array.from(container.querySelectorAll("button")).map( + (b) => b.textContent?.trim(), + ); + expect(texts).toContain("+ New"); + expect(texts).toContain("Upload"); + expect(texts).toContain("Clear"); + expect(texts).toContain("Export"); + expect(texts).toContain("↻"); + }); + + it("hides New + Upload + Clear for /workspace", () => { + const { container } = renderToolbar({ root: "/workspace" }); + const texts = Array.from(container.querySelectorAll("button")).map( + (b) => b.textContent?.trim(), + ); + expect(texts).not.toContain("+ New"); + expect(texts).not.toContain("Upload"); + expect(texts).not.toContain("Clear"); + expect(texts).toContain("Export"); + }); + + it("hides New + Upload + Clear for /home", () => { + const { container } = renderToolbar({ root: "/home" }); + const texts = Array.from(container.querySelectorAll("button")).map( + (b) => b.textContent?.trim(), + ); + expect(texts).not.toContain("+ New"); + expect(texts).not.toContain("Upload"); + expect(texts).not.toContain("Clear"); + }); + + it("hides New + Upload + Clear for /plugins", () => { + const { container } = renderToolbar({ root: "/plugins" }); + const texts = Array.from(container.querySelectorAll("button")).map( + (b) => b.textContent?.trim(), + ); + expect(texts).not.toContain("+ New"); + expect(texts).not.toContain("Upload"); + expect(texts).not.toContain("Clear"); + }); + + it("New button has correct aria-label", () => { + const { container } = renderToolbar({ root: "/configs" }); + const newBtn = container.querySelector('button[aria-label="Create new file"]'); + expect(newBtn?.textContent?.trim()).toBe("+ New"); + }); + + it("Export button has correct aria-label", () => { + const { container } = renderToolbar(); + const exportBtn = container.querySelector('button[aria-label="Download all files"]'); + expect(exportBtn?.textContent?.trim()).toBe("Export"); + }); + + it("Clear button has correct aria-label", () => { + const { container } = renderToolbar({ root: "/configs" }); + const clearBtn = container.querySelector('button[aria-label="Delete all files"]'); + expect(clearBtn?.textContent?.trim()).toBe("Clear"); + }); + + it("Refresh button has correct aria-label", () => { + const { container } = renderToolbar(); + const refreshBtn = container.querySelector('button[aria-label="Refresh file list"]'); + expect(refreshBtn?.textContent?.trim()).toBe("↻"); + }); + + it("calls onNewFile when New button is clicked", () => { + const onNewFile = vi.fn(); + const { container } = renderToolbar({ root: "/configs", onNewFile }); + container.querySelector('button[aria-label="Create new file"]')!.click(); + expect(onNewFile).toHaveBeenCalledTimes(1); + }); + + it("calls onDownloadAll when Export button is clicked", () => { + const onDownloadAll = vi.fn(); + const { container } = renderToolbar({ onDownloadAll }); + container.querySelector('button[aria-label="Download all files"]')!.click(); + expect(onDownloadAll).toHaveBeenCalledTimes(1); + }); + + it("calls onClearAll when Clear button is clicked", () => { + const onClearAll = vi.fn(); + const { container } = renderToolbar({ root: "/configs", onClearAll }); + container.querySelector('button[aria-label="Delete all files"]')!.click(); + expect(onClearAll).toHaveBeenCalledTimes(1); + }); + + it("calls onRefresh when Refresh button is clicked", () => { + const onRefresh = vi.fn(); + const { container } = renderToolbar({ onRefresh }); + container.querySelector('button[aria-label="Refresh file list"]')!.click(); + expect(onRefresh).toHaveBeenCalledTimes(1); + }); + + it("applies focus-visible ring to all interactive buttons", () => { + const { container } = renderToolbar({ root: "/configs" }); + const buttons = container.querySelectorAll("button"); + for (const btn of buttons) { + expect(btn.className).toContain("focus-visible:ring-2"); + } + }); +}); diff --git a/canvas/src/components/tabs/SkillsTab.tsx b/canvas/src/components/tabs/SkillsTab.tsx index 563aff58..60097625 100644 --- a/canvas/src/components/tabs/SkillsTab.tsx +++ b/canvas/src/components/tabs/SkillsTab.tsx @@ -647,7 +647,7 @@ export function SkillsTab({ workspaceId, data }: Props) { ); } -function extractSkills(agentCard: Record | null): SkillEntry[] { +export function extractSkills(agentCard: Record | null): SkillEntry[] { if (!agentCard) return []; const rawSkills = agentCard.skills; if (!Array.isArray(rawSkills)) return []; diff --git a/canvas/src/components/tabs/__tests__/BudgetSection.test.tsx b/canvas/src/components/tabs/__tests__/BudgetSection.test.tsx new file mode 100644 index 00000000..7372ca0d --- /dev/null +++ b/canvas/src/components/tabs/__tests__/BudgetSection.test.tsx @@ -0,0 +1,330 @@ +// @vitest-environment jsdom +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { render, screen, cleanup, fireEvent } from "@testing-library/react"; +import React from "react"; +import { BudgetSection } from "../BudgetSection"; +import { api } from "@/lib/api"; + +// Queue-based mock for the api module. Each api call shifts from the queue. +// Tests push with qGet/qPatch and the module-level mockImplementation +// reads from the queue. +type QueueEntry = { body?: unknown; err?: Error }; +const apiQueue: QueueEntry[] = []; + +vi.mock("@/lib/api", () => ({ + api: { + get: vi.fn(async (path: string) => { + const next = apiQueue.shift(); + if (!next) throw new Error(`api.get queue exhausted at: ${path}`); + if (next.err) throw next.err; + return next.body; + }), + patch: vi.fn(async (path: string, _body?: unknown) => { + const next = apiQueue.shift(); + if (!next) throw new Error(`api.patch queue exhausted at: ${path}`); + if (next.err) throw next.err; + return next.body; + }), + }, +})); + +afterEach(cleanup); + +beforeEach(() => { + apiQueue.length = 0; + vi.clearAllMocks(); +}); + +const WS_ID = "budget-test-ws"; + +function qGet(body: unknown) { + apiQueue.push({ body }); +} + +function qGetErr(status: number, msg: string) { + apiQueue.push({ err: new Error(`${msg}: ${status}`) }); +} + +function qPatch(body: unknown) { + apiQueue.push({ body }); +} + +function qPatchErr(status: number, msg: string) { + apiQueue.push({ err: new Error(`${msg}: ${status}`) }); +} + +function makeBudget(overrides: Partial<{ + budget_limit: number | null; + budget_used: number; + budget_remaining: number | null; +}> = {}) { + return { + budget_limit: 10_000, + budget_used: 3_500, + budget_remaining: 6_500, + ...overrides, + }; +} + +describe("BudgetSection", () => { + describe("loading state", () => { + it("shows loading indicator while fetching", async () => { + let resolveGet: (v: unknown) => void; + vi.mocked(api.get).mockImplementationOnce( + async () => new Promise((r) => { resolveGet = r as (v: unknown) => void; }), + ); + + render(); + + expect(screen.getByTestId("budget-loading")).toBeTruthy(); + + // Resolve after render to verify state clears + resolveGet!(makeBudget()); + await vi.waitFor(() => { + expect(screen.queryByTestId("budget-loading")).toBeNull(); + }); + }); + }); + + describe("fetch error state", () => { + it("shows error message on non-402 fetch failure", async () => { + qGetErr(500, "Internal Server Error"); + + render(); + + await vi.waitFor(() => { + expect(screen.getByTestId("budget-fetch-error")).toBeTruthy(); + }); + expect(screen.getByTestId("budget-fetch-error")!.textContent).toContain("500"); + }); + + it("shows 402 as exceeded banner, not fetch error", async () => { + // 402 means the budget limit was hit — different UX from a network/API error. + qGetErr(402, "Payment Required"); + + render(); + + await vi.waitFor(() => { + expect(screen.getByTestId("budget-exceeded-banner")).toBeTruthy(); + }); + expect(screen.queryByTestId("budget-fetch-error")).toBeNull(); + }); + }); + + describe("budget loaded — display", () => { + it("renders used / limit stats row", async () => { + qGet(makeBudget({ budget_limit: 10_000, budget_used: 3_500 })); + + render(); + + await vi.waitFor(() => { + expect(screen.getByTestId("budget-used-value")!.textContent).toBe("3,500"); + }); + expect(screen.getByTestId("budget-limit-value")!.textContent).toBe("10,000"); + }); + + it("renders 'Unlimited' when budget_limit is null", async () => { + qGet(makeBudget({ budget_limit: null, budget_used: 1_000, budget_remaining: null })); + + render(); + + await vi.waitFor(() => { + expect(screen.getByTestId("budget-limit-value")!.textContent).toBe("Unlimited"); + }); + }); + + it("renders remaining credits when present", async () => { + qGet(makeBudget({ budget_limit: 10_000, budget_used: 3_500, budget_remaining: 6_500 })); + + render(); + + await vi.waitFor(() => { + expect(screen.getByTestId("budget-remaining")!.textContent).toContain("6,500"); + expect(screen.getByTestId("budget-remaining")!.textContent).toContain("credits remaining"); + }); + }); + + it("omits remaining credits when budget_remaining is null", async () => { + qGet(makeBudget({ budget_limit: 10_000, budget_used: 3_500, budget_remaining: null })); + + render(); + + await vi.waitFor(() => { + expect(screen.queryByTestId("budget-remaining")).toBeNull(); + }); + }); + + it("caps progress bar at 100% when used > limit", async () => { + // Over-limit: 12000 used of 10000 limit should show 100%, not 120%. + qGet(makeBudget({ budget_limit: 10_000, budget_used: 12_000, budget_remaining: null })); + + render(); + + await vi.waitFor(() => { + const fill = screen.getByTestId("budget-progress-fill"); + expect(fill.getAttribute("style")).toContain("100%"); + }); + }); + + it("omits progress bar when budget_limit is null (unlimited)", async () => { + qGet(makeBudget({ budget_limit: null, budget_used: 5_000, budget_remaining: null })); + + render(); + + await vi.waitFor(() => { + expect(screen.queryByTestId("budget-progress-fill")).toBeNull(); + }); + }); + }); + + describe("budget exceeded (402)", () => { + it("shows exceeded banner when load returns 402", async () => { + qGetErr(402, "Payment Required"); + + render(); + + await vi.waitFor(() => { + expect(screen.getByTestId("budget-exceeded-banner")).toBeTruthy(); + expect(screen.getByTestId("budget-exceeded-banner")!.textContent).toContain("Budget exceeded"); + }); + }); + + it("clears exceeded banner after successful save", async () => { + qGetErr(402, "Payment Required"); + qPatch(makeBudget({ budget_limit: 50_000, budget_used: 0, budget_remaining: 50_000 })); + + render(); + + await vi.waitFor(() => { + expect(screen.getByTestId("budget-exceeded-banner")).toBeTruthy(); + }); + + const input = screen.getByTestId("budget-limit-input"); + fireEvent.change(input, { target: { value: "50000" } }); + + const saveBtn = screen.getByTestId("budget-save-btn"); + fireEvent.click(saveBtn); + + await vi.waitFor(() => { + expect(screen.queryByTestId("budget-exceeded-banner")).toBeNull(); + }); + }); + }); + + describe("save flow", () => { + it("shows save error on non-402 patch failure", async () => { + qGet(makeBudget()); + qPatchErr(500, "Internal Server Error"); + + render(); + + await vi.waitFor(() => { + expect(screen.getByTestId("budget-limit-input")).toBeTruthy(); + }); + + const saveBtn = screen.getByTestId("budget-save-btn"); + fireEvent.click(saveBtn); + + await vi.waitFor(() => { + expect(screen.getByTestId("budget-save-error")).toBeTruthy(); + expect(screen.getByTestId("budget-save-error")!.textContent).toContain("500"); + }); + }); + + it("updates input to new limit value after successful save", async () => { + qGet(makeBudget({ budget_limit: 10_000 })); + qPatch(makeBudget({ budget_limit: 20_000 })); + + render(); + + // Wait for the input to appear (loading → loaded) + await vi.waitFor(() => { + expect(screen.queryByTestId("budget-loading")).toBeNull(); + }); + + const input = screen.getByTestId("budget-limit-input") as HTMLInputElement; + // Debug: check what values are rendered + const limitValue = screen.getByTestId("budget-limit-value")?.textContent; + expect(input.value).toBe("10000"); // initial value from API + expect(limitValue).toBe("10,000"); + + fireEvent.change(input, { target: { value: "20000" } }); + expect(input.value).toBe("20000"); + + fireEvent.click(screen.getByTestId("budget-save-btn")); + + await vi.waitFor(() => { + expect((screen.getByTestId("budget-limit-input") as HTMLInputElement).value).toBe("20000"); + }); + }); + + it("sends null when input is cleared (unlimited)", async () => { + qGet(makeBudget({ budget_limit: 10_000 })); + qPatch(makeBudget({ budget_limit: null })); + + render(); + + await vi.waitFor(() => { + expect(screen.getByTestId("budget-limit-input")).toBeTruthy(); + }); + + const input = screen.getByTestId("budget-limit-input") as HTMLInputElement; + fireEvent.change(input, { target: { value: "" } }); + fireEvent.click(screen.getByTestId("budget-save-btn")); + + await vi.waitFor(() => { + // After save with null limit, input should show empty (unlimited) + expect(input.value).toBe(""); + }); + }); + + it("shows saving state on button while patch is in flight", async () => { + qGet(makeBudget()); + let resolvePatch: (v: unknown) => void; + vi.mocked(api.patch).mockImplementationOnce( + async () => new Promise((r) => { resolvePatch = r as (v: unknown) => void; }), + ); + + render(); + + await vi.waitFor(() => { + expect(screen.getByTestId("budget-limit-input")).toBeTruthy(); + }); + + fireEvent.change(screen.getByTestId("budget-limit-input"), { target: { value: "50000" } }); + fireEvent.click(screen.getByTestId("budget-save-btn")); + + const btn = screen.getByTestId("budget-save-btn"); + expect(btn.textContent).toContain("Saving"); + + resolvePatch!(makeBudget({ budget_limit: 50_000 })); + await vi.waitFor(() => { + expect(btn.textContent).toContain("Save"); + }); + }); + }); + + describe("isApiError402 — regression coverage", () => { + it("classifies ': 402' with space as 402", async () => { + qGetErr(402, "Payment Required"); + qPatch(makeBudget()); + + render(); + + await vi.waitFor(() => { + expect(screen.getByTestId("budget-exceeded-banner")).toBeTruthy(); + }); + }); + + it("classifies non-402 error messages as regular fetch errors", async () => { + qGetErr(503, "Service Unavailable"); + + render(); + + await vi.waitFor(() => { + expect(screen.getByTestId("budget-fetch-error")).toBeTruthy(); + }); + expect(screen.queryByTestId("budget-exceeded-banner")).toBeNull(); + }); + }); +}); diff --git a/canvas/src/components/tabs/__tests__/extractSkills.test.ts b/canvas/src/components/tabs/__tests__/extractSkills.test.ts new file mode 100644 index 00000000..3e9d203d --- /dev/null +++ b/canvas/src/components/tabs/__tests__/extractSkills.test.ts @@ -0,0 +1,140 @@ +// @vitest-environment jsdom +/** + * Unit tests for extractSkills — pure helper from SkillsTab. + * + * Covers: null card, non-array skills, empty skills, full skill entries + * (id, name, description, tags, examples), id-only fallback, name-only + * fallback, string coercion, array coercion for tags/examples, + * filtering entries with no id after coercion, empty string id (filtered). + */ +import { describe, it, expect } from "vitest"; +import { extractSkills } from "../SkillsTab"; + +describe("extractSkills", () => { + it("returns [] for null card", () => { + expect(extractSkills(null)).toEqual([]); + }); + + it("returns [] when card.skills is not an array", () => { + expect(extractSkills({ skills: undefined })).toEqual([]); + expect(extractSkills({ skills: "not-an-array" })).toEqual([]); + expect(extractSkills({ skills: { id: "x" } })).toEqual([]); + }); + + it("returns [] for empty skills array", () => { + expect(extractSkills({ skills: [] })).toEqual([]); + }); + + it("maps a fully-populated skill entry", () => { + const card = { + skills: [ + { + id: "code_search", + name: "Code Search", + description: "Semantic code search", + tags: ["search", "code"], + examples: ["Find unused exports", "Search by AST pattern"], + }, + ], + }; + expect(extractSkills(card)).toEqual([ + { + id: "code_search", + name: "Code Search", + description: "Semantic code search", + tags: ["search", "code"], + examples: ["Find unused exports", "Search by AST pattern"], + }, + ]); + }); + + it("uses name as id when id is absent", () => { + const card = { skills: [{ name: "web_scraper" }] }; + expect(extractSkills(card)).toEqual([ + { id: "web_scraper", name: "web_scraper", description: "", tags: [], examples: [] }, + ]); + }); + + it("uses id as name when name is absent", () => { + const card = { skills: [{ id: "legacy_skill" }] }; + expect(extractSkills(card)).toEqual([ + { id: "legacy_skill", name: "legacy_skill", description: "", tags: [], examples: [] }, + ]); + }); + + it("filters out entries with neither id nor name", () => { + // id: String(undefined || undefined || "") → "" → filtered (id.length = 0) + const card = { skills: [{ description: "orphan entry" }] }; + expect(extractSkills(card)).toEqual([]); + }); + + it("filters out entries with no id after string coercion", () => { + // id resolves to "" after String(undefined || null || {}) + const card = { skills: [{ id: null, name: null }] }; + expect(extractSkills(card)).toEqual([]); + }); + + it("filters out entries with empty-string id", () => { + const card = { skills: [{ id: "", name: "" }] }; + expect(extractSkills(card)).toEqual([]); + }); + + it("coerces numeric tags to strings", () => { + const card = { skills: [{ id: "x", tags: [1, "two", 3] }] }; + expect(extractSkills(card)).toEqual([ + { id: "x", name: "x", description: "", tags: ["1", "two", "3"], examples: [] }, + ]); + }); + + it("coerces non-array tags to empty array", () => { + const card = { skills: [{ id: "x", tags: "not-an-array" }] }; + expect(extractSkills(card)).toEqual([ + { id: "x", name: "x", description: "", tags: [], examples: [] }, + ]); + }); + + it("coerces non-array examples to empty array", () => { + const card = { skills: [{ id: "x", examples: 42 }] }; + expect(extractSkills(card)).toEqual([ + { id: "x", name: "x", description: "", tags: [], examples: [] }, + ]); + }); + + // NOTE: extractSkills uses `String(skill.description || "")` — falsy values + // (0, null, false) fall through to "", NOT to their string form. + it("returns '' for falsy description values (0, null, false)", () => { + const card = { skills: [{ id: "x", description: 0 }] }; + expect(extractSkills(card)).toEqual([ + { id: "x", name: "x", description: "", tags: [], examples: [] }, + ]); + }); + + it("handles mixed valid/invalid entries", () => { + const card = { + skills: [ + { id: "valid_one", name: "One" }, + { name: "named_only" }, + { description: "orphan" }, // filtered — id becomes "" + { id: "valid_two", examples: ["a", "b"] }, + ], + }; + expect(extractSkills(card)).toEqual([ + { id: "valid_one", name: "One", description: "", tags: [], examples: [] }, + { id: "named_only", name: "named_only", description: "", tags: [], examples: [] }, + { id: "valid_two", name: "valid_two", description: "", tags: [], examples: ["a", "b"] }, + ]); + }); + + it("handles a realistic agent card with multiple skills", () => { + const card = { + skills: [ + { id: "web_search", name: "Web Search", description: "Search the web", tags: ["search"], examples: ["Latest news"] }, + { id: "file_read", name: "Read Files", description: "Read from disk", tags: ["io"], examples: [] }, + ], + }; + const result = extractSkills(card); + expect(result).toHaveLength(2); + expect(result[0].id).toBe("web_search"); + expect(result[1].tags).toEqual(["io"]); + }); +}); diff --git a/canvas/src/components/tabs/__tests__/getSkills.test.ts b/canvas/src/components/tabs/__tests__/getSkills.test.ts new file mode 100644 index 00000000..8b27b2bf --- /dev/null +++ b/canvas/src/components/tabs/__tests__/getSkills.test.ts @@ -0,0 +1,95 @@ +// @vitest-environment jsdom +/** + * Unit tests for getSkills — pure helper from DetailsTab. + * + * Covers: null card, non-array skills, empty skills, id-only entries, + * name-only entries (id derives from name), entries with description, + * entries with neither id nor name (filtered out), mixed entries. + */ +import { describe, it, expect } from "vitest"; +import { getSkills } from "../DetailsTab"; + +describe("getSkills", () => { + it("returns [] for null card", () => { + expect(getSkills(null)).toEqual([]); + }); + + it("returns [] when card.skills is not an array", () => { + expect(getSkills({ skills: undefined })).toEqual([]); + expect(getSkills({ skills: "not-an-array" })).toEqual([]); + expect(getSkills({ skills: { id: "x" } })).toEqual([]); + }); + + it("returns [] for empty skills array", () => { + expect(getSkills({ skills: [] })).toEqual([]); + }); + + it("maps skill with id and description", () => { + const card = { skills: [{ id: "code_search", description: "Find code patterns" }] }; + expect(getSkills(card)).toEqual([{ id: "code_search", description: "Find code patterns" }]); + }); + + it("maps skill with id only (description absent)", () => { + const card = { skills: [{ id: "code_search" }] }; + expect(getSkills(card)).toEqual([{ id: "code_search", description: undefined }]); + }); + + it("derives id from name when id is absent", () => { + const card = { skills: [{ name: "web_scraper" }] }; + expect(getSkills(card)).toEqual([{ id: "web_scraper" }]); + }); + + it("maps description when present", () => { + const card = { skills: [{ id: "file_write", description: "Writes files to disk" }] }; + expect(getSkills(card)).toEqual([{ id: "file_write", description: "Writes files to disk" }]); + }); + + it("returns description as undefined when skill has no description", () => { + const card = { skills: [{ id: "noop_skill" }] }; + const result = getSkills(card); + // The map always includes description; it's undefined when absent + expect(result).toEqual([{ id: "noop_skill", description: undefined }]); + }); + + it("filters out skills with neither id nor name", () => { + // id: String(undefined || undefined || "") → "" → filtered + const card = { skills: [{ description: "loner" }] }; + expect(getSkills(card)).toEqual([]); + }); + + it("handles mixed valid/invalid entries", () => { + const card = { + skills: [ + { id: "valid_one" }, + { name: "named_skill" }, + { description: "orphaned" }, // filtered + { id: "valid_two", description: "Has both" }, + ], + }; + expect(getSkills(card)).toEqual([ + { id: "valid_one", description: undefined }, + { id: "named_skill", description: undefined }, + { id: "valid_two", description: "Has both" }, + ]); + }); + + it("handles string coercion for numeric ids/names", () => { + const card = { skills: [{ id: 42, name: "numeric_id" }] }; + expect(getSkills(card)).toEqual([{ id: "42" }]); + }); + + it("uses id over name when both are present", () => { + const card = { skills: [{ id: "priority_id", name: "fallback_name" }] }; + expect(getSkills(card)).toEqual([{ id: "priority_id", description: undefined }]); + }); + + it("omits description when it is falsy (0 is falsy in JS)", () => { + // The implementation uses `s.description ?` — 0 is falsy, so it's treated + // as absent and undefined is returned. Non-zero numbers coerce fine. + const cardZero = { skills: [{ id: "x", description: 0 }] }; + expect(getSkills(cardZero)).toEqual([{ id: "x", description: undefined }]); + + const cardNum = { skills: [{ id: "x", description: 42 }] }; + expect(getSkills(cardNum)).toEqual([{ id: "x", description: "42" }]); + }); +}); diff --git a/canvas/src/components/tabs/chat/__tests__/AttachmentAudio.test.tsx b/canvas/src/components/tabs/chat/__tests__/AttachmentAudio.test.tsx new file mode 100644 index 00000000..bd8c946e --- /dev/null +++ b/canvas/src/components/tabs/chat/__tests__/AttachmentAudio.test.tsx @@ -0,0 +1,275 @@ +// @vitest-environment jsdom +/** + * AttachmentAudio — inline native HTML5