import { render, screen, waitFor } from "@testing-library/react"; import { Component, type ReactNode } from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { MarkdownDocumentEditor } from "./markdown-document-editor"; import type * as EditorPeers from "./editor-peers"; class ErrorTrap extends Component<{ children: ReactNode }, { message: string | null }> { state = { message: null as string | null }; static getDerivedStateFromError(error: Error) { return { message: error.message }; } render() { return this.state.message ?? this.props.children; } } /** * Imports the editor with a fresh module graph, so its module-scope `lazy` * has not resolved yet. `loadDocumentEditorPeers` is replaced by `load`. */ async function freshEditor(load: () => Promise) { vi.resetModules(); vi.doMock("./editor-peers", async () => ({ ...(await vi.importActual("./editor-peers")), loadDocumentEditorPeers: load, })); return (await import("./markdown-document-editor")).MarkdownDocumentEditor; } afterEach(() => { vi.doUnmock("./editor-peers"); vi.resetModules(); }); describe("MarkdownDocumentEditor", () => { it("renders the real tiptap surface once the peers load", async () => { const { container } = render( , ); await waitFor(() => { expect(container.querySelector(".ProseMirror")).not.toBeNull(); }); const surface = container.querySelector(".ProseMirror"); expect(surface?.querySelector("h1")?.textContent).toBe("Title"); expect(surface?.textContent).toContain("Body text."); // StarterKit arrives through `peers.starterKit.default`. A broken hand-off // leaves the document without the heading node that StarterKit configures. expect(screen.getByTitle(/^Bold/)).toBeInTheDocument(); }); it("holds a placeholder while the peers are still loading", async () => { let releasePeers: (() => void) | null = null; const gate = new Promise((resolve) => { releasePeers = resolve; }); const HeldEditor = await freshEditor(async () => { await gate; const actual = await vi.importActual("./editor-peers"); return actual.loadDocumentEditorPeers(); }); const { container } = render(); // A live region, so a screen reader hears that the editor is still coming. expect(screen.getByRole("status")).toHaveTextContent("Loading editor…"); expect(container.querySelector(".ProseMirror")).toBeNull(); releasePeers?.(); await waitFor(() => { expect(container.querySelector(".ProseMirror h1")?.textContent).toBe("Title"); }); expect(screen.queryByText("Loading editor…")).toBeNull(); }); it("applies a new value prop to the loaded editor without reporting a change", async () => { const onChange = vi.fn(); const { container, rerender } = render( , ); await waitFor(() => { expect(container.querySelector(".ProseMirror")).not.toBeNull(); }); // The editor reports once when it parses the initial content. What must // not grow is the count across a value-prop write: pushing that back // through onChange makes a controlled parent echo its own write. const callsAfterMount = onChange.mock.calls.length; rerender(); await waitFor(() => { expect(container.querySelector(".ProseMirror h1")?.textContent).toBe("Changed"); }); expect(onChange).toHaveBeenCalledTimes(callsAfterMount); }); it("fails with the install list when the peers cannot be loaded", async () => { const missing = "install @tiptap/react and @tiptap/starter-kit"; const LoadFailingEditor = await freshEditor(async () => { throw new Error(missing); }); render( , ); // Loud, and only here: the consumer's build already succeeded without the // peers, so this render is the first place the gap can surface. await waitFor(() => { expect(screen.getByText(missing)).toBeInTheDocument(); }); }); });