Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions src/platforms/browser/services/file.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import opfs from "platforms/browser/lib/opfs";
import { afterEach, describe, expect, it, vi } from "vitest";
import fileService from "./file";

vi.mock("platforms/browser/lib/opfs", () => ({
default: {
init: vi.fn(),
readFile: vi.fn(),
writeFile: vi.fn(),
},
}));

const writeFile = vi.mocked(opfs.writeFile);

function callback() {
return {
success: vi.fn(),
error: vi.fn(),
} as unknown as Callback;
}

function writtenBytes(): ArrayBuffer {
const [path, contents] = writeFile.mock.calls[0];
expect(path).toBe("/files/report.apk");
return contents as ArrayBuffer;
}

afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
writeFile.mockReset();
});

describe("browser file write", () => {
it("decodes the payload when the write is flagged binary", async () => {
// file.writeBinary() sends bytes as base64 with isBinary set; storing
// that string as-is is how a binary download came out corrupted.
const result = callback();
const bytes = new Uint8Array([0x50, 0x4b, 0x03, 0x04]);

await fileService.write(result, [
"/files/report.apk",
btoa("PK\u0003\u0004"),
true,
]);

expect(result.success).toHaveBeenCalledTimes(1);
expect(new Uint8Array(writtenBytes())).toEqual(bytes);
});

it("stores text payloads unchanged", async () => {
const result = callback();

await fileService.write(result, ["/files/report.apk", "plain text"]);

expect(result.success).toHaveBeenCalledTimes(1);
expect(writeFile).toHaveBeenCalledWith("/files/report.apk", "plain text");
});

it("reports a failed write to the callback", async () => {
const result = callback();
writeFile.mockRejectedValueOnce(new Error("disk full"));

await fileService.write(result, ["/files/report.apk", "plain text"]);

expect(result.error).toHaveBeenCalledWith("disk full");
});
});

describe("browser saveToDevice", () => {
it("keeps the blob url alive past the download click", async () => {
vi.useFakeTimers();
vi.mocked(opfs.readFile).mockResolvedValue(new ArrayBuffer(4));
vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
URL.createObjectURL = vi.fn(() => "blob:download");
URL.revokeObjectURL = vi.fn();

const result = callback();
await fileService.saveToDevice(result, [
"files/report.apk",
"report.apk",
"",
"",
]);

expect(URL.createObjectURL).toHaveBeenCalledTimes(1);
// The url is handed to the download manager after click() returns, so
// revoking it on the next tick kills the blob mid-handoff.
vi.advanceTimersByTime(1000);
expect(URL.revokeObjectURL).not.toHaveBeenCalled();

vi.advanceTimersByTime(60_000);
expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:download");
});
});
33 changes: 28 additions & 5 deletions src/platforms/browser/services/file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,23 @@ export default {
}
},
/**
* Write data to a file
* Write data to a file.
*
* `isBinary` mirrors the native FileHandler: `file.writeBinary()` sends the
* bytes base64-encoded and sets this flag, and the native handlers decode
* it. Ignoring the flag stored that base64 text instead of the file's
* contents, so every binary write through this service was corrupted.
* @param {any} callback
* @param {[string, ArrayBuffer]} param1
* @param {[string, ArrayBuffer | string, boolean?]} param1
*/
async write(callback: Callback, [path, data]: [string, ArrayBuffer]) {
async write(
callback: Callback,
[path, data, isBinary]: [string, ArrayBuffer | string, boolean?],
) {
try {
await opfs.writeFile(path, data);
const contents =
isBinary && typeof data === "string" ? base64ToArrayBuffer(data) : data;
await opfs.writeFile(path, contents);
callback.success();
} catch (error) {
callback.error(errorMessage(error));
Expand Down Expand Up @@ -114,10 +124,14 @@ export default {
});
document.body.appendChild(a);
a.click();
// The blob: URL is handed to the download manager (or an external app
// on Android) after `click()` returns, so revoking it on the next tick
// can kill it mid-handoff and the download fails. Leave it long enough
// for the handoff to complete.
setTimeout(() => {
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
}, 0);
}, 60_000);
if (notificationTitle) {
new Notification(notificationTitle, {
body: notificationBody || "",
Expand Down Expand Up @@ -273,6 +287,15 @@ function getContentType(filename: string) {
}
}

function base64ToArrayBuffer(content: string): ArrayBuffer {
const binary = atob(content);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes.buffer;
}

function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}