| 1 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 2 | |
| 3 | import { serializeRenderedMarkdownFragment } from "./markdown-clipboard"; |
| 4 | import { EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts"; |
| 5 | import { |
| 6 | collectAssistantCitations, |
| 7 | serializeAssistantCitation, |
| 8 | } from "@t3tools/shared/assistantCitations"; |
| 9 | |
| 10 | const TEXT_NODE = 3; |
| 11 | const ELEMENT_NODE = 1; |
| 12 | |
| 13 | class FakeText { |
| 14 | readonly nodeType = TEXT_NODE; |
| 15 | readonly childNodes: ReadonlyArray<never> = []; |
| 16 | |
| 17 | constructor(readonly textContent: string) {} |
| 18 | } |
| 19 | |
| 20 | class 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 | |
| 101 | function asNode(element: FakeElement): Node { |
| 102 | return element as unknown as Node; |
| 103 | } |
| 104 | |
| 105 | function 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. */ |
| 111 | function 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 | |
| 125 | describe("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 | }); |