497 lines
14 KiB
TypeScript
497 lines
14 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import { describe, expect, it, vi } from "vitest";
|
|
|
|
import {
|
|
DEFAULT_NATIVE_PICKER_FOCUS_GRACE_MS,
|
|
EnhancedFilePicker,
|
|
NativeInputFilePicker,
|
|
} from "../../src/adapters/browser-files/browser-file-picker.ts";
|
|
import { BrowserFileVault } from "../../src/adapters/browser-files/browser-file-vault.ts";
|
|
import {
|
|
BrowserFilePolicyRegistry,
|
|
browserFilePolicyReference,
|
|
} from "../../src/adapters/browser-files/browser-file-policy-registry.ts";
|
|
import type { RegisteredFileSelectionPolicy } from "../../src/adapters/browser-files/file-policy.ts";
|
|
|
|
const policyDefinition: RegisteredFileSelectionPolicy = Object.freeze({
|
|
policyId: "attachment-v1",
|
|
purpose: "attachment",
|
|
classification: "PERSONAL",
|
|
multiple: false,
|
|
maxCount: 1,
|
|
maxFileBytes: 100,
|
|
maxTotalBytes: 100,
|
|
allowEmpty: false,
|
|
accept: Object.freeze([
|
|
Object.freeze({
|
|
mediaType: "text/plain",
|
|
extensions: Object.freeze([".txt"]),
|
|
}),
|
|
]),
|
|
});
|
|
const policy = browserFilePolicyReference(
|
|
"attachments",
|
|
"select-text-attachment",
|
|
);
|
|
|
|
function createHarness(
|
|
selection: RegisteredFileSelectionPolicy = policyDefinition,
|
|
vaultOptions: Readonly<{
|
|
createReference?: () => string;
|
|
}> = {},
|
|
): Readonly<{
|
|
policies: BrowserFilePolicyRegistry;
|
|
vault: BrowserFileVault;
|
|
}> {
|
|
const policies = new BrowserFilePolicyRegistry({
|
|
profiles: [{ reference: policy, selection }],
|
|
hardLimits: {
|
|
maxInspectionBytes: 64 * 1024,
|
|
maxRetainedFileBytes: 1_024,
|
|
maxPreviewBytes: 1_024,
|
|
maxObjectUrlBytes: 1_024,
|
|
maxTransferBytes: 1_024,
|
|
},
|
|
});
|
|
return {
|
|
policies,
|
|
vault: new BrowserFileVault({ policies, ...vaultOptions }),
|
|
};
|
|
}
|
|
|
|
function labelledFileInput(): HTMLInputElement {
|
|
const label = document.createElement("label");
|
|
label.textContent = "Choose attachment";
|
|
const input = document.createElement("input");
|
|
input.type = "file";
|
|
label.append(input);
|
|
document.body.append(label);
|
|
return input;
|
|
}
|
|
|
|
describe("browser file pickers", () => {
|
|
it("treats native input cancellation as a normal dismissed outcome", async () => {
|
|
const input = labelledFileInput();
|
|
Object.defineProperty(input, "showPicker", {
|
|
configurable: true,
|
|
value: vi.fn(() => {
|
|
queueMicrotask(() => input.dispatchEvent(new Event("cancel")));
|
|
}),
|
|
});
|
|
const harness = createHarness();
|
|
const picker = new NativeInputFilePicker({
|
|
input,
|
|
...harness,
|
|
userActivation: { isActive: true },
|
|
});
|
|
|
|
expect(await picker.select({ policy })).toEqual({
|
|
ok: true,
|
|
value: { kind: "DISMISSED" },
|
|
});
|
|
});
|
|
|
|
it("resets the native input and supports same-file reselection", async () => {
|
|
const input = labelledFileInput();
|
|
const selected = new File(["hello"], "notes.txt", {
|
|
type: "text/plain",
|
|
lastModified: 1,
|
|
});
|
|
Object.defineProperty(input, "files", {
|
|
configurable: true,
|
|
value: [selected],
|
|
});
|
|
const showPicker = vi.fn(() => {
|
|
queueMicrotask(() => input.dispatchEvent(new Event("change")));
|
|
});
|
|
Object.defineProperty(input, "showPicker", {
|
|
configurable: true,
|
|
value: showPicker,
|
|
});
|
|
let sequence = 0;
|
|
const { policies, vault } = createHarness(policyDefinition, {
|
|
createReference: () => `file:${sequence++}`,
|
|
});
|
|
const picker = new NativeInputFilePicker({
|
|
input,
|
|
vault,
|
|
policies,
|
|
userActivation: { isActive: true },
|
|
});
|
|
|
|
const first = await picker.select({ policy });
|
|
const second = await picker.select({ policy });
|
|
expect(first).toMatchObject({
|
|
ok: true,
|
|
value: { kind: "SELECTED" },
|
|
});
|
|
expect(second).toMatchObject({
|
|
ok: true,
|
|
value: { kind: "SELECTED" },
|
|
});
|
|
expect(showPicker).toHaveBeenCalledTimes(2);
|
|
expect(input.value).toBe("");
|
|
expect(vault.activeReferenceCount).toBe(2);
|
|
});
|
|
|
|
it("snapshots selection policy before awaiting picker events", async () => {
|
|
const input = labelledFileInput();
|
|
Object.defineProperty(input, "showPicker", {
|
|
configurable: true,
|
|
value: vi.fn(),
|
|
});
|
|
const mutablePolicy = {
|
|
policyId: "mutable-v1",
|
|
purpose: "attachment",
|
|
classification: "PERSONAL" as const,
|
|
multiple: false,
|
|
maxCount: 1,
|
|
maxFileBytes: 100,
|
|
maxTotalBytes: 100,
|
|
allowEmpty: false,
|
|
accept: [
|
|
{ mediaType: "text/plain", extensions: [".txt"] },
|
|
],
|
|
};
|
|
const harness = createHarness(mutablePolicy, {
|
|
createReference: () => "file:snapshot",
|
|
});
|
|
const picker = new NativeInputFilePicker({
|
|
input,
|
|
...harness,
|
|
userActivation: { isActive: true },
|
|
});
|
|
const pending = picker.select({ policy });
|
|
|
|
mutablePolicy.maxFileBytes = 1;
|
|
mutablePolicy.maxTotalBytes = 1;
|
|
mutablePolicy.accept[0]!.extensions[0] = ".png";
|
|
Object.defineProperty(input, "files", {
|
|
configurable: true,
|
|
value: [
|
|
new File(["hello"], "notes.txt", {
|
|
type: "text/plain",
|
|
lastModified: 1,
|
|
}),
|
|
],
|
|
});
|
|
input.dispatchEvent(new Event("change"));
|
|
|
|
expect(await pending).toMatchObject({
|
|
ok: true,
|
|
value: { kind: "SELECTED" },
|
|
});
|
|
});
|
|
|
|
it("honors AbortSignal while a native dialog is pending", async () => {
|
|
const input = labelledFileInput();
|
|
Object.defineProperty(input, "showPicker", {
|
|
configurable: true,
|
|
value: vi.fn(),
|
|
});
|
|
const harness = createHarness();
|
|
const picker = new NativeInputFilePicker({
|
|
input,
|
|
...harness,
|
|
userActivation: { isActive: true },
|
|
});
|
|
const controller = new AbortController();
|
|
const pending = picker.select({ policy, signal: controller.signal });
|
|
controller.abort();
|
|
|
|
expect(await pending).toMatchObject({
|
|
ok: false,
|
|
error: { code: "ABORTED" },
|
|
});
|
|
});
|
|
|
|
it("gives a late selected FileList a grace window after focus returns", async () => {
|
|
const input = labelledFileInput();
|
|
const selected = new File(["late"], "late.txt", {
|
|
type: "text/plain",
|
|
lastModified: 1,
|
|
});
|
|
let fallback: (() => void) | undefined;
|
|
let delay: number | undefined;
|
|
const clearTimeout = vi.fn();
|
|
Object.defineProperty(input, "showPicker", {
|
|
configurable: true,
|
|
value: vi.fn(() => {
|
|
window.dispatchEvent(new Event("focus"));
|
|
}),
|
|
});
|
|
const { policies, vault } = createHarness(policyDefinition, {
|
|
createReference: () => "file:late",
|
|
});
|
|
const picker = new NativeInputFilePicker({
|
|
input,
|
|
vault,
|
|
policies,
|
|
userActivation: { isActive: true },
|
|
scheduler: {
|
|
setTimeout(callback, delayMs) {
|
|
fallback = callback;
|
|
delay = delayMs;
|
|
return 1;
|
|
},
|
|
clearTimeout,
|
|
},
|
|
});
|
|
const pending = picker.select({ policy });
|
|
expect(delay).toBe(DEFAULT_NATIVE_PICKER_FOCUS_GRACE_MS);
|
|
|
|
Object.defineProperty(input, "files", {
|
|
configurable: true,
|
|
value: [selected],
|
|
});
|
|
fallback?.();
|
|
|
|
expect(await pending).toMatchObject({
|
|
ok: true,
|
|
value: {
|
|
kind: "SELECTED",
|
|
files: [{ displayName: "late.txt" }],
|
|
},
|
|
});
|
|
expect(vault.activeReferenceCount).toBe(1);
|
|
});
|
|
|
|
it("lets the native cancel event settle before the focus fallback", async () => {
|
|
const input = labelledFileInput();
|
|
let fallback: (() => void) | undefined;
|
|
const clearTimeout = vi.fn();
|
|
Object.defineProperty(input, "showPicker", {
|
|
configurable: true,
|
|
value: vi.fn(() => {
|
|
window.dispatchEvent(new Event("focus"));
|
|
input.dispatchEvent(new Event("cancel"));
|
|
}),
|
|
});
|
|
const { policies, vault } = createHarness();
|
|
const picker = new NativeInputFilePicker({
|
|
input,
|
|
vault,
|
|
policies,
|
|
userActivation: { isActive: true },
|
|
scheduler: {
|
|
setTimeout(callback) {
|
|
fallback = callback;
|
|
return 1;
|
|
},
|
|
clearTimeout,
|
|
},
|
|
});
|
|
|
|
expect(await picker.select({ policy })).toEqual({
|
|
ok: true,
|
|
value: { kind: "DISMISSED" },
|
|
});
|
|
expect(clearTimeout).toHaveBeenCalledWith(1);
|
|
fallback?.();
|
|
expect(vault.activeReferenceCount).toBe(0);
|
|
});
|
|
|
|
it("captures native input, window, and scheduler methods at construction", async () => {
|
|
const input = labelledFileInput();
|
|
let focus: EventListener | undefined;
|
|
const originalWindowAdd = vi.fn(
|
|
(_type: string, listener: EventListener) => {
|
|
focus = listener;
|
|
},
|
|
);
|
|
const originalWindowRemove = vi.fn();
|
|
const windowHost = {
|
|
addEventListener: originalWindowAdd,
|
|
removeEventListener: originalWindowRemove,
|
|
};
|
|
const originalSetTimeout = vi.fn(
|
|
(callback: () => void) => {
|
|
queueMicrotask(callback);
|
|
return 1;
|
|
},
|
|
);
|
|
const originalClearTimeout = vi.fn();
|
|
const scheduler = {
|
|
setTimeout: originalSetTimeout,
|
|
clearTimeout: originalClearTimeout,
|
|
};
|
|
const originalShowPicker = vi.fn(() => {
|
|
focus?.(new Event("focus"));
|
|
});
|
|
const replacedShowPicker = vi.fn();
|
|
Object.defineProperty(input, "showPicker", {
|
|
configurable: true,
|
|
writable: true,
|
|
value: originalShowPicker,
|
|
});
|
|
const harness = createHarness();
|
|
const picker = new NativeInputFilePicker({
|
|
input,
|
|
...harness,
|
|
window: windowHost as Pick<
|
|
Window,
|
|
"addEventListener" | "removeEventListener"
|
|
>,
|
|
scheduler,
|
|
userActivation: { isActive: true },
|
|
focusFallbackGraceMs: 0,
|
|
});
|
|
|
|
input.showPicker = replacedShowPicker;
|
|
windowHost.addEventListener = vi.fn();
|
|
windowHost.removeEventListener = vi.fn();
|
|
scheduler.setTimeout = vi.fn();
|
|
scheduler.clearTimeout = vi.fn();
|
|
|
|
expect(await picker.select({ policy })).toEqual({
|
|
ok: true,
|
|
value: { kind: "DISMISSED" },
|
|
});
|
|
expect(originalShowPicker).toHaveBeenCalledOnce();
|
|
expect(replacedShowPicker).not.toHaveBeenCalled();
|
|
expect(originalWindowAdd).toHaveBeenCalledOnce();
|
|
expect(originalWindowRemove).toHaveBeenCalledOnce();
|
|
expect(originalSetTimeout).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("requires a connected and labelled file input", async () => {
|
|
const input = document.createElement("input");
|
|
input.type = "file";
|
|
const harness = createHarness();
|
|
const picker = new NativeInputFilePicker({
|
|
input,
|
|
...harness,
|
|
userActivation: { isActive: true },
|
|
});
|
|
|
|
expect(await picker.select({ policy })).toMatchObject({
|
|
ok: false,
|
|
error: { code: "INVALID_INPUT" },
|
|
});
|
|
});
|
|
|
|
it("keeps enhanced dismissal distinct from an active abort", async () => {
|
|
const dismissedHarness = createHarness();
|
|
const dismissed = new EnhancedFilePicker({
|
|
showOpenFilePicker: async () => {
|
|
throw new DOMException("closed", "AbortError");
|
|
},
|
|
...dismissedHarness,
|
|
userActivation: { isActive: true },
|
|
});
|
|
expect(await dismissed.select({ policy })).toEqual({
|
|
ok: true,
|
|
value: { kind: "DISMISSED" },
|
|
});
|
|
|
|
let rejectPicker:
|
|
| ((reason: DOMException) => void)
|
|
| undefined;
|
|
const controller = new AbortController();
|
|
const abortedHarness = createHarness();
|
|
const aborted = new EnhancedFilePicker({
|
|
showOpenFilePicker: () =>
|
|
new Promise((_, reject: (reason: DOMException) => void) => {
|
|
rejectPicker = reject;
|
|
}),
|
|
...abortedHarness,
|
|
userActivation: { isActive: true },
|
|
});
|
|
const pending = aborted.select({
|
|
policy,
|
|
signal: controller.signal,
|
|
});
|
|
controller.abort();
|
|
rejectPicker?.(new DOMException("closed", "AbortError"));
|
|
expect(await pending).toMatchObject({
|
|
ok: false,
|
|
error: { code: "ABORTED" },
|
|
});
|
|
});
|
|
|
|
it("rejects excessive enhanced selections before reading handles", async () => {
|
|
const getFile = vi.fn(async () => new File(["a"], "a.txt"));
|
|
const multiplePolicy = {
|
|
...policyDefinition,
|
|
multiple: true,
|
|
maxCount: 1,
|
|
};
|
|
const harness = createHarness(multiplePolicy);
|
|
const picker = new EnhancedFilePicker({
|
|
showOpenFilePicker: async () => [
|
|
{ kind: "file", name: "a.txt", getFile },
|
|
{ kind: "file", name: "b.txt", getFile },
|
|
],
|
|
...harness,
|
|
userActivation: { isActive: true },
|
|
});
|
|
|
|
expect(await picker.select({ policy })).toMatchObject({
|
|
ok: false,
|
|
error: { code: "LIMIT_EXCEEDED" },
|
|
});
|
|
expect(getFile).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("snapshots an enhanced picker request before awaiting the host", async () => {
|
|
let resolvePicker:
|
|
| ((handles: readonly {
|
|
kind: "file";
|
|
name: string;
|
|
getFile(): Promise<File>;
|
|
}[]) => void)
|
|
| undefined;
|
|
const harness = createHarness();
|
|
const picker = new EnhancedFilePicker({
|
|
showOpenFilePicker: () =>
|
|
new Promise((resolve) => {
|
|
resolvePicker = resolve;
|
|
}),
|
|
...harness,
|
|
userActivation: { isActive: true },
|
|
});
|
|
const originalController = new AbortController();
|
|
const replacementController = new AbortController();
|
|
const request = {
|
|
policy,
|
|
signal: originalController.signal,
|
|
};
|
|
const pending = picker.select(request);
|
|
|
|
(
|
|
request as {
|
|
policy: typeof policy;
|
|
signal: AbortSignal;
|
|
}
|
|
).policy = browserFilePolicyReference(
|
|
"forged",
|
|
"forged",
|
|
);
|
|
(
|
|
request as { signal: AbortSignal }
|
|
).signal = replacementController.signal;
|
|
replacementController.abort();
|
|
resolvePicker?.([
|
|
{
|
|
kind: "file",
|
|
name: "safe.txt",
|
|
async getFile() {
|
|
return new File(["safe"], "safe.txt", {
|
|
type: "text/plain",
|
|
lastModified: 1,
|
|
});
|
|
},
|
|
},
|
|
]);
|
|
|
|
expect(await pending).toMatchObject({
|
|
ok: true,
|
|
value: {
|
|
kind: "SELECTED",
|
|
files: [{ displayName: "safe.txt" }],
|
|
},
|
|
});
|
|
});
|
|
});
|