305 lines
7.8 KiB
TypeScript
305 lines
7.8 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import { describe, expect, it, vi } from "vitest";
|
|
|
|
import type {
|
|
FileVerificationReceipt,
|
|
LocalFileRef,
|
|
} from "../../src/application/ports/browser-file-storage/file.ts";
|
|
import {
|
|
browserFilePolicyReference,
|
|
createBrowserFileRuntime,
|
|
type BrowserFilePolicyProfile,
|
|
} from "../../src/adapters/browser-files/index.ts";
|
|
|
|
const filePolicy = browserFilePolicyReference(
|
|
"runtime-file",
|
|
"select-and-preview",
|
|
);
|
|
const downloadPolicy = browserFilePolicyReference(
|
|
"runtime-download",
|
|
"bounded-generated-bin",
|
|
);
|
|
const policies: readonly BrowserFilePolicyProfile[] = [
|
|
{
|
|
reference: filePolicy,
|
|
selection: {
|
|
policyId: "attachment-v1",
|
|
purpose: "attachment",
|
|
classification: "PERSONAL",
|
|
multiple: false,
|
|
maxCount: 1,
|
|
maxFileBytes: 8,
|
|
maxTotalBytes: 8,
|
|
allowEmpty: false,
|
|
accept: [{ mediaType: "text/plain", extensions: [".txt"] }],
|
|
},
|
|
inspection: {
|
|
policyId: "preview-v1",
|
|
maxInspectionBytes: 8,
|
|
acceptedSignatures: [
|
|
{
|
|
mediaType: "image/png",
|
|
extensions: [".png"],
|
|
patterns: [{ offset: 0, bytes: [0x89] }],
|
|
},
|
|
],
|
|
},
|
|
preview: {
|
|
allowedMediaTypes: ["image/png"],
|
|
maxPreviewBytes: 8,
|
|
},
|
|
},
|
|
{
|
|
reference: downloadPolicy,
|
|
download: {
|
|
strategy: "BOUNDED_OBJECT_URL",
|
|
mediaType: "application/octet-stream",
|
|
safeExtension: ".bin",
|
|
maxTransferBytes: 8,
|
|
maxBufferedBytes: 8,
|
|
integrity: "OPTIONAL",
|
|
},
|
|
},
|
|
];
|
|
const unusedBrowserManagedCapabilities = {
|
|
resolve() {
|
|
return {
|
|
ok: false as const,
|
|
error: {
|
|
code: "POLICY_REJECTED" as const,
|
|
operation: "DOWNLOAD" as const,
|
|
retryable: false,
|
|
recovery: "NONE" as const,
|
|
},
|
|
};
|
|
},
|
|
};
|
|
|
|
function fileInput(): HTMLInputElement {
|
|
const label = document.createElement("label");
|
|
label.textContent = "Attachment";
|
|
const input = document.createElement("input");
|
|
input.type = "file";
|
|
label.append(input);
|
|
document.body.append(label);
|
|
return input;
|
|
}
|
|
|
|
describe("browser file runtime hard limits and disposal", () => {
|
|
it("revokes active download leases immediately on runtime dispose", async () => {
|
|
const revoked: string[] = [];
|
|
let scheduled: (() => void) | undefined;
|
|
const runtime = createBrowserFileRuntime({
|
|
input: fileInput(),
|
|
policies,
|
|
limits: {
|
|
hardMaxPreviewBytes: 8,
|
|
hardMaxObjectUrlBytes: 8,
|
|
hardMaxTransferBytes: 16,
|
|
},
|
|
objectUrlApi: {
|
|
createObjectURL: () => "blob:runtime-download",
|
|
revokeObjectURL: (url) => revoked.push(url),
|
|
},
|
|
download: {
|
|
host: { handoff: vi.fn() },
|
|
browserManagedCapabilities:
|
|
unusedBrowserManagedCapabilities,
|
|
scheduler: {
|
|
setTimeout(callback) {
|
|
scheduled = callback;
|
|
return 1;
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
const delivery = {
|
|
policy: downloadPolicy,
|
|
source: {
|
|
kind: "GENERATED" as const,
|
|
bytes: {
|
|
byteLength: 3,
|
|
async *stream() {
|
|
yield {
|
|
ok: true as const,
|
|
value: new Uint8Array([1, 2, 3]),
|
|
};
|
|
},
|
|
},
|
|
},
|
|
suggestedFileName: "artifact",
|
|
maxTransferBytes: 8,
|
|
maxBufferedBytes: 8,
|
|
signal: new AbortController().signal,
|
|
onProgress() {},
|
|
};
|
|
expect(await runtime.downloads.deliver(delivery)).toMatchObject({
|
|
ok: true,
|
|
value: { kind: "BROWSER_HANDOFF" },
|
|
});
|
|
expect(revoked).toEqual([]);
|
|
|
|
runtime.dispose();
|
|
expect(revoked).toEqual(["blob:runtime-download"]);
|
|
expect(await runtime.downloads.deliver(delivery)).toMatchObject({
|
|
ok: false,
|
|
error: { code: "UNAVAILABLE" },
|
|
});
|
|
expect(
|
|
await runtime.content.readRange({
|
|
ref: "file:disposed" as LocalFileRef,
|
|
offset: 0,
|
|
length: 0,
|
|
signal: new AbortController().signal,
|
|
}),
|
|
).toMatchObject({
|
|
ok: false,
|
|
error: { code: "UNAVAILABLE" },
|
|
});
|
|
expect(
|
|
await runtime.previews.create({
|
|
ref: "file:disposed" as LocalFileRef,
|
|
verificationReceipt:
|
|
"verification:disposed" as FileVerificationReceipt,
|
|
policy: filePolicy,
|
|
maxPreviewBytes: 1,
|
|
signal: new AbortController().signal,
|
|
}),
|
|
).toMatchObject({
|
|
ok: false,
|
|
error: { code: "UNAVAILABLE" },
|
|
});
|
|
scheduled?.();
|
|
runtime.dispose();
|
|
expect(revoked).toEqual(["blob:runtime-download"]);
|
|
});
|
|
|
|
it("aborts a pending native picker and prevents event resurrection", async () => {
|
|
const input = fileInput();
|
|
Object.defineProperty(input, "showPicker", {
|
|
configurable: true,
|
|
value: vi.fn(),
|
|
});
|
|
const runtime = createBrowserFileRuntime({
|
|
input,
|
|
policies,
|
|
limits: {
|
|
hardMaxPreviewBytes: 8,
|
|
hardMaxObjectUrlBytes: 8,
|
|
hardMaxTransferBytes: 16,
|
|
},
|
|
vault: {
|
|
createReference: () => "file:must-not-exist",
|
|
},
|
|
download: {
|
|
host: { handoff() {} },
|
|
browserManagedCapabilities:
|
|
unusedBrowserManagedCapabilities,
|
|
},
|
|
});
|
|
const pending = runtime.baselinePicker.select({
|
|
policy: filePolicy,
|
|
});
|
|
|
|
runtime.dispose();
|
|
expect(await pending).toMatchObject({
|
|
ok: false,
|
|
error: { code: "ABORTED" },
|
|
});
|
|
Object.defineProperty(input, "files", {
|
|
configurable: true,
|
|
value: [
|
|
new File(["late"], "late.txt", {
|
|
type: "text/plain",
|
|
lastModified: 1,
|
|
}),
|
|
],
|
|
});
|
|
input.dispatchEvent(new Event("change"));
|
|
expect(
|
|
await runtime.baselinePicker.select({
|
|
policy: filePolicy,
|
|
}),
|
|
).toMatchObject({
|
|
ok: false,
|
|
error: { code: "UNAVAILABLE" },
|
|
});
|
|
});
|
|
|
|
it("aborts a generated download whose producer ignores cancellation", async () => {
|
|
let reportStarted: (() => void) | undefined;
|
|
const started = new Promise<void>((resolve) => {
|
|
reportStarted = resolve;
|
|
});
|
|
let unblockProducer: (() => void) | undefined;
|
|
const producerBlock = new Promise<void>((resolve) => {
|
|
unblockProducer = resolve;
|
|
});
|
|
const runtime = createBrowserFileRuntime({
|
|
input: fileInput(),
|
|
policies,
|
|
limits: {
|
|
hardMaxPreviewBytes: 8,
|
|
hardMaxObjectUrlBytes: 8,
|
|
hardMaxTransferBytes: 16,
|
|
},
|
|
download: {
|
|
host: { handoff() {} },
|
|
browserManagedCapabilities:
|
|
unusedBrowserManagedCapabilities,
|
|
},
|
|
});
|
|
const pending = runtime.downloads.deliver({
|
|
policy: downloadPolicy,
|
|
source: {
|
|
kind: "GENERATED",
|
|
bytes: {
|
|
byteLength: 1,
|
|
async *stream() {
|
|
reportStarted?.();
|
|
await producerBlock;
|
|
yield {
|
|
ok: true as const,
|
|
value: new Uint8Array([1]),
|
|
};
|
|
},
|
|
},
|
|
},
|
|
suggestedFileName: "blocked",
|
|
maxTransferBytes: 8,
|
|
maxBufferedBytes: 8,
|
|
signal: new AbortController().signal,
|
|
onProgress() {},
|
|
});
|
|
|
|
await started;
|
|
runtime.dispose();
|
|
expect(await pending).toMatchObject({
|
|
ok: false,
|
|
error: { code: "ABORTED", operation: "DOWNLOAD" },
|
|
});
|
|
unblockProducer?.();
|
|
});
|
|
|
|
it("rejects inconsistent absolute limits during composition", () => {
|
|
expect(() =>
|
|
createBrowserFileRuntime({
|
|
input: fileInput(),
|
|
policies,
|
|
limits: {
|
|
hardMaxPreviewBytes: 8,
|
|
hardMaxObjectUrlBytes: 17,
|
|
hardMaxTransferBytes: 16,
|
|
},
|
|
download: {
|
|
host: { handoff() {} },
|
|
browserManagedCapabilities:
|
|
unusedBrowserManagedCapabilities,
|
|
},
|
|
}),
|
|
).toThrowError("Browser file runtime hard limits are invalid.");
|
|
});
|
|
});
|