Files
kandev/apps/web/lib/utils/file-download.test.ts
T
2026-09-25 18:07:55 +01:00

190 lines
6.7 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { triggerBlobDownload, triggerFileDownload } from "./file-download";
import {
completeNativeBlobDownload,
MAX_NATIVE_BLOB_DOWNLOAD_AGE_MS,
} from "./native-blob-download";
const createObjectURLMock = vi.fn((_blob: Blob): string => "blob:mock");
const revokeObjectURLMock = vi.fn((_url: string): void => undefined);
const SAMPLE_BLOB_CONTENT = "bytes";
const SAMPLE_DOWNLOAD_NAME = "report.zip";
const originalCreateObjectURL = URL.createObjectURL;
const originalRevokeObjectURL = URL.revokeObjectURL;
const originalTauriInternals = Object.getOwnPropertyDescriptor(window, "__TAURI_INTERNALS__");
beforeEach(() => {
vi.useFakeTimers();
URL.createObjectURL = createObjectURLMock as unknown as typeof URL.createObjectURL;
URL.revokeObjectURL = revokeObjectURLMock as unknown as typeof URL.revokeObjectURL;
createObjectURLMock.mockClear();
revokeObjectURLMock.mockClear();
});
afterEach(() => {
vi.runOnlyPendingTimers();
vi.useRealTimers();
URL.createObjectURL = originalCreateObjectURL;
URL.revokeObjectURL = originalRevokeObjectURL;
if (originalTauriInternals) {
Object.defineProperty(window, "__TAURI_INTERNALS__", originalTauriInternals);
} else {
Reflect.deleteProperty(window, "__TAURI_INTERNALS__");
}
vi.restoreAllMocks();
});
function spyAnchorClick(): { click: ReturnType<typeof vi.fn>; download: () => string } {
const click = vi.fn();
const originalCreate = document.createElement.bind(document);
let capturedAnchor: HTMLAnchorElement | null = null;
vi.spyOn(document, "createElement").mockImplementation(
(tagName: string, options?: ElementCreationOptions) => {
const el = originalCreate(tagName as keyof HTMLElementTagNameMap, options);
if (tagName === "a") {
const anchor = el as HTMLAnchorElement;
anchor.click = click;
capturedAnchor = anchor;
}
return el;
},
);
return { click, download: () => capturedAnchor?.download ?? "" };
}
describe("triggerFileDownload", () => {
it("creates a text blob and clicks a link with the file name", () => {
const { click } = spyAnchorClick();
triggerFileDownload({ fileName: "hello.txt", content: "hi", isBinary: false });
expect(createObjectURLMock).toHaveBeenCalledTimes(1);
const blob = createObjectURLMock.mock.calls[0]?.[0] as unknown as Blob;
expect(blob).toBeInstanceOf(Blob);
expect(blob.type).toContain("text/plain");
expect(click).toHaveBeenCalledTimes(1);
expect(revokeObjectURLMock).not.toHaveBeenCalled();
vi.runOnlyPendingTimers();
expect(revokeObjectURLMock).toHaveBeenCalledWith("blob:mock");
});
it("decodes base64 content when isBinary is true", () => {
spyAnchorClick();
triggerFileDownload({ fileName: "hello.bin", content: btoa("hi"), isBinary: true });
const blob = createObjectURLMock.mock.calls[0]?.[0] as unknown as Blob;
expect(blob).toBeInstanceOf(Blob);
expect(blob.type).toBe("application/octet-stream");
// "hi" -> 2 bytes
expect(blob.size).toBe(2);
});
it("sets the download attribute to just the file name (basename)", () => {
const { download } = spyAnchorClick();
triggerFileDownload({ fileName: "src/lib/notes.md", content: "hi", isBinary: false });
expect(download()).toBe("notes.md");
});
});
describe("triggerBlobDownload", () => {
it("downloads an already-built blob under the exact given file name", () => {
const { click, download } = spyAnchorClick();
const blob = new Blob(["zip-bytes"], { type: "application/zip" });
triggerBlobDownload(blob, "kandev-automations.zip");
expect(createObjectURLMock).toHaveBeenCalledWith(blob);
expect(click).toHaveBeenCalledTimes(1);
expect(download()).toBe("kandev-automations.zip");
expect(revokeObjectURLMock).not.toHaveBeenCalled();
vi.runOnlyPendingTimers();
expect(revokeObjectURLMock).toHaveBeenCalledWith("blob:mock");
});
it("keeps native Blob URLs alive through delayed initiation and until a terminal event", () => {
Object.defineProperty(window, "__TAURI_INTERNALS__", {
configurable: true,
value: { invoke: vi.fn(), transformCallback: vi.fn() },
});
const { click } = spyAnchorClick();
triggerBlobDownload(new Blob([SAMPLE_BLOB_CONTENT]), SAMPLE_DOWNLOAD_NAME);
vi.advanceTimersByTime(120_000);
expect(click).toHaveBeenCalledOnce();
expect(revokeObjectURLMock).not.toHaveBeenCalled();
completeNativeBlobDownload({
status: "started",
url: "blob:mock",
fileName: SAMPLE_DOWNLOAD_NAME,
});
vi.advanceTimersByTime(60_000);
expect(revokeObjectURLMock).not.toHaveBeenCalled();
completeNativeBlobDownload({
status: "saved",
url: "blob:mock",
fileName: SAMPLE_DOWNLOAD_NAME,
});
expect(revokeObjectURLMock).toHaveBeenCalledOnce();
expect(revokeObjectURLMock).toHaveBeenCalledWith("blob:mock");
});
it.each(["cancelled", "failed"] as const)(
"revokes a native Blob URL after %s feedback",
(status) => {
Object.defineProperty(window, "__TAURI_INTERNALS__", {
configurable: true,
value: { invoke: vi.fn(), transformCallback: vi.fn() },
});
spyAnchorClick();
triggerBlobDownload(new Blob([SAMPLE_BLOB_CONTENT]), SAMPLE_DOWNLOAD_NAME);
completeNativeBlobDownload({ status, url: "blob:mock", fileName: SAMPLE_DOWNLOAD_NAME });
expect(revokeObjectURLMock).toHaveBeenCalledWith("blob:mock");
},
);
it("extends Blob lifetime while the native Save dialog is selecting a destination", () => {
Object.defineProperty(window, "__TAURI_INTERNALS__", {
configurable: true,
value: { invoke: vi.fn(), transformCallback: vi.fn() },
});
spyAnchorClick();
triggerBlobDownload(new Blob([SAMPLE_BLOB_CONTENT]), SAMPLE_DOWNLOAD_NAME);
vi.advanceTimersByTime(20 * 60 * 1_000);
completeNativeBlobDownload({
status: "selecting",
url: "blob:mock",
fileName: SAMPLE_DOWNLOAD_NAME,
});
vi.advanceTimersByTime(20 * 60 * 1_000);
expect(revokeObjectURLMock).not.toHaveBeenCalled();
completeNativeBlobDownload({
status: "cancelled",
url: "blob:mock",
fileName: SAMPLE_DOWNLOAD_NAME,
});
expect(revokeObjectURLMock).toHaveBeenCalledWith("blob:mock");
});
it("bounds cleanup if native completion feedback never arrives", () => {
Object.defineProperty(window, "__TAURI_INTERNALS__", {
configurable: true,
value: { invoke: vi.fn(), transformCallback: vi.fn() },
});
spyAnchorClick();
triggerBlobDownload(new Blob([SAMPLE_BLOB_CONTENT]), SAMPLE_DOWNLOAD_NAME);
vi.advanceTimersByTime(MAX_NATIVE_BLOB_DOWNLOAD_AGE_MS);
expect(revokeObjectURLMock).toHaveBeenCalledWith("blob:mock");
});
});