fix(files): make dropped-file handling explicit
This commit is contained in:
150
webui/tests/file-drop-resolver.test.ts
Normal file
150
webui/tests/file-drop-resolver.test.ts
Normal 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;
|
||||
});
|
||||
Reference in New Issue
Block a user