// @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; }[]) => 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" }], }, }); }); });