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
4 changes: 4 additions & 0 deletions frontend/src/__mocks__/requests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,10 @@ export const MockRequestClient = {
},
],
}),
installExportRequirements: vi.fn().mockResolvedValue({
source: "server",
formats: [],
}),
exportAsHTML: vi.fn().mockResolvedValue({
contents: "",
filename: "notebook.html",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { MockRequestClient } from "@/__mocks__/requests";
import { Dialog } from "@/components/ui/dialog";
import { TooltipProvider } from "@/components/ui/tooltip";
import { viewStateAtom } from "@/core/mode";
import { kioskModeAtom, viewStateAtom } from "@/core/mode";
import { requestClientAtom } from "@/core/network/requests";
import { createErrorToastingRequests } from "@/core/network/requests-toasting";
import type { ExportAvailabilityResponse } from "@/core/network/types";
import { filenameAtom } from "@/core/saving/file-state";
import { store } from "@/core/state/jotai";
import { isWasm } from "@/core/wasm/utils";
import * as copyModule from "@/utils/copy";
import { Deferred } from "@/utils/Deferred";
import { HTTPError } from "@/utils/errors";
import { ExportDialog } from "../export-dialog";
import {
DEFAULT_EXPORT_OPTIONS,
Expand All @@ -27,14 +31,22 @@ import {
lastExportFormatAtom,
} from "../state";

const { exportNotebookMock } = vi.hoisted(() => ({
const { exportNotebookMock, toastMock } = vi.hoisted(() => ({
exportNotebookMock: vi.fn().mockResolvedValue(undefined),
toastMock: vi.fn(() => ({
dismiss: vi.fn(),
update: vi.fn(),
})),
}));

vi.mock("@/utils/copy", () => ({
copyToClipboard: vi.fn().mockResolvedValue(undefined),
}));

vi.mock("@/components/ui/use-toast", () => ({
toast: toastMock,
}));

vi.mock("@/core/wasm/utils", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/core/wasm/utils")>();
return { ...actual, isWasm: vi.fn(() => false) };
Expand Down Expand Up @@ -67,15 +79,43 @@ async function waitForExportEnabled() {
);
}

type FormatAvailability = ExportAvailabilityResponse["formats"][number];

function formatAvailability(
format: FormatAvailability["format"],
overrides: Partial<Omit<FormatAvailability, "format">> = {},
): FormatAvailability {
return {
format,
dependenciesAvailable: true,
missingPackages: [],
missingSetup: [],
...overrides,
};
}

function serverAvailability(
...formats: FormatAvailability[]
): ExportAvailabilityResponse {
return { source: "server", formats };
}

const PLAYWRIGHT_SETUP = {
name: "playwright-chromium",
command: "uv run playwright install chromium",
} as const;

describe("ExportDialog", () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
store.set(requestClientAtom, MockRequestClient.create());
store.set(filenameAtom, "/project/notebook.py");
store.set(viewStateAtom, { mode: "edit", cellAnchor: null });
store.set(kioskModeAtom, false);
store.set(exportOptionsAtom, DEFAULT_EXPORT_OPTIONS);
store.set(lastExportFormatAtom, "html");
window.history.replaceState(null, "", "/");
vi.mocked(isWasm).mockReturnValue(false);
vi.stubGlobal("matchMedia", () => ({
matches: false,
Expand Down Expand Up @@ -248,54 +288,80 @@ describe("ExportDialog", () => {
expect(tablist).toHaveAttribute("aria-orientation", "vertical");
});

it("keeps unavailable formats visible and names missing packages", async () => {
it("installs missing packages and applies refreshed availability", async () => {
const install = new Deferred<ExportAvailabilityResponse>();
const installExportRequirements = vi.fn(() => install.promise);
const getExportAvailability = vi.fn().mockResolvedValue(
serverAvailability(
formatAvailability("ipynb", {
dependenciesAvailable: false,
missingPackages: ["nbformat"],
}),
formatAvailability("pdf", {
dependenciesAvailable: false,
missingPackages: ["nbconvert[webpdf]", "nbformat"],
}),
),
);
store.set(
requestClientAtom,
MockRequestClient.create({
getExportAvailability: vi.fn().mockResolvedValue({
source: "server",
formats: [
{
format: "pdf",
dependenciesAvailable: false,
missingPackages: ["nbconvert[webpdf]"],
missingSetup: [],
},
],
}),
getExportAvailability,
installExportRequirements,
}),
);

renderDialog("pdf");

expect(await screen.findByText("nbconvert[webpdf]")).toBeVisible();
expect(
screen.getByText(/where marimo is running to use this export/),
).toBeVisible();
const packages = await screen.findByText("nbconvert[webpdf], nbformat");
expect(packages.parentElement).toHaveTextContent(
"PDF export requires nbconvert[webpdf], nbformat.",
);
expect(screen.getByTestId("export-submit")).toBeDisabled();
expect(screen.getByTestId("export-format-pdf")).toBeVisible();

const installButton = screen.getByRole("button", { name: "Install" });
const pdfTab = screen.getByTestId("export-format-pdf");
const htmlTab = screen.getByTestId("export-format-html");

fireEvent.click(installButton);

await waitFor(() =>
expect(installExportRequirements).toHaveBeenCalledWith({ format: "pdf" }),
);
expect(installButton).toHaveAttribute("aria-busy", "true");
expect(htmlTab).toBeDisabled();

fireEvent.click(installButton);
fireEvent.click(htmlTab);
expect(installExportRequirements).toHaveBeenCalledOnce();
expect(pdfTab).toHaveAttribute("data-state", "active");

install.resolve(
serverAvailability(
formatAvailability("ipynb"),
formatAvailability("pdf"),
),
);
await waitForExportEnabled();
expect(getExportAvailability).toHaveBeenCalledOnce();

fireEvent.click(screen.getByTestId("export-format-ipynb"));
expect(screen.getByTestId("export-submit")).toBeEnabled();
});

it("explains missing Playwright Chromium setup", async () => {
store.set(
requestClientAtom,
MockRequestClient.create({
getExportAvailability: vi.fn().mockResolvedValue({
source: "server",
formats: [
{
format: "pdf",
getExportAvailability: vi.fn().mockResolvedValue(
serverAvailability(
formatAvailability("pdf", {
dependenciesAvailable: false,
missingPackages: [],
missingSetup: [
{
name: "playwright-chromium",
command: "uv run playwright install chromium",
},
],
},
],
}),
missingSetup: [PLAYWRIGHT_SETUP],
}),
),
),
}),
);

Expand All @@ -305,9 +371,115 @@ describe("ExportDialog", () => {
await screen.findByText("PDF export requires Playwright Chromium."),
).toBeVisible();
expect(
screen.getByText("uv run playwright install chromium"),
screen.queryByText("uv run playwright install chromium"),
).not.toBeInTheDocument();
expect(screen.getByTestId("export-submit")).toBeDisabled();
expect(screen.getByRole("button", { name: "Install" })).toBeVisible();
});

it.each([
["read mode", "read", false, false],
["connected kiosk mode", "edit", true, false],
["pre-connection kiosk mode", "edit", false, true],
] as const)(
"shows the setup command when installation is not allowed in %s",
async (_label, mode, kioskMode, kioskRequested) => {
store.set(viewStateAtom, { mode, cellAnchor: null });
store.set(kioskModeAtom, kioskMode);
if (kioskRequested) {
window.history.replaceState(null, "", "/?kiosk=true");
}
store.set(
requestClientAtom,
MockRequestClient.create({
getExportAvailability: vi.fn().mockResolvedValue(
serverAvailability(
formatAvailability("pdf", {
dependenciesAvailable: false,
missingSetup: [PLAYWRIGHT_SETUP],
}),
),
),
}),
);

renderDialog("pdf");

expect(
await screen.findByText("PDF export requires Playwright Chromium."),
).toBeVisible();
expect(
screen.getByText("uv run playwright install chromium"),
).toBeVisible();
expect(
screen.queryByRole("button", { name: "Install" }),
).not.toBeInTheDocument();
},
);

it("allows requirement installation when kiosk is false", async () => {
window.history.replaceState(null, "", "/?kiosk=false");
store.set(
requestClientAtom,
MockRequestClient.create({
getExportAvailability: vi.fn().mockResolvedValue(
serverAvailability(
formatAvailability("pdf", {
dependenciesAvailable: false,
missingSetup: [PLAYWRIGHT_SETUP],
}),
),
),
}),
);

renderDialog("pdf");

expect(
await screen.findByRole("button", { name: "Install" }),
).toBeVisible();
});

it("restores the install action when installation fails", async () => {
const error = new HTTPError(500, "Server Error", {
detail: "Playwright Chromium installation failed. Check the server logs.",
});
const installExportRequirements = vi.fn().mockRejectedValue(error);
const getExportAvailability = vi.fn().mockResolvedValue(
serverAvailability(
formatAvailability("pdf", {
dependenciesAvailable: false,
missingPackages: ["nbconvert[webpdf]"],
}),
),
);
store.set(
requestClientAtom,
createErrorToastingRequests(
MockRequestClient.create({
getExportAvailability,
installExportRequirements,
}),
),
);

renderDialog("pdf");

const install = await screen.findByRole("button", { name: "Install" });
fireEvent.click(install);

await waitFor(() =>
expect(toastMock).toHaveBeenCalledWith({
title: "Failed to install export requirements",
description:
"Playwright Chromium installation failed. Check the server logs.",
variant: "danger",
}),
);
expect(install).toBeEnabled();
expect(install).toHaveAttribute("aria-busy", "false");
expect(screen.getByTestId("export-submit")).toBeDisabled();
expect(getExportAvailability).toHaveBeenCalledOnce();
});

it("announces requirement checks as status updates", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,17 @@ export const ExportDialog: React.FC<{
const {
dialogRef,
isExporting,
isInstalling,
canInstall,
formats,
options,
selected,
selectFormat,
updateOptions,
installRequirements,
submit,
} = useExportDialog({ initialFormat, onClose });
const isBusy = isExporting || isInstalling;
const desktopLayout = useMediaQuery(DESKTOP_LAYOUT_QUERY);
const {
format,
Expand Down Expand Up @@ -86,7 +90,7 @@ export const ExportDialog: React.FC<{
<TabsTrigger
key={candidate}
value={candidate}
disabled={isExporting}
disabled={isBusy}
className="min-w-0 justify-start gap-2 px-2.5 py-2 text-xs data-[state=active]:shadow-xs sm:w-full sm:text-sm"
data-testid={`export-format-${candidate}`}
>
Expand Down Expand Up @@ -119,13 +123,15 @@ export const ExportDialog: React.FC<{
format={format}
formatLabel={definition.label}
status={status}
onInstall={canInstall ? installRequirements : undefined}
isInstalling={isInstalling}
/>

{usesBrowserPrint ? null : (
<FormatOptions
options={options}
updateOptions={updateOptions}
disabled={isExporting}
disabled={isBusy}
/>
)}
</TabsContent>
Expand Down Expand Up @@ -173,7 +179,7 @@ export const ExportDialog: React.FC<{
) : null}
<Button
type="button"
disabled={!status.available || isExporting}
disabled={!status.available || isBusy}
aria-busy={isExporting}
onClick={submit}
className={cn(
Expand Down
Loading
Loading