merge: v2/1560-downloadfile (#1586)
This commit is contained in:
commit
52cd90bd50
2 changed files with 109 additions and 14 deletions
|
|
@ -2,22 +2,23 @@
|
|||
* Browser-side helpers for triggering file downloads from in-memory content.
|
||||
*
|
||||
* Centralized so the temp-anchor incantation (`appendChild` for Firefox,
|
||||
* try/finally to keep cleanup bulletproof, `revokeObjectURL` to release the
|
||||
* object URL) lives in one place — and so the wiring is unit-testable
|
||||
* under happy-dom without dragging React along.
|
||||
* deferred `revokeObjectURL` so the scheduled download can read the blob)
|
||||
* lives in one place — and so the wiring is unit-testable under happy-dom
|
||||
* without dragging React along.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Download an in-memory string as a JSON file. Uses a temporary anchor
|
||||
* Download an in-memory {@link Blob} as `filename`. Uses a temporary anchor
|
||||
* element to trigger the browser's save dialog. The append-to-body step is
|
||||
* for older Firefox versions that wouldn't fire `click()` on a detached
|
||||
* anchor; modern browsers don't require it but it stays as the safe path.
|
||||
*
|
||||
* The cleanup (removeChild + revokeObjectURL) runs in a `finally` so even
|
||||
* a thrown `click()` doesn't leak the DOM node or the object URL.
|
||||
* The object-URL revoke is deferred to a task: `link.click()` only schedules
|
||||
* the download, and revoking the URL synchronously can abort it before the
|
||||
* browser reads the blob (Firefox/Safari, intermittently Chrome for larger
|
||||
* blobs).
|
||||
*/
|
||||
export function downloadJsonFile(filename: string, json: string): void {
|
||||
const blob = new Blob([json], { type: "application/json" });
|
||||
export function downloadBlob(filename: string, blob: Blob): void {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
|
|
@ -27,7 +28,43 @@ export function downloadJsonFile(filename: string, json: string): void {
|
|||
anchor.click();
|
||||
} finally {
|
||||
document.body.removeChild(anchor);
|
||||
URL.revokeObjectURL(url);
|
||||
setTimeout(() => URL.revokeObjectURL(url), 0);
|
||||
}
|
||||
}
|
||||
|
||||
/** Download an in-memory JSON string as `filename`. */
|
||||
export function downloadJsonFile(filename: string, json: string): void {
|
||||
downloadBlob(filename, new Blob([json], { type: "application/json" }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a safe suggested filename from a resource URI: the last path segment,
|
||||
* stripped of control/format characters, path separators, and characters
|
||||
* disallowed in filenames on common platforms. Falls back to `"download"` when
|
||||
* nothing usable remains.
|
||||
*/
|
||||
export function fileNameFromUri(uri: string): string {
|
||||
const tail = uri.split(/[\\/]/).pop() ?? "";
|
||||
const safe = tail
|
||||
.replace(/[\p{Cc}\p{Cf}]+/gu, "")
|
||||
.replace(/[\\/:*?"<>|]+/g, "_")
|
||||
.trim();
|
||||
return safe.length > 0 ? safe.slice(0, 255) : "download";
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse `url` and return it only if its scheme is `http:` or `https:`;
|
||||
* otherwise null. Shared http(s)-only allowlist for opening or downloading
|
||||
* server-supplied URLs.
|
||||
*/
|
||||
export function isHttpUrl(url: string): URL | null {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return parsed.protocol === "https:" || parsed.protocol === "http:"
|
||||
? parsed
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,19 +1,26 @@
|
|||
/**
|
||||
* Tests for the downloadJsonFile helper. Mocks URL.createObjectURL +
|
||||
* Tests for the download helpers. Mocks URL.createObjectURL +
|
||||
* URL.revokeObjectURL (happy-dom doesn't ship them) and asserts the temp
|
||||
* anchor's attributes + click + cleanup sequence.
|
||||
* anchor's attributes + click + deferred cleanup sequence.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { downloadJsonFile, buildExportFilename } from "../../lib/downloadFile";
|
||||
import {
|
||||
downloadBlob,
|
||||
downloadJsonFile,
|
||||
buildExportFilename,
|
||||
fileNameFromUri,
|
||||
isHttpUrl,
|
||||
} from "../../lib/downloadFile";
|
||||
|
||||
describe("downloadJsonFile", () => {
|
||||
describe("downloadBlob / downloadJsonFile", () => {
|
||||
const originalCreate = URL.createObjectURL;
|
||||
const originalRevoke = URL.revokeObjectURL;
|
||||
let createMock: ReturnType<typeof vi.fn>;
|
||||
let revokeMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
createMock = vi.fn().mockReturnValue("blob:mock-url");
|
||||
revokeMock = vi.fn();
|
||||
// happy-dom doesn't implement URL.createObjectURL/revokeObjectURL.
|
||||
|
|
@ -22,6 +29,7 @@ describe("downloadJsonFile", () => {
|
|||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
URL.createObjectURL = originalCreate;
|
||||
URL.revokeObjectURL = originalRevoke;
|
||||
vi.restoreAllMocks();
|
||||
|
|
@ -54,6 +62,9 @@ describe("downloadJsonFile", () => {
|
|||
|
||||
// After the call, the anchor must be removed and the URL revoked.
|
||||
expect(document.body.contains(anchor)).toBe(false);
|
||||
// Revoke is deferred so the scheduled download can read the blob first.
|
||||
expect(revokeMock).not.toHaveBeenCalled();
|
||||
vi.runAllTimers();
|
||||
expect(revokeMock).toHaveBeenCalledWith("blob:mock-url");
|
||||
});
|
||||
|
||||
|
|
@ -82,6 +93,7 @@ describe("downloadJsonFile", () => {
|
|||
|
||||
// Cleanup still ran despite the throw.
|
||||
expect(removed).toHaveLength(1);
|
||||
vi.runAllTimers();
|
||||
expect(revokeMock).toHaveBeenCalledWith("blob:mock-url");
|
||||
});
|
||||
|
||||
|
|
@ -91,6 +103,15 @@ describe("downloadJsonFile", () => {
|
|||
const text = await blob.text();
|
||||
expect(text).toBe('{"x":1}');
|
||||
});
|
||||
|
||||
it("downloadBlob passes the given blob through and honours its type", async () => {
|
||||
const blob = new Blob(["plain text"], { type: "text/plain" });
|
||||
downloadBlob("note.txt", blob);
|
||||
expect(createMock).toHaveBeenCalledWith(blob);
|
||||
const passed = createMock.mock.calls[0]?.[0] as Blob;
|
||||
expect(passed.type).toBe("text/plain");
|
||||
expect(await passed.text()).toBe("plain text");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildExportFilename", () => {
|
||||
|
|
@ -127,3 +148,40 @@ describe("buildExportFilename", () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fileNameFromUri", () => {
|
||||
it("returns the last path segment", () => {
|
||||
expect(fileNameFromUri("https://x/y/z/report.pdf")).toBe("report.pdf");
|
||||
});
|
||||
it("splits on backslashes as well as forward slashes", () => {
|
||||
expect(fileNameFromUri("C:\\Users\\me\\report.pdf")).toBe("report.pdf");
|
||||
});
|
||||
it("strips control/format chars and disallowed filename chars", () => {
|
||||
// U+200B (zero-width space, Cf) and U+202E (RTL override, Cf) are
|
||||
// stripped; the disallowed `*?` run collapses to a single `_`.
|
||||
const uri = "https://x/a\u200bb\u202ec*?.txt";
|
||||
expect(fileNameFromUri(uri)).toBe("abc_.txt");
|
||||
});
|
||||
it("truncates very long names to 255 characters", () => {
|
||||
const long = "a".repeat(300) + ".txt";
|
||||
expect(fileNameFromUri(`https://x/${long}`)).toHaveLength(255);
|
||||
});
|
||||
it("falls back to 'download' when nothing usable remains", () => {
|
||||
expect(fileNameFromUri("https://x/")).toBe("download");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isHttpUrl", () => {
|
||||
it("accepts http and https", () => {
|
||||
expect(isHttpUrl("https://example.com")?.href).toBe("https://example.com/");
|
||||
expect(isHttpUrl("http://localhost:3000/a")?.href).toBe(
|
||||
"http://localhost:3000/a",
|
||||
);
|
||||
});
|
||||
it("rejects other schemes and unparsable input", () => {
|
||||
expect(isHttpUrl("javascript:alert(1)")).toBeNull();
|
||||
expect(isHttpUrl("data:text/html,<b>")).toBeNull();
|
||||
expect(isHttpUrl("file:///etc/passwd")).toBeNull();
|
||||
expect(isHttpUrl("not a url")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue