@marimo-team/islands 0.19.5-dev4 → 0.19.5-dev43
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/{ConnectedDataExplorerComponent-D5KcOOzu.js → ConnectedDataExplorerComponent-DjQ_E5BA.js} +3 -3
- package/dist/assets/__vite-browser-external-DRa9CT_O.js +1 -0
- package/dist/assets/{worker-BR7KVExK.js → worker-SqntmiwV.js} +2 -2
- package/dist/{glide-data-editor-DsVDCmV2.js → glide-data-editor-zEomQJ3U.js} +2 -2
- package/dist/main.js +290 -244
- package/dist/{mermaid-DZjjc-kI.js → mermaid-D7wtYc6C.js} +2 -2
- package/dist/{spec-B1PGDiGh.js → spec-Cif4tBMJ.js} +1 -1
- package/dist/style.css +1 -1
- package/dist/{types-CbQF8CBX.js → types-BQOP2pRy.js} +1 -1
- package/dist/{useAsyncData-TLXJC7yx.js → useAsyncData-kqbhbSuf.js} +1 -1
- package/dist/{useDeepCompareMemoize-DVnEG7jx.js → useDeepCompareMemoize-B2QEm3jo.js} +1 -1
- package/dist/{useTheme-BllQjRdW.js → useTheme-CVr6Gb_R.js} +4 -1
- package/dist/{vega-component-B2QrGnW8.js → vega-component-DAeU1_cV.js} +3 -3
- package/package.json +1 -1
- package/src/__mocks__/requests.ts +1 -0
- package/src/components/ai/__tests__/ai-utils.test.ts +276 -0
- package/src/components/ai/ai-utils.ts +101 -0
- package/src/components/app-config/ai-config.tsx +56 -16
- package/src/components/app-config/user-config-form.tsx +63 -1
- package/src/components/chat/chat-panel.tsx +4 -4
- package/src/components/data-table/cell-utils.ts +10 -0
- package/src/components/editor/Output.tsx +21 -14
- package/src/components/editor/actions/useCellActionButton.tsx +2 -2
- package/src/components/editor/actions/useNotebookActions.tsx +61 -21
- package/src/components/editor/cell/cell-actions.tsx +6 -1
- package/src/components/editor/controls/Controls.tsx +1 -8
- package/src/components/editor/file-tree/file-explorer.tsx +4 -2
- package/src/components/editor/file-tree/file-viewer.tsx +9 -6
- package/src/components/editor/navigation/navigation.ts +39 -1
- package/src/components/editor/renderMimeIcon.tsx +2 -0
- package/src/components/editor/renderers/vertical-layout/vertical-layout.tsx +1 -1
- package/src/core/ai/model-registry.ts +21 -3
- package/src/core/codemirror/language/panel/panel.tsx +3 -0
- package/src/core/codemirror/language/panel/sql.tsx +6 -2
- package/src/core/config/config-schema.ts +5 -1
- package/src/core/config/config.ts +4 -0
- package/src/core/config/feature-flag.tsx +2 -0
- package/src/core/export/__tests__/hooks.test.ts +120 -1
- package/src/core/export/hooks.ts +48 -18
- package/src/core/islands/bridge.ts +1 -0
- package/src/core/lsp/__tests__/transport.test.ts +149 -0
- package/src/core/lsp/transport.ts +48 -0
- package/src/core/network/requests-lazy.ts +1 -0
- package/src/core/network/requests-network.ts +9 -0
- package/src/core/network/requests-static.ts +1 -0
- package/src/core/network/requests-toasting.tsx +1 -0
- package/src/core/network/types.ts +2 -0
- package/src/core/wasm/bridge.ts +1 -0
- package/src/css/app/Cell.css +0 -2
- package/src/plugins/layout/TexPlugin.tsx +7 -5
- package/src/utils/__tests__/download.test.tsx +492 -0
- package/src/utils/download.ts +161 -6
- package/src/utils/filenames.ts +3 -0
- package/dist/assets/__vite-browser-external-CgHmDpAZ.js +0 -1
- package/src/components/export/export-output-button.tsx +0 -14
package/src/css/app/Cell.css
CHANGED
|
@@ -249,7 +249,6 @@
|
|
|
249
249
|
}
|
|
250
250
|
|
|
251
251
|
/* Borderless styles for Cell */
|
|
252
|
-
|
|
253
252
|
&.borderless {
|
|
254
253
|
border-color: transparent;
|
|
255
254
|
|
|
@@ -258,7 +257,6 @@
|
|
|
258
257
|
}
|
|
259
258
|
|
|
260
259
|
/* Apply the original styles */
|
|
261
|
-
&:hover,
|
|
262
260
|
&:focus {
|
|
263
261
|
border: 1px solid var(--gray-4);
|
|
264
262
|
}
|
|
@@ -115,16 +115,18 @@ const TexComponent = ({
|
|
|
115
115
|
// isn't a simple way to do that in Python without bringing in a new
|
|
116
116
|
// dependency.
|
|
117
117
|
//
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
//
|
|
118
|
+
// When nested, the inner marimo-tex should not render because the outer
|
|
119
|
+
// marimo-tex's textContent includes the nested delimiters (||(||(x||)||))
|
|
120
|
+
// and will render correctly with displayMode: true. We detect this by
|
|
121
|
+
// checking if the parent element is also a marimo-tex.
|
|
122
|
+
const isNested = host.parentElement?.tagName.toLowerCase() === "marimo-tex";
|
|
121
123
|
|
|
122
124
|
// Re-render when the text content changes.
|
|
123
125
|
useLayoutEffect(() => {
|
|
124
|
-
if (ref.current) {
|
|
126
|
+
if (ref.current && !isNested) {
|
|
125
127
|
renderLatex(ref.current, currentTex);
|
|
126
128
|
}
|
|
127
|
-
}, [currentTex]);
|
|
129
|
+
}, [currentTex, isNested]);
|
|
128
130
|
|
|
129
131
|
return <span ref={ref} />;
|
|
130
132
|
};
|
|
@@ -0,0 +1,492 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import type { CellId } from "@/core/cells/ids";
|
|
4
|
+
import { CellOutputId } from "@/core/cells/ids";
|
|
5
|
+
import {
|
|
6
|
+
downloadByURL,
|
|
7
|
+
downloadCellOutputAsImage,
|
|
8
|
+
downloadHTMLAsImage,
|
|
9
|
+
getImageDataUrlForCell,
|
|
10
|
+
withLoadingToast,
|
|
11
|
+
} from "../download";
|
|
12
|
+
|
|
13
|
+
// Mock html-to-image
|
|
14
|
+
vi.mock("html-to-image", () => ({
|
|
15
|
+
toPng: vi.fn(),
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
// Mock the toast module
|
|
19
|
+
const mockDismiss = vi.fn();
|
|
20
|
+
vi.mock("@/components/ui/use-toast", () => ({
|
|
21
|
+
toast: vi.fn(() => ({
|
|
22
|
+
dismiss: mockDismiss,
|
|
23
|
+
})),
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
// Mock the Spinner component
|
|
27
|
+
vi.mock("@/components/icons/spinner", () => ({
|
|
28
|
+
Spinner: () => "MockSpinner",
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
// Mock Logger
|
|
32
|
+
vi.mock("@/utils/Logger", () => ({
|
|
33
|
+
Logger: {
|
|
34
|
+
error: vi.fn(),
|
|
35
|
+
},
|
|
36
|
+
}));
|
|
37
|
+
|
|
38
|
+
// Mock Filenames
|
|
39
|
+
vi.mock("@/utils/filenames", () => ({
|
|
40
|
+
Filenames: {
|
|
41
|
+
toPNG: (name: string) => `${name}.png`,
|
|
42
|
+
},
|
|
43
|
+
}));
|
|
44
|
+
|
|
45
|
+
import { toPng } from "html-to-image";
|
|
46
|
+
import { toast } from "@/components/ui/use-toast";
|
|
47
|
+
import { Logger } from "@/utils/Logger";
|
|
48
|
+
|
|
49
|
+
describe("withLoadingToast", () => {
|
|
50
|
+
beforeEach(() => {
|
|
51
|
+
vi.clearAllMocks();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("should show a loading toast and dismiss on success", async () => {
|
|
55
|
+
const result = await withLoadingToast("Loading...", async () => {
|
|
56
|
+
return "success";
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
expect(toast).toHaveBeenCalledTimes(1);
|
|
60
|
+
expect(toast).toHaveBeenCalledWith({
|
|
61
|
+
title: "Loading...",
|
|
62
|
+
duration: Infinity,
|
|
63
|
+
});
|
|
64
|
+
expect(mockDismiss).toHaveBeenCalledTimes(1);
|
|
65
|
+
expect(result).toBe("success");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("should dismiss toast and rethrow on error", async () => {
|
|
69
|
+
const error = new Error("Operation failed");
|
|
70
|
+
|
|
71
|
+
await expect(
|
|
72
|
+
withLoadingToast("Loading...", async () => {
|
|
73
|
+
throw error;
|
|
74
|
+
}),
|
|
75
|
+
).rejects.toThrow("Operation failed");
|
|
76
|
+
|
|
77
|
+
expect(toast).toHaveBeenCalledTimes(1);
|
|
78
|
+
expect(mockDismiss).toHaveBeenCalledTimes(1);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("should return the value from the async function", async () => {
|
|
82
|
+
const expectedValue = { data: "test", count: 42 };
|
|
83
|
+
|
|
84
|
+
const result = await withLoadingToast("Processing...", async () => {
|
|
85
|
+
return expectedValue;
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
expect(result).toEqual(expectedValue);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("should handle void functions", async () => {
|
|
92
|
+
let sideEffect = false;
|
|
93
|
+
|
|
94
|
+
await withLoadingToast("Saving...", async () => {
|
|
95
|
+
sideEffect = true;
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
expect(sideEffect).toBe(true);
|
|
99
|
+
expect(mockDismiss).toHaveBeenCalledTimes(1);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("should use the provided title in the toast", async () => {
|
|
103
|
+
const customTitle = "Downloading PDF...";
|
|
104
|
+
|
|
105
|
+
await withLoadingToast(customTitle, async () => "done");
|
|
106
|
+
|
|
107
|
+
expect(toast).toHaveBeenCalledWith(
|
|
108
|
+
expect.objectContaining({
|
|
109
|
+
title: customTitle,
|
|
110
|
+
}),
|
|
111
|
+
);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("should wait for the async function to complete", async () => {
|
|
115
|
+
const events: string[] = [];
|
|
116
|
+
|
|
117
|
+
await withLoadingToast("Loading...", async () => {
|
|
118
|
+
events.push("start");
|
|
119
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
120
|
+
events.push("end");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
expect(events).toEqual(["start", "end"]);
|
|
124
|
+
expect(mockDismiss).toHaveBeenCalledTimes(1);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe("getImageDataUrlForCell", () => {
|
|
129
|
+
const mockDataUrl = "data:image/png;base64,mockbase64data";
|
|
130
|
+
let mockElement: HTMLElement;
|
|
131
|
+
|
|
132
|
+
beforeEach(() => {
|
|
133
|
+
vi.clearAllMocks();
|
|
134
|
+
mockElement = document.createElement("div");
|
|
135
|
+
mockElement.id = CellOutputId.create("cell-1" as CellId);
|
|
136
|
+
document.body.append(mockElement);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
afterEach(() => {
|
|
140
|
+
mockElement.remove();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("should return undefined if element is not found", async () => {
|
|
144
|
+
const result = await getImageDataUrlForCell("nonexistent" as CellId);
|
|
145
|
+
|
|
146
|
+
expect(result).toBeUndefined();
|
|
147
|
+
expect(Logger.error).toHaveBeenCalledWith(
|
|
148
|
+
"Output element not found for cell nonexistent",
|
|
149
|
+
);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("should capture screenshot and return data URL", async () => {
|
|
153
|
+
vi.mocked(toPng).mockResolvedValue(mockDataUrl);
|
|
154
|
+
|
|
155
|
+
const result = await getImageDataUrlForCell("cell-1" as CellId);
|
|
156
|
+
|
|
157
|
+
expect(result).toBe(mockDataUrl);
|
|
158
|
+
expect(toPng).toHaveBeenCalledWith(mockElement);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("should add printing classes before capture", async () => {
|
|
162
|
+
vi.mocked(toPng).mockImplementation(async () => {
|
|
163
|
+
// Check classes are applied during capture
|
|
164
|
+
expect(mockElement.classList.contains("printing-output")).toBe(true);
|
|
165
|
+
expect(document.body.classList.contains("printing")).toBe(true);
|
|
166
|
+
expect(mockElement.style.overflow).toBe("auto");
|
|
167
|
+
return mockDataUrl;
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
await getImageDataUrlForCell("cell-1" as CellId);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("should remove printing classes after capture", async () => {
|
|
174
|
+
vi.mocked(toPng).mockResolvedValue(mockDataUrl);
|
|
175
|
+
|
|
176
|
+
await getImageDataUrlForCell("cell-1" as CellId);
|
|
177
|
+
|
|
178
|
+
expect(mockElement.classList.contains("printing-output")).toBe(false);
|
|
179
|
+
expect(document.body.classList.contains("printing")).toBe(false);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("should restore original overflow style after capture", async () => {
|
|
183
|
+
mockElement.style.overflow = "hidden";
|
|
184
|
+
vi.mocked(toPng).mockResolvedValue(mockDataUrl);
|
|
185
|
+
|
|
186
|
+
await getImageDataUrlForCell("cell-1" as CellId);
|
|
187
|
+
|
|
188
|
+
expect(mockElement.style.overflow).toBe("hidden");
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("should throw error on failure", async () => {
|
|
192
|
+
vi.mocked(toPng).mockRejectedValue(new Error("Capture failed"));
|
|
193
|
+
|
|
194
|
+
await expect(getImageDataUrlForCell("cell-1" as CellId)).rejects.toThrow(
|
|
195
|
+
"Capture failed",
|
|
196
|
+
);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("should cleanup even on failure", async () => {
|
|
200
|
+
mockElement.style.overflow = "scroll";
|
|
201
|
+
vi.mocked(toPng).mockRejectedValue(new Error("Capture failed"));
|
|
202
|
+
|
|
203
|
+
await expect(getImageDataUrlForCell("cell-1" as CellId)).rejects.toThrow();
|
|
204
|
+
|
|
205
|
+
expect(mockElement.classList.contains("printing-output")).toBe(false);
|
|
206
|
+
expect(document.body.classList.contains("printing")).toBe(false);
|
|
207
|
+
expect(mockElement.style.overflow).toBe("scroll");
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("should maintain body.printing during concurrent captures", async () => {
|
|
211
|
+
// Create a second element
|
|
212
|
+
const mockElement2 = document.createElement("div");
|
|
213
|
+
mockElement2.id = CellOutputId.create("cell-2" as CellId);
|
|
214
|
+
document.body.append(mockElement2);
|
|
215
|
+
|
|
216
|
+
// Track body.printing state during each capture
|
|
217
|
+
const printingStateDuringCaptures: boolean[] = [];
|
|
218
|
+
let resolveFirst: () => void;
|
|
219
|
+
let resolveSecond: () => void;
|
|
220
|
+
|
|
221
|
+
const firstPromise = new Promise<void>((resolve) => {
|
|
222
|
+
resolveFirst = resolve;
|
|
223
|
+
});
|
|
224
|
+
const secondPromise = new Promise<void>((resolve) => {
|
|
225
|
+
resolveSecond = resolve;
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
vi.mocked(toPng).mockImplementation(async (element) => {
|
|
229
|
+
printingStateDuringCaptures.push(
|
|
230
|
+
document.body.classList.contains("printing"),
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
// Simulate async work - first capture takes longer
|
|
234
|
+
await (element.id.includes("cell-1") ? firstPromise : secondPromise);
|
|
235
|
+
|
|
236
|
+
// Check state again after waiting
|
|
237
|
+
printingStateDuringCaptures.push(
|
|
238
|
+
document.body.classList.contains("printing"),
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
return mockDataUrl;
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
// Start both captures concurrently
|
|
245
|
+
const capture1 = getImageDataUrlForCell("cell-1" as CellId);
|
|
246
|
+
const capture2 = getImageDataUrlForCell("cell-2" as CellId);
|
|
247
|
+
|
|
248
|
+
// Let second capture complete first
|
|
249
|
+
resolveSecond!();
|
|
250
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
251
|
+
|
|
252
|
+
// body.printing should still be present because cell-1 is still capturing
|
|
253
|
+
expect(document.body.classList.contains("printing")).toBe(true);
|
|
254
|
+
|
|
255
|
+
// Now let first capture complete
|
|
256
|
+
resolveFirst!();
|
|
257
|
+
await Promise.all([capture1, capture2]);
|
|
258
|
+
|
|
259
|
+
// After all captures complete, body.printing should be removed
|
|
260
|
+
expect(document.body.classList.contains("printing")).toBe(false);
|
|
261
|
+
|
|
262
|
+
// All captures should have seen body.printing = true
|
|
263
|
+
expect(printingStateDuringCaptures.every(Boolean)).toBe(true);
|
|
264
|
+
|
|
265
|
+
mockElement2.remove();
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
describe("downloadHTMLAsImage", () => {
|
|
270
|
+
const mockDataUrl = "data:image/png;base64,mockbase64data";
|
|
271
|
+
let mockElement: HTMLElement;
|
|
272
|
+
let mockAppEl: HTMLElement;
|
|
273
|
+
let mockAnchor: HTMLAnchorElement;
|
|
274
|
+
|
|
275
|
+
beforeEach(() => {
|
|
276
|
+
vi.clearAllMocks();
|
|
277
|
+
mockElement = document.createElement("div");
|
|
278
|
+
mockAppEl = document.createElement("div");
|
|
279
|
+
mockAppEl.id = "App";
|
|
280
|
+
// Mock scrollTo since jsdom doesn't implement it
|
|
281
|
+
mockAppEl.scrollTo = vi.fn();
|
|
282
|
+
document.body.append(mockElement);
|
|
283
|
+
document.body.append(mockAppEl);
|
|
284
|
+
|
|
285
|
+
// Mock anchor element for download
|
|
286
|
+
mockAnchor = document.createElement("a");
|
|
287
|
+
vi.spyOn(document, "createElement").mockReturnValue(mockAnchor);
|
|
288
|
+
vi.spyOn(mockAnchor, "click").mockImplementation(() => {
|
|
289
|
+
// <noop></noop>
|
|
290
|
+
});
|
|
291
|
+
vi.spyOn(mockAnchor, "remove").mockImplementation(() => {
|
|
292
|
+
// noop
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
afterEach(() => {
|
|
297
|
+
mockElement.remove();
|
|
298
|
+
mockAppEl.remove();
|
|
299
|
+
vi.restoreAllMocks();
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it("should download image without prepare function", async () => {
|
|
303
|
+
vi.mocked(toPng).mockResolvedValue(mockDataUrl);
|
|
304
|
+
|
|
305
|
+
await downloadHTMLAsImage({ element: mockElement, filename: "test" });
|
|
306
|
+
|
|
307
|
+
expect(toPng).toHaveBeenCalledWith(mockElement);
|
|
308
|
+
expect(mockAnchor.href).toBe(mockDataUrl);
|
|
309
|
+
expect(mockAnchor.download).toBe("test.png");
|
|
310
|
+
expect(mockAnchor.click).toHaveBeenCalled();
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it("should add body.printing class without prepare function", async () => {
|
|
314
|
+
vi.mocked(toPng).mockImplementation(async () => {
|
|
315
|
+
expect(document.body.classList.contains("printing")).toBe(true);
|
|
316
|
+
return mockDataUrl;
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
await downloadHTMLAsImage({ element: mockElement, filename: "test" });
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it("should remove body.printing class after download without prepare", async () => {
|
|
323
|
+
vi.mocked(toPng).mockResolvedValue(mockDataUrl);
|
|
324
|
+
|
|
325
|
+
await downloadHTMLAsImage({ element: mockElement, filename: "test" });
|
|
326
|
+
|
|
327
|
+
expect(document.body.classList.contains("printing")).toBe(false);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it("should use prepare function when provided", async () => {
|
|
331
|
+
vi.mocked(toPng).mockResolvedValue(mockDataUrl);
|
|
332
|
+
const cleanup = vi.fn();
|
|
333
|
+
const prepare = vi.fn().mockReturnValue(cleanup);
|
|
334
|
+
|
|
335
|
+
await downloadHTMLAsImage({
|
|
336
|
+
element: mockElement,
|
|
337
|
+
filename: "test",
|
|
338
|
+
prepare,
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
expect(prepare).toHaveBeenCalledWith(mockElement);
|
|
342
|
+
expect(cleanup).toHaveBeenCalled();
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it("should not add body.printing when prepare is provided", async () => {
|
|
346
|
+
let bodyPrintingDuringCapture = false;
|
|
347
|
+
vi.mocked(toPng).mockImplementation(async () => {
|
|
348
|
+
// Capture the state during toPng execution
|
|
349
|
+
bodyPrintingDuringCapture = document.body.classList.contains("printing");
|
|
350
|
+
return mockDataUrl;
|
|
351
|
+
});
|
|
352
|
+
const cleanup = vi.fn();
|
|
353
|
+
const prepare = vi.fn().mockReturnValue(cleanup);
|
|
354
|
+
|
|
355
|
+
await downloadHTMLAsImage({
|
|
356
|
+
element: mockElement,
|
|
357
|
+
filename: "test",
|
|
358
|
+
prepare,
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
// body.printing should NOT be added by downloadHTMLAsImage when prepare is provided
|
|
362
|
+
// (the prepare function is responsible for managing its own classes)
|
|
363
|
+
expect(bodyPrintingDuringCapture).toBe(false);
|
|
364
|
+
expect(document.body.classList.contains("printing")).toBe(false);
|
|
365
|
+
expect(prepare).toHaveBeenCalledWith(mockElement);
|
|
366
|
+
expect(cleanup).toHaveBeenCalled();
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it("should show error toast on failure", async () => {
|
|
370
|
+
vi.mocked(toPng).mockRejectedValue(new Error("Failed"));
|
|
371
|
+
|
|
372
|
+
await downloadHTMLAsImage({ element: mockElement, filename: "test" });
|
|
373
|
+
|
|
374
|
+
expect(toast).toHaveBeenCalledWith({
|
|
375
|
+
title: "Error",
|
|
376
|
+
description: "Failed to download as PNG.",
|
|
377
|
+
variant: "danger",
|
|
378
|
+
});
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it("should cleanup on failure", async () => {
|
|
382
|
+
vi.mocked(toPng).mockRejectedValue(new Error("Failed"));
|
|
383
|
+
|
|
384
|
+
await downloadHTMLAsImage({ element: mockElement, filename: "test" });
|
|
385
|
+
|
|
386
|
+
expect(document.body.classList.contains("printing")).toBe(false);
|
|
387
|
+
});
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
describe("downloadCellOutputAsImage", () => {
|
|
391
|
+
const mockDataUrl = "data:image/png;base64,mockbase64data";
|
|
392
|
+
let mockElement: HTMLElement;
|
|
393
|
+
let mockAppEl: HTMLElement;
|
|
394
|
+
let mockAnchor: HTMLAnchorElement;
|
|
395
|
+
|
|
396
|
+
beforeEach(() => {
|
|
397
|
+
vi.clearAllMocks();
|
|
398
|
+
mockElement = document.createElement("div");
|
|
399
|
+
mockElement.id = CellOutputId.create("cell-1" as CellId);
|
|
400
|
+
mockAppEl = document.createElement("div");
|
|
401
|
+
mockAppEl.id = "App";
|
|
402
|
+
// Mock scrollTo since jsdom doesn't implement it
|
|
403
|
+
mockAppEl.scrollTo = vi.fn();
|
|
404
|
+
document.body.append(mockElement);
|
|
405
|
+
document.body.append(mockAppEl);
|
|
406
|
+
|
|
407
|
+
mockAnchor = document.createElement("a");
|
|
408
|
+
vi.spyOn(document, "createElement").mockReturnValue(mockAnchor);
|
|
409
|
+
vi.spyOn(mockAnchor, "click").mockImplementation(() => {
|
|
410
|
+
// <noop></noop>
|
|
411
|
+
});
|
|
412
|
+
vi.spyOn(mockAnchor, "remove").mockImplementation(() => {
|
|
413
|
+
// <noop></noop>
|
|
414
|
+
});
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
afterEach(() => {
|
|
418
|
+
mockElement.remove();
|
|
419
|
+
mockAppEl.remove();
|
|
420
|
+
vi.restoreAllMocks();
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
it("should return early if element not found", async () => {
|
|
424
|
+
await downloadCellOutputAsImage("nonexistent" as CellId, "test");
|
|
425
|
+
|
|
426
|
+
expect(toPng).not.toHaveBeenCalled();
|
|
427
|
+
expect(Logger.error).toHaveBeenCalledWith(
|
|
428
|
+
"Output element not found for cell nonexistent",
|
|
429
|
+
);
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
it("should download cell output as image", async () => {
|
|
433
|
+
vi.mocked(toPng).mockResolvedValue(mockDataUrl);
|
|
434
|
+
|
|
435
|
+
await downloadCellOutputAsImage("cell-1" as CellId, "result");
|
|
436
|
+
|
|
437
|
+
expect(toPng).toHaveBeenCalledWith(mockElement);
|
|
438
|
+
expect(mockAnchor.download).toBe("result.png");
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
it("should apply cell-specific preparation", async () => {
|
|
442
|
+
vi.mocked(toPng).mockImplementation(async () => {
|
|
443
|
+
// Check that cell-specific classes are applied
|
|
444
|
+
expect(mockElement.classList.contains("printing-output")).toBe(true);
|
|
445
|
+
expect(document.body.classList.contains("printing")).toBe(true);
|
|
446
|
+
expect(mockElement.style.overflow).toBe("auto");
|
|
447
|
+
return mockDataUrl;
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
await downloadCellOutputAsImage("cell-1" as CellId, "result");
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
it("should cleanup after download", async () => {
|
|
454
|
+
mockElement.style.overflow = "visible";
|
|
455
|
+
vi.mocked(toPng).mockResolvedValue(mockDataUrl);
|
|
456
|
+
|
|
457
|
+
await downloadCellOutputAsImage("cell-1" as CellId, "result");
|
|
458
|
+
|
|
459
|
+
expect(mockElement.classList.contains("printing-output")).toBe(false);
|
|
460
|
+
expect(document.body.classList.contains("printing")).toBe(false);
|
|
461
|
+
expect(mockElement.style.overflow).toBe("visible");
|
|
462
|
+
});
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
describe("downloadByURL", () => {
|
|
466
|
+
let mockAnchor: HTMLAnchorElement;
|
|
467
|
+
|
|
468
|
+
beforeEach(() => {
|
|
469
|
+
mockAnchor = document.createElement("a");
|
|
470
|
+
vi.spyOn(document, "createElement").mockReturnValue(mockAnchor);
|
|
471
|
+
vi.spyOn(mockAnchor, "click").mockImplementation(() => {
|
|
472
|
+
// <noop></noop>
|
|
473
|
+
});
|
|
474
|
+
vi.spyOn(mockAnchor, "remove").mockImplementation(() => {
|
|
475
|
+
// <noop></noop>
|
|
476
|
+
});
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
afterEach(() => {
|
|
480
|
+
vi.restoreAllMocks();
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
it("should create anchor, set attributes, click, and remove", () => {
|
|
484
|
+
downloadByURL("data:test", "filename.png");
|
|
485
|
+
|
|
486
|
+
expect(document.createElement).toHaveBeenCalledWith("a");
|
|
487
|
+
expect(mockAnchor.href).toBe("data:test");
|
|
488
|
+
expect(mockAnchor.download).toBe("filename.png");
|
|
489
|
+
expect(mockAnchor.click).toHaveBeenCalled();
|
|
490
|
+
expect(mockAnchor.remove).toHaveBeenCalled();
|
|
491
|
+
});
|
|
492
|
+
});
|