Files
tech-log-frontend/tests/unit/browser-file-picker.test.ts
T

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" }],
},
});
});
});