diff --git a/canvas/src/components/__tests__/BudgetLimit.DetailsTab.test.tsx b/canvas/src/components/__tests__/BudgetLimit.DetailsTab.test.tsx index a9515374..6709a996 100644 --- a/canvas/src/components/__tests__/BudgetLimit.DetailsTab.test.tsx +++ b/canvas/src/components/__tests__/BudgetLimit.DetailsTab.test.tsx @@ -44,6 +44,15 @@ vi.mock("../tabs/BudgetSection", () => ({ ), })); +// Mock WorkspaceUsage — it has its own test suite (WorkspaceUsage.test.tsx). +// Without this mock its internal api.get call races against the shared mock +// and crashes when the return value is not a valid WorkspaceMetrics object. +vi.mock("../WorkspaceUsage", () => ({ + WorkspaceUsage: ({ workspaceId }: { workspaceId: string }) => ( +
+ ), +})); + import { api } from "@/lib/api"; import { DetailsTab } from "../tabs/DetailsTab"; diff --git a/canvas/src/components/__tests__/ClaudeSettings.test.tsx b/canvas/src/components/__tests__/ClaudeSettings.test.tsx new file mode 100644 index 00000000..b74ab085 --- /dev/null +++ b/canvas/src/components/__tests__/ClaudeSettings.test.tsx @@ -0,0 +1,230 @@ +// @vitest-environment jsdom +/** + * Tests for issue #608 — effort + task_budget fields in workspace config. + * + * Covers: + * 1. toYaml serialization (effort + task_budget → YAML keys) + * 2. parseYaml round-trip (YAML → ConfigData) + * 3. DEFAULT_CONFIG shape (new fields present with zero/empty defaults) + * 4. ConfigTab source assertions (section rendered conditionally) + * 5. React rendering of the section for claude-code and claude model configs + */ +import React from "react"; +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, screen, cleanup } from "@testing-library/react"; + +// ── Module-level mocks ─────────────────────────────────────────────────────── + +vi.mock("@/lib/api", () => ({ + api: { get: vi.fn(), put: vi.fn(), patch: vi.fn(), post: vi.fn() }, +})); + +vi.mock("@/store/canvas", () => ({ + useCanvasStore: vi.fn(() => ({ + restartWorkspace: vi.fn(), + updateNodeData: vi.fn(), + })), +})); + +vi.mock("../tabs/config/secrets-section", () => ({ + SecretsSection: () => , +})); + +// ── Imports ────────────────────────────────────────────────────────────────── + +import { toYaml, parseYaml } from "../tabs/config/yaml-utils"; +import { DEFAULT_CONFIG, type ConfigData } from "../tabs/config/form-inputs"; +import { ConfigTab } from "../tabs/ConfigTab"; +import { api } from "@/lib/api"; + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +// ── 1. toYaml serialization ────────────────────────────────────────────────── + +describe("toYaml — effort field", () => { + it("omits effort when empty string", () => { + const cfg: ConfigData = { ...DEFAULT_CONFIG, effort: "" }; + expect(toYaml(cfg)).not.toContain("effort:"); + }); + + it("omits effort when undefined", () => { + const cfg: ConfigData = { ...DEFAULT_CONFIG, effort: undefined }; + expect(toYaml(cfg)).not.toContain("effort:"); + }); + + it("serializes effort: low", () => { + const cfg: ConfigData = { ...DEFAULT_CONFIG, effort: "low" }; + const yaml = toYaml(cfg); + expect(yaml).toContain("effort: low"); + }); + + it("serializes effort: medium", () => { + const cfg: ConfigData = { ...DEFAULT_CONFIG, effort: "medium" }; + expect(toYaml(cfg)).toContain("effort: medium"); + }); + + it("serializes effort: high", () => { + const cfg: ConfigData = { ...DEFAULT_CONFIG, effort: "high" }; + expect(toYaml(cfg)).toContain("effort: high"); + }); + + it("serializes effort: xhigh", () => { + const cfg: ConfigData = { ...DEFAULT_CONFIG, effort: "xhigh" }; + expect(toYaml(cfg)).toContain("effort: xhigh"); + }); +}); + +describe("toYaml — task_budget field", () => { + it("omits task_budget when 0", () => { + const cfg: ConfigData = { ...DEFAULT_CONFIG, task_budget: 0 }; + expect(toYaml(cfg)).not.toContain("task_budget:"); + }); + + it("omits task_budget when undefined", () => { + const cfg: ConfigData = { ...DEFAULT_CONFIG, task_budget: undefined }; + expect(toYaml(cfg)).not.toContain("task_budget:"); + }); + + it("serializes task_budget: 10000", () => { + const cfg: ConfigData = { ...DEFAULT_CONFIG, task_budget: 10000 }; + expect(toYaml(cfg)).toContain("task_budget: 10000"); + }); + + it("serializes task_budget: 50000", () => { + const cfg: ConfigData = { ...DEFAULT_CONFIG, task_budget: 50000 }; + expect(toYaml(cfg)).toContain("task_budget: 50000"); + }); +}); + +describe("toYaml — effort and task_budget together", () => { + it("serializes both when set", () => { + const cfg: ConfigData = { ...DEFAULT_CONFIG, effort: "xhigh", task_budget: 32000 }; + const yaml = toYaml(cfg); + expect(yaml).toContain("effort: xhigh"); + expect(yaml).toContain("task_budget: 32000"); + }); + + it("effort appears before task_budget in output", () => { + const cfg: ConfigData = { ...DEFAULT_CONFIG, effort: "high", task_budget: 8000 }; + const yaml = toYaml(cfg); + const effortIdx = yaml.indexOf("effort:"); + const budgetIdx = yaml.indexOf("task_budget:"); + expect(effortIdx).toBeGreaterThan(-1); + expect(budgetIdx).toBeGreaterThan(-1); + expect(effortIdx).toBeLessThan(budgetIdx); + }); +}); + +// ── 2. parseYaml round-trip ────────────────────────────────────────────────── + +describe("parseYaml — effort + task_budget round-trip", () => { + it("parses effort from YAML", () => { + const yaml = "name: Test\neffort: high\n"; + const parsed = parseYaml(yaml); + expect(parsed.effort).toBe("high"); + }); + + it("parses task_budget from YAML as integer", () => { + const yaml = "name: Test\ntask_budget: 16000\n"; + const parsed = parseYaml(yaml); + expect(parsed.task_budget).toBe(16000); + }); + + it("round-trips effort: xhigh through toYaml → parseYaml", () => { + const cfg: ConfigData = { ...DEFAULT_CONFIG, effort: "xhigh" }; + const yaml = toYaml(cfg); + const parsed = parseYaml(yaml); + expect(parsed.effort).toBe("xhigh"); + }); + + it("round-trips task_budget: 50000 through toYaml → parseYaml", () => { + const cfg: ConfigData = { ...DEFAULT_CONFIG, task_budget: 50000 }; + const yaml = toYaml(cfg); + const parsed = parseYaml(yaml); + expect(parsed.task_budget).toBe(50000); + }); + + it("round-trips both fields together", () => { + const cfg: ConfigData = { ...DEFAULT_CONFIG, effort: "low", task_budget: 1000 }; + const yaml = toYaml(cfg); + const parsed = parseYaml(yaml); + expect(parsed.effort).toBe("low"); + expect(parsed.task_budget).toBe(1000); + }); +}); + +// ── 3. DEFAULT_CONFIG shape ────────────────────────────────────────────────── + +describe("DEFAULT_CONFIG", () => { + it("has effort defaulting to empty string", () => { + expect(DEFAULT_CONFIG.effort).toBe(""); + }); + + it("has task_budget defaulting to 0", () => { + expect(DEFAULT_CONFIG.task_budget).toBe(0); + }); +}); + +// ── 4. ConfigTab source assertions ────────────────────────────────────────── + +describe("ConfigTab source — Claude Settings section", () => { + it("ConfigTab.tsx contains the effort-select data-testid", async () => { + const { readFileSync } = await import("fs"); + const { join } = await import("path"); + const src = readFileSync(join(__dirname, "../../components/tabs/ConfigTab.tsx"), "utf8"); + expect(src).toContain('data-testid="effort-select"'); + expect(src).toContain('data-testid="task-budget-input"'); + }); + + it("ConfigTab.tsx effort dropdown has all four Claude values", async () => { + const { readFileSync } = await import("fs"); + const { join } = await import("path"); + const src = readFileSync(join(__dirname, "../../components/tabs/ConfigTab.tsx"), "utf8"); + expect(src).toContain('"low"'); + expect(src).toContain('"medium"'); + expect(src).toContain('"high"'); + expect(src).toContain('"xhigh"'); + }); + + it("ConfigTab.tsx section is guarded by claude-code runtime check", async () => { + const { readFileSync } = await import("fs"); + const { join } = await import("path"); + const src = readFileSync(join(__dirname, "../../components/tabs/ConfigTab.tsx"), "utf8"); + expect(src).toContain('config.runtime === "claude-code"'); + expect(src).toContain('"claude"'); + }); +}); + +// ── 5. React rendering ─────────────────────────────────────────────────────── + +describe("ConfigTab — Claude Settings section rendering", () => { + function setupMock(configYaml: string) { + vi.mocked(api.get).mockResolvedValue({ content: configYaml } as never); + } + + it("shows Claude Settings section for claude-code runtime", async () => { + setupMock("name: Bot\nruntime: claude-code\n"); + render(