Skip to content
Source
Sign inSign up

markdown-clipboard.test.ts

376 lines13.3 KB

1import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
2​
3import {
4 serializeCodeBlockToMarkdown,
5 serializeRenderedMarkdownFragment,
6 serializeTableElementToMarkdown,
7} from "./markdown-clipboard";
8import { EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts";
9import {
10 collectAssistantCitations,
11 serializeAssistantCitation,
12} from "@t3tools/shared/assistantCitations";
13import { markdownMathRanges, parseMarkdownWithMath } from "@t3tools/shared/markdownMath";
14​
15const TEXT_NODE = 3;
16const ELEMENT_NODE = 1;
17​
18class FakeText {
19 readonly nodeType = TEXT_NODE;
20 readonly childNodes: ReadonlyArray<never> = [];
21​
22 constructor(readonly textContent: string) {}
24​
25class FakeElement {
26 readonly nodeType = ELEMENT_NODE;
27 checked = false;
28 readonly childNodes: Array<FakeElement | FakeText> = [];
29 readonly classList = {
30 contains: (name: string) => this.classNames.includes(name),
31 };
32​
33 constructor(
34 readonly tagName: string,
35 private readonly classNames: ReadonlyArray<string> = [],
36 private readonly attributes: Readonly<Record<string, string>> = {},
37 ) {}
38​
39 get localName(): string {
40 return this.tagName.toLowerCase();
41 }
42​
43 get textContent(): string {
44 return this.childNodes.map((child) => child.textContent).join("");
45 }
46​
47 get children(): ReadonlyArray<FakeElement> {
48 return this.childNodes.filter((child): child is FakeElement => child instanceof FakeElement);
49 }
50​
51 append(...children: Array<FakeElement | FakeText>): this {
52 this.childNodes.push(...children);
53 return this;
54 }
55​
56 getAttribute(name: string): string | null {
57 return this.attributes[name] ?? null;
58 }
59​
60 hasAttribute(name: string): boolean {
61 return Object.hasOwn(this.attributes, name);
62 }
63​
64 closest(): FakeElement | null {
65 return null;
66 }
67​
68 querySelector(selector: string): FakeElement | null {
69 if (selector.includes(", ")) {
70 for (const part of selector.split(", ")) {
71 const match = this.querySelector(part);
72 if (match) return match;
73 }
74 return null;
75 }
76 const childOnly = selector.startsWith(":scope > ");
77 const [target, ...rest] = (childOnly ? selector.slice(":scope > ".length) : selector).split(
78 " > ",
79 );
80 const matches = (element: FakeElement): boolean => {
81 if (target === 'input[type="checkbox"]') {
82 return element.tagName === "INPUT" && element.getAttribute("type") === "checkbox";
83 }
84 return element.tagName === target?.toUpperCase();
85 };
86 const search = (parent: FakeElement): FakeElement | null => {
87 for (const child of parent.childNodes) {
88 if (!(child instanceof FakeElement)) continue;
89 if (matches(child)) {
90 if (rest.length === 0) return child;
91 const nested = child.querySelector(`:scope > ${rest.join(" > ")}`);
92 if (nested) return nested;
93 }
94 if (!childOnly) {
95 const nested = search(child);
96 if (nested) return nested;
97 }
98 }
99 return null;
100 };
101 return search(this);
102 }
103​
104 /** Supports the `:scope > a > b` child paths the table serializer queries. */
105 querySelectorAll(selector: string): FakeElement[] {
106 return selector.split(", ").flatMap((part) =>
107 part
108 .replace(/^:scope > /, "")
109 .split(" > ")
110 .reduce<FakeElement[]>(
111 (level, tag) =>
112 level.flatMap((element) =>
113 element.children.filter((child) => child.tagName === tag.toUpperCase()),
114 ),
115 [this],
116 ),
117 );
118 }
120​
121function asNode(element: FakeElement): Node {
122 return element as unknown as Node;
124​
125function shikiCodeLine(text: string): FakeElement {
126 const token = new FakeElement("SPAN").append(new FakeText(text));
127 return new FakeElement("SPAN", ["line"]).append(token);
129​
130function renderedCodeBlock(lines: ReadonlyArray<string>): FakeElement {
131 const code = new FakeElement("CODE");
132 lines.forEach((line, index) => {
133 if (index > 0) code.append(new FakeText("\n"));
134 code.append(shikiCodeLine(line));
135 });
136 return new FakeElement("DIV", ["chat-markdown-codeblock"]).append(
137 new FakeElement("DIV", ["chat-markdown-codeblock-header", "select-none"]).append(
138 new FakeText("sh"),
139 ),
140 new FakeElement("DIV", ["chat-markdown-shiki"]).append(new FakeElement("PRE").append(code)),
141 );
143​
144describe("serializeRenderedMarkdownFragment", () => {
145 it("copies a popover context reference once, without its details or nested label", () => {
146 const reference = "[Review comment](t3-context://v1/review-comment/review-1)";
147 const container = new FakeElement("DIV").append(
148 new FakeText("Fix "),
149 new FakeElement("BUTTON", [], { "data-markdown-copy": reference }).append(
150 new FakeElement("SPAN", [], { "data-markdown-copy": reference }).append(
151 new FakeText("Review comment"),
152 ),
153 ),
154 new FakeText(" please"),
155 );
156​
157 expect(serializeRenderedMarkdownFragment(asNode(container))).toBe(`Fix ${reference} please`);
158 });
159​
160 beforeEach(() => {
161 vi.stubGlobal("Node", { TEXT_NODE, ELEMENT_NODE });
162 });
163​
164 afterEach(() => {
165 vi.unstubAllGlobals();
166 });
167​
168 it("copies the complete quote, source, and comment instead of the comment-only chip label", () => {
169 const citation = {
170 version: 1 as const,
171 environmentId: EnvironmentId.make("environment-one"),
172 threadId: ThreadId.make("thread-one"),
173 messageId: MessageId.make("assistant-one"),
174 text: "A complete quote, including the part hidden by the chip preview.",
175 comment: "What does this mean?\nPlease expand on the hidden part.",
176 start: 0,
177 end: 66,
178 prefix: "",
179 suffix: "",
180 };
181 const anchor = new FakeElement("A", [], {
182 "data-markdown-copy": serializeAssistantCitation(citation),
183 href: "/environment-one/thread-one#citation",
184 }).append(new FakeText("What does this mean?…"));
185 const chip = new FakeElement("SPAN", [], {
186 "data-markdown-copy": serializeAssistantCitation(citation),
187 }).append(anchor, new FakeElement("BUTTON").append(new FakeText("Edit comment")));
188 const container = new FakeElement("DIV").append(new FakeText("Explain "), chip);
189 const copied = serializeRenderedMarkdownFragment(asNode(container));
190 expect(copied).toBe(`Explain ${serializeAssistantCitation(citation)}`);
191 expect(collectAssistantCitations(copied).map((entry) => entry.citation)).toEqual([citation]);
192 });
193​
194 it("keeps a multi-line code selection plain instead of inline-wrapping it", () => {
195 const code = new FakeElement("CODE").append(new FakeText("first line\nsecond line"));
196 const container = new FakeElement("DIV").append(code);
197​
198 expect(serializeRenderedMarkdownFragment(asNode(container))).toBe("first line\nsecond line");
199 });
200​
201 it("preserves explicit ordered-list values and continues from resets when copying", () => {
202 const list = new FakeElement("OL", [], { start: "0" }).append(
203 new FakeElement("LI").append(new FakeText("A")),
204 new FakeElement("LI", [], { value: "5" }).append(new FakeText("B")),
205 new FakeElement("LI").append(new FakeText("C")),
206 new FakeElement("LI", [], { value: "0" }).append(new FakeText("D")),
207 );
208 expect(serializeRenderedMarkdownFragment(asNode(new FakeElement("DIV").append(list)))).toBe(
209 "0. A\n5. B\n6. C\n0. D",
210 );
211 });
212​
213 it("keeps a bare list item that sits alongside the code block", () => {
214 const container = new FakeElement("DIV").append(
215 new FakeElement("UL").append(new FakeElement("LI")),
216 renderedCodeBlock(["pnpm test"]),
217 );
218​
219 expect(serializeRenderedMarkdownFragment(asNode(container))).toBe("-\n\npnpm test");
220 });
221​
222 it("copies a command and the prose after it as shown, without code fences", () => {
223 const container = new FakeElement("DIV").append(
224 renderedCodeBlock(["printf 'hello\\n'"]),
225 new FakeElement("P").append(new FakeText("T")),
226 );
227​
228 expect(serializeRenderedMarkdownFragment(asNode(container))).toBe("printf 'hello\\n'\n\nT");
229 });
230​
231 it("keeps a diagram's source exactly as written when copied with prose", () => {
232 const source = 'flowchart TD\n A["first \n\n\nlast"]';
233 const container = new FakeElement("DIV").append(
234 new FakeElement("DIV", [], {
235 "data-markdown-mermaid": "",
236 "data-markdown-copy": serializeCodeBlockToMarkdown(source, "mermaid"),
237 }),
238 new FakeElement("P").append(new FakeText("After")),
239 );
240​
241 expect(serializeRenderedMarkdownFragment(asNode(container))).toBe(
242 `\`\`\`mermaid\n${source}\n\`\`\`\n\nAfter`,
243 );
244 });
245​
246 it("keeps blank lines inside a code block in a list item, quote, or longer fence", () => {
247 const code = ["import os", "", "", "def main():"];
248 const list = new FakeElement("OL").append(
249 new FakeElement("LI").append(new FakeText("Create it:\n"), renderedCodeBlock(code)),
250 );
251 const quote = new FakeElement("BLOCKQUOTE").append(
252 new FakeElement("P").append(new FakeText("Note:")),
253 renderedCodeBlock(code),
254 );
255 const nestedFence = new FakeElement("DIV").append(
256 new FakeElement("P").append(new FakeText("Example:")),
257 renderedCodeBlock(["```js", "a()", "", "", "b() ", "```"]),
258 );
259​
260 expect(serializeRenderedMarkdownFragment(asNode(nestedFence))).toBe(
261 "Example:\n\n```js\na()\n\n\nb() \n```",
262 );
263​
264 expect(serializeRenderedMarkdownFragment(asNode(new FakeElement("DIV").append(list)))).toBe(
265 "1. Create it:\n import os\n\n\n def main():",
266 );
267 expect(serializeRenderedMarkdownFragment(asNode(new FakeElement("DIV").append(quote)))).toBe(
268 "> Note:\n>\n> import os\n>\n>\n> def main():",
269 );
270 });
271​
272 it("omits fences when a selection past the last line drags in the whole code block", () => {
273 const container = new FakeElement("DIV").append(
274 renderedCodeBlock(["printf '%s' 'TOKEN' | gh secret set CLOUDFLARE_API_TOKEN"]),
275 new FakeText("\n"),
276 new FakeElement("P").append(new FakeText("")),
277 );
278​
279 expect(serializeRenderedMarkdownFragment(asNode(container))).toBe(
280 "printf '%s' 'TOKEN' | gh secret set CLOUDFLARE_API_TOKEN",
281 );
282 });
283});
284​
285function renderedFormula(tex: string): FakeElement {
286 // MarkdownMath's wrapper; its children stand in for KaTeX's glyphs.
287 return new FakeElement("SPAN", [], {
288 "data-markdown-math": "inline",
289 "data-markdown-copy": `\\(${tex}\\)`,
290 }).append(new FakeText(tex));
292​
293function renderedTable(...rows: Array<Array<FakeElement | FakeText>>): FakeElement {
294 const row = (tag: string, cells: Array<FakeElement | FakeText>) =>
295 new FakeElement("TR").append(...cells.map((cell) => new FakeElement(tag).append(cell)));
296 const [head = [], ...body] = rows;
297 return new FakeElement("TABLE").append(
298 new FakeElement("THEAD").append(row("TH", head)),
299 new FakeElement("TBODY").append(...body.map((cells) => row("TD", cells))),
300 );
302​
303describe("copying rendered Markdown tables", () => {
304 beforeEach(() => {
305 vi.stubGlobal("Node", { TEXT_NODE, ELEMENT_NODE });
306 });
307​
308 afterEach(() => {
309 vi.unstubAllGlobals();
310 });
311​
312 it("keeps formula pipes as authored while escaping prose pipes", () => {
313 const table = renderedTable(
314 [new FakeText("Formula"), new FakeText("Note")],
315 [renderedFormula("|x|"), new FakeText("a | b")],
316 [renderedFormula("\\|x\\|"), new FakeElement("CODE").append(new FakeText("\\(c|d\\)"))],
317 [
318 new FakeElement("A", [], { href: "https://example.com/?q=a|b" }).append(
319 new FakeText("norm "),
320 renderedFormula("|y|"),
321 ),
322 new FakeText("e"),
323 ],
324 [
325 new FakeElement("UL").append(
326 new FakeElement("LI").append(renderedFormula("|z|"), new FakeText(" a | b")),
327 ),
328 new FakeElement("BLOCKQUOTE").append(new FakeElement("P").append(new FakeText("c | d"))),
329 ],
330 );
331 const markdown = serializeTableElementToMarkdown(table as unknown as Element);
332​
333 const tree = parseMarkdownWithMath(markdown);
334 const parsed = tree.children[0];
335 if (parsed?.type !== "table") throw new Error("Expected a table");
336 expect(parsed.children.map((row) => row.children.length)).toEqual([2, 2, 2, 2, 2]);
337 expect(markdownMathRanges(markdown, tree).map((formula) => formula.tex)).toEqual([
338 "|x|",
339 "\\|x\\|",
340 "|y|",
341 "|z|",
342 ]);
343 expect(parsed.children[1]?.children[1]?.children).toMatchObject([
344 { type: "text", value: "a | b" },
345 ]);
346 expect(parsed.children[4]?.children[1]?.children).toMatchObject([
347 { type: "text", value: "> c | d" },
348 ]);
349 const [, , codeRow, linkRow] = parsed.children;
350 expect(codeRow?.children[1]?.children[0]).toMatchObject({
351 type: "inlineCode",
352 value: "\\(c|d\\)",
353 });
354 expect(linkRow?.children[0]?.children[0]).toMatchObject({
355 type: "link",
356 url: "https://example.com/?q=a|b",
357 });
358 });
359​
360 it("copies code and diagrams in cells without markers or unescaped pipes", () => {
361 const code = new FakeElement("PRE").append(
362 new FakeElement("CODE").append(new FakeText("a b")),
363 );
364 const diagram = new FakeElement("DIV", [], {
365 "data-markdown-mermaid": "",
366 "data-markdown-copy": serializeCodeBlockToMarkdown("A -->|hi| B", "mermaid"),
367 });
368 expect(
369 serializeTableElementToMarkdown(
370 asNode(
371 renderedTable([new FakeText("Command"), new FakeText("Diagram")], [code, diagram]),
372 ) as Element,
373 ),
374 ).toBe("| Command | Diagram |\n| --- | --- |\n| a b | ```mermaid A -->\\|hi\\| B ``` |");
375 });
376});