151 lines
4.9 KiB
TypeScript
151 lines
4.9 KiB
TypeScript
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;
|
|
});
|