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..46e57874 --- /dev/null +++ b/canvas/src/components/tabs/FilesTab/__tests__/FilesTab.test.tsx @@ -0,0 +1,216 @@ +// @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); + }); +}); 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..317703ab --- /dev/null +++ b/canvas/src/components/tabs/__tests__/BudgetSection.test.tsx @@ -0,0 +1,323 @@ +// @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"); + 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"); + 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(); + + 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 () => { + 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 () => { + 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(); + + await vi.waitFor(() => { + expect(screen.queryByTestId("budget-loading")).toBeNull(); + }); + + const input = screen.getByTestId("budget-limit-input") as HTMLInputElement; + expect(input.value).toBe("10000"); + expect(screen.getByTestId("budget-limit-value")!.textContent).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(() => { + 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(); + }); + }); +});