@marimo-team/islands 0.19.5-dev40 → 0.19.5-dev44

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.
@@ -426,14 +426,14 @@ const ChatInput: React.FC<ChatInputProps> = memo(
426
426
  ChatInput.displayName = "ChatInput";
427
427
 
428
428
  const ChatPanel = () => {
429
- const aiEnabled = useAtomValue(aiEnabledAtom);
429
+ const aiConfigured = useAtomValue(aiEnabledAtom);
430
430
  const { handleClick } = useOpenSettingsToTab();
431
431
 
432
- if (!aiEnabled) {
432
+ if (!aiConfigured) {
433
433
  return (
434
434
  <PanelEmptyState
435
435
  title="Chat with AI"
436
- description="AI is currently disabled. Add your API key to enable."
436
+ description="No AI provider configured or model selected"
437
437
  action={
438
438
  <Button variant="outline" size="sm" onClick={() => handleClick("ai")}>
439
439
  Edit AI settings
@@ -26,7 +26,6 @@ import {
26
26
  ZapIcon,
27
27
  ZapOffIcon,
28
28
  } from "lucide-react";
29
- import { downloadCellOutput } from "@/components/export/export-output-button";
30
29
  import { MultiIcon } from "@/components/icons/multi-icon";
31
30
  import { useImperativeModal } from "@/components/modal/ImperativeModal";
32
31
  import {
@@ -54,6 +53,7 @@ import { useRequestClient } from "@/core/network/requests";
54
53
  import type { CellConfig, RuntimeState } from "@/core/network/types";
55
54
  import { canLinkToCell, createCellLink } from "@/utils/cell-urls";
56
55
  import { copyToClipboard } from "@/utils/copy";
56
+ import { downloadCellOutputAsImage } from "@/utils/download";
57
57
  import { MarkdownIcon, PythonIcon } from "../cell/code/icons";
58
58
  import { useDeleteCellCallback } from "../cell/useDeleteCell";
59
59
  import { useRunCell } from "../cell/useRunCells";
@@ -341,7 +341,7 @@ export function useCellActionButtons({ cell, closePopover }: Props) {
341
341
  icon: <ImageIcon size={13} strokeWidth={1.5} />,
342
342
  label: "Export output as PNG",
343
343
  hidden: !hasOutput,
344
- handle: () => downloadCellOutput(cellId),
344
+ handle: () => downloadCellOutputAsImage(cellId, "result"),
345
345
  },
346
346
  {
347
347
  icon: <XCircleIcon size={13} strokeWidth={1.5} />,
@@ -134,6 +134,12 @@ export function useNotebookActions() {
134
134
  const sharingHtmlEnabled = resolvedConfig.sharing?.html ?? true;
135
135
  const sharingWasmEnabled = resolvedConfig.sharing?.wasm ?? true;
136
136
 
137
+ const isServerSidePdfExportEnabled = getFeatureFlag("server_side_pdf_export");
138
+ // With server side pdf export, it doesn't matter what mode we are in,
139
+ // Default export uses browser print, which is better in present mode
140
+ const pdfDownloadEnabled =
141
+ isServerSidePdfExportEnabled || viewState.mode === "present";
142
+
137
143
  const renderCheckboxElement = (checked: boolean) => (
138
144
  <div className="w-8 flex justify-end">
139
145
  {checked && <CheckIcon size={14} />}
@@ -209,22 +215,24 @@ export function useNotebookActions() {
209
215
  if (!app) {
210
216
  return;
211
217
  }
212
- await downloadHTMLAsImage(app, document.title);
218
+ await downloadHTMLAsImage({
219
+ element: app,
220
+ filename: document.title,
221
+ });
213
222
  },
214
223
  },
215
224
  {
216
225
  icon: <FileIcon size={14} strokeWidth={1.5} />,
217
226
  label: "Download as PDF",
218
- disabled: viewState.mode !== "present",
219
- tooltip:
220
- viewState.mode === "present" ? undefined : (
221
- <span>
222
- Only available in app view. <br />
223
- Toggle with: {renderShortcut("global.hideCode", false)}
224
- </span>
225
- ),
227
+ disabled: !pdfDownloadEnabled,
228
+ tooltip: pdfDownloadEnabled ? undefined : (
229
+ <span>
230
+ Only available in app view. <br />
231
+ Toggle with: {renderShortcut("global.hideCode", false)}
232
+ </span>
233
+ ),
226
234
  handle: async () => {
227
- if (getFeatureFlag("server_side_pdf_export")) {
235
+ if (isServerSidePdfExportEnabled) {
228
236
  if (!filename) {
229
237
  toastNotebookMustBeNamed();
230
238
  return;
@@ -185,7 +185,7 @@ const ActionButtons: React.FC<{
185
185
  if (!app) {
186
186
  return;
187
187
  }
188
- await downloadHTMLAsImage(app, document.title);
188
+ await downloadHTMLAsImage({ element: app, filename: document.title });
189
189
  };
190
190
 
191
191
  const handleDownloadAsHTML = async () => {
@@ -21,8 +21,17 @@ export interface AiModel extends AiModelType {
21
21
  custom: boolean;
22
22
  }
23
23
 
24
- const getKnownModelMap = once((): ReadonlyMap<QualifiedModelId, AiModel> => {
24
+ interface KnownModelMaps {
25
+ /** Map of qualified model ID to model info */
26
+ modelMap: ReadonlyMap<QualifiedModelId, AiModel>;
27
+ /** Map of provider ID to first default model (supports chat or edit) */
28
+ defaultModelByProvider: ReadonlyMap<ProviderId, QualifiedModelId>;
29
+ }
30
+
31
+ export const getKnownModelMaps = once((): KnownModelMaps => {
25
32
  const modelMap = new Map<QualifiedModelId, AiModel>();
33
+ const defaultModelByProvider = new Map<ProviderId, QualifiedModelId>();
34
+
26
35
  for (const model of models) {
27
36
  const modelId = model.model as ShortModelId;
28
37
  const modelInfo: AiModel = {
@@ -33,12 +42,21 @@ const getKnownModelMap = once((): ReadonlyMap<QualifiedModelId, AiModel> => {
33
42
  custom: false,
34
43
  };
35
44
 
45
+ const supportsChatOrEdit =
46
+ modelInfo.roles.includes("chat") || modelInfo.roles.includes("edit");
47
+
36
48
  for (const provider of modelInfo.providers) {
37
49
  const qualifiedModelId: QualifiedModelId = `${provider}/${modelId}`;
38
50
  modelMap.set(qualifiedModelId, modelInfo);
51
+
52
+ // Track first model per provider that supports chat or edit
53
+ if (supportsChatOrEdit && !defaultModelByProvider.has(provider)) {
54
+ defaultModelByProvider.set(provider, qualifiedModelId);
55
+ }
39
56
  }
40
57
  }
41
- return modelMap;
58
+
59
+ return { modelMap, defaultModelByProvider };
42
60
  });
43
61
 
44
62
  const getProviderMap = once(
@@ -125,7 +143,7 @@ export class AiModelRegistry {
125
143
  }) {
126
144
  const { displayedModels, customModels } = opts;
127
145
  const hasDisplayedModels = displayedModels.size > 0;
128
- const knownModelMap = getKnownModelMap();
146
+ const knownModelMap = getKnownModelMaps().modelMap;
129
147
  const customModelsMap = new Map<QualifiedModelId, AiModel>();
130
148
 
131
149
  let modelsMap = new Map<QualifiedModelId, AiModel>();
@@ -1,14 +1,14 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
- import { toPng } from "html-to-image";
3
2
  import { atom, useAtom, useAtomValue } from "jotai";
4
3
  import type { MimeType } from "@/components/editor/Output";
5
4
  import { toast } from "@/components/ui/use-toast";
6
5
  import { appConfigAtom } from "@/core/config/config";
7
6
  import { useInterval } from "@/hooks/useInterval";
7
+ import { getImageDataUrlForCell } from "@/utils/download";
8
8
  import { Logger } from "@/utils/Logger";
9
9
  import { Objects } from "@/utils/objects";
10
10
  import { cellsRuntimeAtom } from "../cells/cells";
11
- import { type CellId, CellOutputId } from "../cells/ids";
11
+ import type { CellId } from "../cells/ids";
12
12
  import { connectionAtom } from "../network/connection";
13
13
  import { useRequestClient } from "../network/requests";
14
14
  import type { UpdateCellOutputsRequest } from "../network/types";
@@ -131,16 +131,12 @@ export function useEnrichCellOutputs() {
131
131
  // Capture screenshots
132
132
  const results = await Promise.all(
133
133
  cellsToCaptureScreenshot.map(async ([cellId]) => {
134
- const outputElement = document.getElementById(
135
- CellOutputId.create(cellId),
136
- );
137
- if (!outputElement) {
138
- Logger.error(`Output element not found for cell ${cellId}`);
139
- return null;
140
- }
141
-
142
134
  try {
143
- const dataUrl = await toPng(outputElement);
135
+ const dataUrl = await getImageDataUrlForCell(cellId);
136
+ if (!dataUrl) {
137
+ Logger.error(`Failed to capture screenshot for cell ${cellId}`);
138
+ return null;
139
+ }
144
140
  return [cellId, ["image/png", dataUrl]] as [
145
141
  CellId,
146
142
  ["image/png", string],
@@ -1,6 +1,19 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
- import { beforeEach, describe, expect, it, vi } from "vitest";
3
- import { withLoadingToast } from "../download";
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
+ }));
4
17
 
5
18
  // Mock the toast module
6
19
  const mockDismiss = vi.fn();
@@ -15,7 +28,23 @@ vi.mock("@/components/icons/spinner", () => ({
15
28
  Spinner: () => "MockSpinner",
16
29
  }));
17
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";
18
46
  import { toast } from "@/components/ui/use-toast";
47
+ import { Logger } from "@/utils/Logger";
19
48
 
20
49
  describe("withLoadingToast", () => {
21
50
  beforeEach(() => {
@@ -30,6 +59,7 @@ describe("withLoadingToast", () => {
30
59
  expect(toast).toHaveBeenCalledTimes(1);
31
60
  expect(toast).toHaveBeenCalledWith({
32
61
  title: "Loading...",
62
+ duration: Infinity,
33
63
  });
34
64
  expect(mockDismiss).toHaveBeenCalledTimes(1);
35
65
  expect(result).toBe("success");
@@ -94,3 +124,369 @@ describe("withLoadingToast", () => {
94
124
  expect(mockDismiss).toHaveBeenCalledTimes(1);
95
125
  });
96
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
+ });