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) {}
23}
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 }
119}
120
121function asNode(element: FakeElement): Node {
122 return element as unknown as Node;
123}
124
125function shikiCodeLine(text: string): FakeElement {
126 const token = new FakeElement("SPAN").append(new FakeText(text));
127 return new FakeElement("SPAN", ["line"]).append(token);
128}
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 );
142}
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("copies alert and insight callouts as their Markdown instead of the rendered label", () => {
202 // The shape ChatMarkdown renders for `> [!WARNING]\n> Back up first.`.
203 const callout = new FakeElement("DIV", [], {
204 role: "note",
205 "data-markdown-alert": "warning",
206 }).append(
207 new FakeElement("P", [], {
208 "data-thread-find-ignore": "true",
209 "data-markdown-alert-label": "",
210 }).append(new FakeElement("SVG", [], { "aria-hidden": "true" }), new FakeText("Warning")),
211 new FakeElement("P").append(new FakeText("Back up first.")),
212 );
213 const container = new FakeElement("DIV").append(callout);
214
215 expect(serializeRenderedMarkdownFragment(asNode(container))).toBe(
216 "> [!WARNING]\n> Back up first.",
217 );
218 const insight = new FakeElement("DIV", [], {
219 role: "note",
220 "data-markdown-alert": "insight",
221 }).append(
222 new FakeElement("P", [], {
223 "data-thread-find-ignore": "true",
224 "data-markdown-alert-label": "",
225 }).append(new FakeText("Insight")),
226 new FakeElement("P").append(new FakeText("Caches are warm.")),
227 );
228 expect(serializeRenderedMarkdownFragment(asNode(new FakeElement("DIV").append(insight)))).toBe(
229 `★ Insight ${"─".repeat(37)}\nCaches are warm.\n${"─".repeat(49)}`,
230 );
231 });
232
233 it("preserves explicit ordered-list values and continues from resets when copying", () => {
234 const list = new FakeElement("OL", [], { start: "0" }).append(
235 new FakeElement("LI").append(new FakeText("A")),
236 new FakeElement("LI", [], { value: "5" }).append(new FakeText("B")),
237 new FakeElement("LI").append(new FakeText("C")),
238 new FakeElement("LI", [], { value: "0" }).append(new FakeText("D")),
239 );
240 expect(serializeRenderedMarkdownFragment(asNode(new FakeElement("DIV").append(list)))).toBe(
241 "0. A\n5. B\n6. C\n0. D",
242 );
243 });
244
245 it("keeps a bare list item that sits alongside the code block", () => {
246 const container = new FakeElement("DIV").append(
247 new FakeElement("UL").append(new FakeElement("LI")),
248 renderedCodeBlock(["pnpm test"]),
249 );
250
251 expect(serializeRenderedMarkdownFragment(asNode(container))).toBe("-\n\npnpm test");
252 });
253
254 it("copies a command and the prose after it as shown, without code fences", () => {
255 const container = new FakeElement("DIV").append(
256 renderedCodeBlock(["printf 'hello\\n'"]),
257 new FakeElement("P").append(new FakeText("T")),
258 );
259
260 expect(serializeRenderedMarkdownFragment(asNode(container))).toBe("printf 'hello\\n'\n\nT");
261 });
262
263 it("keeps a diagram's source exactly as written when copied with prose", () => {
264 const source = 'flowchart TD\n A["first \n\n\nlast"]';
265 const container = new FakeElement("DIV").append(
266 new FakeElement("DIV", [], {
267 "data-markdown-mermaid": "",
268 "data-markdown-copy": serializeCodeBlockToMarkdown(source, "mermaid"),
269 }),
270 new FakeElement("P").append(new FakeText("After")),
271 );
272
273 expect(serializeRenderedMarkdownFragment(asNode(container))).toBe(
274 `\`\`\`mermaid\n${source}\n\`\`\`\n\nAfter`,
275 );
276 });
277
278 it("keeps blank lines inside a code block in a list item, quote, or longer fence", () => {
279 const code = ["import os", "", "", "def main():"];
280 const list = new FakeElement("OL").append(
281 new FakeElement("LI").append(new FakeText("Create it:\n"), renderedCodeBlock(code)),
282 );
283 const quote = new FakeElement("BLOCKQUOTE").append(
284 new FakeElement("P").append(new FakeText("Note:")),
285 renderedCodeBlock(code),
286 );
287 const nestedFence = new FakeElement("DIV").append(
288 new FakeElement("P").append(new FakeText("Example:")),
289 renderedCodeBlock(["```js", "a()", "", "", "b() ", "```"]),
290 );
291
292 expect(serializeRenderedMarkdownFragment(asNode(nestedFence))).toBe(
293 "Example:\n\n```js\na()\n\n\nb() \n```",
294 );
295
296 expect(serializeRenderedMarkdownFragment(asNode(new FakeElement("DIV").append(list)))).toBe(
297 "1. Create it:\n import os\n\n\n def main():",
298 );
299 expect(serializeRenderedMarkdownFragment(asNode(new FakeElement("DIV").append(quote)))).toBe(
300 "> Note:\n>\n> import os\n>\n>\n> def main():",
301 );
302 });
303
304 it("omits fences when a selection past the last line drags in the whole code block", () => {
305 const container = new FakeElement("DIV").append(
306 renderedCodeBlock(["printf '%s' 'TOKEN' | gh secret set CLOUDFLARE_API_TOKEN"]),
307 new FakeText("\n"),
308 new FakeElement("P").append(new FakeText("")),
309 );
310
311 expect(serializeRenderedMarkdownFragment(asNode(container))).toBe(
312 "printf '%s' 'TOKEN' | gh secret set CLOUDFLARE_API_TOKEN",
313 );
314 });
315});
316
317function renderedFormula(tex: string): FakeElement {
318 // MarkdownMath's wrapper; its children stand in for KaTeX's glyphs.
319 return new FakeElement("SPAN", [], {
320 "data-markdown-math": "inline",
321 "data-markdown-copy": `\\(${tex}\\)`,
322 }).append(new FakeText(tex));
323}
324
325function renderedTable(...rows: Array<Array<FakeElement | FakeText>>): FakeElement {
326 const row = (tag: string, cells: Array<FakeElement | FakeText>) =>
327 new FakeElement("TR").append(...cells.map((cell) => new FakeElement(tag).append(cell)));
328 const [head = [], ...body] = rows;
329 return new FakeElement("TABLE").append(
330 new FakeElement("THEAD").append(row("TH", head)),
331 new FakeElement("TBODY").append(...body.map((cells) => row("TD", cells))),
332 );
333}
334
335describe("copying rendered Markdown tables", () => {
336 beforeEach(() => {
337 vi.stubGlobal("Node", { TEXT_NODE, ELEMENT_NODE });
338 });
339
340 afterEach(() => {
341 vi.unstubAllGlobals();
342 });
343
344 it("keeps formula pipes as authored while escaping prose pipes", () => {
345 const table = renderedTable(
346 [new FakeText("Formula"), new FakeText("Note")],
347 [renderedFormula("|x|"), new FakeText("a | b")],
348 [renderedFormula("\\|x\\|"), new FakeElement("CODE").append(new FakeText("\\(c|d\\)"))],
349 [
350 new FakeElement("A", [], { href: "https://example.com/?q=a|b" }).append(
351 new FakeText("norm "),
352 renderedFormula("|y|"),
353 ),
354 new FakeText("e"),
355 ],
356 [
357 new FakeElement("UL").append(
358 new FakeElement("LI").append(renderedFormula("|z|"), new FakeText(" a | b")),
359 ),
360 new FakeElement("BLOCKQUOTE").append(new FakeElement("P").append(new FakeText("c | d"))),
361 ],
362 );
363 const markdown = serializeTableElementToMarkdown(table as unknown as Element);
364
365 const tree = parseMarkdownWithMath(markdown);
366 const parsed = tree.children[0];
367 if (parsed?.type !== "table") throw new Error("Expected a table");
368 expect(parsed.children.map((row) => row.children.length)).toEqual([2, 2, 2, 2, 2]);
369 expect(markdownMathRanges(markdown, tree).map((formula) => formula.tex)).toEqual([
370 "|x|",
371 "\\|x\\|",
372 "|y|",
373 "|z|",
374 ]);
375 expect(parsed.children[1]?.children[1]?.children).toMatchObject([
376 { type: "text", value: "a | b" },
377 ]);
378 expect(parsed.children[4]?.children[1]?.children).toMatchObject([
379 { type: "text", value: "> c | d" },
380 ]);
381 const [, , codeRow, linkRow] = parsed.children;
382 expect(codeRow?.children[1]?.children[0]).toMatchObject({
383 type: "inlineCode",
384 value: "\\(c|d\\)",
385 });
386 expect(linkRow?.children[0]?.children[0]).toMatchObject({
387 type: "link",
388 url: "https://example.com/?q=a|b",
389 });
390 });
391
392 it("copies code and diagrams in cells without markers or unescaped pipes", () => {
393 const code = new FakeElement("PRE").append(
394 new FakeElement("CODE").append(new FakeText("a b")),
395 );
396 const diagram = new FakeElement("DIV", [], {
397 "data-markdown-mermaid": "",
398 "data-markdown-copy": serializeCodeBlockToMarkdown("A -->|hi| B", "mermaid"),
399 });
400 expect(
401 serializeTableElementToMarkdown(
402 asNode(
403 renderedTable([new FakeText("Command"), new FakeText("Diagram")], [code, diagram]),
404 ) as Element,
405 ),
406 ).toBe("| Command | Diagram |\n| --- | --- |\n| a b | ```mermaid A -->\\|hi\\| B ``` |");
407 });
408});