@marimo-team/islands 0.23.17-dev1 → 0.23.17-dev13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/dist/{chat-ui-DsZJj75A.js → chat-ui-DH3hwWoI.js} +2 -2
  2. package/dist/{common-BGCQJb-W.js → common-D8DGPf0N.js} +5 -5
  3. package/dist/{html-to-image-CZ1kLKkq.js → html-to-image-CxlSazqu.js} +2095 -2088
  4. package/dist/main.js +5 -5
  5. package/dist/{process-output-bVfbcx5_.js → process-output-B24cDGzV.js} +1 -1
  6. package/dist/{reveal-component-NNi374so.js → reveal-component-UfXDaVe-.js} +2 -2
  7. package/dist/style.css +1 -1
  8. package/package.json +1 -1
  9. package/src/__mocks__/requests.ts +5 -0
  10. package/src/components/databases/icons/huggingface.svg +8 -0
  11. package/src/components/editor/actions/__tests__/pair-with-agent-commands.test.ts +1 -1
  12. package/src/components/editor/actions/export-dialog/__tests__/export-command.test.ts +115 -0
  13. package/src/components/editor/actions/export-dialog/__tests__/export-dialog.test.tsx +458 -0
  14. package/src/components/editor/actions/export-dialog/__tests__/export-notebook.test.ts +178 -0
  15. package/src/components/editor/actions/export-dialog/__tests__/state.test.ts +295 -0
  16. package/src/components/editor/actions/export-dialog/__tests__/use-export-dialog.test.tsx +287 -0
  17. package/src/components/editor/actions/export-dialog/export-command.ts +149 -0
  18. package/src/components/editor/actions/export-dialog/export-dialog.tsx +194 -0
  19. package/src/components/editor/actions/export-dialog/export-notebook.ts +98 -0
  20. package/src/components/editor/actions/export-dialog/format-notice.tsx +166 -0
  21. package/src/components/editor/actions/export-dialog/format-options.tsx +531 -0
  22. package/src/components/editor/actions/export-dialog/state.ts +339 -0
  23. package/src/components/editor/actions/export-dialog/use-export-dialog.ts +372 -0
  24. package/src/components/editor/actions/pair-with-agent-commands.ts +1 -21
  25. package/src/components/editor/actions/useNotebookActions.tsx +72 -170
  26. package/src/components/editor/chrome/panels/outline/__tests__/useActiveOutline.test.ts +26 -0
  27. package/src/components/editor/connections/add-connection-dialog.tsx +1 -1
  28. package/src/components/editor/connections/quick-add-data-sources.tsx +14 -7
  29. package/src/components/editor/connections/storage/__tests__/__snapshots__/as-code.test.ts.snap +14 -0
  30. package/src/components/editor/connections/storage/__tests__/as-code.test.ts +20 -0
  31. package/src/components/editor/connections/storage/add-storage-form.tsx +10 -0
  32. package/src/components/editor/connections/storage/as-code.ts +19 -1
  33. package/src/components/editor/connections/storage/schemas.ts +19 -0
  34. package/src/components/editor/controls/__tests__/notebook-menu-dropdown.test.tsx +187 -0
  35. package/src/components/editor/controls/notebook-menu-dropdown.tsx +4 -2
  36. package/src/components/storage/__tests__/storage-snippets.test.ts +88 -0
  37. package/src/components/storage/components.tsx +2 -0
  38. package/src/components/storage/storage-snippets.ts +58 -0
  39. package/src/core/__tests__/mode.test.ts +85 -0
  40. package/src/core/dom/outline.ts +25 -2
  41. package/src/core/mode.ts +11 -9
  42. package/src/core/network/__tests__/requests-lazy.test.ts +1 -0
  43. package/src/core/storage/types.ts +1 -0
  44. package/src/utils/__tests__/download.test.tsx +6 -4
  45. package/src/utils/download.ts +3 -1
  46. package/src/utils/shell.ts +17 -0
@@ -0,0 +1,458 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import {
4
+ act,
5
+ fireEvent,
6
+ render,
7
+ screen,
8
+ waitFor,
9
+ } from "@testing-library/react";
10
+ import { Provider } from "jotai";
11
+ import type React from "react";
12
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
13
+ import { MockRequestClient } from "@/__mocks__/requests";
14
+ import { Dialog } from "@/components/ui/dialog";
15
+ import { TooltipProvider } from "@/components/ui/tooltip";
16
+ import { viewStateAtom } from "@/core/mode";
17
+ import { requestClientAtom } from "@/core/network/requests";
18
+ import { filenameAtom } from "@/core/saving/file-state";
19
+ import { store } from "@/core/state/jotai";
20
+ import { isWasm } from "@/core/wasm/utils";
21
+ import * as copyModule from "@/utils/copy";
22
+ import { ExportDialog } from "../export-dialog";
23
+ import {
24
+ DEFAULT_EXPORT_OPTIONS,
25
+ type ExportFormat,
26
+ exportOptionsAtom,
27
+ lastExportFormatAtom,
28
+ } from "../state";
29
+
30
+ const { exportNotebookMock } = vi.hoisted(() => ({
31
+ exportNotebookMock: vi.fn().mockResolvedValue(undefined),
32
+ }));
33
+
34
+ vi.mock("@/utils/copy", () => ({
35
+ copyToClipboard: vi.fn().mockResolvedValue(undefined),
36
+ }));
37
+
38
+ vi.mock("@/core/wasm/utils", async (importOriginal) => {
39
+ const actual = await importOriginal<typeof import("@/core/wasm/utils")>();
40
+ return { ...actual, isWasm: vi.fn(() => false) };
41
+ });
42
+
43
+ vi.mock("../export-notebook", () => ({
44
+ exportNotebook: exportNotebookMock,
45
+ }));
46
+
47
+ function wrapper({ children }: { children: React.ReactNode }) {
48
+ return (
49
+ <Provider store={store}>
50
+ <TooltipProvider>
51
+ <Dialog open={true}>{children}</Dialog>
52
+ </TooltipProvider>
53
+ </Provider>
54
+ );
55
+ }
56
+
57
+ function renderDialog(initialFormat?: ExportFormat, onClose = vi.fn()) {
58
+ return render(
59
+ <ExportDialog initialFormat={initialFormat} onClose={onClose} />,
60
+ { wrapper },
61
+ );
62
+ }
63
+
64
+ async function waitForExportEnabled() {
65
+ await waitFor(() =>
66
+ expect(screen.getByTestId("export-submit")).toBeEnabled(),
67
+ );
68
+ }
69
+
70
+ describe("ExportDialog", () => {
71
+ beforeEach(() => {
72
+ vi.clearAllMocks();
73
+ localStorage.clear();
74
+ store.set(requestClientAtom, MockRequestClient.create());
75
+ store.set(filenameAtom, "/project/notebook.py");
76
+ store.set(viewStateAtom, { mode: "edit", cellAnchor: null });
77
+ store.set(exportOptionsAtom, DEFAULT_EXPORT_OPTIONS);
78
+ store.set(lastExportFormatAtom, "html");
79
+ vi.mocked(isWasm).mockReturnValue(false);
80
+ vi.stubGlobal("matchMedia", () => ({
81
+ matches: false,
82
+ addEventListener: vi.fn(),
83
+ removeEventListener: vi.fn(),
84
+ }));
85
+ });
86
+
87
+ afterEach(() => {
88
+ vi.unstubAllGlobals();
89
+ });
90
+
91
+ it("locks format and option controls while exporting", async () => {
92
+ let finishExport: (() => void) | undefined;
93
+ exportNotebookMock.mockImplementationOnce(
94
+ () =>
95
+ new Promise<void>((resolve) => {
96
+ finishExport = resolve;
97
+ }),
98
+ );
99
+ const onClose = vi.fn();
100
+ renderDialog("pdf", onClose);
101
+ await waitForExportEnabled();
102
+
103
+ fireEvent.click(screen.getByTestId("export-submit"));
104
+ await waitFor(() => expect(exportNotebookMock).toHaveBeenCalledOnce());
105
+
106
+ expect(screen.getByTestId("export-submit")).toHaveAttribute(
107
+ "aria-busy",
108
+ "true",
109
+ );
110
+ expect(screen.getByTestId("export-format-html")).toBeDisabled();
111
+ expect(screen.getByRole("radio", { name: "Document" })).toBeDisabled();
112
+ expect(screen.getByRole("switch", { name: "Include code" })).toBeDisabled();
113
+
114
+ finishExport?.();
115
+ await waitFor(() => expect(onClose).toHaveBeenCalledOnce());
116
+ });
117
+
118
+ it("describes the selected PDF options", async () => {
119
+ renderDialog("pdf");
120
+ await waitForExportEnabled();
121
+
122
+ expect(
123
+ screen.getByRole("radiogroup", { name: "Layout" }),
124
+ ).toHaveAccessibleDescription("Creates pages for reading or printing.");
125
+ expect(
126
+ screen.getByRole("switch", { name: "Include code" }),
127
+ ).toHaveAccessibleDescription("Includes code cells in the PDF.");
128
+ const method = screen.getByRole("radiogroup", { name: "Method" });
129
+ expect(method).toHaveAccessibleDescription(
130
+ "Uses browser rendering to preserve the notebook's visual output.",
131
+ );
132
+ expect(screen.getByRole("radio", { name: "Browser" })).toBeChecked();
133
+
134
+ fireEvent.click(screen.getByRole("radio", { name: "LaTeX first" }));
135
+ expect(method).toHaveAccessibleDescription(
136
+ "Uses Pandoc and TeX when available, then falls back to browser rendering.",
137
+ );
138
+ expect(screen.getByRole("radio", { name: "LaTeX first" })).toBeChecked();
139
+
140
+ fireEvent.click(screen.getByRole("switch", { name: "Include code" }));
141
+ expect(
142
+ screen.getByRole("switch", { name: "Include code" }),
143
+ ).toHaveAccessibleDescription("Leaves code cells out of the PDF.");
144
+
145
+ fireEvent.click(screen.getByRole("radio", { name: "Slides" }));
146
+ expect(
147
+ screen.getByRole("radiogroup", { name: "Layout" }),
148
+ ).toHaveAccessibleDescription(
149
+ "Creates presentation slides from the notebook.",
150
+ );
151
+ });
152
+
153
+ it("describes the selected Markdown format", () => {
154
+ renderDialog("markdown");
155
+
156
+ expect(
157
+ screen.getByRole("combobox", { name: "Format" }),
158
+ ).toHaveAccessibleDescription(
159
+ "Chooses a format from the notebook filename. Defaults to Markdown (.md).",
160
+ );
161
+
162
+ act(() => {
163
+ store.set(exportOptionsAtom, {
164
+ ...store.get(exportOptionsAtom),
165
+ markdown: { flavor: "qmd" },
166
+ });
167
+ });
168
+
169
+ expect(
170
+ screen.getByRole("combobox", { name: "Format" }),
171
+ ).toHaveAccessibleDescription("Creates a .qmd file for Quarto.");
172
+ });
173
+
174
+ it("describes the selected Jupyter cell order", () => {
175
+ renderDialog("ipynb");
176
+
177
+ expect(
178
+ screen.getByRole("radiogroup", { name: "Cell order" }),
179
+ ).toHaveAccessibleDescription(
180
+ "Reorders cells so each cell appears after the cells it depends on.",
181
+ );
182
+
183
+ fireEvent.click(screen.getByRole("radio", { name: "Top to bottom" }));
184
+
185
+ expect(
186
+ screen.getByRole("radiogroup", { name: "Cell order" }),
187
+ ).toHaveAccessibleDescription(
188
+ "Keeps cells in the same order as this notebook.",
189
+ );
190
+ });
191
+
192
+ it("shows every format and updates the equivalent command with options", async () => {
193
+ renderDialog();
194
+
195
+ for (const format of [
196
+ "html",
197
+ "markdown",
198
+ "ipynb",
199
+ "pdf",
200
+ "script",
201
+ "png",
202
+ ]) {
203
+ expect(screen.getByTestId(`export-format-${format}`)).toBeVisible();
204
+ }
205
+
206
+ await waitForExportEnabled();
207
+ expect(screen.getByTestId("export-cli-command")).toHaveTextContent(
208
+ "marimo export html /project/notebook.py --include-code",
209
+ );
210
+ expect(
211
+ screen.getByText(
212
+ "Uses the current session state. The copied command exports the saved notebook.",
213
+ ),
214
+ ).toBeVisible();
215
+
216
+ fireEvent.click(screen.getByRole("switch", { name: "Include code" }));
217
+ expect(screen.getByTestId("export-cli-command")).toHaveTextContent(
218
+ "--no-include-code",
219
+ );
220
+ });
221
+
222
+ it("matches tab keyboard orientation to the responsive layout", () => {
223
+ const listeners = new Set<() => void>();
224
+ const media = {
225
+ matches: false,
226
+ addEventListener: vi.fn((_event: "change", listener: () => void) =>
227
+ listeners.add(listener),
228
+ ),
229
+ removeEventListener: vi.fn((_event: "change", listener: () => void) =>
230
+ listeners.delete(listener),
231
+ ),
232
+ };
233
+ vi.stubGlobal(
234
+ "matchMedia",
235
+ vi.fn(() => media),
236
+ );
237
+
238
+ renderDialog();
239
+ const tablist = screen.getByRole("tablist", { name: "Export format" });
240
+ expect(tablist).toHaveAttribute("aria-orientation", "horizontal");
241
+
242
+ act(() => {
243
+ media.matches = true;
244
+ for (const listener of listeners) {
245
+ listener();
246
+ }
247
+ });
248
+ expect(tablist).toHaveAttribute("aria-orientation", "vertical");
249
+ });
250
+
251
+ it("keeps unavailable formats visible and names missing packages", async () => {
252
+ store.set(
253
+ requestClientAtom,
254
+ MockRequestClient.create({
255
+ getExportAvailability: vi.fn().mockResolvedValue({
256
+ source: "server",
257
+ formats: [
258
+ {
259
+ format: "pdf",
260
+ dependenciesAvailable: false,
261
+ missingPackages: ["nbconvert[webpdf]"],
262
+ missingSetup: [],
263
+ },
264
+ ],
265
+ }),
266
+ }),
267
+ );
268
+
269
+ renderDialog("pdf");
270
+
271
+ expect(await screen.findByText("nbconvert[webpdf]")).toBeVisible();
272
+ expect(
273
+ screen.getByText(/where marimo is running to use this export/),
274
+ ).toBeVisible();
275
+ expect(screen.getByTestId("export-submit")).toBeDisabled();
276
+ expect(screen.getByTestId("export-format-pdf")).toBeVisible();
277
+ });
278
+
279
+ it("explains missing Playwright Chromium setup", async () => {
280
+ store.set(
281
+ requestClientAtom,
282
+ MockRequestClient.create({
283
+ getExportAvailability: vi.fn().mockResolvedValue({
284
+ source: "server",
285
+ formats: [
286
+ {
287
+ format: "pdf",
288
+ dependenciesAvailable: false,
289
+ missingPackages: [],
290
+ missingSetup: [
291
+ {
292
+ name: "playwright-chromium",
293
+ command: "uv run playwright install chromium",
294
+ },
295
+ ],
296
+ },
297
+ ],
298
+ }),
299
+ }),
300
+ );
301
+
302
+ renderDialog("pdf");
303
+
304
+ expect(
305
+ await screen.findByText("PDF export requires Playwright Chromium."),
306
+ ).toBeVisible();
307
+ expect(
308
+ screen.getByText("uv run playwright install chromium"),
309
+ ).toBeVisible();
310
+ expect(screen.getByTestId("export-submit")).toBeDisabled();
311
+ });
312
+
313
+ it("announces requirement checks as status updates", () => {
314
+ store.set(
315
+ requestClientAtom,
316
+ MockRequestClient.create({
317
+ getExportAvailability: vi.fn(() => new Promise(() => undefined)),
318
+ }),
319
+ );
320
+
321
+ renderDialog("pdf");
322
+
323
+ expect(screen.getByRole("status")).toHaveTextContent(
324
+ "Checking PDF requirements…",
325
+ );
326
+ });
327
+
328
+ it("hides the document renderer when exporting slides", async () => {
329
+ renderDialog("pdf");
330
+ await waitForExportEnabled();
331
+
332
+ fireEvent.click(screen.getByRole("radio", { name: "Slides" }));
333
+
334
+ expect(
335
+ screen.queryByRole("radiogroup", { name: "Method" }),
336
+ ).not.toBeInTheDocument();
337
+ });
338
+
339
+ it("requires a saved file for notebook source but not flat script", () => {
340
+ store.set(filenameAtom, null);
341
+
342
+ renderDialog("script");
343
+
344
+ expect(
345
+ screen.getByText("Name and save this notebook before exporting."),
346
+ ).toBeVisible();
347
+ expect(screen.getByTestId("export-submit")).toBeDisabled();
348
+
349
+ fireEvent.click(screen.getByRole("radio", { name: "Flat script" }));
350
+
351
+ expect(
352
+ screen.queryByText("Name and save this notebook before exporting."),
353
+ ).not.toBeInTheDocument();
354
+ expect(screen.getByTestId("export-submit")).toBeEnabled();
355
+ });
356
+
357
+ it("allows an export attempt when requirements cannot be checked", async () => {
358
+ store.set(
359
+ requestClientAtom,
360
+ MockRequestClient.create({
361
+ getExportAvailability: vi
362
+ .fn()
363
+ .mockRejectedValue(new Error("unavailable")),
364
+ }),
365
+ );
366
+
367
+ renderDialog("pdf");
368
+
369
+ expect(
370
+ await screen.findByText(
371
+ "Couldn't check whether this export is available. You can still try it.",
372
+ ),
373
+ ).toBeVisible();
374
+ expect(screen.getByTestId("export-submit")).toBeEnabled();
375
+ });
376
+
377
+ it("shows browser printing for PDF export in WebAssembly", () => {
378
+ vi.mocked(isWasm).mockReturnValue(true);
379
+
380
+ renderDialog("pdf");
381
+
382
+ expect(
383
+ screen.getByText(
384
+ "Use your browser's print dialog to save the current app view as a PDF.",
385
+ ),
386
+ ).toBeVisible();
387
+ expect(screen.queryByRole("radio", { name: "Document" })).toBeNull();
388
+ expect(screen.queryByTestId("export-cli-command")).not.toBeInTheDocument();
389
+ expect(screen.getByRole("button", { name: "Print to PDF" })).toBeEnabled();
390
+ });
391
+
392
+ it("copies the displayed CLI command", async () => {
393
+ store.set(exportOptionsAtom, {
394
+ ...DEFAULT_EXPORT_OPTIONS,
395
+ script: { type: "flat" },
396
+ });
397
+ renderDialog("script");
398
+ await waitForExportEnabled();
399
+
400
+ fireEvent.click(
401
+ screen.getByRole("button", { name: "Copy POSIX shell command" }),
402
+ );
403
+
404
+ await waitFor(() =>
405
+ expect(copyModule.copyToClipboard).toHaveBeenCalledWith(
406
+ "marimo export script /project/notebook.py -o /project/notebook.script.py",
407
+ ),
408
+ );
409
+ });
410
+
411
+ it("describes and selects each Python format", () => {
412
+ renderDialog("script");
413
+
414
+ expect(
415
+ screen.getByRole("radio", { name: "Notebook source" }),
416
+ ).toBeChecked();
417
+ expect(
418
+ screen.getByRole("radiogroup", { name: "Format" }),
419
+ ).toHaveAccessibleDescription(
420
+ "Downloads the saved notebook source for continued editing in marimo.",
421
+ );
422
+ expect(
423
+ screen.getByRole("button", { name: "Export notebook source" }),
424
+ ).toBeVisible();
425
+ expect(screen.queryByTestId("export-cli-command")).not.toBeInTheDocument();
426
+
427
+ fireEvent.click(screen.getByRole("radio", { name: "Flat script" }));
428
+
429
+ expect(
430
+ screen.getByRole("radiogroup", { name: "Format" }),
431
+ ).toHaveAccessibleDescription(
432
+ "Reorders cells so the Python script runs from top to bottom.",
433
+ );
434
+ expect(
435
+ screen.getByRole("button", { name: "Export flat script" }),
436
+ ).toBeVisible();
437
+ expect(screen.getByTestId("export-cli-command")).toHaveTextContent(
438
+ "marimo export script /project/notebook.py",
439
+ );
440
+ });
441
+
442
+ it("hides the shell command until the notebook is saved", () => {
443
+ store.set(filenameAtom, null);
444
+ store.set(exportOptionsAtom, {
445
+ ...DEFAULT_EXPORT_OPTIONS,
446
+ script: { type: "flat" },
447
+ });
448
+
449
+ renderDialog("script");
450
+
451
+ expect(screen.queryByTestId("export-cli-command")).not.toBeInTheDocument();
452
+ expect(
453
+ screen.getByText(
454
+ "Save the notebook to copy an equivalent shell command.",
455
+ ),
456
+ ).toBeVisible();
457
+ });
458
+ });
@@ -0,0 +1,178 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { beforeEach, describe, expect, it, vi } from "vitest";
4
+ import type { ExportedFile } from "@/core/network/types";
5
+ import { exportNotebook } from "../export-notebook";
6
+ import { DEFAULT_EXPORT_OPTIONS, type ExportOptions } from "../state";
7
+
8
+ type Requests = Parameters<typeof exportNotebook>[0]["requests"];
9
+
10
+ const FILE: ExportedFile<string> = {
11
+ contents: "exported",
12
+ filename: "notebook.txt",
13
+ mediaType: "text/plain",
14
+ };
15
+
16
+ function makeOptions(overrides: Partial<ExportOptions> = {}): ExportOptions {
17
+ return {
18
+ ...DEFAULT_EXPORT_OPTIONS,
19
+ ...overrides,
20
+ };
21
+ }
22
+
23
+ function makeRequests(): Requests {
24
+ return {
25
+ exportAsHTML: vi.fn().mockResolvedValue(FILE),
26
+ exportAsMarkdown: vi.fn().mockResolvedValue(FILE),
27
+ exportAsIPYNB: vi.fn().mockResolvedValue(FILE),
28
+ exportAsPDF: vi.fn().mockResolvedValue({
29
+ ...FILE,
30
+ contents: new Blob(),
31
+ filename: "notebook.pdf",
32
+ mediaType: "application/pdf",
33
+ }),
34
+ exportAsScript: vi.fn().mockResolvedValue(FILE),
35
+ readCode: vi.fn().mockResolvedValue({
36
+ contents: "import marimo",
37
+ }),
38
+ };
39
+ }
40
+
41
+ describe("exportNotebook", () => {
42
+ let requests: Requests;
43
+ let captureOutputs: ReturnType<typeof vi.fn>;
44
+ let capturePNG: ReturnType<typeof vi.fn>;
45
+ let downloadFile: ReturnType<typeof vi.fn>;
46
+
47
+ beforeEach(() => {
48
+ requests = makeRequests();
49
+ captureOutputs = vi.fn().mockResolvedValue(undefined);
50
+ capturePNG = vi.fn().mockResolvedValue(undefined);
51
+ downloadFile = vi.fn();
52
+ });
53
+
54
+ const run = (
55
+ format: Parameters<typeof exportNotebook>[0]["format"],
56
+ options = makeOptions(),
57
+ ) =>
58
+ exportNotebook({
59
+ format,
60
+ options,
61
+ requests,
62
+ sourceFilename: "notebook.py",
63
+ htmlFiles: ["data.csv"],
64
+ captureOutputs,
65
+ capturePNG,
66
+ downloadFile,
67
+ });
68
+
69
+ it("passes HTML settings and virtual files through the session API", async () => {
70
+ await run("html", makeOptions({ html: { includeCode: false } }));
71
+
72
+ expect(requests.exportAsHTML).toHaveBeenCalledWith({
73
+ download: false,
74
+ files: ["data.csv"],
75
+ includeCode: false,
76
+ });
77
+ expect(downloadFile).toHaveBeenCalledWith(FILE);
78
+ });
79
+
80
+ it("passes the selected Markdown flavor through the session API", async () => {
81
+ await run("markdown", makeOptions({ markdown: { flavor: "qmd" } }));
82
+
83
+ expect(requests.exportAsMarkdown).toHaveBeenCalledWith({
84
+ download: false,
85
+ flavor: "qmd",
86
+ });
87
+ expect(downloadFile).toHaveBeenCalledWith(FILE);
88
+ });
89
+
90
+ it("captures outputs before an IPYNB export when requested", async () => {
91
+ const calls: string[] = [];
92
+ captureOutputs.mockImplementation(async () => {
93
+ calls.push("capture");
94
+ });
95
+ vi.mocked(requests.exportAsIPYNB).mockImplementation(async () => {
96
+ calls.push("export");
97
+ return FILE;
98
+ });
99
+
100
+ await run(
101
+ "ipynb",
102
+ makeOptions({
103
+ ipynb: { sortMode: "top-down", includeOutputs: true },
104
+ }),
105
+ );
106
+
107
+ expect(calls).toEqual(["capture", "export"]);
108
+ expect(requests.exportAsIPYNB).toHaveBeenCalledWith({
109
+ download: false,
110
+ sortMode: "top-down",
111
+ includeOutputs: true,
112
+ });
113
+ expect(downloadFile).toHaveBeenCalledWith(FILE);
114
+ });
115
+
116
+ it("skips browser capture when IPYNB outputs are excluded", async () => {
117
+ await run("ipynb");
118
+
119
+ expect(captureOutputs).not.toHaveBeenCalled();
120
+ expect(requests.exportAsIPYNB).toHaveBeenCalledWith({
121
+ download: false,
122
+ sortMode: "topological",
123
+ includeOutputs: false,
124
+ });
125
+ expect(downloadFile).toHaveBeenCalledWith(FILE);
126
+ });
127
+
128
+ it("captures current outputs before PDF export and sends every option", async () => {
129
+ const pdf = makeOptions({
130
+ pdf: {
131
+ preset: "slides",
132
+ includeInputs: false,
133
+ includeOutputs: true,
134
+ webpdf: false,
135
+ },
136
+ });
137
+
138
+ await run("pdf", pdf);
139
+
140
+ expect(captureOutputs).toHaveBeenCalledOnce();
141
+ expect(requests.exportAsPDF).toHaveBeenCalledWith(pdf.pdf);
142
+ expect(downloadFile).toHaveBeenCalledWith(
143
+ expect.objectContaining({ filename: "notebook.pdf" }),
144
+ );
145
+ });
146
+
147
+ it("downloads the editable notebook source", async () => {
148
+ await run("script");
149
+
150
+ expect(requests.readCode).toHaveBeenCalledOnce();
151
+ expect(requests.exportAsScript).not.toHaveBeenCalled();
152
+ expect(downloadFile).toHaveBeenCalledWith({
153
+ contents: "import marimo",
154
+ filename: "notebook.py",
155
+ mediaType: "text/plain",
156
+ });
157
+ });
158
+
159
+ it("downloads a flat script through the export API", async () => {
160
+ await run(
161
+ "script",
162
+ makeOptions({
163
+ script: { type: "flat" },
164
+ }),
165
+ );
166
+
167
+ expect(requests.exportAsScript).toHaveBeenCalledWith({ download: false });
168
+ expect(requests.readCode).not.toHaveBeenCalled();
169
+ expect(downloadFile).toHaveBeenCalledWith(FILE);
170
+ });
171
+
172
+ it("uses client-side capture for PNG", async () => {
173
+ await run("png");
174
+
175
+ expect(capturePNG).toHaveBeenCalledOnce();
176
+ expect(downloadFile).not.toHaveBeenCalled();
177
+ });
178
+ });