/** * PortableTextEditor component tests. * * Tests the TipTap-based rich text editor in vitest browser mode, * covering Portable Text ↔ ProseMirror round-trip conversion, * toolbar behaviour, focus modes, and editor lifecycle. */ import type { Editor } from "@tiptap/react"; import * as React from "react"; import { describe, it, expect, vi } from "vitest"; import type { PluginBlockDef } from "../../src/components/PortableTextEditor"; import { _buildPluginBlockFormValues, _hasPluginBlockFormData, PortableTextEditor, } from "../../src/components/PortableTextEditor"; import { render } from "../utils/render"; // --------------------------------------------------------------------------- // Mocks — heavy components that need network / Astro context // --------------------------------------------------------------------------- vi.mock("../../src/components/MediaPickerModal", () => ({ MediaPickerModal: () => null, })); vi.mock("../../src/components/SectionPickerModal", () => ({ SectionPickerModal: () => null, })); vi.mock("../../src/components/editor/DragHandleWrapper", () => ({ DragHandleWrapper: () => null, })); vi.mock("../../src/components/editor/ImageNode", async () => { const { Node } = await import("@tiptap/core"); const ImageExtension = Node.create({ name: "image", group: "block", atom: true, addAttributes() { return { src: { default: null }, alt: { default: "" }, title: { default: "" }, caption: { default: "" }, mediaId: { default: null }, provider: { default: "local" }, width: { default: null }, height: { default: null }, displayWidth: { default: null }, displayHeight: { default: null }, }; }, parseHTML() { return [{ tag: "img[src]" }]; }, renderHTML({ HTMLAttributes }) { return ["img", HTMLAttributes]; }, }); return { ImageExtension }; }); vi.mock("../../src/components/editor/PluginBlockNode", async () => { const { Node } = await import("@tiptap/core"); const PluginBlockExtension = Node.create({ name: "pluginBlock", group: "block", atom: true, addAttributes() { return { blockType: { default: "embed" }, id: { default: "" }, data: { default: {} }, }; }, parseHTML() { return [{ tag: "div[data-plugin-block]" }]; }, renderHTML({ HTMLAttributes }) { return ["div", { ...HTMLAttributes, "data-plugin-block": "" }]; }, }); return { PluginBlockExtension, getEmbedMeta: () => ({ label: "Embed", Icon: () => null }), registerPluginBlocks: () => {}, resolveIcon: () => () => null, }; }); // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- const SPOTLIGHT_MODE_PATTERN = /Spotlight Mode/i; /** Wait for the ProseMirror editor to mount inside the container */ async function waitForEditor(): Promise { let pm: HTMLElement | null = null; await vi.waitFor( () => { pm = document.querySelector(".ProseMirror") as HTMLElement | null; expect(pm).toBeTruthy(); }, { timeout: 3000 }, ); return pm!; } /** Focus the ProseMirror contenteditable and wait for it to be focused */ async function focusEditor(pm: HTMLElement) { pm.focus(); await vi.waitFor(() => expect(document.activeElement).toBe(pm), { timeout: 1000 }); } /** * Render the editor, wait for it to initialize, and return the Editor instance. * Useful for tests that need to type or manipulate content programmatically. */ async function renderAndGetEditor(props: Partial[0]> = {}) { let capturedEditor: Editor | null = null; const screen = await render( { capturedEditor = editor; }} {...props} />, ); const pm = await waitForEditor(); await vi.waitFor(() => expect(capturedEditor).toBeTruthy(), { timeout: 2000 }); return { screen, editor: capturedEditor!, pm }; } /** * Simulate typing text into the editor via TipTap's API. * This avoids browser keyboard API issues and is more reliable in tests. */ function typeIntoEditor(editor: Editor, text: string) { editor.chain().focus().insertContent(text).run(); } // Shorthand block builders function textBlock( text: string, opts: { style?: "normal" | "h1" | "h2" | "h3" | "blockquote"; marks?: string[]; listItem?: "bullet" | "number"; level?: number; markDefs?: Array<{ _type: string; _key: string; [k: string]: unknown }>; } = {}, ) { return { _type: "block" as const, _key: Math.random().toString(36).slice(2, 9), style: opts.style ?? "normal", ...(opts.listItem ? { listItem: opts.listItem, level: opts.level ?? 1 } : {}), children: [ { _type: "span" as const, _key: Math.random().toString(36).slice(2, 9), text, marks: opts.marks, }, ], markDefs: opts.markDefs, }; } // ============================================================================= // 1. Plugin block helpers // ============================================================================= describe("plugin block helpers", () => { it("builds form state from field initial_value defaults", () => { const block: PluginBlockDef = { type: "readingTime", pluginId: "reading-time", label: "Reading Time", fields: [ { type: "select", action_id: "variant", label: "Style", options: [ { label: "Inline", value: "inline" }, { label: "Compact", value: "compact" }, ], initial_value: "inline", }, { type: "toggle", action_id: "includeHeadings", label: "Include headings", initial_value: true, }, ], }; expect(_buildPluginBlockFormValues(block)).toEqual({ variant: "inline", includeHeadings: true, }); expect(_hasPluginBlockFormData(_buildPluginBlockFormValues(block))).toBe(true); }); it("merges existing block data over defaults when editing", () => { const block: PluginBlockDef = { type: "readingTime", pluginId: "reading-time", label: "Reading Time", fields: [ { type: "select", action_id: "variant", label: "Style", options: [ { label: "Inline", value: "inline" }, { label: "Compact", value: "compact" }, ], initial_value: "inline", }, { type: "toggle", action_id: "includeHeadings", label: "Include headings", initial_value: true, }, ], }; expect( _buildPluginBlockFormValues(block, { variant: "compact", customLabel: "Custom label", includeHeadings: false, }), ).toEqual({ variant: "compact", customLabel: "Custom label", includeHeadings: false, }); }); it("keeps explicit existing values over defaults", () => { const block: PluginBlockDef = { type: "readingTime", pluginId: "reading-time", label: "Reading Time", fields: [ { type: "number_input", action_id: "minutes", label: "Minutes", initial_value: 5, }, { type: "text_input", action_id: "label", label: "Label", initial_value: "Default label", }, { type: "toggle", action_id: "includeHeadings", label: "Include headings", initial_value: true, }, ], }; expect( _buildPluginBlockFormValues(block, { minutes: 0, label: "", includeHeadings: false, }), ).toEqual({ minutes: 0, label: "", includeHeadings: false, }); }); }); // ============================================================================= // 2. Portable Text ↔ ProseMirror Conversion (via component) // ============================================================================= describe("Portable Text ↔ ProseMirror conversion", () => { it("renders a paragraph from PT value", async () => { await render(); const pm = await waitForEditor(); const p = pm.querySelector("p"); expect(p).toBeTruthy(); expect(p!.textContent).toBe("Hello world"); }); it("renders an h1 heading", async () => { await render(); const pm = await waitForEditor(); const h1 = pm.querySelector("h1"); expect(h1).toBeTruthy(); expect(h1!.textContent).toBe("Title"); }); it("renders bold text", async () => { await render(); const pm = await waitForEditor(); const strong = pm.querySelector("strong"); expect(strong).toBeTruthy(); expect(strong!.textContent).toBe("Bold text"); }); it("renders a link from markDef", async () => { const linkKey = "lnk1"; await render( , ); const pm = await waitForEditor(); const anchor = pm.querySelector("a"); expect(anchor).toBeTruthy(); expect(anchor!.textContent).toBe("Click me"); expect(anchor!.getAttribute("href")).toBe("https://example.com"); }); it("renders a bullet list", async () => { await render( , ); const pm = await waitForEditor(); const ul = pm.querySelector("ul"); expect(ul).toBeTruthy(); const items = ul!.querySelectorAll("li"); expect(items.length).toBe(2); expect(items[0]!.textContent).toBe("Item one"); expect(items[1]!.textContent).toBe("Item two"); }); it("renders an ordered list", async () => { await render( , ); const pm = await waitForEditor(); const ol = pm.querySelector("ol"); expect(ol).toBeTruthy(); const items = ol!.querySelectorAll("li"); expect(items.length).toBe(2); }); it("renders a blockquote", async () => { await render( , ); const pm = await waitForEditor(); const bq = pm.querySelector("blockquote"); expect(bq).toBeTruthy(); expect(bq!.textContent).toBe("A wise quote"); }); it("renders a code block", async () => { await render( , ); const pm = await waitForEditor(); const pre = pm.querySelector("pre"); expect(pre).toBeTruthy(); expect(pre!.textContent).toContain("const x = 1"); }); it("renders an image block", async () => { await render( , ); const pm = await waitForEditor(); // The mock ImageExtension renders as const img = pm.querySelector("img"); expect(img).toBeTruthy(); expect(img!.getAttribute("src")).toBe("/test.jpg"); }); it("renders a horizontal rule", async () => { await render( , ); const pm = await waitForEditor(); const hr = pm.querySelector("hr"); expect(hr).toBeTruthy(); }); it("renders empty editor when value is empty array", async () => { await render(); const pm = await waitForEditor(); // Empty editor should have a single empty paragraph const paragraphs = pm.querySelectorAll("p"); expect(paragraphs.length).toBeGreaterThanOrEqual(1); // Placeholder should appear expect(pm.textContent).toBe(""); }); it("renders empty editor when value is undefined", async () => { await render(); const pm = await waitForEditor(); expect(pm).toBeTruthy(); // Empty editor — no meaningful text const textContent = pm.textContent ?? ""; expect(textContent.trim()).toBe(""); }); it("renders bold+italic text with multiple marks", async () => { await render( , ); const pm = await waitForEditor(); const strong = pm.querySelector("strong"); const em = pm.querySelector("em"); expect(strong).toBeTruthy(); expect(em).toBeTruthy(); // The text is wrapped in both marks expect(pm.textContent).toContain("Bold italic"); }); it("fires onChange with valid PT blocks when typing", async () => { const onChange = vi.fn(); const { editor } = await renderAndGetEditor({ onChange }); typeIntoEditor(editor, "Hello"); await vi.waitFor( () => { expect(onChange).toHaveBeenCalled(); }, { timeout: 2000 }, ); const lastCall = onChange.mock.calls.at(-1)!; const blocks = lastCall[0] as Array<{ _type: string }>; expect(blocks.length).toBeGreaterThan(0); expect(blocks[0]!._type).toBe("block"); }); }); // ============================================================================= // 2. Editor Component Behaviour // ============================================================================= describe("Editor component behaviour", () => { it("shows placeholder text in empty editor", async () => { await render(); const pm = await waitForEditor(); // TipTap sets placeholder via data-placeholder or a .is-empty class // Check for the placeholder content in a before pseudo-element or attribute const placeholderEl = pm.querySelector("[data-placeholder]"); if (placeholderEl) { expect(placeholderEl.getAttribute("data-placeholder")).toBe("Write something..."); } else { // Fallback: check the class-based placeholder const emptyNode = pm.querySelector(".is-empty, .is-editor-empty"); expect(emptyNode).toBeTruthy(); } }); it("sets contenteditable=false when editable is false", async () => { await render(); const pm = await waitForEditor(); expect(pm.getAttribute("contenteditable")).toBe("false"); }); it("sets contenteditable=true by default", async () => { await render(); const pm = await waitForEditor(); expect(pm.getAttribute("contenteditable")).toBe("true"); }); it("applies spotlight-mode class when focusMode is spotlight", async () => { await render(); await waitForEditor(); const wrapper = document.querySelector(".spotlight-mode"); expect(wrapper).toBeTruthy(); }); it("does not apply spotlight-mode class when focusMode is normal", async () => { await render(); await waitForEditor(); const wrapper = document.querySelector(".spotlight-mode"); expect(wrapper).toBeNull(); }); it("calls onFocusModeChange when spotlight button is clicked", async () => { const onFocusModeChange = vi.fn(); const screen = await render( , ); await waitForEditor(); // The spotlight button has aria-label containing "Spotlight Mode" const spotlightBtn = screen.getByRole("button", { name: SPOTLIGHT_MODE_PATTERN }); await spotlightBtn.click(); expect(onFocusModeChange).toHaveBeenCalledWith("spotlight"); }); it("hides toolbar and footer in minimal mode", async () => { await render(); await waitForEditor(); // Toolbar has role="toolbar" — should not exist const toolbar = document.querySelector('[role="toolbar"]'); expect(toolbar).toBeNull(); // Footer shows word count — should not exist const footer = document.querySelector(".border-t"); expect(footer).toBeNull(); }); it("calls onEditorReady with Editor instance", async () => { const onEditorReady = vi.fn(); await render(); await waitForEditor(); await vi.waitFor(() => expect(onEditorReady).toHaveBeenCalledTimes(1), { timeout: 2000 }); const editorArg = onEditorReady.mock.calls[0]![0] as Editor; expect(editorArg).toBeTruthy(); expect(typeof editorArg.getJSON).toBe("function"); expect(typeof editorArg.chain).toBe("function"); }); it("calls onEditorReady with null on unmount so consumers can clear stale references", async () => { // Without this cleanup, ContentEditor's `portableTextEditor` slot keeps // pointing at a destroyed TipTap instance during the brief remount window // when switching translations (FieldRenderer is re-keyed by item.id), // causing DocumentOutline to render against a destroyed editor. const onEditorReady = vi.fn(); const screen = await render( , ); await waitForEditor(); await vi.waitFor(() => expect(onEditorReady).toHaveBeenCalledTimes(1), { timeout: 2000 }); expect(onEditorReady.mock.calls[0]![0]).toBeTruthy(); // Unmount and verify the cleanup fires onEditorReady(null). await screen.unmount(); await vi.waitFor(() => expect(onEditorReady).toHaveBeenCalledTimes(2), { timeout: 2000 }); expect(onEditorReady.mock.calls[1]![0]).toBeNull(); }); it("shows word count and character count in footer", async () => { await render(); await waitForEditor(); await vi.waitFor( () => { const text = document.body.textContent ?? ""; expect(text).toContain("words"); expect(text).toContain("characters"); expect(text).toContain("min read"); }, { timeout: 2000 }, ); }); }); // ============================================================================= // 3. Toolbar // ============================================================================= describe("Toolbar", () => { async function renderWithToolbar() { const screen = await render(); await waitForEditor(); return screen; } it("renders a toolbar with text formatting aria-label", async () => { const screen = await renderWithToolbar(); const toolbar = screen.getByRole("toolbar"); await expect.element(toolbar).toHaveAttribute("aria-label", "Text formatting"); }); it("has inline formatting buttons", async () => { const screen = await renderWithToolbar(); await expect.element(screen.getByRole("button", { name: "Bold" })).toBeInTheDocument(); await expect.element(screen.getByRole("button", { name: "Italic" })).toBeInTheDocument(); await expect.element(screen.getByRole("button", { name: "Underline" })).toBeInTheDocument(); await expect.element(screen.getByRole("button", { name: "Strikethrough" })).toBeInTheDocument(); await expect.element(screen.getByRole("button", { name: "Inline Code" })).toBeInTheDocument(); }); it("has heading buttons", async () => { const screen = await renderWithToolbar(); await expect.element(screen.getByRole("button", { name: "Heading 1" })).toBeInTheDocument(); await expect.element(screen.getByRole("button", { name: "Heading 2" })).toBeInTheDocument(); await expect.element(screen.getByRole("button", { name: "Heading 3" })).toBeInTheDocument(); }); it("has list buttons", async () => { const screen = await renderWithToolbar(); await expect.element(screen.getByRole("button", { name: "Bullet List" })).toBeInTheDocument(); await expect.element(screen.getByRole("button", { name: "Numbered List" })).toBeInTheDocument(); }); it("has block buttons", async () => { const screen = await renderWithToolbar(); await expect.element(screen.getByRole("button", { name: "Quote" })).toBeInTheDocument(); await expect.element(screen.getByRole("button", { name: "Code Block" })).toBeInTheDocument(); }); it("has alignment buttons", async () => { const screen = await renderWithToolbar(); await expect.element(screen.getByRole("button", { name: "Align Left" })).toBeInTheDocument(); await expect.element(screen.getByRole("button", { name: "Align Center" })).toBeInTheDocument(); await expect.element(screen.getByRole("button", { name: "Align Right" })).toBeInTheDocument(); }); it("has insert buttons", async () => { const screen = await renderWithToolbar(); await expect.element(screen.getByRole("button", { name: "Insert Link" })).toBeInTheDocument(); await expect.element(screen.getByRole("button", { name: "Insert Image" })).toBeInTheDocument(); await expect .element(screen.getByRole("button", { name: "Insert Horizontal Rule" })) .toBeInTheDocument(); }); it("has history buttons (initially disabled)", async () => { const screen = await renderWithToolbar(); const undoBtn = screen.getByRole("button", { name: "Undo" }); const redoBtn = screen.getByRole("button", { name: "Redo" }); await expect.element(undoBtn).toBeInTheDocument(); await expect.element(redoBtn).toBeInTheDocument(); await expect.element(undoBtn).toBeDisabled(); await expect.element(redoBtn).toBeDisabled(); }); it("has spotlight mode button", async () => { const screen = await renderWithToolbar(); await expect .element(screen.getByRole("button", { name: SPOTLIGHT_MODE_PATTERN })) .toBeInTheDocument(); }); it("toggles bold aria-pressed when clicked", async () => { const screen = await renderWithToolbar(); const pm = document.querySelector(".ProseMirror") as HTMLElement; await focusEditor(pm); const boldBtn = screen.getByRole("button", { name: "Bold" }); await expect.element(boldBtn).toHaveAttribute("aria-pressed", "false"); await boldBtn.click(); await vi.waitFor( async () => { await expect.element(boldBtn).toHaveAttribute("aria-pressed", "true"); }, { timeout: 2000 }, ); }); it("toggles italic aria-pressed when clicked", async () => { const screen = await renderWithToolbar(); const pm = document.querySelector(".ProseMirror") as HTMLElement; await focusEditor(pm); const italicBtn = screen.getByRole("button", { name: "Italic" }); await expect.element(italicBtn).toHaveAttribute("aria-pressed", "false"); await italicBtn.click(); await vi.waitFor( async () => { await expect.element(italicBtn).toHaveAttribute("aria-pressed", "true"); }, { timeout: 2000 }, ); }); it("toggles Heading 1 aria-pressed when clicked", async () => { const screen = await renderWithToolbar(); const pm = document.querySelector(".ProseMirror") as HTMLElement; await focusEditor(pm); const h1Btn = screen.getByRole("button", { name: "Heading 1" }); await expect.element(h1Btn).toHaveAttribute("aria-pressed", "false"); await h1Btn.click(); await vi.waitFor( async () => { await expect.element(h1Btn).toHaveAttribute("aria-pressed", "true"); }, { timeout: 2000 }, ); }); it("enables Undo after typing and Redo after undoing", async () => { let editorRef: Editor | null = null; const screen = await render( { editorRef = editor; }} />, ); await waitForEditor(); await vi.waitFor(() => expect(editorRef).toBeTruthy(), { timeout: 2000 }); const undoBtn = screen.getByRole("button", { name: "Undo" }); const redoBtn = screen.getByRole("button", { name: "Redo" }); // Initially both disabled await expect.element(undoBtn).toBeDisabled(); await expect.element(redoBtn).toBeDisabled(); // Type something via editor API typeIntoEditor(editorRef!, "Some text"); // Undo should become enabled await vi.waitFor( async () => { await expect.element(undoBtn).toBeEnabled(); }, { timeout: 2000 }, ); // Click undo await undoBtn.click(); // Redo should become enabled await vi.waitFor( async () => { await expect.element(redoBtn).toBeEnabled(); }, { timeout: 2000 }, ); }); it("toggles spotlight mode button aria-pressed", async () => { const onFocusModeChange = vi.fn(); const screen = await render( , ); await waitForEditor(); const btn = screen.getByRole("button", { name: SPOTLIGHT_MODE_PATTERN }); await expect.element(btn).toHaveAttribute("aria-pressed", "false"); }); it("spotlight button shows pressed when focusMode is spotlight", async () => { const screen = await render( {}} value={[textBlock("Focused")]} />, ); await waitForEditor(); const btn = screen.getByRole("button", { name: SPOTLIGHT_MODE_PATTERN }); await expect.element(btn).toHaveAttribute("aria-pressed", "true"); }); it("toolbar not present in minimal mode", async () => { await render(); await waitForEditor(); const toolbar = document.querySelector('[role="toolbar"]'); expect(toolbar).toBeNull(); }); }); // ============================================================================= // 4. Slash Commands // ============================================================================= describe("Slash commands", () => { it("renders without errors with default commands", async () => { await render(); const pm = await waitForEditor(); expect(pm).toBeTruthy(); }); it("renders without errors with pluginBlocks prop", async () => { const pluginBlocks: PluginBlockDef[] = [ { type: "youtube", pluginId: "embeds", label: "YouTube Video" }, { type: "tweet", pluginId: "social", label: "Tweet" }, ]; await render( , ); const pm = await waitForEditor(); expect(pm).toBeTruthy(); }); it("editor accepts pluginBlocks without crashing when typing", async () => { const pluginBlocks: PluginBlockDef[] = [ { type: "youtube", pluginId: "embeds", label: "YouTube Video" }, ]; const onChange = vi.fn(); const { editor } = await renderAndGetEditor({ pluginBlocks, onChange, }); typeIntoEditor(editor, "Hello"); await vi.waitFor(() => expect(onChange).toHaveBeenCalled(), { timeout: 2000 }); }); }); // ============================================================================= // 5. Round-trip: onChange output shape // ============================================================================= describe("onChange output shape", () => { it("onChange returns blocks with _type and _key", async () => { const onChange = vi.fn(); const { editor } = await renderAndGetEditor({ onChange }); typeIntoEditor(editor, "Test"); await vi.waitFor(() => expect(onChange).toHaveBeenCalled(), { timeout: 2000 }); const blocks = onChange.mock.calls.at(-1)![0] as Array<{ _type: string; _key: string; children?: Array<{ _type: string; text: string }>; }>; expect(blocks.length).toBeGreaterThan(0); const block = blocks[0]!; expect(block._type).toBe("block"); expect(typeof block._key).toBe("string"); expect(block.children).toBeDefined(); expect(block.children!.length).toBeGreaterThan(0); expect(block.children![0]!._type).toBe("span"); expect(block.children![0]!.text).toContain("Test"); }); it("heading value roundtrips through onEditorReady", async () => { let capturedEditor: Editor | null = null; const value = [textBlock("My Heading", { style: "h1" })]; await render( { capturedEditor = editor; }} />, ); await waitForEditor(); await vi.waitFor(() => expect(capturedEditor).toBeTruthy(), { timeout: 2000 }); // Verify the editor has a heading node const json = capturedEditor!.getJSON(); const headingNode = json.content?.find((n: { type: string }) => n.type === "heading"); expect(headingNode).toBeTruthy(); expect((headingNode as { attrs?: { level?: number } }).attrs?.level).toBe(1); }); it("code block value roundtrips through onEditorReady", async () => { let capturedEditor: Editor | null = null; const value = [{ _type: "code" as const, _key: "c1", code: "let a = 1;", language: "js" }]; await render( { capturedEditor = editor; }} />, ); await waitForEditor(); await vi.waitFor(() => expect(capturedEditor).toBeTruthy(), { timeout: 2000 }); const json = capturedEditor!.getJSON(); const codeNode = json.content?.find((n: { type: string }) => n.type === "codeBlock"); expect(codeNode).toBeTruthy(); }); it("list value roundtrips through onEditorReady", async () => { let capturedEditor: Editor | null = null; const value = [ textBlock("Alpha", { listItem: "bullet" }), textBlock("Beta", { listItem: "bullet" }), ]; await render( { capturedEditor = editor; }} />, ); await waitForEditor(); await vi.waitFor(() => expect(capturedEditor).toBeTruthy(), { timeout: 2000 }); const json = capturedEditor!.getJSON(); const listNode = json.content?.find((n: { type: string }) => n.type === "bulletList"); expect(listNode).toBeTruthy(); }); });