/* Copyright 2026 Marimo. All rights reserved. */
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Mocks } from "@/__mocks__/common";
import { cellId } from "@/__tests__/branded";
import { CellOutputId } from "@/core/cells/ids";
import {
downloadAsPDF,
downloadByURL,
downloadCellOutputAsImage,
downloadHTMLAsImage,
getImageDataUrlForCell,
withLoadingToast,
} from "../download";
const { mockExportAsPDF } = vi.hoisted(() => ({
mockExportAsPDF: vi.fn(),
}));
// Mock html-to-image
vi.mock("html-to-image", () => ({
toPng: vi.fn(),
}));
vi.mock("@/core/network/requests", () => ({
getRequestClient: () => ({
exportAsPDF: mockExportAsPDF,
}),
}));
// Mock the toast module
const { mockDismiss, mockUpdate, toastMock } = vi.hoisted(() => {
const dismiss = vi.fn();
const update = vi.fn();
return {
mockDismiss: dismiss,
mockUpdate: update,
toastMock: { toast: vi.fn(() => ({ dismiss, update })) },
};
});
vi.mock("@/components/ui/use-toast", () => toastMock);
// Mock the Spinner component
vi.mock("@/components/icons/spinner", () => ({
Spinner: () => "MockSpinner",
}));
vi.mock("@/utils/Logger", () => ({ Logger: Mocks.quietLogger() }));
// Mock Filenames
vi.mock("@/utils/filenames", () => ({
Filenames: {
toPNG: (name: string) => `${name}.png`,
toPDF: (name: string) => `${name}.pdf`,
},
}));
import { toPng } from "html-to-image";
import { toast } from "@/components/ui/use-toast";
import { Logger } from "@/utils/Logger";
describe("withLoadingToast", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("should show a loading toast and dismiss on success", async () => {
const result = await withLoadingToast("Loading...", async () => {
return "success";
});
expect(toast).toHaveBeenCalledTimes(1);
expect(toast).toHaveBeenCalledWith(
expect.objectContaining({
title: "Loading...",
duration: Infinity,
}),
);
expect(mockDismiss).toHaveBeenCalledTimes(1);
expect(result).toBe("success");
});
it("should dismiss toast and rethrow on error", async () => {
const error = new Error("Operation failed");
await expect(
withLoadingToast("Loading...", async () => {
throw error;
}),
).rejects.toThrow("Operation failed");
expect(toast).toHaveBeenCalledTimes(1);
expect(mockDismiss).toHaveBeenCalledTimes(1);
});
it("should return the value from the async function", async () => {
const expectedValue = { data: "test", count: 42 };
const result = await withLoadingToast("Processing...", async () => {
return expectedValue;
});
expect(result).toEqual(expectedValue);
});
it("should handle void functions", async () => {
let sideEffect = false;
await withLoadingToast("Saving...", async () => {
sideEffect = true;
});
expect(sideEffect).toBe(true);
expect(mockDismiss).toHaveBeenCalledTimes(1);
});
it("should use the provided title in the toast", async () => {
const customTitle = "Downloading PDF...";
await withLoadingToast(customTitle, async () => "done");
expect(toast).toHaveBeenCalledWith(
expect.objectContaining({
title: customTitle,
}),
);
});
it("should update toast on finish when onFinish is provided", async () => {
await withLoadingToast("Uploading files...", async () => "done", {
title: "Upload complete",
});
expect(toast).toHaveBeenCalledTimes(1);
expect(mockUpdate).toHaveBeenCalledTimes(1);
expect(mockUpdate).toHaveBeenCalledWith(
expect.objectContaining({
title: "Upload complete",
description: undefined,
duration: 1200,
}),
);
expect(mockDismiss).not.toHaveBeenCalled();
});
it("should allow onFinish to override duration", async () => {
await withLoadingToast("Uploading files...", async () => "done", {
title: "Upload complete",
duration: 2000,
});
expect(mockUpdate).toHaveBeenCalledWith(
expect.objectContaining({
duration: 2000,
}),
);
});
it("should not update toast when the operation fails", async () => {
await expect(
withLoadingToast(
"Uploading files...",
async () => {
throw new Error("Upload failed");
},
{ title: "Upload complete" },
),
).rejects.toThrow("Upload failed");
expect(toast).toHaveBeenCalledTimes(1);
expect(mockUpdate).not.toHaveBeenCalled();
});
it("should wait for the async function to complete", async () => {
const events: string[] = [];
await withLoadingToast("Loading...", async () => {
events.push("start");
await new Promise((resolve) => setTimeout(resolve, 10));
events.push("end");
});
expect(events).toEqual(["start", "end"]);
expect(mockDismiss).toHaveBeenCalledTimes(1);
});
});
describe("downloadAsPDF", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("should send the preset in export request payload", async () => {
mockExportAsPDF.mockRejectedValue(new Error("network"));
await expect(
downloadAsPDF({
filename: "path/to/notebook.py",
webpdf: false,
preset: "slides",
}),
).rejects.toThrow("network");
expect(mockExportAsPDF).toHaveBeenCalledWith({
webpdf: false,
preset: "slides",
includeInputs: true,
rasterizeOutputs: true,
rasterScale: 4,
rasterServer: "static",
});
});
});
describe("getImageDataUrlForCell", () => {
const mockDataUrl = "data:image/png;base64,mockbase64data";
let mockElement: HTMLElement;
beforeEach(() => {
vi.clearAllMocks();
mockElement = document.createElement("div");
mockElement.id = CellOutputId.create(cellId("cell-1"));
document.body.append(mockElement);
});
afterEach(() => {
mockElement.remove();
});
it("should return undefined if element is not found", async () => {
const result = await getImageDataUrlForCell(cellId("nonexistent"));
expect(result).toBeUndefined();
expect(Logger.error).toHaveBeenCalledWith(
"Output element not found for cell nonexistent",
);
});
it("should capture screenshot and return data URL", async () => {
vi.mocked(toPng).mockResolvedValue(mockDataUrl);
const result = await getImageDataUrlForCell(cellId("cell-1"));
expect(result).toBe(mockDataUrl);
expect(toPng).toHaveBeenCalledWith(
mockElement,
expect.objectContaining({
filter: expect.any(Function),
onImageErrorHandler: expect.any(Function),
}),
);
});
it("should pass style options to prevent clipping", async () => {
vi.mocked(toPng).mockResolvedValue(mockDataUrl);
await getImageDataUrlForCell(cellId("cell-1"));
expect(toPng).toHaveBeenCalledWith(
mockElement,
expect.objectContaining({
style: {
maxHeight: "none",
overflow: "visible",
},
}),
);
});
it("should pass scrollHeight as height option", async () => {
// Set up element with scrollHeight
Object.defineProperty(mockElement, "scrollHeight", {
value: 500,
configurable: true,
});
vi.mocked(toPng).mockResolvedValue(mockDataUrl);
await getImageDataUrlForCell(cellId("cell-1"));
expect(toPng).toHaveBeenCalledWith(
mockElement,
expect.objectContaining({
height: 500,
}),
);
});
it("should pass scrollbar hiding styles via extraStyleContent", async () => {
vi.mocked(toPng).mockResolvedValue(mockDataUrl);
await getImageDataUrlForCell(cellId("cell-1"));
expect(toPng).toHaveBeenCalledWith(
mockElement,
expect.objectContaining({
extraStyleContent: expect.stringContaining("scrollbar-width: none"),
}),
);
});
it("should not modify the live DOM element", async () => {
mockElement.style.overflow = "hidden";
mockElement.style.maxHeight = "100px";
vi.mocked(toPng).mockResolvedValue(mockDataUrl);
await getImageDataUrlForCell(cellId("cell-1"));
// DOM should remain unchanged
expect(mockElement.style.overflow).toBe("hidden");
expect(mockElement.style.maxHeight).toBe("100px");
});
it("should throw error on failure", async () => {
vi.mocked(toPng).mockRejectedValue(new Error("Capture failed"));
await expect(getImageDataUrlForCell(cellId("cell-1"))).rejects.toThrow(
"Capture failed",
);
});
it("should handle concurrent captures correctly", async () => {
// Create a second element
const mockElement2 = document.createElement("div");
mockElement2.id = CellOutputId.create(cellId("cell-2"));
document.body.append(mockElement2);
vi.mocked(toPng).mockResolvedValue(mockDataUrl);
const capture1 = getImageDataUrlForCell(cellId("cell-1"));
const capture2 = getImageDataUrlForCell(cellId("cell-2"));
await Promise.all([capture1, capture2]);
expect(toPng).toHaveBeenCalledTimes(2);
mockElement2.remove();
});
});
describe("downloadHTMLAsImage", () => {
const mockDataUrl = "data:image/png;base64,mockbase64data";
let mockElement: HTMLElement;
let mockAppEl: HTMLElement;
let mockAnchor: HTMLAnchorElement;
beforeEach(() => {
vi.clearAllMocks();
mockElement = document.createElement("div");
mockAppEl = document.createElement("div");
mockAppEl.id = "App";
// Mock scrollTo since jsdom doesn't implement it
mockAppEl.scrollTo = vi.fn();
document.body.append(mockElement);
document.body.append(mockAppEl);
// Mock anchor element for download
mockAnchor = document.createElement("a");
vi.spyOn(document, "createElement").mockReturnValue(mockAnchor);
vi.spyOn(mockAnchor, "click").mockImplementation(() => {
//
});
vi.spyOn(mockAnchor, "remove").mockImplementation(() => {
// noop
});
});
afterEach(() => {
mockElement.remove();
mockAppEl.remove();
vi.restoreAllMocks();
});
it("should download image without prepare function", async () => {
vi.mocked(toPng).mockResolvedValue(mockDataUrl);
await downloadHTMLAsImage({ element: mockElement, filename: "test" });
expect(toPng).toHaveBeenCalledWith(
mockElement,
expect.objectContaining({
filter: expect.any(Function),
onImageErrorHandler: expect.any(Function),
}),
);
expect(mockAnchor.href).toBe(mockDataUrl);
expect(mockAnchor.download).toBe("test.png");
expect(mockAnchor.click).toHaveBeenCalled();
});
it("should use prepare function when provided", async () => {
vi.mocked(toPng).mockResolvedValue(mockDataUrl);
const cleanup = vi.fn();
const prepare = vi.fn().mockReturnValue(cleanup);
await downloadHTMLAsImage({
element: mockElement,
filename: "test",
prepare,
});
expect(prepare).toHaveBeenCalledWith(mockElement);
expect(cleanup).toHaveBeenCalled();
});
it("should delegate body.printing management to prepare function", async () => {
let bodyPrintingDuringCapture = false;
vi.mocked(toPng).mockImplementation(async () => {
// Capture the state during toPng execution
bodyPrintingDuringCapture = document.body.classList.contains("printing");
return mockDataUrl;
});
const cleanup = vi.fn();
// Mock prepare that adds body.printing
const prepare = vi.fn().mockImplementation(() => {
document.body.classList.add("printing");
return () => {
document.body.classList.remove("printing");
cleanup();
};
});
await downloadHTMLAsImage({
element: mockElement,
filename: "test",
prepare,
});
// body.printing should be added by prepare function
expect(bodyPrintingDuringCapture).toBe(true);
expect(document.body.classList.contains("printing")).toBe(false);
expect(prepare).toHaveBeenCalledWith(mockElement);
expect(cleanup).toHaveBeenCalled();
});
it("should show error toast on failure", async () => {
vi.mocked(toPng).mockRejectedValue(new Error("Failed"));
await downloadHTMLAsImage({ element: mockElement, filename: "test" });
expect(toast).toHaveBeenCalledWith({
title: "Failed to download as PNG",
description: "Failed",
variant: "danger",
});
});
it("should cleanup on failure", async () => {
vi.mocked(toPng).mockRejectedValue(new Error("Failed"));
await downloadHTMLAsImage({ element: mockElement, filename: "test" });
expect(document.body.classList.contains("printing")).toBe(false);
});
});
describe("downloadCellOutputAsImage", () => {
const mockDataUrl = "data:image/png;base64,mockbase64data";
let mockElement: HTMLElement;
let mockAppEl: HTMLElement;
let mockAnchor: HTMLAnchorElement;
beforeEach(() => {
vi.clearAllMocks();
mockElement = document.createElement("div");
mockElement.id = CellOutputId.create(cellId("cell-1"));
mockAppEl = document.createElement("div");
mockAppEl.id = "App";
// Mock scrollTo since jsdom doesn't implement it
mockAppEl.scrollTo = vi.fn();
document.body.append(mockElement);
document.body.append(mockAppEl);
mockAnchor = document.createElement("a");
vi.spyOn(document, "createElement").mockReturnValue(mockAnchor);
vi.spyOn(mockAnchor, "click").mockImplementation(() => {
//
});
vi.spyOn(mockAnchor, "remove").mockImplementation(() => {
//
});
});
afterEach(() => {
mockElement.remove();
mockAppEl.remove();
vi.restoreAllMocks();
});
it("should show error toast if element not found", async () => {
await downloadCellOutputAsImage(cellId("nonexistent"), "test");
expect(toPng).not.toHaveBeenCalled();
expect(Logger.error).toHaveBeenCalledWith(
"Output element not found for cell nonexistent",
);
expect(toast).toHaveBeenCalledWith({
title: "Failed to download PNG",
description: expect.any(String),
variant: "danger",
});
});
it("should show error toast if toPng fails", async () => {
vi.mocked(toPng).mockRejectedValue(new Error("Screenshot failed"));
await downloadCellOutputAsImage(cellId("cell-1"), "result");
expect(toast).toHaveBeenCalledWith({
title: "Failed to download PNG",
description: expect.stringContaining("Screenshot failed"),
variant: "danger",
});
});
it("should download cell output as image", async () => {
vi.mocked(toPng).mockResolvedValue(mockDataUrl);
await downloadCellOutputAsImage(cellId("cell-1"), "result");
expect(toPng).toHaveBeenCalledWith(
mockElement,
expect.objectContaining({
filter: expect.any(Function),
onImageErrorHandler: expect.any(Function),
}),
);
expect(mockAnchor.download).toBe("result.png");
});
it("should pass style options to toPng for full content capture", async () => {
vi.mocked(toPng).mockResolvedValue(mockDataUrl);
await downloadCellOutputAsImage(cellId("cell-1"), "result");
expect(toPng).toHaveBeenCalledWith(
mockElement,
expect.objectContaining({
style: {
maxHeight: "none",
overflow: "visible",
},
}),
);
});
it("should not modify the live DOM element", async () => {
mockElement.style.overflow = "hidden";
mockElement.style.maxHeight = "100px";
vi.mocked(toPng).mockResolvedValue(mockDataUrl);
await downloadCellOutputAsImage(cellId("cell-1"), "result");
// DOM should remain unchanged
expect(mockElement.style.overflow).toBe("hidden");
expect(mockElement.style.maxHeight).toBe("100px");
});
});
describe("downloadByURL", () => {
let mockAnchor: HTMLAnchorElement;
beforeEach(() => {
mockAnchor = document.createElement("a");
vi.spyOn(document, "createElement").mockReturnValue(mockAnchor);
vi.spyOn(mockAnchor, "click").mockImplementation(() => {
//
});
vi.spyOn(mockAnchor, "remove").mockImplementation(() => {
//
});
});
afterEach(() => {
vi.restoreAllMocks();
});
it("should create anchor, set attributes, click, and remove", () => {
downloadByURL("data:test", "filename.png");
expect(document.createElement).toHaveBeenCalledWith("a");
expect(mockAnchor.href).toBe("data:test");
expect(mockAnchor.download).toBe("filename.png");
expect(mockAnchor.click).toHaveBeenCalled();
expect(mockAnchor.remove).toHaveBeenCalled();
});
});