1// @vitest-environment jsdom
2import { EditorView } from "@codemirror/view";
3import { act } from "react";
4import { createRoot, type Root } from "react-dom/client";
5import { EnvironmentId, ThreadId } from "@t3tools/contracts";
6import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
7
8const externalLink = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
9const fileLink = vi.hoisted(() => vi.fn());
10const media = vi.hoisted(() => ({ source: vi.fn(), showMenu: vi.fn() }));
11vi.mock("../media/MediaActions", () => ({
12 MediaActions: ({
13 source,
14 children,
15 }: {
16 source: unknown;
17 children: import("react").ReactNode;
18 }) => {
19 media.source(source);
20 return children;
21 },
22 useMediaActionMenu: () => ({ showMenu: media.showMenu }),
23}));
24vi.mock("~/browser/useOpenLink", () => ({ useOpenLink: () => externalLink }));
25vi.mock("~/rightPanelStore", () => ({
26 useRightPanelStore: { getState: () => ({ openFile: fileLink }) },
27}));
28vi.mock("~/assets/assetUrls", () => ({
29 useAssetUrls: (_environmentId: unknown, resources: Array<{ path: string }>) =>
30 resources.map(
31 (resource) => `https://host.example/asset?path=${encodeURIComponent(resource.path)}`,
32 ),
33}));
34vi.mock("~/components/composerCodeBlockHighlight", () => ({ composerCodeBlockHighlight: [] }));
35
36import { FileMarkdownPreview } from "./FileMarkdownPreview";
37import { MediaPlaybackProvider } from "../media/MediaPlaybackHost";
38
39let root: Root;
40let container: HTMLDivElement;
41const change = vi.fn();
42let playing: WeakSet<HTMLMediaElement>;
43const threadRef = {
44 environmentId: EnvironmentId.make("environment"),
45 threadId: ThreadId.make("thread"),
46};
47
48beforeEach(() => {
49 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
50 playing = new WeakSet();
51 vi.spyOn(HTMLMediaElement.prototype, "paused", "get").mockImplementation(function (
52 this: HTMLMediaElement,
53 ) {
54 return !playing.has(this);
55 });
56 vi.spyOn(HTMLMediaElement.prototype, "play").mockImplementation(function (
57 this: HTMLMediaElement,
58 ) {
59 playing.add(this);
60 this.dispatchEvent(new Event("play"));
61 return Promise.resolve();
62 });
63 vi.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(function (
64 this: HTMLMediaElement,
65 ) {
66 playing.delete(this);
67 this.dispatchEvent(new Event("pause"));
68 });
69 vi.spyOn(HTMLMediaElement.prototype, "load").mockImplementation(() => {});
70 Object.defineProperty(Range.prototype, "getClientRects", { configurable: true, value: () => [] });
71 Object.defineProperty(Range.prototype, "getBoundingClientRect", {
72 configurable: true,
73 value: () => new DOMRect(),
74 });
75 container = document.createElement("div");
76 document.body.append(container);
77 root = createRoot(container);
78 change.mockClear();
79 externalLink.mockClear();
80 fileLink.mockClear();
81 media.source.mockClear();
82 media.showMenu.mockClear();
83});
84afterEach(async () => {
85 await act(() => root.unmount());
86 container.remove();
87 vi.restoreAllMocks();
88 vi.unstubAllGlobals();
89});
90async function render(text: string, readOnly = false, revision: string | null = null) {
91 await act(async () =>
92 root.render(
93 <MediaPlaybackProvider>
94 <FileMarkdownPreview
95 cwd="/workspace"
96 relativePath="docs/note.md"
97 text={text}
98 threadRef={threadRef}
99 draft={false}
100 readOnly={readOnly}
101 revision={revision}
102 onChange={change}
103 />
104 </MediaPlaybackProvider>,
105 ),
106 );
107 return EditorView.findFromDOM(container.querySelector(".cm-editor")!)!;
108}
109
110describe("Markdown document file viewer", () => {
111 it("edits the source without remounting and adopts external updates without saving them back", async () => {
112 const view = await render("# Original\n\n- [ ] Task");
113 expect(container.querySelector(".cm-lp-h1")).not.toBeNull();
114 await act(() =>
115 view.dispatch({ changes: { from: 2, to: 10, insert: "Edited" }, userEvent: "input" }),
116 );
117 expect(change).toHaveBeenLastCalledWith("# Edited\n\n- [ ] Task");
118 change.mockClear();
119 await act(() => view.dispatch({ selection: { anchor: 4, head: 7 } }));
120 expect(await render("# External\n\nFresh contents")).toBe(view);
121 expect(view.state.doc.toString()).toBe("# External\n\nFresh contents");
122 expect(change).not.toHaveBeenCalled();
123 expect(view.state.selection.main.anchor).toBe(4);
124 expect(view.state.selection.main.head).toBe(7);
125 });
126
127 it("opens file-relative and external links from the keyboard", async () => {
128 const view = await render("[Note](./other.md) [Site](https://example.com)");
129 await act(() => {
130 view.dispatch({ selection: { anchor: 3 } });
131 view.contentDOM.dispatchEvent(
132 new KeyboardEvent("keydown", {
133 key: "Enter",
134 metaKey: true,
135 bubbles: true,
136 cancelable: true,
137 }),
138 );
139 });
140 expect(fileLink).toHaveBeenCalledWith(threadRef, "docs/./other.md", undefined);
141 await act(() => {
142 view.dispatch({ selection: { anchor: 21 } });
143 view.contentDOM.dispatchEvent(
144 new KeyboardEvent("keydown", {
145 key: "Enter",
146 metaKey: true,
147 bubbles: true,
148 cancelable: true,
149 }),
150 );
151 });
152 expect(externalLink).toHaveBeenCalledWith("https://example.com");
153 });
154
155 it("keeps read-only files and task widgets uneditable", async () => {
156 const view = await render("- [ ] Task", true);
157 expect(view.state.readOnly).toBe(true);
158 expect(view.contentDOM.getAttribute("aria-readonly")).toBe("true");
159 expect(view.contentDOM.getAttribute("contenteditable")).toBe("false");
160 const box = container.querySelector<HTMLInputElement>('input[type="checkbox"]')!;
161 expect(box.disabled).toBe(true);
162 await act(() => box.click());
163 expect(view.state.doc.toString()).toBe("- [ ] Task");
164 expect(change).not.toHaveBeenCalled();
165 });
166
167 it("resolves relative images against the Markdown file's directory through host assets", async () => {
168 await render("![Figure](figure.png)");
169 expect(container.querySelector<HTMLImageElement>(".cm-lp-image img")?.src).toBe(
170 "https://host.example/asset?path=%2Fworkspace%2Fdocs%2Ffigure.png",
171 );
172 });
173
174 it("uses the shared custom player and source-aware actions for document videos", async () => {
175 const source = "![Clip](clip.mp4)";
176 const view = await render(source, true);
177 const video = container.querySelector("video")!;
178 expect(video).not.toBeNull();
179 expect(video.controls).toBe(false);
180 expect(video.src).toBe("https://host.example/asset?path=%2Fworkspace%2Fdocs%2Fclip.mp4");
181 expect(container.querySelector('button[aria-label="Play video"]')).not.toBeNull();
182 const menu = container.querySelector<HTMLButtonElement>('button[aria-label="Video actions"]')!;
183 await act(() => menu.click());
184 expect(media.showMenu).toHaveBeenCalledOnce();
185 expect(media.source).toHaveBeenLastCalledWith(
186 expect.objectContaining({
187 kind: "video",
188 name: "clip.mp4",
189 reference: {
190 kind: "file",
191 path: "/workspace/docs/clip.mp4",
192 relativePath: "docs/clip.mp4",
193 },
194 asset: {
195 environmentId: threadRef.environmentId,
196 resource: {
197 _tag: "media-file",
198 threadId: threadRef.threadId,
199 path: "/workspace/docs/clip.mp4",
200 },
201 },
202 }),
203 );
204 expect(view.state.doc.toString()).toBe(source);
205 expect(change).not.toHaveBeenCalled();
206 });
207
208 it("keeps the playing document video when navigating to another chat", async () => {
209 await render("![Clip](clip.mp4)", true);
210 const video = container.querySelector("video")!;
211 video.currentTime = 12;
212 await act(() => video.play());
213 await act(() =>
214 root.render(
215 <MediaPlaybackProvider>
216 <main>Another chat</main>
217 </MediaPlaybackProvider>,
218 ),
219 );
220 expect(container.querySelector("[data-media-floating-player] video")).toBe(video);
221 expect(video.currentTime).toBe(12);
222 expect(video.paused).toBe(false);
223 });
224
225 it("preserves the document player across signed source revisions", async () => {
226 const text = "![Clip](clip.mp4)";
227 await render(text, true, "first");
228 const video = container.querySelector("video")!;
229 video.currentTime = 12;
230 await act(() => video.play());
231 await render(text, true, "second");
232 expect(container.querySelector("video")).toBe(video);
233 expect(video.src).toContain("workspace-revision=first");
234 expect(video.currentTime).toBe(12);
235 expect(video.paused).toBe(false);
236 });
237});