1// @vitest-environment jsdom
2
3import "./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
7import { javascriptLanguage } from "@codemirror/lang-javascript";
8import { markdown, markdownLanguage } from "@codemirror/lang-markdown";
9import { EditorSelection, EditorState } from "@codemirror/state";
10import { afterEach, describe, expect, it } from "vite-plus/test";
11
12import { createMarkdownEditor } from "../src/editor.ts";
13import { formattingDecorations } from "../src/live-preview.ts";
14
15let cleanup: (() => void) | undefined;
16afterEach(() => {
17 cleanup?.();
18 cleanup = undefined;
19});
20
21function 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
30describe("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
67describe("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
83describe("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.
91describe("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
99it("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
112it("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});