1import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
2
3import { serializeRenderedMarkdownFragment } from "./markdown-clipboard";
4import { EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts";
5import {
6 collectAssistantCitations,
7 serializeAssistantCitation,
8} from "@t3tools/shared/assistantCitations";
9
10const TEXT_NODE = 3;
11const ELEMENT_NODE = 1;
12
13class FakeText {
14 readonly nodeType = TEXT_NODE;
15 readonly childNodes: ReadonlyArray<never> = [];
16
17 constructor(readonly textContent: string) {}
18}
19
20class FakeElement {
21 readonly nodeType = ELEMENT_NODE;
22 checked = false;
23 readonly childNodes: Array<FakeElement | FakeText> = [];
24 readonly classList = {
25 contains: (name: string) => this.classNames.includes(name),
26 };
27
28 constructor(
29 readonly tagName: string,
30 private readonly classNames: ReadonlyArray<string> = [],
31 private readonly attributes: Readonly<Record<string, string>> = {},
32 ) {}
33
34 get localName(): string {
35 return this.tagName.toLowerCase();
36 }
37
38 get textContent(): string {
39 return this.childNodes.map((child) => child.textContent).join("");
40 }
41
42 get children(): ReadonlyArray<FakeElement> {
43 return this.childNodes.filter((child): child is FakeElement => child instanceof FakeElement);
44 }
45
46 append(...children: Array<FakeElement | FakeText>): this {
47 this.childNodes.push(...children);
48 return this;
49 }
50
51 getAttribute(name: string): string | null {
52 return this.attributes[name] ?? null;
53 }
54
55 hasAttribute(name: string): boolean {
56 return Object.hasOwn(this.attributes, name);
57 }
58
59 closest(): FakeElement | null {
60 return null;
61 }
62
63 /** Supports only the selectors markdown-clipboard actually asks for. */
64 querySelector(selector: string): FakeElement | null {
65 if (selector.includes(", ")) {
66 for (const part of selector.split(", ")) {
67 const match = this.querySelector(part);
68 if (match) return match;
69 }
70 return null;
71 }
72 const childOnly = selector.startsWith(":scope > ");
73 const [target, ...rest] = (childOnly ? selector.slice(":scope > ".length) : selector).split(
74 " > ",
75 );
76 const matches = (element: FakeElement): boolean => {
77 if (target === 'input[type="checkbox"]') {
78 return element.tagName === "INPUT" && element.getAttribute("type") === "checkbox";
79 }
80 return element.tagName === target?.toUpperCase();
81 };
82 const search = (parent: FakeElement): FakeElement | null => {
83 for (const child of parent.childNodes) {
84 if (!(child instanceof FakeElement)) continue;
85 if (matches(child)) {
86 if (rest.length === 0) return child;
87 const nested = child.querySelector(`:scope > ${rest.join(" > ")}`);
88 if (nested) return nested;
89 }
90 if (!childOnly) {
91 const nested = search(child);
92 if (nested) return nested;
93 }
94 }
95 return null;
96 };
97 return search(this);
98 }
99}
100
101function asNode(element: FakeElement): Node {
102 return element as unknown as Node;
103}
104
105function shikiCodeLine(text: string): FakeElement {
106 const token = new FakeElement("SPAN").append(new FakeText(text));
107 return new FakeElement("SPAN", ["line"]).append(token);
108}
109
110/** Mirrors a rendered code block: select-none header chrome plus a shiki pre. */
111function renderedCodeBlock(lines: ReadonlyArray<string>): FakeElement {
112 const code = new FakeElement("CODE");
113 lines.forEach((line, index) => {
114 if (index > 0) code.append(new FakeText("\n"));
115 code.append(shikiCodeLine(line));
116 });
117 return new FakeElement("DIV", ["chat-markdown-codeblock"]).append(
118 new FakeElement("DIV", ["chat-markdown-codeblock-header", "select-none"]).append(
119 new FakeText("sh"),
120 ),
121 new FakeElement("DIV", ["chat-markdown-shiki"]).append(new FakeElement("PRE").append(code)),
122 );
123}
124
125describe("serializeRenderedMarkdownFragment", () => {
126 it("copies a popover context reference once, without its details or nested label", () => {
127 const reference = "[Review comment](t3-context://v1/review-comment/review-1)";
128 const container = new FakeElement("DIV").append(
129 new FakeText("Fix "),
130 new FakeElement("BUTTON", [], { "data-markdown-copy": reference }).append(
131 new FakeElement("SPAN", [], { "data-markdown-copy": reference }).append(
132 new FakeText("Review comment"),
133 ),
134 ),
135 new FakeText(" please"),
136 );
137
138 expect(serializeRenderedMarkdownFragment(asNode(container))).toBe(`Fix ${reference} please`);
139 });
140
141 beforeEach(() => {
142 vi.stubGlobal("Node", { TEXT_NODE, ELEMENT_NODE });
143 });
144
145 afterEach(() => {
146 vi.unstubAllGlobals();
147 });
148
149 it("wraps inline code in backticks", () => {
150 const paragraph = new FakeElement("P").append(
151 new FakeText("run "),
152 new FakeElement("CODE").append(new FakeText("git status")),
153 new FakeText(" first"),
154 );
155 const container = new FakeElement("DIV").append(paragraph);
156
157 expect(serializeRenderedMarkdownFragment(asNode(container))).toBe("run `git status` first");
158 });
159
160 describe.each([
161 { parentLayout: "tight", childLayout: "tight" },
162 { parentLayout: "tight", childLayout: "loose" },
163 { parentLayout: "loose", childLayout: "tight" },
164 { parentLayout: "loose", childLayout: "loose" },
165 ])("$parentLayout parent with $childLayout child", ({ parentLayout, childLayout }) => {
166 it.each([
167 { parentChecked: null, childChecked: true, parent: "- Parent", child: " - [x] Child" },
168 {
169 parentChecked: false,
170 childChecked: true,
171 parent: "- [ ] Parent",
172 child: " - [x] Child",
173 },
174 {
175 parentChecked: true,
176 childChecked: false,
177 parent: "- [x] Parent",
178 child: " - [ ] Child",
179 },
180 ])("copies $parent with $child", ({ parentChecked, childChecked, parent, child }) => {
181 const parentContent = parentLayout === "loose" ? new FakeElement("P") : new FakeElement("LI");
182 if (parentChecked !== null) {
183 const checkbox = new FakeElement("INPUT", [], { type: "checkbox" });
184 checkbox.checked = parentChecked;
185 parentContent.append(checkbox, new FakeText(" "));
186 }
187 parentContent.append(new FakeText("Parent"));
188 const parentItem =
189 parentLayout === "loose" ? new FakeElement("LI").append(parentContent) : parentContent;
190 const checkbox = new FakeElement("INPUT", [], { type: "checkbox" });
191 checkbox.checked = childChecked;
192 const childContent = new FakeElement(childLayout === "loose" ? "P" : "LI").append(
193 checkbox,
194 new FakeText(" Child"),
195 );
196 const childItem =
197 childLayout === "loose" ? new FakeElement("LI").append(childContent) : childContent;
198 parentItem.append(new FakeText("\n"), new FakeElement("UL").append(childItem));
199 const container = new FakeElement("DIV").append(
200 new FakeElement("P").append(new FakeText("Before")),
201 new FakeElement("UL").append(parentItem),
202 new FakeElement("P").append(new FakeText("After")),
203 );
204
205 expect(serializeRenderedMarkdownFragment(asNode(container))).toBe(
206 `Before\n\n${parent}${parentLayout === "loose" ? "\n\n" : "\n"}${child}\n\nAfter`,
207 );
208 });
209 });
210
211 it("copies the complete quote, source, and comment instead of the comment-only chip label", () => {
212 const citation = {
213 version: 1 as const,
214 environmentId: EnvironmentId.make("environment-one"),
215 threadId: ThreadId.make("thread-one"),
216 messageId: MessageId.make("assistant-one"),
217 text: "A complete quote, including the part hidden by the chip preview.",
218 comment: "What does this mean?\nPlease expand on the hidden part.",
219 start: 0,
220 end: 66,
221 prefix: "",
222 suffix: "",
223 };
224 const anchor = new FakeElement("A", [], {
225 "data-markdown-copy": serializeAssistantCitation(citation),
226 href: "/environment-one/thread-one#citation",
227 }).append(new FakeText("What does this mean?…"));
228 const chip = new FakeElement("SPAN", [], {
229 "data-markdown-copy": serializeAssistantCitation(citation),
230 }).append(anchor, new FakeElement("BUTTON").append(new FakeText("Edit comment")));
231 const container = new FakeElement("DIV").append(new FakeText("Explain "), chip);
232 const copied = serializeRenderedMarkdownFragment(asNode(container));
233 expect(copied).toBe(`Explain ${serializeAssistantCitation(citation)}`);
234 expect(collectAssistantCitations(copied).map((entry) => entry.citation)).toEqual([citation]);
235 });
236
237 it("keeps a highlighted block code selection plain when its pre wrapper is outside the range", () => {
238 const code = new FakeElement("CODE").append(
239 shikiCodeLine("git show-ref --verify refs/remotes/origin/opt/deploy/dev"),
240 );
241 const container = new FakeElement("DIV").append(code);
242
243 expect(serializeRenderedMarkdownFragment(asNode(container))).toBe(
244 "git show-ref --verify refs/remotes/origin/opt/deploy/dev",
245 );
246 });
247
248 it("keeps a multi-line code selection plain instead of inline-wrapping it", () => {
249 const code = new FakeElement("CODE").append(new FakeText("first line\nsecond line"));
250 const container = new FakeElement("DIV").append(code);
251
252 expect(serializeRenderedMarkdownFragment(asNode(container))).toBe("first line\nsecond line");
253 });
254
255 it("keeps fences when a bare list item sits alongside the code block", () => {
256 // serializeListItem emits "- " for an item with no text, so the item is
257 // content the plain-code path would drop.
258 const container = new FakeElement("DIV").append(
259 new FakeElement("UL").append(new FakeElement("LI")),
260 renderedCodeBlock(["pnpm test"]),
261 );
262
263 expect(serializeRenderedMarkdownFragment(asNode(container))).toBe("-\n\n```\npnpm test\n```");
264 });
265
266 it("keeps fences when a checkbox-only task item sits alongside the code block", () => {
267 // The checkbox is a skipped tag, so the item renders no text of its own, but
268 // it still carries the task state.
269 const container = new FakeElement("DIV").append(
270 new FakeElement("UL").append(
271 new FakeElement("LI").append(new FakeElement("INPUT", [], { type: "checkbox" })),
272 ),
273 renderedCodeBlock(["pnpm test"]),
274 );
275
276 expect(serializeRenderedMarkdownFragment(asNode(container))).toBe(
277 "- [ ]\n\n```\npnpm test\n```",
278 );
279 });
280
281 it("still drops fences for a code block that is the whole list item", () => {
282 // The item only wraps the block, so a selection that never left the pre
283 // would drop the marker too.
284 const container = new FakeElement("DIV").append(
285 new FakeElement("UL").append(new FakeElement("LI").append(renderedCodeBlock(["pnpm test"]))),
286 new FakeText("\n"),
287 );
288
289 expect(serializeRenderedMarkdownFragment(asNode(container))).toBe("pnpm test");
290 });
291
292 it("keeps fences when a file chip sits alongside the code block", () => {
293 // The chip renders as a button, a skipped tag, but its data-markdown-copy
294 // still contributes markdown, so the block is not the only visible content.
295 const container = new FakeElement("DIV").append(
296 renderedCodeBlock(["pnpm test"]),
297 new FakeText("\n"),
298 new FakeElement("BUTTON", [], { "data-markdown-copy": "`src/foo.ts`" }),
299 );
300
301 expect(serializeRenderedMarkdownFragment(asNode(container))).toBe(
302 "```\npnpm test\n```\n\n`src/foo.ts`",
303 );
304 });
305
306 it("omits fences when a selection past the last line drags in the whole code block", () => {
307 // Dragging over the final newline ends the range after the pre, so the
308 // fragment carries the block plus the empty head of the next paragraph.
309 const container = new FakeElement("DIV").append(
310 renderedCodeBlock(["printf '%s' 'TOKEN' | gh secret set CLOUDFLARE_API_TOKEN"]),
311 new FakeText("\n"),
312 new FakeElement("P").append(new FakeText("")),
313 );
314
315 expect(serializeRenderedMarkdownFragment(asNode(container))).toBe(
316 "printf '%s' 'TOKEN' | gh secret set CLOUDFLARE_API_TOKEN",
317 );
318 });
319
320 it("still fences a code block copied alongside prose", () => {
321 const container = new FakeElement("DIV").append(
322 new FakeElement("P").append(new FakeText("Run this:")),
323 renderedCodeBlock(["gh workflow run Deploy --ref main"]),
324 );
325
326 expect(serializeRenderedMarkdownFragment(asNode(container))).toBe(
327 "Run this:\n\n```\ngh workflow run Deploy --ref main\n```",
328 );
329 });
330
331 it("uses a rendered card's explicit Markdown copy representation", () => {
332 const card = new FakeElement("DIV", [], {
333 "data-markdown-copy": "Hello World (Document template)\n\n",
334 }).append(
335 new FakeElement("SPAN").append(new FakeText("Hello World")),
336 new FakeElement("SPAN").append(new FakeText("Document template")),
337 new FakeElement("BUTTON").append(new FakeText("Use template")),
338 );
339 const container = new FakeElement("DIV").append(card);
340
341 expect(serializeRenderedMarkdownFragment(asNode(container))).toBe(
342 "Hello World (Document template)",
343 );
344 });
345});