fix(files): make dropped-file handling explicit

This commit is contained in:
2026-07-20 16:57:29 +02:00
parent 78b4afdec4
commit b5cfba666c
6 changed files with 345 additions and 6 deletions

View File

@@ -0,0 +1,150 @@
import { resolveDroppedFiles } from "../src/components/resolveDroppedFiles";
type FileDropItem = Omit<DataTransferItem, "getAsFileSystemHandle" | "webkitGetAsEntry"> & {
getAsFileSystemHandle?: () => Promise<unknown>;
webkitGetAsEntry?: () => unknown;
};
function assert(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(message);
}
function assertFiles(actual: File[], expected: File[], message: string): void {
assert(actual.length === expected.length, `${message}: expected ${expected.length} file(s), got ${actual.length}`);
expected.forEach((file, index) => {
assert(actual[index] === file, `${message}: file ${index + 1} did not preserve object identity`);
});
}
function transfer(files: File[] = [], items: FileDropItem[] = []): Pick<DataTransfer, "files" | "items"> {
return {
files: files as unknown as FileList,
items: items as unknown as DataTransferItemList
};
}
function item(overrides: Partial<FileDropItem> = {}): FileDropItem {
return {
kind: "file",
type: "application/octet-stream",
getAsFile: () => null,
getAsString: () => undefined,
...overrides
} as FileDropItem;
}
function namedFile(name: string): File {
return new File([name], name, { type: "text/plain" });
}
async function* handles(...entries: unknown[]): AsyncIterable<unknown> {
for (const entry of entries) yield entry;
}
async function run(): Promise<void> {
const direct = namedFile("direct.txt");
let directItemCalls = 0;
const directResult = await resolveDroppedFiles(transfer([direct], [item({
getAsFile: () => {
directItemCalls += 1;
return direct;
}
})]));
assertFiles(directResult, [direct], "native DataTransfer.files takes precedence");
assert(directItemCalls === 0, "native files must not also be resolved from items");
const itemA = namedFile("item-a.txt");
const itemB = namedFile("item-b.txt");
let itemHandleCalls = 0;
const itemResult = await resolveDroppedFiles(transfer([], [
item({
getAsFile: () => itemA,
getAsFileSystemHandle: async () => {
itemHandleCalls += 1;
return { kind: "file", getFile: async () => itemA };
}
}),
item({ getAsFile: () => itemB }),
item({ kind: "string", getAsFile: () => null })
]));
assertFiles(itemResult, [itemA, itemB], "DataTransferItem.getAsFile fallback resolves file items");
assert(itemHandleCalls === 0, "item files must not also be resolved from file-system handles");
const topLevelFile = namedFile("top-level.txt");
const rootFile = namedFile("root.txt");
const nestedFile = namedFile("nested.txt");
const deepFile = namedFile("deep.txt");
const deepDirectory = {
kind: "directory",
values: () => handles({ kind: "file", getFile: async () => deepFile })
};
const nestedDirectory = {
kind: "directory",
values: () => handles(
{ kind: "file", getFile: async () => nestedFile },
deepDirectory
)
};
const rootDirectory = {
kind: "directory",
values: () => handles(
{ kind: "file", getFile: async () => rootFile },
nestedDirectory
)
};
const handleResult = await resolveDroppedFiles(transfer([], [
item({
getAsFileSystemHandle: async () => ({
kind: "file",
getFile: async () => topLevelFile
})
}),
item({ getAsFileSystemHandle: async () => rootDirectory })
]));
assertFiles(
handleResult,
[topLevelFile, rootFile, nestedFile, deepFile],
"File System Access file handles resolve and directory handles recurse in enumeration order"
);
const legacy = namedFile("legacy.txt");
const legacyResult = await resolveDroppedFiles(transfer([], [item({
getAsFileSystemHandle: async () => {
throw new Error("file-system handles unavailable");
},
webkitGetAsEntry: () => ({
isFile: true,
isDirectory: false,
file: (resolve: (file: File) => void) => resolve(legacy)
})
})]));
assertFiles(legacyResult, [legacy], "legacy webkit file entry remains a final fallback");
const emptyResult = await resolveDroppedFiles(transfer([], [
item({ kind: "string" }),
item({
getAsFileSystemHandle: async () => ({ kind: "directory" }),
webkitGetAsEntry: () => ({ isFile: false, isDirectory: true })
})
]));
assertFiles(emptyResult, [], "empty and non-readable directory paths resolve to an empty drop");
let unreadableRejected = false;
try {
await resolveDroppedFiles(transfer([], [item({
getAsFileSystemHandle: async () => ({
kind: "file",
getFile: async () => {
throw new Error("permission denied");
}
})
})]));
} catch (error) {
unreadableRejected = error instanceof Error && error.message === "permission denied";
}
assert(unreadableRejected, "an unreadable file handle rejects so the component can report the unreadable reason");
}
void run().catch((error) => {
throw error;
});