1// @vitest-environment jsdom
2
3import "./dom";
4// The composer factory end to end: real EditorView in happy-dom, keys delivered as KeyboardEvents
5// on contentDOM so the keymap stack (submit precedence, code-block Enter, picker claims) is what
6// runs — not the commands called directly.
7import { EditorSelection } from "@codemirror/state";
8import { syntaxTree } from "@codemirror/language";
9import { EditorView } from "@codemirror/view";
10import { afterEach, describe, expect, it, vi } from "vite-plus/test";
11
12import { createMarkdownEditor, type MarkdownEditorOptions } from "../src/editor.ts";
13
14let destroy: (() => void) | undefined;
15afterEach(() => {
16 destroy?.();
17 destroy = undefined;
18});
19
20function mount(opts: MarkdownEditorOptions = {}) {
21 const editor = createMarkdownEditor(opts);
22 document.body.append(editor.dom);
23 destroy = () => {
24 editor.destroy();
25 editor.dom.remove();
26 };
27 return editor;
28}
29
30function press(view: EditorView, key: string, init: KeyboardEventInit = {}): void {
31 view.contentDOM.dispatchEvent(
32 new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true, ...init }),
33 );
34}
35
36describe("submit keymap", () => {
37 it("Enter submits; Shift-Enter inserts a newline instead", () => {
38 const onSubmit = vi.fn();
39 const editor = mount({ onSubmit });
40 editor.setValue("ship it");
41 press(editor.view, "Enter");
42 expect(onSubmit).toHaveBeenCalledTimes(1);
43 press(editor.view, "Enter", { shiftKey: true });
44 expect(onSubmit).toHaveBeenCalledTimes(1);
45 expect(editor.getValue()).toContain("\n");
46 });
47
48 it("Enter inside an unclosed fence scaffolds instead of submitting", () => {
49 const onSubmit = vi.fn();
50 const editor = mount({ onSubmit });
51 editor.setValue("```ts");
52 editor.view.dispatch({ selection: EditorSelection.single(5) });
53 press(editor.view, "Enter");
54 expect(onSubmit).not.toHaveBeenCalled();
55 expect(editor.getValue()).toBe("```ts\n\n```");
56 });
57
58 it("an open picker claims Enter before submit", () => {
59 const onSubmit = vi.fn();
60 const onAccept = vi.fn(() => true);
61 const editor = mount({ onSubmit, onAccept });
62 editor.setValue("hi");
63 press(editor.view, "Enter");
64 expect(onAccept).toHaveBeenCalled();
65 expect(onSubmit).not.toHaveBeenCalled();
66 });
67
68 it("without onSubmit, Enter is a plain newline (document use)", () => {
69 const editor = mount();
70 editor.setValue("line");
71 editor.view.dispatch({ selection: EditorSelection.single(4) });
72 press(editor.view, "Enter");
73 expect(editor.getValue()).toBe("line\n");
74 });
75});
76
77describe("value round-trip", () => {
78 it.each(["-", "--", "=", "=="])(
79 "retains short %s underline headings in the document preset",
80 (marker) => {
81 const doc = `Document heading\n${marker}`;
82 const editor = mount({ preset: "document", doc });
83 const heading = marker[0] === "=" ? "SetextHeading1" : "SetextHeading2";
84 expect(syntaxTree(editor.view.state).topNode.getChild(heading)).not.toBeNull();
85 expect(editor.getValue()).toBe(doc);
86 },
87 );
88
89 it.each(["Title\n---", "Title\n --- \t", "> Title\n> ---", "- Title\n ---", "Title\n--\n---"])(
90 "retains deliberate composer underline headings in %j",
91 (doc) => {
92 const editor = mount({ doc });
93 expect(syntaxTree(editor.view.state).toString()).toContain("SetextHeading2");
94 expect(syntaxTree(editor.view.state).toString()).not.toContain("HorizontalRule");
95 expect(editor.getValue()).toBe(doc);
96 },
97 );
98
99 it("setValue hydrates mention tokens and getValue serializes them back", () => {
100 const editor = mount();
101 editor.setValue("see @src/app.ts:3-5 ok");
102 expect(editor.view.state.doc.toString()).toMatch(/⟦mention:\d+⟧/);
103 expect(editor.getValue()).toBe("see @src/app.ts:3-5 ok");
104 });
105
106 it("insertMention replaces the @query and serializes to the token", () => {
107 const editor = mount();
108 editor.setValue("see @ap");
109 editor.insertMention(
110 { kind: "file", label: "app.ts", token: "src/app.ts" },
111 { from: 4, to: 7 },
112 );
113 expect(editor.getValue().trim()).toBe("see @src/app.ts");
114 });
115
116 it("onChange reports serialized text on every edit", () => {
117 const changes: string[] = [];
118 const editor = mount({ onChange: (value) => changes.push(value) });
119 editor.setValue("see @src/app.ts");
120 editor.view.dispatch({
121 selection: EditorSelection.single(editor.view.state.doc.length),
122 });
123 editor.insertText("now");
124 expect(changes.at(-1)).toBe("see @src/app.ts now");
125 });
126});
127
128describe.each(["composer", "document"] as const)("%s blockquotes", (preset) => {
129 it.each([
130 ["> quoted\nnormal", [true, false]],
131 ["> first\n> second\nnormal", [true, true, false]],
132 ["> quoted\n indented", [true, false]],
133 ["> quoted\n\tindented", [true, false]],
134 ["> outer\n>> inner\n> outer again\nnormal", [true, true, true, false]],
135 ["- > quoted\n normal\n- sibling", [true, false, false]],
136 ["> [!NOTE]\n> details\nnormal", [true, true, false]],
137 ["> first\n>\n> second\nnormal", [true, true, true, false]],
138 ["> ```\n> code\n> ```\nnormal", [true, true, true, false]],
139 ])("requires an explicit quote marker in %j", (doc, quoted) => {
140 const editor = mount({ preset, doc, mentions: false });
141
142 expect(
143 [...editor.dom.querySelectorAll(".cm-line")].map((line) =>
144 line.classList.contains("cm-lp-quote"),
145 ),
146 ).toEqual(quoted);
147 expect(editor.getValue()).toBe(doc);
148 expect(syntaxTree(editor.view.state).resolveInner(doc.length, -1).parent?.name).not.toBe(
149 "Blockquote",
150 );
151 });
152
153 it("reparses quote boundaries when a marker is added or removed", () => {
154 const editor = mount({ preset, doc: "> first\nsecond" });
155 const secondLine = editor.view.state.doc.line(2).from;
156
157 editor.view.dispatch({ changes: { from: secondLine, insert: "> " } });
158 expect(editor.dom.querySelectorAll(".cm-lp-quote")).toHaveLength(2);
159
160 editor.view.dispatch({ changes: { from: secondLine, to: secondLine + 2 } });
161 expect(editor.dom.querySelectorAll(".cm-lp-quote")).toHaveLength(1);
162 expect(editor.getValue()).toBe("> first\nsecond");
163 });
164
165 it("finishes quoted link definitions before unmarked text", () => {
166 const doc = '> [example]: https://example.test "Title"\nnormal';
167 const editor = mount({ preset, doc });
168 const root = syntaxTree(editor.view.state).topNode;
169
170 expect(root.getChild("Blockquote")?.getChild("LinkReference")).not.toBeNull();
171 const paragraph = root.getChild("Paragraph")!;
172 expect(editor.view.state.sliceDoc(paragraph.from, paragraph.to)).toBe("normal");
173 });
174
175 it("keeps quoted tables while excluding an unmarked row", () => {
176 const doc = "> | A | B |\n> | - | - |\n> | 1 | 2 |\n| 3 | 4 |";
177 const editor = mount({ preset, doc });
178 const root = syntaxTree(editor.view.state).topNode;
179
180 expect(root.getChild("Blockquote")?.getChild("Table")).not.toBeNull();
181 const paragraph = root.getChild("Paragraph")!;
182 expect(editor.view.state.sliceDoc(paragraph.from, paragraph.to)).toBe("| 3 | 4 |");
183 });
184
185 it("keeps inline formatting from crossing the end of a quote", () => {
186 const editor = mount({ preset, doc: "> **quoted\nnormal**" });
187
188 expect(editor.dom.querySelector(".cm-lp-strong")).toBeNull();
189 expect(syntaxTree(editor.view.state).topNode.getChild("Paragraph")?.from).toBe(11);
190 });
191
192 it("continues explicit quotes with Enter and exits an empty quote", () => {
193 const editor = mount({ preset, doc: "> quoted" });
194 editor.view.dispatch({ selection: EditorSelection.single(editor.view.state.doc.length) });
195 const modifiers = preset === "composer" ? { shiftKey: true } : {};
196
197 press(editor.view, "Enter", modifiers);
198 expect(editor.getValue()).toBe("> quoted\n> ");
199
200 press(editor.view, "Enter", modifiers);
201 editor.insertText("normal");
202 expect(editor.getValue()).toBe("> quoted\nnormal");
203 expect(editor.dom.querySelectorAll(".cm-lp-quote")).toHaveLength(1);
204 });
205});
206
207describe("trigger hooks", () => {
208 it("fires onMention/onSlash/onEmoji as the caret enters and leaves runs", () => {
209 const mentions: unknown[] = [];
210 const slashes: unknown[] = [];
211 const editor = mount({
212 onMention: (t) => mentions.push(t),
213 onSlash: (t) => slashes.push(t),
214 });
215 // setValue leaves the caret at 0; a trigger needs the caret at the end of its run.
216 editor.setValue("@ap");
217 editor.view.dispatch({ selection: EditorSelection.single(3) });
218 expect(mentions.at(-1)).toEqual({ from: 0, to: 3, query: "ap" });
219 editor.setValue("/go");
220 editor.view.dispatch({ selection: EditorSelection.single(3) });
221 expect(slashes.at(-1)).toEqual({ from: 0, to: 3, query: "go" });
222 editor.setValue("plain");
223 editor.view.dispatch({ selection: EditorSelection.single(5) });
224 expect(mentions.at(-1)).toBeNull();
225 expect(slashes.at(-1)).toBeNull();
226 });
227});
228
229describe("consumer extension seam", () => {
230 it("mounts consumer extensions into the same view", () => {
231 const editor = mount({
232 extensions: [EditorView.editorAttributes.of({ class: "consumer-marker" })],
233 });
234 expect(editor.dom.className).toContain("consumer-marker");
235 });
236
237 it("placeholder renders only when configured", () => {
238 const editor = mount({ placeholder: "Say something" });
239 expect(editor.dom.textContent).toContain("Say something");
240 const bare = createMarkdownEditor();
241 expect(bare.dom.querySelector(".cm-placeholder")).toBeNull();
242 bare.destroy();
243 });
244});
245
246describe("host-owned composer layers", () => {
247 it("preserves raw source instead of hydrating mentions when the host owns chips", () => {
248 const editor = mount({ mentions: false, preview: false, doc: "see @src/app.ts now" });
249 expect(editor.view.state.doc.toString()).toBe("see @src/app.ts now");
250 editor.setValue("next @src/other.ts now");
251 expect(editor.view.state.doc.toString()).toBe("next @src/other.ts now");
252 expect(editor.dom.querySelector(".chip")).toBeNull();
253 });
254});