| 1 | // @vitest-environment jsdom |
| 2 | import "./dom"; |
| 3 | |
| 4 | import { Compartment, EditorSelection } from "@codemirror/state"; |
| 5 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 6 | |
| 7 | import { createMarkdownEditor, type MarkdownEditor } from "../src/editor.ts"; |
| 8 | import { livePreview } from "../src/live-preview.ts"; |
| 9 | import type { VideoPreviewInput, VideoPreviewRenderer } from "../src/widgets.ts"; |
| 10 | |
| 11 | let editor: MarkdownEditor | undefined; |
| 12 | |
| 13 | beforeEach(() => { |
| 14 | vi.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => {}); |
| 15 | }); |
| 16 | |
| 17 | afterEach(() => { |
| 18 | editor?.destroy(); |
| 19 | editor?.dom.remove(); |
| 20 | editor = undefined; |
| 21 | vi.restoreAllMocks(); |
| 22 | }); |
| 23 | |
| 24 | function mount(source: string) { |
| 25 | editor = createMarkdownEditor({ preset: "document", doc: source }); |
| 26 | document.body.append(editor.dom); |
| 27 | return editor; |
| 28 | } |
| 29 | |
| 30 | describe("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 = ``; |
| 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: "", |
| 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("\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(""); |
| 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(""); |
| 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: "", |
| 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 | }); |