// @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from "vitest"; import { createElement } from "react"; import { flushSync } from "react-dom"; import { createRoot } from "react-dom/client"; import { Editor } from "@tiptap/core"; import { Document } from "@tiptap/extension-document"; import { Text } from "@tiptap/extension-text"; import { EditorContent } from "@tiptap/react"; import { CODE_LANGUAGE_OPTIONS } from "@/components/code-block-full-language-catalog"; import { codeBlockEditorExtension } from "@/components/code-block-node-view"; import { blockExtensions, pageColorExtensions } from "@/components/pages/blocks"; vi.mock("next-intl", () => ({ useTranslations: () => (key: string) => ({ codeLanguage: "Code language", codeLanguageEmpty: "No languages found", codeLanguageSearch: "Search languages", codePlainText: "Plain text", codeWrap: "Wrap long lines", collapse: "Collapse", copied: "Copied", copy: "Copy", expand: "Expand", })[key] ?? key, })); // Stubs for the UI primitives the component under test renders. const uiStubs = vi.hoisted(() => { let value: Promise> | undefined; return () => (value ??= (async () => { const { createElement, forwardRef } = await import("react"); const Control = forwardRef< HTMLButtonElement, React.ButtonHTMLAttributes & { size?: string; variant?: string } >(function Control({ children, size: _size, variant: _variant, ...props }, ref) { return createElement("button", { ...props, ref }, children); }); return { Button: Control, IconButton: Control, cn: (...classes: Array) => classes.filter(Boolean).join(" "), }; })()); }); vi.mock("@/components/ui/button", async (importOriginal) => { const stubs = await uiStubs(); return { ...(await importOriginal>()), Button: stubs.Button }; }); vi.mock("@/components/ui/icon-button", async (importOriginal) => { const stubs = await uiStubs(); return { ...(await importOriginal>()), IconButton: stubs.IconButton }; }); // The real picker opens through an `asChild` trigger: Radix clones the // trigger element with its click handler. The mock keeps that contract, so a // trigger that drops the props it receives cannot open the menu here either. vi.mock("@/components/search-select", async () => { const { cloneElement } = await import("react"); return { SearchSelect: ({ trigger, onChange, }: { trigger: React.ReactElement<{ onClick?: () => void }>; onChange: (value: string) => void; }) => cloneElement(trigger, { onClick: () => onChange("python") }), }; }); vi.mock("@/components/ui/tooltip", async () => { const { createElement, Fragment } = await import("react"); return { Tooltip: ({ children }: { children: React.ReactNode }) => createElement(Fragment, null, children), TooltipContent: ({ children }: { children: React.ReactNode }) => createElement("span", null, children), TooltipTrigger: ({ children }: { children: React.ReactNode }) => createElement(Fragment, null, children), }; }); afterEach(() => vi.unstubAllGlobals()); function makeEditor(editable = false) { return new Editor({ element: document.createElement("div"), editable, content: { type: "doc", content: [ { type: "codeBlock", attrs: { language: "typescript" }, content: [{ type: "text", text: "const answer = 42" }], }, ], }, extensions: [Document, Text, ...blockExtensions()] as never, }); } function makeLiveEditor() { const editor = new Editor({ editable: true, content: { type: "doc", content: [ { type: "codeBlock", attrs: { language: "typescript" }, content: [{ type: "text", text: "const answer = 42" }], }, ], }, // The code block names the page's color marks in its schema. extensions: [Document, Text, codeBlockEditorExtension(), ...pageColorExtensions()] as never, }); const host = document.createElement("div"); document.body.append(host); const root = createRoot(host); flushSync(() => root.render(createElement(EditorContent, { editor }))); return { editor, destroy: () => { flushSync(() => root.unmount()); editor.destroy(); host.remove(); }, }; } describe("TipTap code block node view", () => { it("offers a broad searchable language catalog", () => { expect(CODE_LANGUAGE_OPTIONS.length).toBeGreaterThan(180); expect(CODE_LANGUAGE_OPTIONS.map((option) => option.value)).toEqual( expect.arrayContaining(["javascript", "typescript", "python", "rust"]), ); }); it("renders syntax decorations and a working copy button on pages", async () => { const writeText = vi.fn().mockResolvedValue(undefined); Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText }, }); const editor = makeEditor(); const button = editor.view.dom.querySelector( ".code-block-node-copy", ); expect(button).not.toBeNull(); expect(button?.getAttribute("aria-label")).toBe("Copy"); expect(button?.textContent).toBe(""); expect(button?.querySelector("svg")).not.toBeNull(); expect(editor.view.dom.querySelector(".hljs-keyword")).not.toBeNull(); button?.click(); await vi.waitFor(() => expect(writeText).toHaveBeenCalledWith("const answer = 42")); editor.destroy(); }); it("persists a language edited in the block header", () => { const editor = makeEditor(true); const language = editor.view.dom.querySelector( ".code-block-node-language", ); expect(language?.value).toBe("typescript"); if (!language) throw new Error("The code language input was not rendered"); language.value = "javascript"; language.dispatchEvent(new Event("input", { bubbles: true })); expect(editor.getJSON().content?.[0]?.attrs?.language).toBe("javascript"); editor.destroy(); }); it("renders compact live controls without a leading code icon", async () => { const live = makeLiveEditor(); const { editor } = live; await vi.waitFor(() => expect(editor.view.dom.querySelector(".code-block-node-language-trigger")).not.toBeNull(), ); const trigger = editor.view.dom.querySelector(".code-block-node-language-trigger"); const wrap = editor.view.dom.querySelector(".code-block-node-wrap"); expect(trigger?.textContent).toContain("TypeScript"); expect(trigger?.querySelector(".lucide-code-2")).toBeNull(); expect(trigger?.querySelector(".lucide-chevrons-up-down")).toBeNull(); expect(trigger?.querySelector("svg")).not.toBeNull(); expect(wrap?.getAttribute("aria-pressed")).toBe("true"); wrap?.click(); await vi.waitFor(() => expect(wrap?.getAttribute("aria-pressed")).toBe("false")); live.destroy(); }); it("changes the language from the header picker", async () => { const live = makeLiveEditor(); const { editor } = live; let trigger: HTMLButtonElement | null = null; await vi.waitFor(() => { trigger = editor.view.dom.querySelector(".code-block-node-language-trigger"); expect(trigger).not.toBeNull(); }); trigger!.click(); await vi.waitFor(() => expect(editor.getJSON().content?.[0]?.attrs?.language).toBe("python")); live.destroy(); }); it("offers expansion only when the live code body exceeds the height cap", async () => { let notifyResize = () => {}; class TestResizeObserver { constructor(callback: ResizeObserverCallback) { notifyResize = () => callback([], this as unknown as ResizeObserver); } observe() {} disconnect() {} } vi.stubGlobal("ResizeObserver", TestResizeObserver); const live = makeLiveEditor(); const { editor } = live; let pre: Element | null = null; await vi.waitFor(() => { pre = editor.view.dom.querySelector(".code-block-node-view > pre"); expect(pre).not.toBeNull(); }); Object.defineProperty(pre, "scrollHeight", { configurable: true, value: 461 }); notifyResize(); await vi.waitFor(() => expect(editor.view.dom.querySelector(".code-block-node-expand")).not.toBeNull(), ); const view = editor.view.dom.querySelector(".code-block-node-view"); const button = editor.view.dom.querySelector( ".code-block-node-expand button", ); expect(view?.getAttribute("data-expanded")).toBe("false"); expect(button?.textContent).toContain("Expand"); button?.click(); await vi.waitFor(() => expect(view?.getAttribute("data-expanded")).toBe("true")); expect(button?.textContent).toContain("Collapse"); live.destroy(); }); });