From bd859a52ef5113a1581709d8cd92032725de4ce1 Mon Sep 17 00:00:00 2001 From: kumarabhirup Date: Wed, 4 Mar 2026 11:15:04 -0800 Subject: [PATCH] test(web): add rich document editor interaction coverage --- .../workspace/rich-document-editor.test.tsx | 570 ++++++++++++++++++ 1 file changed, 570 insertions(+) create mode 100644 apps/web/app/components/workspace/rich-document-editor.test.tsx diff --git a/apps/web/app/components/workspace/rich-document-editor.test.tsx b/apps/web/app/components/workspace/rich-document-editor.test.tsx new file mode 100644 index 00000000000..5b193a27b21 --- /dev/null +++ b/apps/web/app/components/workspace/rich-document-editor.test.tsx @@ -0,0 +1,570 @@ +// @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", + }); + }); +});