feat: 기능 추가 과정중
This commit is contained in:
@@ -0,0 +1,496 @@
|
||||
// @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" }],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user