diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7f53cf1f83..4b994f283e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -132,6 +132,7 @@ jobs: __tests__/unit/homepage-demo-accessibility.test.ts __tests__/unit/linkified-text.test.ts __tests__/unit/loom-import-ui.test.ts + __tests__/unit/media-format-converter-selection.test.ts __tests__/unit/media-format-converter.test.ts __tests__/unit/media-player-store-refs.test.ts __tests__/unit/playback-store.test.ts diff --git a/apps/web/__tests__/unit/media-format-converter-selection.test.ts b/apps/web/__tests__/unit/media-format-converter-selection.test.ts new file mode 100644 index 0000000000..df4dbb0d65 --- /dev/null +++ b/apps/web/__tests__/unit/media-format-converter-selection.test.ts @@ -0,0 +1,144 @@ +// @vitest-environment jsdom + +import { act, createElement } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { MediaFormatConverter } from "@/components/tools/MediaFormatConverter"; + +vi.mock("@cap/ui", () => ({ + Button: ({ children, ...props }: React.ComponentProps<"button">) => + createElement("button", { type: "button", ...props }, children), +})); +vi.mock("next/link", () => ({ + default: ({ children, ...props }: React.ComponentProps<"a">) => + createElement("a", props, children), +})); +vi.mock("next/navigation", () => ({ + usePathname: () => "/tools/convert/mp4-to-webm", + useRouter: () => ({ push: vi.fn() }), +})); +vi.mock("@/app/utils/analytics", () => ({ trackEvent: vi.fn() })); +vi.mock("@remotion/media-parser", async (importOriginal) => ({ + ...(await importOriginal()), + mediaParserController: () => ({ abort: vi.fn() }), + parseMedia: () => Promise.resolve({ durationInSeconds: 1 }), +})); +vi.mock("@remotion/webcodecs", async (importOriginal) => ({ + ...(await importOriginal()), + convertMedia: vi.fn(), +})); + +describe("media converter file selection", () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("MediaRecorder", { isTypeSupported: () => true }); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + await act(async () => + root.render( + createElement(MediaFormatConverter, { + initialConversionPath: "mp4-to-webm", + }), + ), + ); + }); + + afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + delete (ArrayBuffer.prototype as { resize?: () => void }).resize; + vi.unstubAllGlobals(); + }); + + const selectFile = async (container: HTMLElement, file: File) => { + const input = container.querySelector("input[type=file]"); + if (!(input instanceof HTMLInputElement)) throw new Error("Missing input"); + Object.defineProperty(input, "files", { + configurable: true, + value: [file], + }); + await act(async () => + input.dispatchEvent(new Event("change", { bubbles: true })), + ); + }; + + it.each([ + ["invalid type", new File(["wrong"], "notes.txt", { type: "text/plain" })], + ["oversized", new File(["x"], "huge.mp4", { type: "video/mp4" })], + ])( + "clears the previous video after an %s replacement", + async (reason, replacement) => { + if (reason === "oversized") { + Object.defineProperty(replacement, "size", { + value: 500 * 1024 * 1024 + 1, + }); + } + await selectFile( + container, + new File(["video"], "first.mp4", { type: "video/mp4" }), + ); + expect(container.textContent).toContain("first.mp4"); + await selectFile(container, replacement); + expect(container.textContent).not.toContain("first.mp4"); + expect(container.textContent).not.toContain("Convert to WEBM"); + }, + ); + + it("keeps the active file when an invalid replacement is dropped during conversion", async () => { + const { convertMedia } = await import("@remotion/webcodecs"); + let finishConversion: (blob: Blob) => void = () => {}; + vi.mocked(convertMedia).mockResolvedValueOnce({ + save: () => + new Promise((resolve) => { + finishConversion = resolve; + }), + } as Awaited>); + vi.stubGlobal("VideoDecoder", class {}); + vi.stubGlobal("AudioDecoder", class {}); + Object.defineProperty(ArrayBuffer.prototype, "resize", { + configurable: true, + value: vi.fn(), + }); + vi.stubGlobal( + "URL", + class extends URL { + static createObjectURL = vi.fn(() => "blob:converted"); + static revokeObjectURL = vi.fn(); + }, + ); + + await selectFile( + container, + new File(["video"], "first.mp4", { type: "video/mp4" }), + ); + const convertButton = [...container.querySelectorAll("button")].find( + (button) => button.textContent?.includes("Convert to WEBM"), + ); + await act(async () => { + convertButton?.click(); + }); + expect(container.textContent).toContain("Converting..."); + + const dropZone = container.querySelector("[class*='border-dashed']"); + if (!dropZone) throw new Error("Missing drop zone"); + const drop = new Event("drop", { bubbles: true }); + Object.defineProperty(drop, "dataTransfer", { + value: { + files: [new File(["wrong"], "notes.txt", { type: "text/plain" })], + }, + }); + await act(async () => dropZone.dispatchEvent(drop)); + await act(async () => + finishConversion(new Blob(["converted"], { type: "video/webm" })), + ); + + expect(container.textContent).toContain("first.mp4"); + expect(container.textContent).toContain("Conversion complete!"); + expect(container.textContent).toContain("Download WEBM"); + expect(container.textContent).not.toContain("Please select a MP4 file."); + }); +}); diff --git a/apps/web/components/tools/MediaFormatConverter.tsx b/apps/web/components/tools/MediaFormatConverter.tsx index fd184a168c..47f419b3a3 100644 --- a/apps/web/components/tools/MediaFormatConverter.tsx +++ b/apps/web/components/tools/MediaFormatConverter.tsx @@ -251,8 +251,15 @@ export const MediaFormatConverter = ({ }; const validateAndSetFile = (selectedFile: File) => { + if (isConverting) return; + setError(null); + setFile(null); + if (outputUrl) { + URL.revokeObjectURL(outputUrl); + } setOutputUrl(null); + recordedChunksRef.current = []; const expectedMimeType = getMimeType(currentSourceFormat); @@ -974,6 +981,7 @@ export const MediaFormatConverter = ({