Skip to content
Source
Sign inSign up

ChatMarkdown.browser.test.tsx

717 lines27.4 KB

1import { EnvironmentId, ThreadId } from "@t3tools/contracts";
2import { AsyncResult } from "effect/reactivity";
3import { type ReactElement } from "react";
4import { describe, expect, it, vi } from "vite-plus/test";
5import { page, userEvent } from "vite-plus/test/browser";
6​
7import { act, render } from "../../test/render";
8import "../index.css";
9​
10import { filesystemEnvironment } from "../state/filesystem";
11import { useRightPanelStore } from "../rightPanelStore";
12import { remarkEnableOrderedListNumbers } from "@t3tools/shared/markdownPipeline";
13import { setMarkdownTaskChecked } from "@t3tools/shared/markdownTaskList";
14import { HostImageUrlContext } from "./chat/hostImageUrl";
15​
16const { workspaceSearch, filesystemMetadata } = vi.hoisted(() => ({
17 workspaceSearch: vi.fn(),
18 filesystemMetadata: vi.fn(),
19}));
20const parsedSources = vi.hoisted(() => [] as string[]);
21vi.mock(import("mdast-util-from-markdown"), async (original) => {
22 const actual = await original();
23 return {
24 ...actual,
25 fromMarkdown: ((source: string, ...rest: never[]) => {
26 parsedSources.push(String(source));
27 return (actual.fromMarkdown as (...args: unknown[]) => unknown)(source, ...rest);
28 }) as typeof actual.fromMarkdown,
29 };
30});
31​
32vi.mock(import("../state/use-atom-query-runner"), async (original) => ({
33 ...(await original()),
34 useAtomQueryRunner: ((family: unknown) =>
35 family === filesystemEnvironment.getMetadata ? filesystemMetadata : workspaceSearch) as never,
36}));
37​
38// Atoms derived from the server config run in the real registry over a missing config.
39vi.mock(import("../state/server"), async (original) => {
40 const actual = await original();
41 const { Atom } = await import("effect/reactivity");
42 const noConfig = Atom.make(null);
43 const modelsConfig = Atom.make({
44 environment: { platform: { os: "darwin" } },
45 availableEditors: [],
46 settings: { providerInstances: {} },
47 providers: [
48 {
49 instanceId: "codex",
50 driver: "codex",
51 displayName: "Codex",
52 models: [{ slug: "gpt-6.1", name: "GPT-6.1", isCustom: false, capabilities: {} }],
53 },
54 ],
55 });
56 return {
57 ...actual,
58 serverEnvironment: {
59 ...actual.serverEnvironment,
60 configValueAtom: ((environmentId: string) =>
61 environmentId === "models" ? modelsConfig : noConfig) as never,
62 },
63 };
64});
65vi.mock(import("../state/session"), async (original) => {
66 const { AuthStandardClientScopes } = await import("@t3tools/contracts");
67 const granted = new Set<string>(AuthStandardClientScopes);
68 const hasScope = (environmentId: unknown, scope: string) =>
69 environmentId !== null && granted.has(scope);
70 return {
71 ...(await original()),
72 useEnvironmentScope: hasScope as never,
73 readEnvironmentScope: hasScope as never,
74 };
75});
76vi.mock(import("../remoteOpen"), async (original) => ({
77 ...(await original()),
78 useRemoteOpenResolution: () => ({ state: { mode: "local-exec" }, isResolved: true }) as never,
79}));
80const { renderDiagram } = vi.hoisted(() => ({
81 renderDiagram: vi.fn<(source: string) => Promise<string> | undefined>(),
82}));
83// Control rendering latency without running Mermaid layout.
84vi.mock(import("./chat/MermaidDiagram"), async (original) => {
85 const { use } = await import("react");
86 return {
87 ...(await original()),
88 MermaidDiagram: ({ source }: { source: string }) => {
89 const pending = renderDiagram(source);
90 return <svg role="img" aria-label={pending ? use(pending) : "Diagram"} />;
91 },
92 };
93});
94​
95const appearance = vi.hoisted(() => ({
96 value: "light" as "light" | "dark",
97 listeners: new Set<() => void>(),
98}));
99vi.mock(import("../hooks/useTheme"), async (original) => {
100 const { useSyncExternalStore } = await import("react");
101 const subscribe = (listener: () => void) => {
102 appearance.listeners.add(listener);
103 return () => appearance.listeners.delete(listener);
104 };
105 return {
106 ...(await original()),
107 useRenderedAppearance: () => useSyncExternalStore(subscribe, () => appearance.value),
108 };
109});
110​
111const settingsOverrides = vi.hoisted(() => ({ mathRenderingEnabled: false }));
112vi.mock(import("../hooks/useSettings"), async (original) => {
113 const actual = await original();
114 type Settings = ReturnType<typeof actual.getClientSettings>;
115 function useClientSettings<T = Settings>(select?: (settings: Settings) => T): T {
116 const settings = { ...actual.getClientSettings(), ...settingsOverrides };
117 return select ? select(settings) : (settings as T);
118 }
119 return { ...actual, useClientSettings };
120});
121​
122import ChatMarkdown from "./ChatMarkdown";
123import { chatMarkdownClipboardPayload } from "../markdown-clipboard";
124import { AudioPreview } from "./files/AudioPreview";
125​
126it("keeps compact audio playback loaded across signed URL renewals", async () => {
127 const original =
128 "data:audio/wav;base64,UklGRiUAAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQEAAACA";
129 const renewed = `${original}#renewed`;
130 const preview = (src: string, revision = "clip-v1") => (
131 <AudioPreview compact name="clip.wav" src={src} revision={revision} />
132 );
133 await using screen = await render(preview(original));
134 const audio = screen.container.querySelector("audio")!;
135 await act(() => {
136 audio.dispatchEvent(new Event("loadedmetadata"));
137 audio.dispatchEvent(new Event("play"));
138 });
139 await screen.rerender(preview(renewed));
140 expect(screen.container.querySelector("audio")).toBe(audio);
141 expect(audio.getAttribute("src")).toBe(original);
142 await expect.element(screen.getByRole("button", { name: "Pause clip.wav" })).toBeEnabled();
143 await screen.rerender(preview(renewed, "clip-v2"));
144 expect(screen.container.querySelector("audio")).not.toBe(audio);
145 expect(screen.container.querySelector("audio")?.getAttribute("src")).toBe(renewed);
146});
147​
148function setup() {
149 appearance.value = "light";
150 parsedSources.length = 0;
151 settingsOverrides.mathRenderingEnabled = false;
152 renderDiagram.mockReset();
153 workspaceSearch.mockReset().mockResolvedValue(AsyncResult.success({ entries: [] }));
154 filesystemMetadata.mockReset().mockResolvedValue(AsyncResult.success({ entries: [null] }));
156​
157function renderText(text: string, props: Partial<Parameters<typeof ChatMarkdown>[0]> = {}) {
158 return render(<ChatMarkdown cwd="/tmp/project" text={text} {...props} />);
160​
161function buttonsLabelled(container: HTMLElement, label: string) {
162 return [...container.querySelectorAll<HTMLButtonElement>("button")].filter(
163 (button) => button.getAttribute("aria-label") === label,
164 );
166​
167const SOURCE_NUMBER_PLUGINS = [remarkEnableOrderedListNumbers];
168​
169it("keeps decimal markers in nested user lists while documents keep their counter styles", async () => {
170 setup();
171 const text = "1. Outer\n 1. Nested\n 7. Skipped\n 1. Deeper\n 4. Fourth";
172 await using screen = await render(
173 <div className="grid grid-cols-2 gap-8 p-8">
174 <section>
175 <h2>User message</h2>
176 <ChatMarkdown
177 cwd={undefined}
178 text={text}
179 className="user-message-markdown"
180 extraRemarkPlugins={SOURCE_NUMBER_PLUGINS}
181 />
182 </section>
183 <section>
184 <h2>Markdown preview</h2>
185 <ChatMarkdown cwd={undefined} text={text} />
186 </section>
187 </div>,
188 );
189 await expect.element(screen.getByText("User message", { exact: true })).toBeVisible();
190 const [message, preview] = screen.container.querySelectorAll(".chat-markdown");
191 expect(
192 [...message!.querySelectorAll("ol")].map((list) => getComputedStyle(list).listStyleType),
193 ).toEqual(["decimal", "decimal", "decimal"]);
194 expect(
195 [...preview!.querySelectorAll("ol")].map((list) => getComputedStyle(list).listStyleType),
196 ).toEqual(["decimal", "lower-alpha", "lower-roman"]);
197});
198​
199it("shows unavailable audio when a workspace embed has no thread", async () => {
200 setup();
201 await using screen = await renderText("![recording](clip.wav)");
202 expect(screen.container.textContent).toContain("Audio unavailable");
203 expect(screen.container.textContent).not.toContain("Loading audio");
204 expect(screen.container.querySelector("audio")).toBeNull();
205});
206​
207it("loads a host image through the page's own route when there is no environment", async () => {
208 setup();
209 const pixel =
210 "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=";
211 const hostImageUrl = vi.fn((_path: string) => pixel);
212 await using screen = await render(
213 <HostImageUrlContext value={hostImageUrl}>
214 <ChatMarkdown cwd={undefined} text="![Shot](</work/my shot.png>)" />
215 </HostImageUrlContext>,
216 );
217 await expect.element(screen.getByAltText("Shot")).toHaveAttribute("src", pixel);
218 expect(new Set(hostImageUrl.mock.calls.map(([path]) => path))).toEqual(
219 new Set(["/work/my shot.png"]),
220 );
221});
222​
223it("loads the <picture> source for the current theme through the image route", async () => {
224 setup();
225 const pixel =
226 "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=";
227 const hostImageUrl = (path: string) => `${pixel}#${path}`;
228 await using screen = await render(
229 <HostImageUrlContext value={hostImageUrl}>
230 <ChatMarkdown
231 cwd={undefined}
232 text={
233 '<picture><source media="(prefers-color-scheme: dark)" srcset="/work/dark.png"><img src="/work/light.png" alt="Shot"></picture>'
234 }
235 />
236 </HostImageUrlContext>,
237 );
238 await expect
239 .element(screen.getByAltText("Shot"))
240 .toHaveAttribute("src", `${pixel}#/work/light.png`);
241​
242 await act(() => {
243 appearance.value = "dark";
244 for (const listener of appearance.listeners) listener();
245 });
246 await expect
247 .element(screen.getByAltText("Shot"))
248 .toHaveAttribute("src", `${pixel}#/work/dark.png`);
249});
250​
251it("reuses the parse of a streaming reply's finished blocks when it remounts", async () => {
252 setup();
253 const message = (
254 <ChatMarkdown cwd="/tmp/project" text={"Finished block\n\nStill growing"} isStreaming />
255 );
256 {
257 await using _first = await render(message);
258 }
259 await using screen = await render(message);
260 await expect.element(screen.getByText("Still growing")).toBeVisible();
261 expect(parsedSources.filter((source) => source.includes("Finished block"))).toHaveLength(1);
262});
263​
264it("reuses the parse of a remounted message that has stable extra plugins", async () => {
265 setup();
266 const parsed = vi.fn();
267 const countingPlugins = [() => () => void parsed()];
268 const message = (
269 <ChatMarkdown cwd="/tmp/project" text="Cached text" extraRemarkPlugins={countingPlugins} />
270 );
271 {
272 await using _first = await render(message);
273 }
274 await using screen = await render(message);
275 await expect.element(screen.getByText("Cached text")).toBeVisible();
276 expect(parsed).toHaveBeenCalledTimes(1);
277});
278​
279it("highlights a code block's own source when an earlier block of the same length was cached", async () => {
280 setup();
281 const highlightedText = (container: HTMLElement) =>
282 container.querySelector(".chat-markdown-shiki")?.textContent?.trim();
283 // These two lines share a 32-bit FNV-1a hash and a length.
284 const first = 'const commitSha = "f36a6d8fe0aa";';
285 const second = 'const commitSha = "cb06f8d135b3";';
286 {
287 await using screen = await renderText(`\`\`\`ts\n${first}\n\`\`\``);
288 await vi.waitFor(() => expect(highlightedText(screen.container)).toBe(first));
289 }
290 await using screen = await renderText(`\`\`\`ts\n${second}\n\`\`\``);
291 await vi.waitFor(() => expect(highlightedText(screen.container)).toBe(second));
292});
293​
294describe("ChatMarkdown favicon privacy", () => {
295 it("suppresses private link images while preserving public links across updates", async () => {
296 setup();
297 const markdown = (url: string) => <ChatMarkdown cwd="/tmp/project" text={`[Link](${url})`} />;
298 await using screen = await render(markdown("https://example.com"));
299 const { container, rerender } = screen;
300 expect([...container.querySelectorAll("img")].map((image) => image.src)).toEqual([
301 "https://www.google.com/s2/favicons?domain=example.com&sz=32",
302 ]);
303 for (const url of ["http://192.168.1.10:8080", "http://localhost:3000", "http://home.arpa"]) {
304 await rerender(markdown(url));
305 expect(container.querySelectorAll("img")).toHaveLength(0);
306 }
307 await rerender(markdown("https://example.com"));
308 expect(container.querySelectorAll("img")).toHaveLength(1);
309 await rerender(markdown("https://github.com/pingdotgg/t3code/pull/1"));
310 expect(container.querySelectorAll("img")).toHaveLength(0);
311 expect(container.querySelector("a svg")).not.toBeNull();
312 });
313});
314​
315describe("ChatMarkdown external link labels", () => {
316 it("keeps a leading emoji whole", async () => {
317 setup();
318 await using screen = await render(
319 <ChatMarkdown cwd="/tmp/project" text="[πŸ˜€ Read the documentation](https://example.com)" />,
320 );
321 await vi.waitFor(() => {
322 const link = screen.container.querySelector("a");
323 expect(link?.textContent).toContain("πŸ˜€ Read");
324 const walker = document.createTreeWalker(link!, NodeFilter.SHOW_TEXT);
325 const textNodes: string[] = [];
326 while (walker.nextNode()) textNodes.push(walker.currentNode.nodeValue ?? "");
327 expect(textNodes.filter((node) => /\p{Surrogate}/u.test(node))).toEqual([]);
328 });
329 });
330});
331​
332describe("ChatMarkdown streaming", () => {
333 it("runs only a complete single-line shell block after a click", async () => {
334 setup();
335 const onRunShellCommand = vi.fn();
336 const message = (text: string, isStreaming = false) => (
337 <ChatMarkdown
338 cwd="/tmp/project"
339 text={text}
340 isStreaming={isStreaming}
341 onRunShellCommand={onRunShellCommand}
342 />
343 );
344 await using screen = await render(message("```bash\necho hello\n```", true));
345 const { container, rerender } = screen;
346 const runButtons = () => buttonsLabelled(container, "Run in terminal");
347 expect(runButtons()).toHaveLength(0);
348​
349 await rerender(message("```bash\necho hello\n```"));
350 await act(async () => runButtons()[0]!.click());
351 expect(onRunShellCommand).toHaveBeenCalledExactlyOnceWith("echo hello");
352​
353 for (const text of [
354 "~~~bash\necho tilde\n~~~",
355 "> ```bash\n> echo quote\n> ```",
356 "````bash\necho four\n````",
357 "- Command\n\n ```bash\n echo list\n ```",
358 "```bash\r\necho crlf\r\n```\r",
359 ]) {
360 await rerender(message(text));
361 expect(runButtons()).toHaveLength(1);
362 }
363​
364 for (const text of [
365 "```bash\necho one\necho two\n```",
366 "```typescript\necho hello\n```",
367 "```bash\n\n```",
368 "```bash\necho hello\n\n```",
369 "```bash\necho hello\\\n```",
370 "```bash\necho safe \u202e#\n```",
371 "```bash\necho incomplete",
372 "~~~bash\necho incomplete",
373 "````bash\necho incomplete\n```",
374 '<pre><code class="language-bash">echo html</code></pre>',
375 ]) {
376 await rerender(message(text));
377 expect(runButtons()).toHaveLength(0);
378 }
379 });
380​
381 it("edits the current task text and marker after reusing a renderer", async () => {
382 setup();
383 let editedText: string | undefined;
384 let rerender: (next: ReactElement) => Promise<void> = async () => {};
385 const message = (text: string): ReactElement => (
386 <ChatMarkdown
387 cwd="/tmp/project"
388 text={text}
389 onTaskListChange={({ markerOffset, checked }) => {
390 editedText = setMarkdownTaskChecked(text, markerOffset, checked);
391 void rerender(message(editedText));
392 }}
393 />
394 );
395 await using rendered = await render(message("- [ ] First\n- [ ] Second"));
396 rerender = rendered.rerender;
397 const { container } = rendered;
398 const checkboxes = () => container.querySelectorAll<HTMLInputElement>('input[type="checkbox"]');
399 const original = checkboxes()[1]!;
400 await rerender(message("- [ ] A longer first task\n- [ ] Second"));
401 expect(checkboxes()[1]).toBe(original);
402​
403 await act(async () => original.click());
404 expect(editedText).toBe("- [ ] A longer first task\n- [x] Second");
405 await vi.waitFor(() => expect(checkboxes()[1]!.checked).toBe(true));
406 await rerender(message("- [ ] A longer first task\n- [ ] Second"));
407 expect(checkboxes()[1]).toBe(original);
408 expect(checkboxes()[1]!.checked).toBe(false);
409 });
410​
411 it.each([
412 ["read-only", undefined],
413 ["editable", () => {}],
414 ])(
415 "unchecks the remaining %s task when the checked row above it is removed",
416 async (_, onTaskListChange) => {
417 setup();
418 const message = (text: string) => (
419 <ChatMarkdown cwd="/tmp/project" text={text} onTaskListChange={onTaskListChange} />
420 );
421 await using screen = await render(message("- [x] Completed item\n- [ ] Pending item"));
422 await screen.rerender(message("- [ ] Pending item"));
423 const checkboxes =
424 screen.container.querySelectorAll<HTMLInputElement>('input[type="checkbox"]');
425 expect(checkboxes).toHaveLength(1);
426 expect(checkboxes[0]!.checked).toBe(false);
427 },
428 );
429});
430​
431describe("ChatMarkdown workspace file opens", () => {
432 const threadRef = {
433 environmentId: EnvironmentId.make("workspace"),
434 threadId: ThreadId.make("thread"),
435 };
436​
437 it("keeps a newer absolute file open when an earlier repo-relative lookup finishes", async () => {
438 setup();
439 const result = AsyncResult.success({
440 entries: [{ path: "notes/a.md", kind: "file" }],
441 truncated: false,
442 });
443 let resolvePending!: (value: typeof result) => void;
444 const pending = new Promise<typeof result>((resolve) => {
445 resolvePending = resolve;
446 });
447 workspaceSearch.mockReturnValueOnce(pending);
448 using openFile = vi
449 .spyOn(useRightPanelStore.getState(), "openFile")
450 .mockImplementation(() => {});
451 await using screen = await renderText(
452 "[Notes](physics/notes/a.md) and [Other](/elsewhere/b.md:9)",
453 {
454 cwd: "/school/physics",
455 threadRef,
456 },
457 );
458 const { container } = screen;
459 const [notes, other] = container.querySelectorAll("a");
460 await act(async () => notes!.click());
461 expect(workspaceSearch).toHaveBeenCalledOnce();
462 expect(openFile).not.toHaveBeenCalled();
463​
464 await act(async () => other!.click());
465 await act(async () => resolvePending(result));
466​
467 expect(openFile).toHaveBeenCalledExactlyOnceWith(threadRef, "/elsewhere/b.md", 9);
468 expect(workspaceSearch).toHaveBeenCalledOnce();
469 });
470});
471​
472describe("ChatMarkdown skill chips", () => {
473 it("shows the skill's details and opens its instructions", async () => {
474 setup();
475 const threadRef = {
476 environmentId: EnvironmentId.make("workspace"),
477 threadId: ThreadId.make("thread"),
478 };
479 await using screen = await renderText("Run $review now", {
480 threadRef,
481 skills: [
482 {
483 name: "review",
484 displayName: "Review",
485 description: "Reviews the current branch.",
486 path: "/skills/review/SKILL.md",
487 enabled: true,
488 },
489 ],
490 });
491 using openFile = vi
492 .spyOn(useRightPanelStore.getState(), "openFile")
493 .mockImplementation(() => {});
494​
495 await userEvent.click(screen.getByRole("button", { name: "Skill Review. Show details" }));
496 await expect.element(page.getByText("Reviews the current branch.")).toBeVisible();
497 await userEvent.click(page.getByRole("button", { name: "View instructions" }));
498​
499 expect(openFile).toHaveBeenCalledExactlyOnceWith(threadRef, "/skills/review/SKILL.md");
500 });
501​
502 it("names a mentioned model as its provider does, without a thread", async () => {
503 setup();
504 await using screen = await renderText("Ask (model:codex/gpt-6.1), not model:openai/gpt-4o.", {
505 environmentId: EnvironmentId.make("models"),
506 });
507​
508 await expect
509 .element(screen.getByRole("button", { name: "Model GPT-6.1. Show details" }))
510 .toBeVisible();
511 expect(buttonsLabelled(screen.container, "Model gpt-4o. Show details")).toEqual([]);
512 expect(screen.container.textContent).toBe("Ask (GPT-6.1), not model:openai/gpt-4o.");
513 });
514});
515​
516describe("ChatMarkdown headings", () => {
517 it("keeps insight streaming, reference links, and heading anchors in one message", async () => {
518 setup();
519 const rule = "─".repeat(20);
520 const text = `## Setup\n\nBefore [source][src].\n\nβ˜… Insight ${rule}\nA **streaming** note.`;
521 const message = (text: string, isStreaming: boolean) => (
522 <ChatMarkdown
523 cwd={undefined}
524 text={text}
525 renderInsights
526 lineBreaks
527 isStreaming={isStreaming}
528 />
529 );
530 await using screen = await render(message(text, true));
531 await expect.element(screen.getByRole("note")).toHaveTextContent("Insight A streaming note.");
532 await screen.rerender(
533 message(
534 `${text}\n${rule}\n\n## Setup\n\nAfter [source][src].\n\n[src]:\n https://example.com`,
535 false,
536 ),
537 );
538 await expect.element(screen.getByRole("note")).toHaveTextContent("Insight A streaming note.");
539 expect([...screen.container.querySelectorAll("h2")].map((heading) => heading.id)).toEqual([
540 "user-content-setup",
541 "user-content-setup-1",
542 ]);
543 expect(
544 [...screen.container.querySelectorAll("a")].filter(
545 (link) => link.href === "https://example.com/",
546 ),
547 ).toHaveLength(2);
548 await expect.element(screen.getByRole("note")).not.toHaveTextContent("After");
549 });
550​
551 it("never gives two headings the same id, even when a suffix matches another heading", async () => {
552 setup();
553 await using screen = await renderText(
554 '## Setup\n\n## Setup\n\n## Setup-1\n\n<h2 id="install-1">Pinned</h2>\n\n## Install\n\n## Install',
555 { parseRawHtml: true },
556 );
557 const { container } = screen;
558 expect([...container.querySelectorAll("h2")].map((heading) => heading.id)).toEqual([
559 "user-content-setup",
560 "user-content-setup-1",
561 "user-content-setup-1-1",
562 "user-content-install-1",
563 "user-content-install",
564 "user-content-install-2",
565 ]);
566 });
567});
568​
569describe("ChatMarkdown inline code selection", () => {
570 it("selects the whole inline code span on double-click, but only a word in fenced code", async () => {
571 setup();
572 await using screen = await renderText(
573 "Run `migration-gate --dry-run` first.\n\n```txt\nfenced-block word\n```",
574 );
575 await userEvent.dblClick(screen.getByText("migration-gate --dry-run"));
576 expect(window.getSelection()?.toString()).toBe("migration-gate --dry-run");
577​
578 window.getSelection()?.removeAllRanges();
579 const fenced = screen.container.querySelector("pre code")!;
580 await userEvent.dblClick(fenced, { position: { x: 4, y: 4 } });
581 expect(window.getSelection()?.toString()).toBe("fenced");
582 });
583​
584 it("selects the whole line on triple-click beside a favicon link or a file chip", async () => {
585 setup();
586 await using screen = await renderText(
587 "Before [docs](https://example.com/docs) after words\n\nBefore [App.tsx](/tmp/project/src/App.tsx) after words",
588 );
589 const tripleClickWord = async (paragraph: HTMLElement, word: string) => {
590 const text = [...paragraph.childNodes].find((node) => node.textContent?.includes(word))!;
591 const range = document.createRange();
592 range.setStart(text, text.textContent!.indexOf(word));
593 range.setEnd(text, text.textContent!.indexOf(word) + word.length);
594 const wordBox = range.getBoundingClientRect();
595 const paragraphBox = paragraph.getBoundingClientRect();
596 window.getSelection()?.removeAllRanges();
597 await userEvent.tripleClick(paragraph, {
598 position: {
599 x: wordBox.left - paragraphBox.left + wordBox.width / 2,
600 y: wordBox.top - paragraphBox.top + wordBox.height / 2,
601 },
602 });
603 return window.getSelection()?.toString().trim();
604 };
605​
606 const paragraphs = screen.container.querySelectorAll("p");
607 expect(paragraphs).toHaveLength(2);
608 const selections = [];
609 const lines = [];
610 for (const paragraph of paragraphs) {
611 lines.push(paragraph.textContent?.trim(), paragraph.textContent?.trim());
612 selections.push(
613 await tripleClickWord(paragraph, "Before"),
614 await tripleClickWord(paragraph, "after"),
615 );
616 }
617 expect(selections).toEqual(lines);
618 });
619});
620​
621describe("ChatMarkdown alert copy", () => {
622 it("copies a fully selected callout as alert Markdown and a partial one as text", async () => {
623 setup();
624 await using screen = await renderText("> [!WARNING]\n> Back up first.");
625 const [label, body] = screen.container.querySelectorAll('[role="note"] > p');
626 const select = (startNode: Node, endNode: Node, endOffset: number) => {
627 const range = document.createRange();
628 range.setStart(startNode, 0);
629 range.setEnd(endNode, endOffset);
630 const selection = window.getSelection()!;
631 selection.removeAllRanges();
632 selection.addRange(range);
633 return chatMarkdownClipboardPayload(selection)?.text;
634 };
635 const labelText = label!.lastChild!;
636 const bodyText = body!.firstChild!;
637​
638 expect(select(labelText, bodyText, bodyText.textContent!.length)).toBe(
639 "> [!WARNING]\n> Back up first.",
640 );
641 expect(select(labelText, bodyText, "Back up".length)).toBe("Warning\n\nBack up");
642 });
643});
644​
645describe("ChatMarkdown file chip tooltips", () => {
646 it("wraps a long path so its file name stays inside the tooltip", async () => {
647 setup();
648 const path =
649 "/tmp/project/packages/client-runtime/src/state/generated/environment-connection-handshake-retry-policy-fixtures.ts";
650 await using screen = await renderText(`See [the fixtures](${path}).`);
651​
652 await userEvent.hover(screen.container.querySelector(".chat-markdown-file-link")!);
653 const tooltip = page.getByText(path);
654 await expect.element(tooltip).toBeVisible();
655​
656 const popup = tooltip.element().closest("[data-side]")!;
657 const pathEnd = document.createRange();
658 pathEnd.selectNodeContents(tooltip.element());
659 expect(pathEnd.getBoundingClientRect().right).toBeLessThanOrEqual(
660 popup.getBoundingClientRect().right,
661 );
662 });
663});
664​
665describe("ChatMarkdown editor deep links", () => {
666 const hrefs = (container: HTMLElement) =>
667 [...container.querySelectorAll("a")].map((link) => link.getAttribute("href"));
668​
669 it("keeps local editor file links", async () => {
670 setup();
671 await using screen = await renderText(
672 "[file.md line 3](cursor://file/Users/me/repo/file.md:3) and [same](vscode://file/Users/me/repo/file.md:3:7)",
673 );
674 expect(hrefs(screen.container)).toEqual([
675 "cursor://file/Users/me/repo/file.md:3",
676 "vscode://file/Users/me/repo/file.md:3:7",
677 ]);
678 });
679​
680 it("still drops other editor links and unknown schemes", async () => {
681 setup();
682 await using screen = await renderText(
683 "[prompt](cursor://anysphere.cursor-deeplink/prompt?text=hi) [user](cursor://me@file/Users/me/file.md) [unknown](foo://file/Users/me/file.md)",
684 );
685 expect(screen.container.innerHTML).not.toContain("://");
686 expect(hrefs(screen.container).filter(Boolean)).toEqual([]);
687 });
688});
689​
690describe("ChatMarkdown Windows file links", () => {
691 const environmentId = EnvironmentId.make("env-windows");
692 const renderWindows = (text: string, parseRawHtml: boolean) =>
693 renderText(text, {
694 cwd: "C:/Users/shawn/project",
695 environmentId,
696 lineBreaks: !parseRawHtml,
697 parseRawHtml,
698 });
699 const fileHrefs = (container: HTMLElement) =>
700 [...container.querySelectorAll(".chat-markdown-file-link")].map((chip) =>
701 chip.getAttribute("href"),
702 );
703​
704 it("still rejects unsafe schemes", async () => {
705 setup();
706 for (const parseRawHtml of [true, false]) {
707 await using screen = await renderWindows(
708 "[unsafe](javascript:alert(1)) and [unknown](d:alert(1))",
709 parseRawHtml,
710 );
711 const { container } = screen;
712 expect(container.innerHTML).not.toContain("javascript:");
713 expect(container.innerHTML).not.toContain("d:alert");
714 expect(fileHrefs(container)).toEqual([]);
715 }
716 });
717});