From fe165fb43d1e2453bfaa86fec7c403e7c2c12b39 Mon Sep 17 00:00:00 2001 From: Gabriel Costa Date: Mon, 2 Nov 2026 15:06:13 +0000 Subject: [PATCH] feat: Download helper lib Signed-off-by: Gabriel Costa --- src/lib/download.test.ts | 55 ++++++++++++++++++++++++++++++++++++++++ src/lib/download.ts | 13 ++++++++++ 2 files changed, 68 insertions(+) create mode 100644 src/lib/download.test.ts create mode 100644 src/lib/download.ts diff --git a/src/lib/download.test.ts b/src/lib/download.test.ts new file mode 100644 index 00000000..0d089367 --- /dev/null +++ b/src/lib/download.test.ts @@ -0,0 +1,55 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { downloadJson } from "./download"; + +describe("downloadJson", () => { + let createObjectURL: ReturnType; + let revokeObjectURL: ReturnType; + + beforeEach(() => { + vi.useFakeTimers(); + // jsdom has no object URL support. + createObjectURL = vi.fn().mockReturnValue("blob:test-url"); + revokeObjectURL = vi.fn(); + Object.assign(URL, { createObjectURL, revokeObjectURL }); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it("downloads a pretty-printed JSON blob under the given filename", async () => { + const click = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(function ( + this: HTMLAnchorElement, + ) { + expect(this.href).toBe("blob:test-url"); + expect(this.download).toBe("export.json"); + expect(this.isConnected).toBe(true); + }); + + downloadJson({ a: 1 }, "export.json"); + + expect(click).toHaveBeenCalledOnce(); + const blob = createObjectURL.mock.calls[0][0] as Blob; + expect(blob.type).toBe("application/json"); + // jsdom's Blob is only readable through its own FileReader, which needs real timers. + vi.useRealTimers(); + const text = await new Promise((resolve) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.readAsText(blob); + }); + expect(text).toBe('{\n "a": 1\n}'); + }); + + it("removes the link and revokes the URL after the click", () => { + vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {}); + + downloadJson({}, "x.json"); + + expect(document.querySelector("a[download]")).toBeNull(); + expect(revokeObjectURL).not.toHaveBeenCalled(); + vi.runAllTimers(); + expect(revokeObjectURL).toHaveBeenCalledWith("blob:test-url"); + }); +}); diff --git a/src/lib/download.ts b/src/lib/download.ts new file mode 100644 index 00000000..01c05457 --- /dev/null +++ b/src/lib/download.ts @@ -0,0 +1,13 @@ +/** Saves `data` as a pretty-printed JSON file via a temporary object URL. */ +export function downloadJson(data: unknown, filename: string): void { + const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + link.remove(); + // Deferred: some browsers (Firefox, Safari) cancel the download if revoked synchronously. + setTimeout(() => URL.revokeObjectURL(url), 0); +}