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();
+ });
+ });
+});