// @vitest-environment jsdom import React from "react"; import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; import { render, screen, act, fireEvent, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import htmlToDocx from "html-to-docx"; import { RichDocumentEditor, isDocxFile, isTxtFile, textToHtml, } from "./rich-document-editor"; type EditorOptions = { onUpdate?: () => void; }; type MockChain = { focus: ReturnType; run: ReturnType; undo: ReturnType; redo: ReturnType; toggleBold: ReturnType; toggleItalic: ReturnType; toggleUnderline: ReturnType; toggleStrike: ReturnType; toggleSuperscript: ReturnType; toggleSubscript: ReturnType; setTextAlign: ReturnType; toggleBulletList: ReturnType; toggleOrderedList: ReturnType; toggleTaskList: ReturnType; toggleBlockquote: ReturnType; toggleCodeBlock: ReturnType; setHorizontalRule: ReturnType; setLink: ReturnType; unsetLink: ReturnType; setImage: ReturnType; insertTable: ReturnType; toggleHeading: ReturnType; setParagraph: ReturnType; setColor: ReturnType; unsetColor: ReturnType; toggleHighlight: ReturnType; unsetHighlight: ReturnType; }; type MockEditor = { chain: ReturnType; can: ReturnType; isActive: ReturnType; getAttributes: ReturnType; getText: ReturnType; getHTML: ReturnType; storage: { characterCount: { words: ReturnType; characters: ReturnType } }; view: { dom: HTMLDivElement }; }; let lastEditorOptions: EditorOptions | null = null; let currentEditor: MockEditor; let currentChain: MockChain; const useEditorMock = vi.fn((options: EditorOptions) => { lastEditorOptions = options; return currentEditor; }); vi.mock("@tiptap/react", () => ({ useEditor: (options: EditorOptions) => useEditorMock(options), EditorContent: () =>
, })); vi.mock("@tiptap/react/menus", () => ({ BubbleMenu: ({ children }: { children: React.ReactNode }) => (
{children}
), })); vi.mock("html-to-docx", () => ({ default: vi.fn(async () => new Blob([new Uint8Array([1, 2, 3])], { type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document" })), })); function createMockChain(): MockChain { const chain = {} as MockChain; const passthrough = [ "focus", "undo", "redo", "toggleBold", "toggleItalic", "toggleUnderline", "toggleStrike", "toggleSuperscript", "toggleSubscript", "setTextAlign", "toggleBulletList", "toggleOrderedList", "toggleTaskList", "toggleBlockquote", "toggleCodeBlock", "setHorizontalRule", "setLink", "unsetLink", "setImage", "insertTable", "toggleHeading", "setParagraph", "setColor", "unsetColor", "toggleHighlight", "unsetHighlight", ] as const; for (const method of passthrough) { (chain[method] as unknown as ReturnType) = vi.fn(() => chain); } chain.run = vi.fn(() => true); return chain; } function createMockEditor(opts?: { isActive?: (name: unknown, attrs?: unknown) => boolean; getText?: string; getHTML?: string; }) { const chain = createMockChain(); const dom = document.createElement("div"); document.body.appendChild(dom); const editor: MockEditor = { chain: vi.fn(() => chain), can: vi.fn(() => ({ undo: () => true, redo: () => true })), isActive: vi.fn(opts?.isActive ?? (() => false)), getAttributes: vi.fn(() => ({ color: undefined })), getText: vi.fn(() => opts?.getText ?? "plain txt content"), getHTML: vi.fn(() => opts?.getHTML ?? "

DOCX body

"), storage: { characterCount: { words: vi.fn(() => 2), characters: vi.fn(() => 12), }, }, view: { dom }, }; return { editor, chain, dom }; } function markDirty() { act(() => { lastEditorOptions?.onUpdate?.(); }); } describe("rich-document-editor helpers", () => { it("detects .doc and .docx regardless of case (prevents wrong renderer selection)", () => { expect(isDocxFile("proposal.docx")).toBe(true); expect(isDocxFile("proposal.DOCX")).toBe(true); expect(isDocxFile("legacy.doc")).toBe(true); expect(isDocxFile("notes.txt")).toBe(false); expect(isDocxFile("archive.docx.bak")).toBe(false); }); it("detects .txt regardless of case (routes plain text to text-safe mode)", () => { expect(isTxtFile("notes.txt")).toBe(true); expect(isTxtFile("NOTES.TXT")).toBe(true); expect(isTxtFile("notes.md")).toBe(false); expect(isTxtFile("notes.txt.bak")).toBe(false); }); it("converts plain text to paragraph HTML and escapes HTML-sensitive characters", () => { const html = textToHtml("line \n\nTom & Jerry"); expect(html).toContain("

line <a>

"); expect(html).toContain("


"); expect(html).toContain("

Tom & Jerry

"); }); it("returns a single empty paragraph for blank content (keeps editor mount stable)", () => { expect(textToHtml(" ")).toBe("

"); }); }); describe("RichDocumentEditor rendering modes", () => { beforeEach(() => { const { editor, chain } = createMockEditor(); currentEditor = editor; currentChain = chain; lastEditorOptions = null; useEditorMock.mockClear(); vi.restoreAllMocks(); global.fetch = vi.fn(); }); afterEach(() => { document.body.innerHTML = ""; }); it("renders DOCX mode with full formatting toolbar (critical authoring controls stay available)", () => { render( , ); expect(screen.getByText("spec.docx")).toBeInTheDocument(); expect(screen.getByTitle("Paragraph style")).toBeInTheDocument(); expect(screen.getByTitle("Text color")).toBeInTheDocument(); expect(screen.getByTitle("Insert table")).toBeInTheDocument(); expect(screen.getByText("DOCX")).toBeInTheDocument(); expect(screen.getByText("2 words")).toBeInTheDocument(); }); it("renders TXT mode with minimal controls and preservation warning", () => { render( , ); expect(screen.getByText("today.txt")).toBeInTheDocument(); expect(screen.getByText("Plain text — formatting not preserved on save")).toBeInTheDocument(); expect(screen.queryByTitle("Paragraph style")).not.toBeInTheDocument(); expect(screen.getByText("TXT")).toBeInTheDocument(); }); it("hides status bar in compact mode (prevents sidebar overcrowding)", () => { render( , ); expect(screen.queryByText("2 words")).not.toBeInTheDocument(); expect(screen.queryByText("DOCX")).not.toBeInTheDocument(); }); }); describe("RichDocumentEditor save flows", () => { beforeEach(() => { const { editor, chain } = createMockEditor({ getText: "Updated plain text", getHTML: "

Updated DOCX body

", }); currentEditor = editor; currentChain = chain; lastEditorOptions = null; useEditorMock.mockClear(); vi.restoreAllMocks(); global.fetch = vi.fn(); }); afterEach(() => { document.body.innerHTML = ""; vi.useRealTimers(); }); it("keeps save disabled until editor reports changes (prevents redundant writes)", () => { render( , ); expect(screen.getByRole("button", { name: "Save" })).toBeDisabled(); markDirty(); expect(screen.getByRole("button", { name: "Save" })).not.toBeDisabled(); expect(screen.getByText("Unsaved changes")).toBeInTheDocument(); }); it("saves TXT via /api/workspace/file with exact path and plain text body", async () => { const fetchMock = vi.fn().mockResolvedValue( new Response(JSON.stringify({ ok: true }), { status: 200, headers: { "Content-Type": "application/json" } }), ); global.fetch = fetchMock; const onSave = vi.fn(); const user = userEvent.setup(); render( , ); markDirty(); await user.click(screen.getByRole("button", { name: "Save" })); expect(fetchMock).toHaveBeenCalledWith("/api/workspace/file", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ path: "notes/today.txt", content: "Updated plain text" }), }); expect(onSave).toHaveBeenCalledTimes(1); expect(screen.getByText("Saved")).toBeInTheDocument(); }); it("saves DOCX via html-to-docx and /api/workspace/write-binary", async () => { const fetchMock = vi.fn().mockResolvedValue( new Response(JSON.stringify({ ok: true }), { status: 200, headers: { "Content-Type": "application/json" } }), ); global.fetch = fetchMock; const user = userEvent.setup(); render( , ); markDirty(); await user.click(screen.getByRole("button", { name: "Save" })); expect(vi.mocked(htmlToDocx)).toHaveBeenCalledWith( "

Updated DOCX body

", undefined, expect.objectContaining({ table: { row: { cantSplit: true } }, footer: true, pageNumber: true, }), ); const reqInit = fetchMock.mock.calls[0]?.[1] as RequestInit; expect(fetchMock.mock.calls[0]?.[0]).toBe("/api/workspace/write-binary"); expect(reqInit.method).toBe("POST"); expect(reqInit.body).toBeInstanceOf(FormData); const form = reqInit.body as FormData; expect(form.get("path")).toBe("docs/spec.docx"); expect(form.get("file")).toBeInstanceOf(Blob); }); it("handles HTTP failure responses by surfacing Save failed", async () => { global.fetch = vi.fn().mockResolvedValue(new Response("fail", { status: 500 })); const user = userEvent.setup(); render( , ); markDirty(); await user.click(screen.getByRole("button", { name: "Save" })); expect(screen.getByText("Save failed")).toBeInTheDocument(); }); it("handles network exceptions by surfacing Save failed", async () => { global.fetch = vi.fn().mockRejectedValue(new TypeError("Network error")); const user = userEvent.setup(); render( , ); markDirty(); await user.click(screen.getByRole("button", { name: "Save" })); expect(screen.getByText("Save failed")).toBeInTheDocument(); }); it("supports Cmd/Ctrl+S shortcut for save (keyboard-first editing flow)", async () => { const fetchMock = vi.fn().mockResolvedValue( new Response(JSON.stringify({ ok: true }), { status: 200, headers: { "Content-Type": "application/json" } }), ); global.fetch = fetchMock; render( , ); markDirty(); fireEvent.keyDown(document, { key: "s", ctrlKey: true }); await waitFor(() => { expect(fetchMock).toHaveBeenCalledTimes(1); }); }); it("clears saved indicator after timeout (status feedback resets correctly)", async () => { vi.useFakeTimers(); global.fetch = vi.fn().mockResolvedValue( new Response(JSON.stringify({ ok: true }), { status: 200, headers: { "Content-Type": "application/json" } }), ); render( , ); markDirty(); await act(async () => { fireEvent.click(screen.getByRole("button", { name: "Save" })); }); expect(screen.getByText("Saved")).toBeInTheDocument(); act(() => { vi.advanceTimersByTime(2000); }); expect(screen.queryByText("Saved")).not.toBeInTheDocument(); }); }); describe("RichDocumentEditor interaction details", () => { beforeEach(() => { const { editor, chain } = createMockEditor(); currentEditor = editor; currentChain = chain; lastEditorOptions = null; useEditorMock.mockClear(); vi.restoreAllMocks(); global.fetch = vi.fn(); }); afterEach(() => { document.body.innerHTML = ""; }); it("runs paragraph style command for selected heading level", async () => { const user = userEvent.setup(); render( , ); await user.click(screen.getByTitle("Paragraph style")); await user.click(screen.getByRole("button", { name: "Heading 2" })); expect(currentChain.toggleHeading).toHaveBeenCalledWith({ level: 2 }); }); it("toggles rich formatting commands when toolbar buttons are clicked", async () => { const user = userEvent.setup(); render( , ); await user.click(screen.getByTitle("Bold (Cmd+B)")); await user.click(screen.getByTitle("Italic (Cmd+I)")); await user.click(screen.getByTitle("Underline (Cmd+U)")); await user.click(screen.getByTitle("Align center")); await user.click(screen.getByTitle("Bullet list")); await user.click(screen.getByTitle("Blockquote")); await user.click(screen.getByTitle("Insert table")); expect(currentChain.toggleBold).toHaveBeenCalled(); expect(currentChain.toggleItalic).toHaveBeenCalled(); expect(currentChain.toggleUnderline).toHaveBeenCalled(); expect(currentChain.setTextAlign).toHaveBeenCalledWith("center"); expect(currentChain.toggleBulletList).toHaveBeenCalled(); expect(currentChain.toggleBlockquote).toHaveBeenCalled(); expect(currentChain.insertTable).toHaveBeenCalledWith({ rows: 3, cols: 3, withHeaderRow: true, }); }); it("applies text color and removes color from palette actions", async () => { const user = userEvent.setup(); render( , ); await user.click(screen.getByTitle("Text color")); await user.click(screen.getByTitle("#ff0000")); expect(currentChain.setColor).toHaveBeenCalledWith("#ff0000"); await user.click(screen.getByTitle("Text color")); await user.click(screen.getByTitle("Remove color")); expect(currentChain.unsetColor).toHaveBeenCalled(); }); it("applies highlight color and allows unsetting highlight", async () => { const user = userEvent.setup(); render( , ); await user.click(screen.getByTitle("Highlight color")); await user.click(screen.getByTitle("#ffd966")); expect(currentChain.toggleHighlight).toHaveBeenCalledWith({ color: "#ffd966" }); await user.click(screen.getByTitle("Highlight color")); await user.click(screen.getByTitle("Remove color")); expect(currentChain.unsetHighlight).toHaveBeenCalled(); }); it("closes open palette when clicking outside (prevents stuck overlays)", async () => { const user = userEvent.setup(); render( , ); await user.click(screen.getByTitle("Text color")); expect(screen.getByTitle("Remove color")).toBeInTheDocument(); fireEvent.mouseDown(document.body); expect(screen.queryByTitle("Remove color")).not.toBeInTheDocument(); }); it("inserts a link when prompt returns URL (primary link UX path)", async () => { const user = userEvent.setup(); vi.spyOn(window, "prompt").mockReturnValue("https://docs.openclaw.ai"); render( , ); await user.click(screen.getByTitle("Insert link")); expect(currentChain.setLink).toHaveBeenCalledWith({ href: "https://docs.openclaw.ai" }); }); it("removes existing link when link is active (prevents duplicate nested link marks)", async () => { const { editor, chain } = createMockEditor({ isActive: (name) => name === "link", }); currentEditor = editor; currentChain = chain; useEditorMock.mockClear(); const user = userEvent.setup(); render( , ); await user.click(screen.getByTitle("Insert link")); expect(currentChain.unsetLink).toHaveBeenCalled(); }); it("uploads image files and inserts resulting asset URL into editor", async () => { const fetchMock = vi.fn().mockResolvedValue( new Response(JSON.stringify({ path: "assets/uploads/screenshot.png" }), { status: 200, headers: { "Content-Type": "application/json" }, }), ); global.fetch = fetchMock; const user = userEvent.setup(); const { container } = render( , ); const input = container.querySelector("input[type=\"file\"]") as HTMLInputElement; expect(input).toBeTruthy(); const file = new File(["img"], "screenshot.png", { type: "image/png" }); await user.upload(input, file); await waitFor(() => { expect(fetchMock).toHaveBeenCalledWith("/api/workspace/upload", { method: "POST", body: expect.any(FormData), }); }); expect(currentChain.setImage).toHaveBeenCalledWith({ src: "/api/workspace/assets/uploads/screenshot.png", alt: "screenshot.png", }); }); it("handles dropped image files by uploading and inserting them (drag-drop flow)", async () => { const fetchMock = vi.fn().mockResolvedValue( new Response(JSON.stringify({ path: "assets/uploads/drop.png" }), { status: 200, headers: { "Content-Type": "application/json" }, }), ); global.fetch = fetchMock; render( , ); const dropFile = new File(["img"], "drop.png", { type: "image/png" }); const dropEvent = new Event("drop", { bubbles: true, cancelable: true }); Object.defineProperty(dropEvent, "dataTransfer", { value: { files: [dropFile], types: ["Files"] }, configurable: true, }); currentEditor.view.dom.dispatchEvent(dropEvent); await waitFor(() => { expect(fetchMock).toHaveBeenCalled(); }); expect(currentChain.setImage).toHaveBeenCalledWith({ src: "/api/workspace/assets/uploads/drop.png", alt: "drop.png", }); }); });