Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,8 @@ jobs:
__tests__/unit/invite-decline.test.ts
__tests__/unit/linkified-text.test.ts
__tests__/unit/loom-import-ui.test.ts
__tests__/unit/loom-downloader-lifecycle.test.ts
__tests__/unit/loom-conversion-abort.test.ts
__tests__/unit/media-comment-playback-cleanup.test.ts
__tests__/unit/media-format-converter-selection.test.ts
__tests__/unit/media-format-converter.test.ts
Expand Down
126 changes: 126 additions & 0 deletions apps/web/__tests__/unit/loom-conversion-abort.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { saveLoomStreamAsMp4 } from "@/lib/loom-browser-conversion";

const mocks = vi.hoisted(() => ({
video: vi.fn(),
audio: vi.fn(),
init: vi.fn(),
execute: vi.fn(),
cancel: vi.fn(),
dispose: vi.fn(),
abort: vi.fn(),
}));
vi.mock("mediabunny", () => {
class Empty {}
return {
ALL_FORMATS: [],
QUALITY_HIGH: 1,
QUALITY_MEDIUM: 1,
CustomPathedSource: Empty,
UrlSource: Empty,
Mp4OutputFormat: Empty,
BufferTarget: class {
buffer = new Uint8Array([1]).buffer;
},
StreamTarget: Empty,
Input: class {
dispose = mocks.dispose;
async getPrimaryAudioTrack() {
return null;
}
},
Output: class {
cancel = mocks.cancel;
},
canEncodeVideo: mocks.video,
canEncodeAudio: mocks.audio,
Conversion: { init: mocks.init },
};
});
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((done) => {
resolve = done;
});
return { promise, resolve };
}
beforeEach(() => {
for (const key of [
"VideoDecoder",
"VideoEncoder",
"AudioDecoder",
"AudioEncoder",
])
vi.stubGlobal(key, class {});
vi.stubGlobal("showSaveFilePicker", undefined);
mocks.video.mockReset().mockResolvedValue(true);
mocks.audio.mockReset().mockResolvedValue(true);
mocks.execute.mockReset().mockResolvedValue(undefined);
mocks.cancel.mockReset().mockResolvedValue(undefined);
mocks.dispose.mockReset();
mocks.abort.mockReset().mockResolvedValue(undefined);
mocks.init.mockReset().mockResolvedValue({
isValid: true,
discardedTracks: [],
execute: mocks.execute,
cancel: mocks.cancel,
output: { cancel: mocks.cancel },
});
});
afterEach(() => vi.unstubAllGlobals());
const start = (signal: AbortSignal) =>
saveLoomStreamAsMp4({
url: "https://example.com/video.m3u8",
filename: "video.mp4",
signal,
});
it("does not open the save picker when aborted during codec checks", async () => {
const gate = deferred<boolean>();
mocks.video.mockReturnValueOnce(gate.promise);
const picker = vi.fn();
vi.stubGlobal("showSaveFilePicker", picker);
const controller = new AbortController();
const result = start(controller.signal);
const rejected = expect(result).rejects.toMatchObject({ name: "AbortError" });
await vi.waitFor(() => expect(mocks.video).toHaveBeenCalled());
controller.abort();
gate.resolve(true);
await rejected;
expect(picker).not.toHaveBeenCalled();
expect(mocks.init).not.toHaveBeenCalled();
});
it("does not execute when aborted while conversion initializes", async () => {
const gate = deferred<object>();
mocks.init.mockReturnValueOnce(gate.promise);
const controller = new AbortController();
const result = start(controller.signal);
const rejected = expect(result).rejects.toMatchObject({ name: "AbortError" });
await vi.waitFor(() => expect(mocks.init).toHaveBeenCalled());
controller.abort();
gate.resolve({
isValid: true,
discardedTracks: [],
execute: mocks.execute,
cancel: mocks.cancel,
output: { cancel: mocks.cancel },
});
await rejected;
expect(mocks.execute).not.toHaveBeenCalled();
expect(mocks.cancel).toHaveBeenCalled();
expect(mocks.dispose).toHaveBeenCalled();
});
it("aborts the writable when cancellation arrives while the picker is open", async () => {
const gate = deferred<object>();
const picker = vi.fn(() => gate.promise);
vi.stubGlobal("showSaveFilePicker", picker);
const controller = new AbortController();
const result = start(controller.signal);
const rejected = expect(result).rejects.toMatchObject({ name: "AbortError" });
await vi.waitFor(() => expect(picker).toHaveBeenCalled());
controller.abort();
gate.resolve({ createWritable: async () => ({ abort: mocks.abort }) });
await rejected;
expect(mocks.abort).toHaveBeenCalled();
expect(mocks.init).not.toHaveBeenCalled();
});
161 changes: 161 additions & 0 deletions apps/web/__tests__/unit/loom-downloader-lifecycle.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
// @vitest-environment jsdom

import { act, type ComponentProps, createElement } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { LoomDownloader } from "@/components/tools/LoomDownloader";
import { saveLoomStreamAsMp4 } from "@/lib/loom-browser-conversion";
import { resolveLoomBrowserDownload } from "@/lib/loom-browser-download";

vi.mock("@cap/ui", () => ({
Button: ({
spinner: _spinner,
...props
}: ComponentProps<"button"> & { spinner?: boolean }) =>
createElement("button", { ...props, type: "button" }),
}));
vi.mock("@/lib/loom-browser-download", () => ({
resolveLoomBrowserDownload: vi.fn(),
}));
vi.mock("@/lib/loom-browser-conversion", () => ({
saveLoomStreamAsMp4: vi.fn(),
getLoomBrowserConversionSupport: () => ({ supported: true }),
getLoomBrowserConversionErrorMessage: (error: Error) => error.message,
isLoomBrowserConversionAbort: (error: Error) => error.name === "AbortError",
}));

function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((done) => {
resolve = done;
});
return { promise, resolve };
}
const downloadInfo = (direct = false) => ({
success: true,
videoId: "fixture-video",
videoName: "Fixture video",
downloadUrl: `https://cdn.example/video.${direct ? "mp4" : "mpd"}`,
downloadMode: direct
? ("direct-download" as const)
: ("browser-conversion" as const),
});
let container: HTMLDivElement;
let root: Root;
let mounted: boolean;
let converter: ReturnType<typeof deferred<Blob | undefined>>;

beforeEach(async () => {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
vi.stubGlobal(
"URL",
class extends URL {
static createObjectURL = vi.fn(() => "blob:download");
static revokeObjectURL = vi.fn();
},
);
vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
vi.mocked(resolveLoomBrowserDownload)
.mockReset()
.mockResolvedValue(downloadInfo());
converter = deferred<Blob | undefined>();
vi.mocked(saveLoomStreamAsMp4).mockReset().mockReturnValue(converter.promise);
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
mounted = true;
await act(async () => root.render(createElement(LoomDownloader)));
const input = container.querySelector("input");
if (!input) throw new Error("Missing Loom URL input");
const setValue = Object.getOwnPropertyDescriptor(
HTMLInputElement.prototype,
"value",
)?.set;
setValue?.call(input, "https://www.loom.com/share/fixture-video");
await act(async () =>
input.dispatchEvent(new Event("input", { bubbles: true })),
);
});
afterEach(async () => {
if (mounted) await unmount();
container.remove();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
async function unmount() {
await act(async () => root.unmount());
mounted = false;
}
async function start() {
const button = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent === "Download Video",
);
if (!button) throw new Error("Missing download action");
await act(async () => button.click());
}

it("aborts the active conversion when the downloader unmounts", async () => {
await start();
const signal = vi.mocked(saveLoomStreamAsMp4).mock.calls[0]?.[0].signal;
expect(signal?.aborted).toBe(false);
await unmount();
expect(signal?.aborted).toBe(true);
await act(async () => converter.resolve(undefined));
});

it("does not create a download or retain a Blob from late conversion completion", async () => {
await start();
await unmount();
await act(async () =>
converter.resolve(new Blob(["MP4"], { type: "video/mp4" })),
);
expect(URL.createObjectURL).not.toHaveBeenCalled();
expect(HTMLAnchorElement.prototype.click).not.toHaveBeenCalled();
});

it.each([false, true])(
"ignores late URL resolution after unmount (direct: %s)",
async (direct) => {
const resolver =
deferred<Awaited<ReturnType<typeof resolveLoomBrowserDownload>>>();
vi.mocked(resolveLoomBrowserDownload).mockReturnValueOnce(resolver.promise);
await start();
await unmount();
await act(async () => resolver.resolve(downloadInfo(direct)));
expect(saveLoomStreamAsMp4).not.toHaveBeenCalled();
expect(HTMLAnchorElement.prototype.click).not.toHaveBeenCalled();
await act(async () => converter.resolve(undefined));
},
);

it("keeps a completed MP4 available until the downloader unmounts", async () => {
await start();
await act(async () =>
converter.resolve(new Blob(["MP4"], { type: "video/mp4" })),
);
expect(HTMLAnchorElement.prototype.click).toHaveBeenCalledTimes(1);
expect(URL.createObjectURL).toHaveBeenCalledTimes(1);
expect(URL.revokeObjectURL).not.toHaveBeenCalled();
await unmount();
expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:download");
});

it("still starts a direct MP4 download while mounted", async () => {
vi.mocked(resolveLoomBrowserDownload).mockResolvedValueOnce(
downloadInfo(true),
);
await start();
expect(HTMLAnchorElement.prototype.click).toHaveBeenCalledTimes(1);
expect(saveLoomStreamAsMp4).not.toHaveBeenCalled();
});

it("allows another attempt after conversion fails", async () => {
vi.mocked(saveLoomStreamAsMp4).mockRejectedValueOnce(
new Error("Decoder failed"),
);
await start();
expect(container.textContent).toContain("Decoder failed");
await start();
expect(saveLoomStreamAsMp4).toHaveBeenCalledTimes(2);
await act(async () => converter.resolve(undefined));
});
Loading
Loading