| 1 | // @vitest-environment jsdom |
| 2 | |
| 3 | import "./dom"; |
| 4 | // The live-preview decoration layer, asserted through a real EditorView so the StateField, |
| 5 | // provide-wiring, and class names are all exercised — the piece the kiln properties cannot reach. |
| 6 | |
| 7 | import { javascriptLanguage } from "@codemirror/lang-javascript"; |
| 8 | import { markdown, markdownLanguage } from "@codemirror/lang-markdown"; |
| 9 | import { EditorSelection, EditorState } from "@codemirror/state"; |
| 10 | import { afterEach, describe, expect, it } from "vite-plus/test"; |
| 11 | |
| 12 | import { createMarkdownEditor } from "../src/editor.ts"; |
| 13 | import { formattingDecorations } from "../src/live-preview.ts"; |
| 14 | |
| 15 | let cleanup: (() => void) | undefined; |
| 16 | afterEach(() => { |
| 17 | cleanup?.(); |
| 18 | cleanup = undefined; |
| 19 | }); |
| 20 | |
| 21 | function mount(doc: string, head = 0) { |
| 22 | const editor = createMarkdownEditor({ doc }); |
| 23 | document.body.append(editor.dom); |
| 24 | editor.view.contentDOM.dispatchEvent(new FocusEvent("focus")); |
| 25 | editor.view.dispatch({ selection: EditorSelection.single(head) }); |
| 26 | cleanup = () => editor.destroy(); |
| 27 | return editor; |
| 28 | } |
| 29 | |
| 30 | describe("live preview in the view", () => { |
| 31 | it("hides emphasis marks away from the selection and styles the content", () => { |
| 32 | const editor = mount("plain **bold** tail", 0); |
| 33 | const strong = editor.view.dom.querySelector(".cm-lp-strong"); |
| 34 | expect(strong?.textContent).toBe("bold"); |
| 35 | expect(editor.view.dom.textContent).not.toContain("**"); |
| 36 | }); |
| 37 | |
| 38 | it("reveals muted marks when the caret touches the node", () => { |
| 39 | const editor = mount("plain **bold** tail", 9); |
| 40 | expect(editor.view.dom.textContent).toContain("**bold**"); |
| 41 | expect(editor.view.dom.querySelector(".cm-lp-mark")).not.toBeNull(); |
| 42 | }); |
| 43 | |
| 44 | it("boxes fenced code line by line with rounded first and last lines", () => { |
| 45 | const editor = mount("```ts\ncode\n```\n", 0); |
| 46 | const lines = editor.view.dom.querySelectorAll(".cm-lp-codeblock"); |
| 47 | expect(lines.length).toBe(3); |
| 48 | expect(lines[0]?.className).toContain("cm-lp-codeblock-top"); |
| 49 | expect(lines[lines.length - 1]?.className).toContain("cm-lp-codeblock-bottom"); |
| 50 | expect(editor.view.dom.querySelector(".cm-lp-code-lang")?.textContent).toBe("ts"); |
| 51 | }); |
| 52 | |
| 53 | it("uses a consumer-supplied parser to highlight fenced code", () => { |
| 54 | const editor = createMarkdownEditor({ |
| 55 | doc: "```ts\nexport const answer = 42\n```", |
| 56 | codeLanguages: (info: string) => (info === "ts" ? javascriptLanguage : null), |
| 57 | }); |
| 58 | document.body.append(editor.dom); |
| 59 | cleanup = () => editor.destroy(); |
| 60 | const token = [...editor.dom.querySelectorAll(".cm-line span")].find( |
| 61 | (element) => element.textContent === "export", |
| 62 | ); |
| 63 | expect(token?.className).not.toBe(""); |
| 64 | }); |
| 65 | }); |
| 66 | |
| 67 | describe("formattingDecorations bounds", () => { |
| 68 | it("never emits a range outside the document", () => { |
| 69 | const docs = ["", "*a*", "``", "**x** `y` ~~z~~", "```\nunclosed", "- [ ] task *i*"]; |
| 70 | for (const doc of docs) { |
| 71 | const state = EditorState.create({ |
| 72 | doc, |
| 73 | extensions: [markdown({ base: markdownLanguage })], |
| 74 | }); |
| 75 | for (const range of formattingDecorations(state)) { |
| 76 | expect(range.from).toBeGreaterThanOrEqual(0); |
| 77 | expect(range.to).toBeLessThanOrEqual(doc.length); |
| 78 | } |
| 79 | } |
| 80 | }); |
| 81 | }); |
| 82 | |
| 83 | describe("html stays inert", () => { |
| 84 | it("keeps HTML out of the grammar (no tag nodes, rendered literally)", () => { |
| 85 | const editor = mount("<div>hi</div>", 0); |
| 86 | expect(editor.view.dom.textContent).toContain("<div>hi</div>"); |
| 87 | }); |
| 88 | }); |
| 89 | |
| 90 | // Sanity for the vitest/happy-dom substrate itself: a view mounts and measures without throwing. |
| 91 | describe("view smoke", () => { |
| 92 | it("mounts, edits, and destroys", () => { |
| 93 | const editor = mount("hello", 0); |
| 94 | editor.view.dispatch({ changes: { from: 5, insert: " world" } }); |
| 95 | expect(editor.getValue()).toBe("hello world"); |
| 96 | }); |
| 97 | }); |
| 98 | |
| 99 | it("leaves host-owned atomic source ranges untouched by link preview", () => { |
| 100 | const value = "before [file.ts](src/file.ts) after"; |
| 101 | const state = EditorState.create({ |
| 102 | doc: value, |
| 103 | extensions: [markdown({ base: markdownLanguage })], |
| 104 | }); |
| 105 | const from = value.indexOf("["); |
| 106 | const to = value.indexOf(" after"); |
| 107 | const ranges = formattingDecorations(state, { exclude: () => [{ from, to }] }); |
| 108 | expect(ranges.some((range) => range.from >= from && range.to <= to)).toBe(false); |
| 109 | expect(state.doc.toString()).toBe(value); |
| 110 | }); |
| 111 | |
| 112 | it("renders readonly tasks as disabled controls and never rewrites their source", () => { |
| 113 | const editor = createMarkdownEditor({ |
| 114 | doc: "- [ ] task", |
| 115 | extensions: [EditorState.readOnly.of(true)], |
| 116 | }); |
| 117 | document.body.append(editor.dom); |
| 118 | cleanup = () => editor.destroy(); |
| 119 | const checkbox = editor.dom.querySelector<HTMLInputElement>('input[type="checkbox"]')!; |
| 120 | expect(checkbox.disabled).toBe(true); |
| 121 | checkbox.dispatchEvent(new Event("change", { bubbles: true })); |
| 122 | expect(editor.getValue()).toBe("- [ ] task"); |
| 123 | }); |