@marimo-team/islands 0.23.17-dev1 → 0.23.17-dev10
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.
- package/dist/{common-BGCQJb-W.js → common-CL5RzjRX.js} +4 -4
- package/dist/main.js +2 -2
- package/dist/{reveal-component-NNi374so.js → reveal-component-o0NFIMKL.js} +1 -1
- package/dist/style.css +1 -1
- package/package.json +1 -1
- package/src/components/databases/icons/huggingface.svg +8 -0
- package/src/components/editor/actions/__tests__/pair-with-agent-commands.test.ts +1 -1
- package/src/components/editor/actions/export-dialog/__tests__/export-command.test.ts +115 -0
- package/src/components/editor/actions/export-dialog/__tests__/export-dialog.test.tsx +423 -0
- package/src/components/editor/actions/export-dialog/__tests__/export-notebook.test.ts +178 -0
- package/src/components/editor/actions/export-dialog/__tests__/state.test.ts +234 -0
- package/src/components/editor/actions/export-dialog/__tests__/use-export-dialog.test.tsx +287 -0
- package/src/components/editor/actions/export-dialog/export-command.ts +149 -0
- package/src/components/editor/actions/export-dialog/export-dialog.tsx +194 -0
- package/src/components/editor/actions/export-dialog/export-notebook.ts +98 -0
- package/src/components/editor/actions/export-dialog/format-notice.tsx +138 -0
- package/src/components/editor/actions/export-dialog/format-options.tsx +531 -0
- package/src/components/editor/actions/export-dialog/state.ts +321 -0
- package/src/components/editor/actions/export-dialog/use-export-dialog.ts +382 -0
- package/src/components/editor/actions/pair-with-agent-commands.ts +1 -21
- package/src/components/editor/actions/useNotebookActions.tsx +72 -170
- package/src/components/editor/connections/add-connection-dialog.tsx +1 -1
- package/src/components/editor/connections/quick-add-data-sources.tsx +14 -7
- package/src/components/editor/connections/storage/__tests__/__snapshots__/as-code.test.ts.snap +14 -0
- package/src/components/editor/connections/storage/__tests__/as-code.test.ts +20 -0
- package/src/components/editor/connections/storage/add-storage-form.tsx +10 -0
- package/src/components/editor/connections/storage/as-code.ts +19 -1
- package/src/components/editor/connections/storage/schemas.ts +19 -0
- package/src/components/editor/controls/__tests__/notebook-menu-dropdown.test.tsx +187 -0
- package/src/components/editor/controls/notebook-menu-dropdown.tsx +4 -2
- package/src/components/storage/__tests__/storage-snippets.test.ts +88 -0
- package/src/components/storage/components.tsx +2 -0
- package/src/components/storage/storage-snippets.ts +58 -0
- package/src/core/storage/types.ts +1 -0
- package/src/utils/__tests__/download.test.tsx +6 -4
- package/src/utils/download.ts +3 -1
- package/src/utils/shell.ts +17 -0
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import type { ExportAvailabilityResponse } from "@/core/network/types";
|
|
5
|
+
import {
|
|
6
|
+
applyExportOptionOverrides,
|
|
7
|
+
DEFAULT_EXPORT_OPTIONS,
|
|
8
|
+
type ExportFormat,
|
|
9
|
+
type ExportOptions,
|
|
10
|
+
getExportFormatStatus,
|
|
11
|
+
isExportFormat,
|
|
12
|
+
mergeExportOptions,
|
|
13
|
+
} from "../state";
|
|
14
|
+
|
|
15
|
+
const AVAILABLE: ExportAvailabilityResponse = {
|
|
16
|
+
source: "server",
|
|
17
|
+
formats: [
|
|
18
|
+
{
|
|
19
|
+
format: "ipynb",
|
|
20
|
+
dependenciesAvailable: true,
|
|
21
|
+
missingPackages: [],
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
format: "pdf",
|
|
25
|
+
dependenciesAvailable: true,
|
|
26
|
+
missingPackages: [],
|
|
27
|
+
},
|
|
28
|
+
],
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const EXPECTED_DEFAULT_OPTIONS: ExportOptions = {
|
|
32
|
+
html: { includeCode: true },
|
|
33
|
+
markdown: { flavor: null },
|
|
34
|
+
ipynb: { sortMode: "topological", includeOutputs: false },
|
|
35
|
+
pdf: {
|
|
36
|
+
preset: "document",
|
|
37
|
+
includeInputs: true,
|
|
38
|
+
includeOutputs: true,
|
|
39
|
+
webpdf: true,
|
|
40
|
+
},
|
|
41
|
+
script: { type: "source" },
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
type StatusOptions = Parameters<typeof getExportFormatStatus>[0];
|
|
45
|
+
|
|
46
|
+
function status(
|
|
47
|
+
format: ExportFormat,
|
|
48
|
+
overrides: Partial<Omit<StatusOptions, "format">> = {},
|
|
49
|
+
) {
|
|
50
|
+
return getExportFormatStatus({
|
|
51
|
+
format,
|
|
52
|
+
options: DEFAULT_EXPORT_OPTIONS,
|
|
53
|
+
runtime: "server",
|
|
54
|
+
filename: "notebook.py",
|
|
55
|
+
availability: { status: "success", data: AVAILABLE },
|
|
56
|
+
...overrides,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
describe("export option state", () => {
|
|
61
|
+
it("adds current defaults to a stored partial option shape", () => {
|
|
62
|
+
expect(
|
|
63
|
+
mergeExportOptions({
|
|
64
|
+
html: { includeCode: false },
|
|
65
|
+
pdf: { preset: "slides" },
|
|
66
|
+
}),
|
|
67
|
+
).toEqual({
|
|
68
|
+
...EXPECTED_DEFAULT_OPTIONS,
|
|
69
|
+
html: { includeCode: false },
|
|
70
|
+
pdf: {
|
|
71
|
+
...EXPECTED_DEFAULT_OPTIONS.pdf,
|
|
72
|
+
preset: "slides",
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("keeps valid stored fields and resets invalid fields", () => {
|
|
78
|
+
expect(
|
|
79
|
+
mergeExportOptions({
|
|
80
|
+
html: { includeCode: "false" },
|
|
81
|
+
markdown: { flavor: "unknown" },
|
|
82
|
+
ipynb: { sortMode: "unknown", includeOutputs: true },
|
|
83
|
+
pdf: {
|
|
84
|
+
preset: "unknown",
|
|
85
|
+
includeInputs: false,
|
|
86
|
+
includeOutputs: "false",
|
|
87
|
+
webpdf: false,
|
|
88
|
+
},
|
|
89
|
+
script: { type: "unknown" },
|
|
90
|
+
}),
|
|
91
|
+
).toEqual({
|
|
92
|
+
...EXPECTED_DEFAULT_OPTIONS,
|
|
93
|
+
ipynb: {
|
|
94
|
+
...EXPECTED_DEFAULT_OPTIONS.ipynb,
|
|
95
|
+
includeOutputs: true,
|
|
96
|
+
},
|
|
97
|
+
pdf: {
|
|
98
|
+
...EXPECTED_DEFAULT_OPTIONS.pdf,
|
|
99
|
+
includeInputs: false,
|
|
100
|
+
webpdf: false,
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it.each([null, { pdf: "invalid" }])(
|
|
106
|
+
"resets malformed stored options",
|
|
107
|
+
(stored) => {
|
|
108
|
+
expect(mergeExportOptions(stored)).toEqual(EXPECTED_DEFAULT_OPTIONS);
|
|
109
|
+
},
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
it("applies a shortcut preset without resetting sibling options", () => {
|
|
113
|
+
const options = {
|
|
114
|
+
...DEFAULT_EXPORT_OPTIONS,
|
|
115
|
+
pdf: {
|
|
116
|
+
preset: "document" as const,
|
|
117
|
+
includeInputs: false,
|
|
118
|
+
includeOutputs: false,
|
|
119
|
+
webpdf: false,
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
expect(
|
|
124
|
+
applyExportOptionOverrides(options, {
|
|
125
|
+
pdf: { preset: "slides" },
|
|
126
|
+
}).pdf,
|
|
127
|
+
).toEqual({
|
|
128
|
+
...options.pdf,
|
|
129
|
+
preset: "slides",
|
|
130
|
+
});
|
|
131
|
+
expect(
|
|
132
|
+
applyExportOptionOverrides(options, {
|
|
133
|
+
script: { type: "flat" },
|
|
134
|
+
}).script,
|
|
135
|
+
).toEqual({ type: "flat" });
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("accepts current formats and rejects unknown values", () => {
|
|
139
|
+
expect(isExportFormat("markdown")).toBe(true);
|
|
140
|
+
expect(isExportFormat("wasm")).toBe(false);
|
|
141
|
+
expect(isExportFormat(null)).toBe(false);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe("getExportFormatStatus", () => {
|
|
146
|
+
it("waits only for formats with server dependencies", () => {
|
|
147
|
+
expect(status("ipynb", { availability: { status: "pending" } })).toEqual({
|
|
148
|
+
available: false,
|
|
149
|
+
reason: { type: "checking-requirements" },
|
|
150
|
+
});
|
|
151
|
+
expect(status("html", { availability: { status: "pending" } })).toEqual({
|
|
152
|
+
available: true,
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("requires a notebook name for file-backed server formats", () => {
|
|
157
|
+
expect(status("markdown", { filename: null })).toEqual({
|
|
158
|
+
available: false,
|
|
159
|
+
reason: { type: "notebook-must-be-named" },
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("requires a saved file for notebook source but not flat script", () => {
|
|
164
|
+
expect(status("script", { filename: null })).toEqual({
|
|
165
|
+
available: false,
|
|
166
|
+
reason: { type: "notebook-must-be-named" },
|
|
167
|
+
});
|
|
168
|
+
expect(
|
|
169
|
+
status("script", {
|
|
170
|
+
filename: null,
|
|
171
|
+
options: {
|
|
172
|
+
...DEFAULT_EXPORT_OPTIONS,
|
|
173
|
+
script: { type: "flat" },
|
|
174
|
+
},
|
|
175
|
+
}),
|
|
176
|
+
).toEqual({ available: true });
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("reports missing server packages", () => {
|
|
180
|
+
expect(
|
|
181
|
+
status("pdf", {
|
|
182
|
+
availability: {
|
|
183
|
+
status: "success",
|
|
184
|
+
data: {
|
|
185
|
+
source: "server",
|
|
186
|
+
formats: [
|
|
187
|
+
{
|
|
188
|
+
format: "pdf",
|
|
189
|
+
dependenciesAvailable: false,
|
|
190
|
+
missingPackages: ["nbconvert[webpdf]"],
|
|
191
|
+
},
|
|
192
|
+
],
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
}),
|
|
196
|
+
).toEqual({
|
|
197
|
+
available: false,
|
|
198
|
+
reason: {
|
|
199
|
+
type: "missing-packages",
|
|
200
|
+
packages: ["nbconvert[webpdf]"],
|
|
201
|
+
},
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it.each([
|
|
206
|
+
["html", true, undefined],
|
|
207
|
+
["markdown", true, undefined],
|
|
208
|
+
["ipynb", false, "wasm-runtime"],
|
|
209
|
+
["pdf", true, "wasm-runtime"],
|
|
210
|
+
["script", true, undefined],
|
|
211
|
+
["png", true, undefined],
|
|
212
|
+
] as const)(
|
|
213
|
+
"describes %s availability in WebAssembly",
|
|
214
|
+
(format, available, reason) => {
|
|
215
|
+
expect(
|
|
216
|
+
status(format, {
|
|
217
|
+
runtime: "wasm",
|
|
218
|
+
filename: null,
|
|
219
|
+
availability: { status: "success", data: null },
|
|
220
|
+
}),
|
|
221
|
+
).toEqual({
|
|
222
|
+
available,
|
|
223
|
+
...(reason ? { reason: { type: reason } } : {}),
|
|
224
|
+
});
|
|
225
|
+
},
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
it("allows an export attempt when the availability check fails", () => {
|
|
229
|
+
expect(status("pdf", { availability: { status: "error" } })).toEqual({
|
|
230
|
+
available: true,
|
|
231
|
+
availabilityCheckFailed: true,
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
});
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import { act, render, renderHook, waitFor } from "@testing-library/react";
|
|
4
|
+
import { Provider } from "jotai";
|
|
5
|
+
import type React from "react";
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
7
|
+
import { MockRequestClient } from "@/__mocks__/requests";
|
|
8
|
+
import { viewStateAtom } from "@/core/mode";
|
|
9
|
+
import { requestClientAtom } from "@/core/network/requests";
|
|
10
|
+
import { filenameAtom } from "@/core/saving/file-state";
|
|
11
|
+
import { store } from "@/core/state/jotai";
|
|
12
|
+
import { isWasm } from "@/core/wasm/utils";
|
|
13
|
+
import {
|
|
14
|
+
DEFAULT_EXPORT_OPTIONS,
|
|
15
|
+
type ExportFormat,
|
|
16
|
+
exportOptionsAtom,
|
|
17
|
+
lastExportFormatAtom,
|
|
18
|
+
} from "../state";
|
|
19
|
+
import { useExportDialog } from "../use-export-dialog";
|
|
20
|
+
|
|
21
|
+
const { downloadHTMLAsImageMock, exportNotebookMock, toastMock } = vi.hoisted(
|
|
22
|
+
() => ({
|
|
23
|
+
downloadHTMLAsImageMock: vi.fn().mockResolvedValue(true),
|
|
24
|
+
exportNotebookMock: vi.fn().mockResolvedValue(undefined),
|
|
25
|
+
toastMock: vi.fn(() => ({
|
|
26
|
+
dismiss: vi.fn(),
|
|
27
|
+
update: vi.fn(),
|
|
28
|
+
})),
|
|
29
|
+
}),
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
vi.mock("@/components/ui/use-toast", () => ({
|
|
33
|
+
toast: toastMock,
|
|
34
|
+
}));
|
|
35
|
+
|
|
36
|
+
vi.mock("@/core/wasm/utils", async (importOriginal) => {
|
|
37
|
+
const actual = await importOriginal<typeof import("@/core/wasm/utils")>();
|
|
38
|
+
return { ...actual, isWasm: vi.fn(() => false) };
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
vi.mock("@/utils/download", async (importOriginal) => {
|
|
42
|
+
const actual = await importOriginal<typeof import("@/utils/download")>();
|
|
43
|
+
return { ...actual, downloadHTMLAsImage: downloadHTMLAsImageMock };
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
vi.mock("../export-notebook", () => ({
|
|
47
|
+
exportNotebook: exportNotebookMock,
|
|
48
|
+
}));
|
|
49
|
+
|
|
50
|
+
function wrapper({ children }: { children: React.ReactNode }) {
|
|
51
|
+
return <Provider store={store}>{children}</Provider>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function renderController(initialFormat?: ExportFormat, onClose = vi.fn()) {
|
|
55
|
+
return renderHook(() => useExportDialog({ initialFormat, onClose }), {
|
|
56
|
+
wrapper,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function waitForAvailable(
|
|
61
|
+
getController: () => ReturnType<typeof useExportDialog>,
|
|
62
|
+
) {
|
|
63
|
+
await waitFor(() =>
|
|
64
|
+
expect(getController().selected.status.available).toBe(true),
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
describe("useExportDialog", () => {
|
|
69
|
+
beforeEach(() => {
|
|
70
|
+
vi.clearAllMocks();
|
|
71
|
+
localStorage.clear();
|
|
72
|
+
store.set(requestClientAtom, MockRequestClient.create());
|
|
73
|
+
store.set(filenameAtom, "/project/notebook.py");
|
|
74
|
+
store.set(viewStateAtom, { mode: "edit", cellAnchor: null });
|
|
75
|
+
store.set(exportOptionsAtom, DEFAULT_EXPORT_OPTIONS);
|
|
76
|
+
store.set(lastExportFormatAtom, "html");
|
|
77
|
+
vi.mocked(isWasm).mockReturnValue(false);
|
|
78
|
+
document.title = "Notebook";
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
afterEach(() => {
|
|
82
|
+
vi.unstubAllGlobals();
|
|
83
|
+
document.getElementById("App")?.remove();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("keeps PNG export open when the app view is missing", async () => {
|
|
87
|
+
exportNotebookMock.mockImplementationOnce(
|
|
88
|
+
async ({ capturePNG }: { capturePNG: () => Promise<void> }) =>
|
|
89
|
+
capturePNG(),
|
|
90
|
+
);
|
|
91
|
+
const onClose = vi.fn();
|
|
92
|
+
const { result } = renderController("png", onClose);
|
|
93
|
+
await waitForAvailable(() => result.current);
|
|
94
|
+
|
|
95
|
+
await act(async () => {
|
|
96
|
+
await result.current.submit();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
expect(toastMock).toHaveBeenCalledWith({
|
|
100
|
+
title: "Failed to download as PNG",
|
|
101
|
+
description: "The current app view could not be captured.",
|
|
102
|
+
variant: "danger",
|
|
103
|
+
});
|
|
104
|
+
expect(result.current.isExporting).toBe(false);
|
|
105
|
+
expect(store.get(viewStateAtom).mode).toBe("edit");
|
|
106
|
+
expect(onClose).not.toHaveBeenCalled();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("captures the app view and restores the dialog", async () => {
|
|
110
|
+
const app = document.createElement("div");
|
|
111
|
+
app.id = "App";
|
|
112
|
+
document.body.append(app);
|
|
113
|
+
const { container: dialogContainer } = render(<div />);
|
|
114
|
+
const dialog = dialogContainer.firstElementChild as HTMLDivElement;
|
|
115
|
+
exportNotebookMock.mockImplementationOnce(
|
|
116
|
+
async ({ capturePNG }: { capturePNG: () => Promise<void> }) =>
|
|
117
|
+
capturePNG(),
|
|
118
|
+
);
|
|
119
|
+
downloadHTMLAsImageMock.mockImplementationOnce(
|
|
120
|
+
async ({ prepare }: Parameters<typeof downloadHTMLAsImageMock>[0]) => {
|
|
121
|
+
const cleanup = prepare?.(app);
|
|
122
|
+
expect(dialogContainer).not.toBeVisible();
|
|
123
|
+
cleanup?.();
|
|
124
|
+
return true;
|
|
125
|
+
},
|
|
126
|
+
);
|
|
127
|
+
const onClose = vi.fn();
|
|
128
|
+
const { result } = renderController("png", onClose);
|
|
129
|
+
await waitForAvailable(() => result.current);
|
|
130
|
+
act(() => {
|
|
131
|
+
result.current.dialogRef.current = dialog;
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
await act(async () => {
|
|
135
|
+
await result.current.submit();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
expect(onClose).toHaveBeenCalledOnce();
|
|
139
|
+
expect(store.get(viewStateAtom).mode).toBe("edit");
|
|
140
|
+
expect(dialogContainer).toBeVisible();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("restores the dialog and edit mode when PNG capture fails", async () => {
|
|
144
|
+
const app = document.createElement("div");
|
|
145
|
+
app.id = "App";
|
|
146
|
+
document.body.append(app);
|
|
147
|
+
const { container: dialogContainer } = render(<div />);
|
|
148
|
+
const dialog = dialogContainer.firstElementChild as HTMLDivElement;
|
|
149
|
+
exportNotebookMock.mockImplementationOnce(
|
|
150
|
+
async ({ capturePNG }: { capturePNG: () => Promise<void> }) =>
|
|
151
|
+
capturePNG(),
|
|
152
|
+
);
|
|
153
|
+
downloadHTMLAsImageMock.mockImplementationOnce(
|
|
154
|
+
async ({ prepare }: Parameters<typeof downloadHTMLAsImageMock>[0]) => {
|
|
155
|
+
const cleanup = prepare?.(app);
|
|
156
|
+
cleanup?.();
|
|
157
|
+
return false;
|
|
158
|
+
},
|
|
159
|
+
);
|
|
160
|
+
const onClose = vi.fn();
|
|
161
|
+
const { result } = renderController("png", onClose);
|
|
162
|
+
await waitForAvailable(() => result.current);
|
|
163
|
+
act(() => {
|
|
164
|
+
result.current.dialogRef.current = dialog;
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
await act(async () => {
|
|
168
|
+
await result.current.submit();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
expect(result.current.isExporting).toBe(false);
|
|
172
|
+
expect(store.get(viewStateAtom).mode).toBe("edit");
|
|
173
|
+
expect(dialogContainer).toBeVisible();
|
|
174
|
+
expect(onClose).not.toHaveBeenCalled();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("does not close a newer modal after unmounting", async () => {
|
|
178
|
+
let finishExport: (() => void) | undefined;
|
|
179
|
+
exportNotebookMock.mockImplementationOnce(
|
|
180
|
+
() =>
|
|
181
|
+
new Promise<void>((resolve) => {
|
|
182
|
+
finishExport = resolve;
|
|
183
|
+
}),
|
|
184
|
+
);
|
|
185
|
+
const onClose = vi.fn();
|
|
186
|
+
const { result, unmount } = renderController(undefined, onClose);
|
|
187
|
+
await waitForAvailable(() => result.current);
|
|
188
|
+
|
|
189
|
+
let submit: Promise<void> | undefined;
|
|
190
|
+
act(() => {
|
|
191
|
+
submit = result.current.submit();
|
|
192
|
+
});
|
|
193
|
+
await waitFor(() => expect(exportNotebookMock).toHaveBeenCalledOnce());
|
|
194
|
+
|
|
195
|
+
unmount();
|
|
196
|
+
finishExport?.();
|
|
197
|
+
await submit;
|
|
198
|
+
|
|
199
|
+
expect(onClose).not.toHaveBeenCalled();
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("keeps the dialog open when a server export fails", async () => {
|
|
203
|
+
exportNotebookMock.mockRejectedValueOnce(new Error("export failed"));
|
|
204
|
+
const onClose = vi.fn();
|
|
205
|
+
const { result } = renderController(undefined, onClose);
|
|
206
|
+
await waitForAvailable(() => result.current);
|
|
207
|
+
|
|
208
|
+
await act(async () => {
|
|
209
|
+
await result.current.submit();
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
expect(result.current.isExporting).toBe(false);
|
|
213
|
+
expect(onClose).not.toHaveBeenCalled();
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("preserves the saved notebook filename for source export", async () => {
|
|
217
|
+
store.set(filenameAtom, "/project/report.qmd");
|
|
218
|
+
document.title = "Custom app title";
|
|
219
|
+
const { result } = renderController("script");
|
|
220
|
+
await waitForAvailable(() => result.current);
|
|
221
|
+
|
|
222
|
+
await act(async () => {
|
|
223
|
+
await result.current.submit();
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
expect(exportNotebookMock).toHaveBeenCalledWith(
|
|
227
|
+
expect.objectContaining({ sourceFilename: "report.qmd" }),
|
|
228
|
+
);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("uses the page title for WebAssembly source export", async () => {
|
|
232
|
+
vi.mocked(isWasm).mockReturnValue(true);
|
|
233
|
+
store.set(filenameAtom, "notebook.py");
|
|
234
|
+
document.title = "Shared analysis";
|
|
235
|
+
const { result } = renderController("script");
|
|
236
|
+
await waitForAvailable(() => result.current);
|
|
237
|
+
|
|
238
|
+
await act(async () => {
|
|
239
|
+
await result.current.submit();
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
expect(exportNotebookMock).toHaveBeenCalledWith(
|
|
243
|
+
expect.objectContaining({ sourceFilename: "Shared analysis.py" }),
|
|
244
|
+
);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("keeps equivalent CLI commands for WebAssembly exports", () => {
|
|
248
|
+
vi.mocked(isWasm).mockReturnValue(true);
|
|
249
|
+
const { result } = renderController("html");
|
|
250
|
+
|
|
251
|
+
expect(result.current.selected.command).toBe(
|
|
252
|
+
"marimo export html /project/notebook.py --include-code -o /project/notebook.html",
|
|
253
|
+
);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("persists the selected format and its options", () => {
|
|
257
|
+
const first = renderController();
|
|
258
|
+
|
|
259
|
+
act(() => {
|
|
260
|
+
first.result.current.selectFormat("pdf");
|
|
261
|
+
first.result.current.updateOptions("pdf", { preset: "slides" });
|
|
262
|
+
});
|
|
263
|
+
first.unmount();
|
|
264
|
+
|
|
265
|
+
const second = renderController();
|
|
266
|
+
expect(second.result.current.selected.format).toBe("pdf");
|
|
267
|
+
expect(second.result.current.options.pdf.preset).toBe("slides");
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("prints the current view for PDF export in WebAssembly", async () => {
|
|
271
|
+
vi.mocked(isWasm).mockReturnValue(true);
|
|
272
|
+
const print = vi.fn();
|
|
273
|
+
vi.stubGlobal("print", print);
|
|
274
|
+
const onClose = vi.fn();
|
|
275
|
+
const { result } = renderController("pdf", onClose);
|
|
276
|
+
|
|
277
|
+
expect(result.current.selected.usesBrowserPrint).toBe(true);
|
|
278
|
+
|
|
279
|
+
await act(async () => {
|
|
280
|
+
await result.current.submit();
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
expect(onClose).toHaveBeenCalledOnce();
|
|
284
|
+
await waitFor(() => expect(print).toHaveBeenCalledOnce());
|
|
285
|
+
expect(exportNotebookMock).not.toHaveBeenCalled();
|
|
286
|
+
});
|
|
287
|
+
});
|