// @vitest-environment jsdom import React from "react"; import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; import { render, screen, waitFor, act } from "@testing-library/react"; import { extFromFilename, monacoLangFromFilename, displayLang, MonacoCodeEditor, } from "./code-editor"; // --------------------------------------------------------------------------- // Mock infrastructure // --------------------------------------------------------------------------- type ContentChangeHandler = () => void; type CursorChangeHandler = (e: { position: { lineNumber: number; column: number } }) => void; let lastMockEditor: { getValue: ReturnType; addCommand: ReturnType; onDidChangeCursorPosition: ReturnType; onDidChangeModelContent: ReturnType; focus: ReturnType; _value: string; _contentChangeHandler: ContentChangeHandler | null; _cursorChangeHandler: CursorChangeHandler | null; } | null = null; let lastMockMonaco: { editor: { defineTheme: ReturnType; setTheme: ReturnType; }; KeyMod: { CtrlCmd: number }; KeyCode: { KeyS: number }; } | null = null; function createMockEditor(defaultValue: string) { const editor = { _value: defaultValue, _contentChangeHandler: null as ContentChangeHandler | null, _cursorChangeHandler: null as CursorChangeHandler | null, getValue: vi.fn(() => editor._value), addCommand: vi.fn(), onDidChangeCursorPosition: vi.fn((handler: CursorChangeHandler) => { editor._cursorChangeHandler = handler; }), onDidChangeModelContent: vi.fn((handler: ContentChangeHandler) => { editor._contentChangeHandler = handler; }), focus: vi.fn(), }; return editor; } function createMockMonaco() { return { editor: { defineTheme: vi.fn(), setTheme: vi.fn(), }, KeyMod: { CtrlCmd: 2048 }, KeyCode: { KeyS: 49 }, }; } vi.mock("@monaco-editor/react", () => ({ default: function MockEditor(props: Record) { const mountedRef = React.useRef(false); React.useEffect(() => { if (mountedRef.current) {return;} mountedRef.current = true; if (typeof props.onMount === "function") { const ed = createMockEditor(props.defaultValue as string); const monaco = createMockMonaco(); lastMockEditor = ed; lastMockMonaco = monaco; props.onMount(ed, monaco); } }, []); return (
); }, })); vi.mock("../diff-viewer", () => ({ DiffCard: ({ diff }: { diff: string }) => (
{diff.slice(0, 50)}
), })); // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function jsonResponse(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" }, }); } function simulateContentChange(newValue: string) { if (!lastMockEditor) {throw new Error("Editor not mounted");} lastMockEditor._value = newValue; lastMockEditor.getValue.mockReturnValue(newValue); act(() => { lastMockEditor!._contentChangeHandler?.(); }); } function simulateCursorMove(line: number, col: number) { if (!lastMockEditor) {throw new Error("Editor not mounted");} act(() => { lastMockEditor!._cursorChangeHandler?.({ position: { lineNumber: line, column: col } }); }); } // --------------------------------------------------------------------------- // extFromFilename — extension extraction with special-filename handling // --------------------------------------------------------------------------- describe("extFromFilename", () => { it("extracts standard dot-separated extensions (basic routing correctness)", () => { expect(extFromFilename("app.ts")).toBe("ts"); expect(extFromFilename("style.css")).toBe("css"); expect(extFromFilename("data.json")).toBe("json"); expect(extFromFilename("README.md")).toBe("md"); }); it("handles multi-dot filenames by taking only the last segment (prevents wrong language)", () => { expect(extFromFilename("package.config.json")).toBe("json"); expect(extFromFilename("app.module.ts")).toBe("ts"); expect(extFromFilename("my.page.test.tsx")).toBe("tsx"); }); it("is case-insensitive (prevents language mismatch on case-variant filenames)", () => { expect(extFromFilename("App.TS")).toBe("ts"); expect(extFromFilename("STYLE.CSS")).toBe("css"); expect(extFromFilename("Data.JSON")).toBe("json"); }); it("recognizes Dockerfile variants regardless of case (special-filename routing)", () => { expect(extFromFilename("Dockerfile")).toBe("dockerfile"); expect(extFromFilename("dockerfile")).toBe("dockerfile"); expect(extFromFilename("DOCKERFILE")).toBe("dockerfile"); expect(extFromFilename("Dockerfile.dev")).toBe("dockerfile"); expect(extFromFilename("Dockerfile.prod")).toBe("dockerfile"); expect(extFromFilename("dockerfile.test")).toBe("dockerfile"); }); it("recognizes Makefile variants regardless of case (special-filename routing)", () => { expect(extFromFilename("Makefile")).toBe("makefile"); expect(extFromFilename("makefile")).toBe("makefile"); expect(extFromFilename("GNUmakefile")).toBe("makefile"); expect(extFromFilename("gnumakefile")).toBe("makefile"); }); it("recognizes CMakeLists.txt regardless of case (special-filename routing)", () => { expect(extFromFilename("CMakeLists.txt")).toBe("cmake"); expect(extFromFilename("cmakelists.txt")).toBe("cmake"); }); it("returns empty string for extensionless files (falls back to plaintext)", () => { expect(extFromFilename("LICENSE")).toBe("license"); expect(extFromFilename("CHANGELOG")).toBe("changelog"); }); it("returns empty string for empty input (no crash on degenerate input)", () => { expect(extFromFilename("")).toBe(""); }); it("does not treat path separators as extensions (prevents path-based misrouting)", () => { expect(extFromFilename("src/components/App.tsx")).toBe("tsx"); expect(extFromFilename("deeply/nested/path/file.py")).toBe("py"); }); it("does not misidentify files that merely contain 'dockerfile' in the name", () => { expect(extFromFilename("not-a-dockerfile.txt")).toBe("txt"); }); }); // --------------------------------------------------------------------------- // monacoLangFromFilename — maps filename → Monaco language ID // --------------------------------------------------------------------------- describe("monacoLangFromFilename", () => { it("maps common web languages correctly (editor syntax highlighting)", () => { expect(monacoLangFromFilename("app.ts")).toBe("typescript"); expect(monacoLangFromFilename("app.tsx")).toBe("typescript"); expect(monacoLangFromFilename("script.js")).toBe("javascript"); expect(monacoLangFromFilename("script.jsx")).toBe("javascript"); expect(monacoLangFromFilename("style.css")).toBe("css"); expect(monacoLangFromFilename("page.html")).toBe("html"); }); it("maps systems/backend languages correctly", () => { expect(monacoLangFromFilename("main.py")).toBe("python"); expect(monacoLangFromFilename("main.go")).toBe("go"); expect(monacoLangFromFilename("main.rs")).toBe("rust"); expect(monacoLangFromFilename("Main.java")).toBe("java"); expect(monacoLangFromFilename("main.c")).toBe("c"); expect(monacoLangFromFilename("main.cpp")).toBe("cpp"); expect(monacoLangFromFilename("Program.cs")).toBe("csharp"); expect(monacoLangFromFilename("main.rb")).toBe("ruby"); expect(monacoLangFromFilename("main.swift")).toBe("swift"); expect(monacoLangFromFilename("main.kt")).toBe("kotlin"); }); it("maps shell variants to 'shell' (unified shell highlighting)", () => { expect(monacoLangFromFilename("script.sh")).toBe("shell"); expect(monacoLangFromFilename("script.bash")).toBe("shell"); expect(monacoLangFromFilename("script.zsh")).toBe("shell"); expect(monacoLangFromFilename("config.fish")).toBe("shell"); }); it("maps CJS/MJS variants to javascript (prevents plaintext fallback)", () => { expect(monacoLangFromFilename("server.mjs")).toBe("javascript"); expect(monacoLangFromFilename("config.cjs")).toBe("javascript"); }); it("maps header files to the correct base language", () => { expect(monacoLangFromFilename("header.h")).toBe("c"); expect(monacoLangFromFilename("header.hpp")).toBe("cpp"); }); it("maps config formats correctly", () => { expect(monacoLangFromFilename("config.yaml")).toBe("yaml"); expect(monacoLangFromFilename("config.yml")).toBe("yaml"); expect(monacoLangFromFilename("data.json")).toBe("json"); expect(monacoLangFromFilename("tsconfig.jsonc")).toBe("json"); expect(monacoLangFromFilename("config.ini")).toBe("ini"); expect(monacoLangFromFilename(".env")).toBe("ini"); }); it("maps template languages to html as fallback (some highlighting > none)", () => { expect(monacoLangFromFilename("App.vue")).toBe("html"); expect(monacoLangFromFilename("Page.svelte")).toBe("html"); }); it("falls back to plaintext for unsupported extensions (no crash on unknown files)", () => { expect(monacoLangFromFilename("data.toml")).toBe("plaintext"); expect(monacoLangFromFilename("main.zig")).toBe("plaintext"); expect(monacoLangFromFilename("app.tf")).toBe("plaintext"); expect(monacoLangFromFilename("unknown.xyz")).toBe("plaintext"); }); it("falls back to plaintext for extensionless files", () => { expect(monacoLangFromFilename("LICENSE")).toBe("plaintext"); expect(monacoLangFromFilename("CHANGELOG")).toBe("plaintext"); }); it("special filenames map through their virtual extension", () => { expect(monacoLangFromFilename("Dockerfile")).toBe("dockerfile"); expect(monacoLangFromFilename("Makefile")).toBe("plaintext"); expect(monacoLangFromFilename("CMakeLists.txt")).toBe("plaintext"); }); }); // --------------------------------------------------------------------------- // displayLang — determines what label is shown in the header badge // --------------------------------------------------------------------------- describe("displayLang", () => { it("shows the language name for recognized languages", () => { expect(displayLang("app.ts")).toBe("typescript"); expect(displayLang("main.py")).toBe("python"); expect(displayLang("style.css")).toBe("css"); expect(displayLang("query.sql")).toBe("sql"); }); it("shows the raw extension for plaintext-mapped languages (user can still identify the file type)", () => { expect(displayLang("data.toml")).toBe("toml"); expect(displayLang("main.zig")).toBe("zig"); expect(displayLang("deploy.tf")).toBe("tf"); }); it("shows TEXT for files with no extension at all (graceful fallback)", () => { expect(displayLang("LICENSE")).toBe("license"); // empty filename edge case expect(displayLang("")).toBe("TEXT"); }); it("shows the special filename as extension when it maps to plaintext", () => { expect(displayLang("Makefile")).toBe("makefile"); expect(displayLang("CMakeLists.txt")).toBe("cmake"); }); it("shows 'dockerfile' for Dockerfile (which has a real Monaco language)", () => { expect(displayLang("Dockerfile")).toBe("dockerfile"); }); }); // --------------------------------------------------------------------------- // MonacoCodeEditor — component routing (diff/patch vs editor) // --------------------------------------------------------------------------- describe("MonacoCodeEditor routing", () => { beforeEach(() => { lastMockEditor = null; lastMockMonaco = null; }); it("routes .diff files to DiffCard instead of the editor (prevents rendering diffs as raw text)", () => { render(); expect(screen.getByTestId("diff-card")).toBeInTheDocument(); expect(screen.queryByTestId("monaco-editor")).not.toBeInTheDocument(); }); it("routes .patch files to DiffCard instead of the editor", () => { render(); expect(screen.getByTestId("diff-card")).toBeInTheDocument(); expect(screen.queryByTestId("monaco-editor")).not.toBeInTheDocument(); }); it("routes .DIFF files to DiffCard (case-insensitive routing)", () => { render(); expect(screen.getByTestId("diff-card")).toBeInTheDocument(); }); it("routes normal code files to the Monaco editor", () => { render(); expect(screen.getByTestId("monaco-editor")).toBeInTheDocument(); expect(screen.queryByTestId("diff-card")).not.toBeInTheDocument(); }); it("routes unknown extensions to the editor (not DiffCard)", () => { render(); expect(screen.getByTestId("monaco-editor")).toBeInTheDocument(); }); }); // --------------------------------------------------------------------------- // MonacoCodeEditor — header bar rendering // --------------------------------------------------------------------------- describe("MonacoCodeEditor header bar", () => { beforeEach(() => { lastMockEditor = null; lastMockMonaco = null; }); it("displays the filename in the header", () => { render(); expect(screen.getByText("server.py")).toBeInTheDocument(); }); it("displays the language badge in uppercase", () => { render(); expect(screen.getByText("PYTHON")).toBeInTheDocument(); }); it("displays the correct line count", () => { const content = "line1\nline2\nline3"; render(); expect(screen.getByText("3 lines")).toBeInTheDocument(); }); it("displays single line for single-line content", () => { render(); expect(screen.getByText("1 lines")).toBeInTheDocument(); }); it("displays default cursor position Ln 1, Col 1", () => { render(); expect(screen.getByText("Ln 1, Col 1")).toBeInTheDocument(); }); it("updates cursor position when editor reports cursor change", () => { render(); simulateCursorMove(2, 5); expect(screen.getByText("Ln 2, Col 5")).toBeInTheDocument(); }); it("passes the correct language to Monaco editor", () => { render(); const editor = screen.getByTestId("monaco-editor"); expect(editor.dataset.language).toBe("typescript"); }); it("passes correct language for shell scripts", () => { render(); const editor = screen.getByTestId("monaco-editor"); expect(editor.dataset.language).toBe("shell"); }); }); // --------------------------------------------------------------------------- // MonacoCodeEditor — read-only enforcement // --------------------------------------------------------------------------- describe("MonacoCodeEditor read-only enforcement", () => { beforeEach(() => { lastMockEditor = null; lastMockMonaco = null; }); it("sets editor to read-only when filePath is absent (prevents unintended writes)", () => { render(); const editor = screen.getByTestId("monaco-editor"); expect(editor.dataset.readonly).toBe("true"); }); it("does not render save button when filePath is absent (no save affordance for read-only)", () => { render(); expect(screen.queryByRole("button", { name: /save/i })).not.toBeInTheDocument(); }); it("sets editor to editable when filePath is provided", () => { render(); const editor = screen.getByTestId("monaco-editor"); expect(editor.dataset.readonly).toBe("false"); }); it("renders save button when filePath is provided", () => { render(); expect(screen.getByRole("button", { name: /save/i })).toBeInTheDocument(); }); }); // --------------------------------------------------------------------------- // MonacoCodeEditor — save state machine // --------------------------------------------------------------------------- describe("MonacoCodeEditor save flow", () => { beforeEach(() => { lastMockEditor = null; lastMockMonaco = null; vi.useFakeTimers(); }); afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); }); it("save button is disabled in clean state (prevents redundant API calls)", () => { render(); const btn = screen.getByRole("button", { name: /save/i }); expect(btn).toBeDisabled(); }); it("marks editor as dirty when content changes differ from original (tracks unsaved changes)", () => { render(); simulateContentChange("hello world"); expect(screen.getByTitle("Unsaved changes")).toBeInTheDocument(); const btn = screen.getByRole("button", { name: /save/i }); expect(btn).not.toBeDisabled(); }); it("returns to clean state when content is reverted to original (no false dirty indicator)", () => { render(); simulateContentChange("hello world"); expect(screen.getByTitle("Unsaved changes")).toBeInTheDocument(); simulateContentChange("hello"); expect(screen.queryByTitle("Unsaved changes")).not.toBeInTheDocument(); }); it("sends correct path and content to the save API on save (data integrity)", async () => { const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ ok: true })); global.fetch = fetchMock; render(); simulateContentChange("modified content"); const btn = screen.getByRole("button", { name: /save/i }); await act(async () => { btn.click(); }); expect(fetchMock).toHaveBeenCalledWith("/api/workspace/file", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ path: "src/app.ts", content: "modified content" }), }); }); it("shows 'Saved' indicator after successful save", async () => { global.fetch = vi.fn().mockResolvedValue(jsonResponse({ ok: true })); render(); simulateContentChange("modified"); const btn = screen.getByRole("button", { name: /save/i }); await act(async () => { btn.click(); }); expect(screen.getByText("Saved")).toBeInTheDocument(); }); it("clears 'Saved' indicator after timeout (returns to clean state)", async () => { global.fetch = vi.fn().mockResolvedValue(jsonResponse({ ok: true })); render(); simulateContentChange("modified"); await act(async () => { screen.getByRole("button", { name: /save/i }).click(); }); expect(screen.getByText("Saved")).toBeInTheDocument(); act(() => { vi.advanceTimersByTime(2000); }); expect(screen.queryByText("Saved")).not.toBeInTheDocument(); }); it("shows 'Save failed' on HTTP error response (user sees error feedback)", async () => { global.fetch = vi.fn().mockResolvedValue(new Response("Error", { status: 500 })); render(); simulateContentChange("modified"); await act(async () => { screen.getByRole("button", { name: /save/i }).click(); }); expect(screen.getByText("Save failed")).toBeInTheDocument(); }); it("shows 'Save failed' on network error (user sees error feedback)", async () => { global.fetch = vi.fn().mockRejectedValue(new TypeError("Network error")); render(); simulateContentChange("modified"); await act(async () => { screen.getByRole("button", { name: /save/i }).click(); }); expect(screen.getByText("Save failed")).toBeInTheDocument(); }); it("reverts to dirty state after error timeout (allows retry)", async () => { global.fetch = vi.fn().mockRejectedValue(new TypeError("Network error")); render(); simulateContentChange("modified"); await act(async () => { screen.getByRole("button", { name: /save/i }).click(); }); expect(screen.getByText("Save failed")).toBeInTheDocument(); act(() => { vi.advanceTimersByTime(3000); }); expect(screen.queryByText("Save failed")).not.toBeInTheDocument(); expect(screen.getByTitle("Unsaved changes")).toBeInTheDocument(); }); it("shows 'Saving...' indicator during save (user knows operation is in progress)", async () => { let resolvePromise!: (value: Response) => void; const pendingPromise = new Promise((resolve) => { resolvePromise = resolve; }); global.fetch = vi.fn().mockReturnValue(pendingPromise); render(); simulateContentChange("modified"); await act(async () => { screen.getByRole("button", { name: /save/i }).click(); }); expect(screen.getByText("Saving...")).toBeInTheDocument(); await act(async () => { resolvePromise(jsonResponse({ ok: true })); }); expect(screen.queryByText("Saving...")).not.toBeInTheDocument(); }); it("updates baseline after successful save so matching content shows clean (prevents false dirty after save)", async () => { global.fetch = vi.fn().mockResolvedValue(jsonResponse({ ok: true })); render(); simulateContentChange("modified"); await act(async () => { screen.getByRole("button", { name: /save/i }).click(); }); act(() => { vi.advanceTimersByTime(2000); }); // Content is now "modified" which matches the saved baseline expect(screen.queryByTitle("Unsaved changes")).not.toBeInTheDocument(); expect(screen.getByRole("button", { name: /save/i })).toBeDisabled(); }); it("after save, editing back to saved content shows clean (baseline tracks last-saved value)", async () => { global.fetch = vi.fn().mockResolvedValue(jsonResponse({ ok: true })); render(); simulateContentChange("modified"); await act(async () => { screen.getByRole("button", { name: /save/i }).click(); }); act(() => { vi.advanceTimersByTime(2000); }); // Now edit to something else simulateContentChange("something else"); expect(screen.getByTitle("Unsaved changes")).toBeInTheDocument(); // Edit back to the saved value ("modified") — should be clean simulateContentChange("modified"); expect(screen.queryByTitle("Unsaved changes")).not.toBeInTheDocument(); }); it("after save, editing to original (pre-save) content still shows dirty (baseline is the saved value, not original)", async () => { global.fetch = vi.fn().mockResolvedValue(jsonResponse({ ok: true })); render(); simulateContentChange("saved-value"); await act(async () => { screen.getByRole("button", { name: /save/i }).click(); }); act(() => { vi.advanceTimersByTime(2000); }); // Edit to the original prop value — should still be dirty because baseline is now "saved-value" simulateContentChange("original"); expect(screen.getByTitle("Unsaved changes")).toBeInTheDocument(); }); it("does not call save API when editor ref is missing (guard against null ref)", async () => { const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ ok: true })); global.fetch = fetchMock; // Render without filePath (which means no save button), but also validates // the internal guard in saveFile that checks editorRef.current render(); expect(fetchMock).not.toHaveBeenCalled(); }); }); // --------------------------------------------------------------------------- // MonacoCodeEditor — editor mount behavior // --------------------------------------------------------------------------- describe("MonacoCodeEditor editor mount", () => { beforeEach(() => { lastMockEditor = null; lastMockMonaco = null; }); it("focuses the editor on mount (ready-to-type UX)", () => { render(); expect(lastMockEditor!.focus).toHaveBeenCalled(); }); it("registers Cmd+S keybinding on mount (keyboard shortcut for save)", () => { render(); expect(lastMockEditor!.addCommand).toHaveBeenCalledWith( 2048 | 49, // CtrlCmd | KeyS expect.any(Function), ); }); it("registers content change and cursor position listeners on mount", () => { render(); expect(lastMockEditor!.onDidChangeModelContent).toHaveBeenCalledWith(expect.any(Function)); expect(lastMockEditor!.onDidChangeCursorPosition).toHaveBeenCalledWith(expect.any(Function)); }); it("registers custom themes with Monaco on mount", () => { render(); // defineTheme is called by registerThemes, which runs idempotently // At minimum, setTheme should be called with the current theme expect(lastMockMonaco!.editor.setTheme).toHaveBeenCalled(); }); }); // --------------------------------------------------------------------------- // MonacoCodeEditor — theme switching // --------------------------------------------------------------------------- describe("MonacoCodeEditor theme", () => { beforeEach(() => { lastMockEditor = null; lastMockMonaco = null; document.documentElement.classList.remove("dark"); }); afterEach(() => { document.documentElement.classList.remove("dark"); }); it("uses light theme when html element does not have dark class", () => { render(); const editor = screen.getByTestId("monaco-editor"); expect(editor.dataset.theme).toBe("denchclaw-light"); }); it("uses dark theme when html element has dark class", () => { document.documentElement.classList.add("dark"); render(); const editor = screen.getByTestId("monaco-editor"); expect(editor.dataset.theme).toBe("denchclaw-dark"); }); it("switches theme dynamically when html class changes (MutationObserver)", async () => { render(); const editor = screen.getByTestId("monaco-editor"); expect(editor.dataset.theme).toBe("denchclaw-light"); act(() => { document.documentElement.classList.add("dark"); }); await waitFor(() => { expect(editor.dataset.theme).toBe("denchclaw-dark"); }); }); it("switches back to light theme when dark class is removed", async () => { document.documentElement.classList.add("dark"); render(); const editor = screen.getByTestId("monaco-editor"); expect(editor.dataset.theme).toBe("denchclaw-dark"); act(() => { document.documentElement.classList.remove("dark"); }); await waitFor(() => { expect(editor.dataset.theme).toBe("denchclaw-light"); }); }); }); // --------------------------------------------------------------------------- // MonacoCodeEditor — className passthrough // --------------------------------------------------------------------------- describe("MonacoCodeEditor className", () => { beforeEach(() => { lastMockEditor = null; lastMockMonaco = null; }); it("applies custom className to the root element", () => { const { container } = render( , ); const root = container.firstElementChild!; expect(root.classList.contains("my-custom-class")).toBe(true); }); it("does not crash when className is undefined", () => { const { container } = render( , ); const root = container.firstElementChild!; expect(root.classList.contains("flex")).toBe(true); }); });