1// @vitest-environment jsdom
2import "./dom";
3
4import { Compartment, EditorSelection } from "@codemirror/state";
5import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
6
7import { createMarkdownEditor, type MarkdownEditor } from "../src/editor.ts";
8import { livePreview } from "../src/live-preview.ts";
9import type { VideoPreviewInput, VideoPreviewRenderer } from "../src/widgets.ts";
10
11let editor: MarkdownEditor | undefined;
12
13beforeEach(() => {
14 vi.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => {});
15});
16
17afterEach(() => {
18 editor?.destroy();
19 editor?.dom.remove();
20 editor = undefined;
21 vi.restoreAllMocks();
22});
23
24function mount(source: string) {
25 editor = createMarkdownEditor({ preset: "document", doc: source });
26 document.body.append(editor.dom);
27 return editor;
28}
29
30describe("document video embeds", () => {
31 it.each(["./clip.mp4", "https://media.example/CLIP.WEBM?token=abc#t=2", "<./my clip.mov>"])(
32 "plays %s through native controls without editing the source",
33 (url) => {
34 const source = `![Demonstration](${url})`;
35 const instance = mount(source);
36 const video = instance.dom.querySelector("video")!;
37 expect(video).not.toBeNull();
38 expect(instance.dom.querySelector(".cm-lp-image img")).toBeNull();
39 expect(video.controls).toBe(true);
40 expect(video.autoplay).toBe(false);
41 expect(video.getAttribute("aria-label")).toBe("Demonstration");
42 video.focus();
43 expect(document.activeElement).toBe(video);
44 const key = new KeyboardEvent("keydown", { key: " ", bubbles: true, cancelable: true });
45 video.dispatchEvent(key);
46 expect(key.defaultPrevented).toBe(false);
47 expect(instance.getValue()).toBe(source);
48 expect(instance.dom.querySelector("video")).toBe(video);
49 },
50 );
51
52 it("recognizes the authored path before host signing and preserves playback through URL refreshes", () => {
53 const presentation = new Compartment();
54 const options = (url: string) => livePreview({ blockWidgets: true, resolveUrl: () => url });
55 editor = createMarkdownEditor({
56 preset: "document",
57 preview: false,
58 doc: "![Clip](clip.mp4)",
59 extensions: [presentation.of(options("https://host.example/asset?token=first"))],
60 });
61 document.body.append(editor.dom);
62 const video = editor.dom.querySelector("video")!;
63 let paused = false;
64 Object.defineProperty(video, "paused", { configurable: true, get: () => paused });
65 Object.defineProperty(video, "duration", { configurable: true, value: 100 });
66 video.currentTime = 12;
67
68 editor.view.dispatch({
69 effects: presentation.reconfigure(options("https://host.example/asset?token=second")),
70 });
71 expect(editor.dom.querySelector("video")).toBe(video);
72 expect(video.src).toBe("https://host.example/asset?token=first");
73 expect(video.currentTime).toBe(12);
74 expect(video.pause).not.toHaveBeenCalled();
75
76 paused = true;
77 video.dispatchEvent(new Event("pause"));
78 expect(video.src).toBe("https://host.example/asset?token=second");
79 video.currentTime = 0;
80 video.dispatchEvent(new Event("loadedmetadata"));
81 expect(video.currentTime).toBe(12);
82 expect(video.autoplay).toBe(false);
83
84 paused = false;
85 editor.view.dispatch({
86 effects: presentation.reconfigure(options("https://host.example/asset?token=third")),
87 });
88 expect(video.src).toBe("https://host.example/asset?token=second");
89 video.dispatchEvent(new Event("error"));
90 expect(video.src).toBe("https://host.example/asset?token=third");
91 expect(editor.dom.querySelector("video")).toBe(video);
92 });
93
94 it("stops playback when editing reveals the video source", () => {
95 const instance = mount("![Clip](clip.mp4)\n\nOther text");
96 const video = instance.dom.querySelector("video")!;
97 instance.view.contentDOM.dispatchEvent(new FocusEvent("focus"));
98 instance.view.dispatch({ selection: EditorSelection.single(3) });
99 expect(instance.dom.querySelector("video")).toBeNull();
100 expect(instance.dom.textContent).toContain("![Clip](clip.mp4)");
101 expect(video.pause).toHaveBeenCalledOnce();
102 });
103
104 it("does not mistake a video extension inside an image query for a video path", () => {
105 const instance = mount("![Figure](figure.png?label=clip.mp4)");
106 expect(instance.dom.querySelector("video")).toBeNull();
107 expect(instance.dom.querySelector(".cm-lp-image img")).not.toBeNull();
108 });
109
110 it("lets the host retain its player through signed URL refreshes and dispose it when removed", () => {
111 const presentation = new Compartment();
112 const update = vi.fn();
113 const destroy = vi.fn();
114 const inputs: VideoPreviewInput[] = [];
115 const renderVideo: VideoPreviewRenderer = (input) => {
116 inputs.push(input);
117 const dom = document.createElement("span");
118 dom.textContent = "Hosted player";
119 return { dom, update, destroy };
120 };
121 const options = (token: string) =>
122 livePreview({
123 blockWidgets: true,
124 renderVideo,
125 resolveUrl: () => `https://host.example/clip?token=${token}`,
126 });
127 editor = createMarkdownEditor({
128 preset: "document",
129 preview: false,
130 doc: "![Clip](clip.mp4)",
131 extensions: [presentation.of(options("first"))],
132 });
133 document.body.append(editor.dom);
134 const hosted = Array.from(editor.dom.querySelectorAll("span")).find(
135 (element) => element.textContent === "Hosted player",
136 )!;
137 expect(inputs).toEqual([
138 { source: "clip.mp4", url: "https://host.example/clip?token=first", alt: "Clip" },
139 ]);
140 expect(editor.dom.querySelector("video")).toBeNull();
141 editor.view.dispatch({ effects: presentation.reconfigure(options("second")) });
142 expect(hosted.isConnected).toBe(true);
143 expect(inputs).toHaveLength(1);
144 expect(update).toHaveBeenCalledWith({
145 source: "clip.mp4",
146 url: "https://host.example/clip?token=second",
147 alt: "Clip",
148 });
149 expect(destroy).not.toHaveBeenCalled();
150 editor.setValue("Video removed");
151 expect(destroy).toHaveBeenCalledOnce();
152 });
153});