diff --git a/__tests__/fixtures/filesystem.ts b/__tests__/fixtures/filesystem.ts new file mode 100644 index 000000000..f5ffbd92f --- /dev/null +++ b/__tests__/fixtures/filesystem.ts @@ -0,0 +1,74 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +/** + * Helpers to create *real* `FileSystemEntry` objects, as they are handed to the + * uploader when files are dropped or selected using ``. + * + * They are backed by the sandboxed filesystem of the browser (Chromium only), + * so the entries behave exactly like the ones of a real drag-and-drop operation, + * including quirks like `readEntries` only returning a limited number of entries per call. + */ + +// `FileWriter` is part of the deprecated (but still implemented) filesystem API and thus not typed. +interface FileWriter { + write(data: Blob): void + onwriteend: (() => void) | null + onerror: ((error: unknown) => void) | null +} + +/** + * Create a directory entry containing the given files in the sandboxed filesystem. + * + * @param files - Map of file paths (relative to the created directory) to their content + * @return The entry of the newly created directory + */ +export async function createDirectoryEntry(files: Record): Promise { + const filesystem = await new Promise((resolve, reject) => { + // 0 = window.TEMPORARY + window.webkitRequestFileSystem(0, 32 * 1024 * 1024, resolve, reject) + }) + + // Use a unique name as the sandboxed filesystem is shared between tests + const root = await getDirectory(filesystem.root as FileSystemDirectoryEntry, `test-${crypto.randomUUID()}`) + for (const [path, content] of Object.entries(files)) { + const segments = path.split('/') + const name = segments.pop()! + + let directory = root + for (const segment of segments) { + directory = await getDirectory(directory, segment) + } + await writeFile(directory, name, content) + } + return root +} + +/** + * Get or create a sub directory of the given directory. + * + * @param parent - The parent directory + * @param name - Name of the sub directory + */ +async function getDirectory(parent: FileSystemDirectoryEntry, name: string): Promise { + return await new Promise((resolve, reject) => parent.getDirectory(name, { create: true }, resolve, reject)) +} + +/** + * Create a file with the given content inside the given directory. + * + * @param parent - The directory to create the file in + * @param name - Name of the file + * @param content - Content of the file + */ +async function writeFile(parent: FileSystemDirectoryEntry, name: string, content: string): Promise { + const entry = await new Promise((resolve, reject) => parent.getFile(name, { create: true }, resolve as FileSystemEntryCallback, reject)) + const writer = await new Promise((resolve, reject) => (entry as unknown as { createWriter(success: (writer: FileWriter) => void, error: (error: unknown) => void): void }).createWriter(resolve, reject)) + await new Promise((resolve, reject) => { + writer.onwriteend = () => resolve() + writer.onerror = reject + writer.write(new Blob([content], { type: 'text/plain' })) + }) +} diff --git a/__tests__/index.spec.ts b/__tests__/index.spec.ts index c6fc7160a..428d5d01d 100644 --- a/__tests__/index.spec.ts +++ b/__tests__/index.spec.ts @@ -15,7 +15,7 @@ import { Permission, removeNewFileMenuEntry, } from '../lib/index.ts' -import { getFileActions, registerFileAction } from '~/ui/actions/fileAction.ts' +import { getFileActions, registerFileAction } from '@/ui/actions/fileAction.ts' describe('Exports checks', () => { test('formatFileSize', () => { diff --git a/__tests__/tsconfig.json b/__tests__/tsconfig.json index 4cad69e30..802a60318 100644 --- a/__tests__/tsconfig.json +++ b/__tests__/tsconfig.json @@ -1,13 +1,11 @@ { "extends": "../tsconfig.json", "include": ["../lib/**/*.ts", "./**/*.ts", "../window.d.ts"], + "exclude": [], "compilerOptions": { // Allow us to pass js files to ts-jest without babel "allowJs": true, "rootDir": "..", - "paths": { - "~/*": ["../lib/*"] - } } } \ No newline at end of file diff --git a/__tests__/ui/actions/fileAction.spec.ts b/__tests__/ui/actions/fileAction.spec.ts index fe503fbac..e12f41b2d 100644 --- a/__tests__/ui/actions/fileAction.spec.ts +++ b/__tests__/ui/actions/fileAction.spec.ts @@ -3,14 +3,14 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import type { IFolder, INode } from '~/node/index.ts' -import type { IFileAction } from '~/ui/index.ts' -import type { View } from '~/ui/navigation/index.ts' +import type { IFolder, INode } from '@/node/index.ts' +import type { IFileAction } from '@/ui/index.ts' +import type { View } from '@/ui/navigation/index.ts' import { beforeEach, describe, expect, test, vi } from 'vitest' -import { scopedGlobals } from '~/globalScope.ts' -import { DefaultType, getFileActions, getFilesRegistry, registerFileAction } from '~/ui/index.ts' -import logger from '~/utils/logger.ts' +import { scopedGlobals } from '@/globalScope.ts' +import { DefaultType, getFileActions, getFilesRegistry, registerFileAction } from '@/ui/index.ts' +import logger from '@/utils/logger.ts' const folder = {} as IFolder const view = {} as View diff --git a/__tests__/ui/actions/fileListAction.spec.ts b/__tests__/ui/actions/fileListAction.spec.ts index f63548e6c..12663f094 100644 --- a/__tests__/ui/actions/fileListAction.spec.ts +++ b/__tests__/ui/actions/fileListAction.spec.ts @@ -3,13 +3,13 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import type { IFolder } from '~/node/index.ts' -import type { IFileListAction, View } from '~/ui/index.ts' +import type { IFolder } from '@/node/index.ts' +import type { IFileListAction, View } from '@/ui/index.ts' import { beforeEach, describe, expect, test, vi } from 'vitest' -import { scopedGlobals } from '~/globalScope.ts' -import { getFileListActions, getFilesRegistry, registerFileListAction } from '~/ui/index.ts' -import logger from '~/utils/logger.ts' +import { scopedGlobals } from '@/globalScope.ts' +import { getFileListActions, getFilesRegistry, registerFileListAction } from '@/ui/index.ts' +import logger from '@/utils/logger.ts' const folder = {} as IFolder const view = {} as View diff --git a/__tests__/ui/filters/listFilter.spec.ts b/__tests__/ui/filters/listFilter.spec.ts index b704305c3..6d2fe310e 100644 --- a/__tests__/ui/filters/listFilter.spec.ts +++ b/__tests__/ui/filters/listFilter.spec.ts @@ -3,11 +3,11 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import type { IFileListFilterChip } from '~/ui/index.ts' +import type { IFileListFilterChip } from '@/ui/index.ts' import { beforeEach, describe, expect, test, vi } from 'vitest' -import { scopedGlobals } from '~/globalScope.ts' -import { FileListFilter, getFileListFilters, getFilesRegistry, registerFileListFilter, unregisterFileListFilter } from '~/ui/index.ts' +import { scopedGlobals } from '@/globalScope.ts' +import { FileListFilter, getFileListFilters, getFilesRegistry, registerFileListFilter, unregisterFileListFilter } from '@/ui/index.ts' class TestFilter extends FileListFilter { public testUpdated() { diff --git a/__tests__/ui/headers/listHeaders.spec.ts b/__tests__/ui/headers/listHeaders.spec.ts index d1188c9b7..a56a54142 100644 --- a/__tests__/ui/headers/listHeaders.spec.ts +++ b/__tests__/ui/headers/listHeaders.spec.ts @@ -3,12 +3,12 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import type { IFileListHeader, IFolder, IView } from '~/index.ts' +import type { IFileListHeader, IFolder, IView } from '@/index.ts' import { beforeEach, describe, expect, test, vi } from 'vitest' -import { scopedGlobals } from '~/globalScope.ts' -import { getFileListHeaders, getFilesRegistry, registerFileListHeader } from '~/ui/index.ts' -import logger from '~/utils/logger.ts' +import { scopedGlobals } from '@/globalScope.ts' +import { getFileListHeaders, getFilesRegistry, registerFileListHeader } from '@/ui/index.ts' +import logger from '@/utils/logger.ts' describe('FileListHeader init', () => { beforeEach(() => { diff --git a/__tests__/ui/navigation/navigation.spec.ts b/__tests__/ui/navigation/navigation.spec.ts index 972d651c6..223eabc58 100644 --- a/__tests__/ui/navigation/navigation.spec.ts +++ b/__tests__/ui/navigation/navigation.spec.ts @@ -5,9 +5,9 @@ import { describe, expect, it, vi } from 'vitest' import { mockView } from '../../fixtures/view.ts' -import { scopedGlobals } from '~/globalScope.ts' -import { View } from '~/index.ts' -import { getNavigation, Navigation } from '~/ui/navigation/navigation.ts' +import { scopedGlobals } from '@/globalScope.ts' +import { View } from '@/index.ts' +import { getNavigation, Navigation } from '@/ui/navigation/navigation.ts' describe('getNavigation', () => { it('creates a new navigation if needed', () => { diff --git a/__tests__/ui/navigation/view.spec.ts b/__tests__/ui/navigation/view.spec.ts index dc38cedde..24b232261 100644 --- a/__tests__/ui/navigation/view.spec.ts +++ b/__tests__/ui/navigation/view.spec.ts @@ -3,11 +3,11 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import type { IView } from '~/ui/navigation/view.ts' +import type { IView } from '@/ui/navigation/view.ts' import { describe, expect, test } from 'vitest' import { mockView } from '../../fixtures/view.ts' -import { View } from '~/ui/navigation/index.ts' +import { View } from '@/ui/navigation/index.ts' describe('Invalid View creation', () => { test('Invalid id', () => { diff --git a/__tests__/ui/newMenu/newFileMenu.spec.ts b/__tests__/ui/newMenu/newFileMenu.spec.ts index 807dc1f43..f170530a3 100644 --- a/__tests__/ui/newMenu/newFileMenu.spec.ts +++ b/__tests__/ui/newMenu/newFileMenu.spec.ts @@ -3,15 +3,15 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import type { NewMenuEntry } from '~/ui/index.ts' +import type { NewMenuEntry } from '@/ui/index.ts' import { afterEach, describe, expect, test, vi } from 'vitest' -import { scopedGlobals } from '~/globalScope.ts' -import { Folder } from '~/node/index.ts' -import { Permission } from '~/permissions.ts' -import { addNewFileMenuEntry, getNewFileMenu, getNewFileMenuEntries, removeNewFileMenuEntry } from '~/ui/index.ts' -import { NewMenu, NewMenuEntryCategory } from '~/ui/newMenu/NewMenu.ts' -import logger from '~/utils/logger.ts' +import { scopedGlobals } from '@/globalScope.ts' +import { Folder } from '@/node/index.ts' +import { Permission } from '@/permissions.ts' +import { addNewFileMenuEntry, getNewFileMenu, getNewFileMenuEntries, removeNewFileMenuEntry } from '@/ui/index.ts' +import { NewMenu, NewMenuEntryCategory } from '@/ui/newMenu/NewMenu.ts' +import logger from '@/utils/logger.ts' describe('NewFileMenu init', () => { test('Initializing NewFileMenu', () => { diff --git a/__tests__/ui/sidebar/sidebar.spec.ts b/__tests__/ui/sidebar/sidebar.spec.ts index abaa8edd9..91c5cbeb4 100644 --- a/__tests__/ui/sidebar/sidebar.spec.ts +++ b/__tests__/ui/sidebar/sidebar.spec.ts @@ -3,11 +3,11 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import type { ISidebar, ISidebarContext } from '~/ui/index.ts' +import type { ISidebar, ISidebarContext } from '@/ui/index.ts' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { File } from '~/node/index.ts' -import { getSidebar } from '~/ui/index.ts' +import { File } from '@/node/index.ts' +import { getSidebar } from '@/ui/index.ts' const node = new File({ id: 1, diff --git a/__tests__/ui/sidebar/sidebarTab.spec.ts b/__tests__/ui/sidebar/sidebarTab.spec.ts index 87c554c65..d9a2e9345 100644 --- a/__tests__/ui/sidebar/sidebarTab.spec.ts +++ b/__tests__/ui/sidebar/sidebarTab.spec.ts @@ -3,11 +3,11 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import type { ISidebarTab } from '~/ui/index.ts' +import type { ISidebarTab } from '@/ui/index.ts' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { scopedGlobals } from '~/globalScope.ts' -import { getSidebarTabs, registerSidebarTab } from '~/ui/index.ts' +import { scopedGlobals } from '@/globalScope.ts' +import { getSidebarTabs, registerSidebarTab } from '@/ui/index.ts' // missing in JSDom but supported by every browser! import 'css.escape' diff --git a/__tests__/uploader/upload.e2e.spec.ts b/__tests__/uploader/upload.e2e.spec.ts index 983859778..63c0906e0 100644 --- a/__tests__/uploader/upload.e2e.spec.ts +++ b/__tests__/uploader/upload.e2e.spec.ts @@ -3,10 +3,13 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ +import type { FileStat } from 'webdav' + import { describe, expect, it, vi } from 'vitest' -import { defaultRemoteURL, getClient } from '~/dav/index.ts' -import { Folder } from '~/node/index.ts' -import { Uploader, UploaderStatus, UploadStatus } from '~/upload/index.ts' +import { createDirectoryEntry } from '../fixtures/filesystem.ts' +import { defaultRemoteURL, getClient } from '@/dav/index.ts' +import { Folder } from '@/node/index.ts' +import { Uploader, UploaderStatus, UploadStatus } from '@/upload/index.ts' vi.mock('@nextcloud/auth', async (def) => ({ ...(await def()), @@ -164,6 +167,33 @@ describe('Uploader (current API)', () => { await expect(client.getFileContents('/files/admin/test-folder/upload/subdir/deep/deep.txt', { format: 'text' })).resolves.toBe('deep file') }) + it('should upload all files of a dropped folder with more entries than one `readEntries` call returns', async () => { + const client = getClient() + await client.deleteFile('/files/admin/test-drop').catch(() => {}) + await client.createDirectory('/files/admin/test-drop') + + const folder = new Folder({ + owner: 'admin', + root: '/files/admin', + source: `${defaultRemoteURL}/files/admin/test-drop`, + }) + const uploader = new Uploader(false, folder) + + // Chromium returns at most 100 entries per `readEntries` call, + // so a folder with more entries is only fully read if `readEntries` is called repeatedly. + const files = Object.fromEntries(Array.from({ length: 150 }, (_, index) => [`file-${index}.txt`, `content-${index}`])) + const entry = await createDirectoryEntry(files) + + const finishedPromise = new Promise((resolve) => uploader.addEventListener('finished', () => resolve())) + await uploader.batchUpload('', [entry]) + await finishedPromise + + const contents = await client.getDirectoryContents(`/files/admin/test-drop/${entry.name}`) as FileStat[] + const uploaded = contents.filter(({ type }) => type === 'file').map(({ basename }) => basename) + expect(uploaded.sort()).toEqual(Object.keys(files).sort()) + await expect(client.getFileContents(`/files/admin/test-drop/${entry.name}/file-149.txt`, { format: 'text' })).resolves.toBe('content-149') + }, 120_000) + it('should track upload status transitions', async () => { const client = getClient() await client.deleteFile('/files/admin/test-status').catch(() => {}) diff --git a/lib/upload/utils/fileTree.spec.ts b/lib/upload/utils/fileTree.spec.ts new file mode 100644 index 000000000..03b84ea60 --- /dev/null +++ b/lib/upload/utils/fileTree.spec.ts @@ -0,0 +1,57 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { describe, expect, it } from 'vitest' +import { isFileSystemDirectoryEntry, isFileSystemEntry, isFileSystemFileEntry } from './filesystem.ts' +import { Directory } from './fileTree.ts' +import { createDirectoryEntry } from '~/__tests__/fixtures/filesystem.ts' + +describe('file system entry detection', () => { + it('detects real file system entries', async () => { + const directoryEntry = await createDirectoryEntry({ 'a.txt': 'a' }) + const fileEntry = await new Promise((resolve, reject) => directoryEntry.getFile('a.txt', {}, resolve, reject)) + + expect(isFileSystemEntry(directoryEntry)).toBe(true) + expect(isFileSystemDirectoryEntry(directoryEntry)).toBe(true) + expect(isFileSystemFileEntry(directoryEntry)).toBe(false) + + expect(isFileSystemEntry(fileEntry)).toBe(true) + expect(isFileSystemFileEntry(fileEntry)).toBe(true) + expect(isFileSystemDirectoryEntry(fileEntry)).toBe(false) + }) + + it('does not detect files or directories as file system entries', () => { + expect(isFileSystemEntry(new File(['a'], 'a.txt'))).toBe(false) + expect(isFileSystemEntry(new Directory('folder'))).toBe(false) + expect(isFileSystemEntry(null)).toBe(false) + expect(isFileSystemEntry({ name: 'a.txt' })).toBe(false) + }) +}) + +describe('Directory (file tree)', () => { + it('adds all entries of a directory with more entries than one `readEntries` call returns', async () => { + // Chromium returns at most 100 entries per `readEntries` call + const files = Object.fromEntries(Array.from({ length: 150 }, (_, index) => [`file-${index}.txt`, `content-${index}`])) + const entry = await createDirectoryEntry(files) + + const root = new Directory('') + await root.addChild(entry) + + const directory = root.getChild(entry.name) as Directory + expect(directory.children).toHaveLength(150) + expect(directory.children.map(({ name }) => name).sort()).toEqual(Object.keys(files).sort()) + }) + + it('adds all entries of a nested directory with more entries than one `readEntries` call returns', async () => { + const files = Object.fromEntries(Array.from({ length: 150 }, (_, index) => [`nested/file-${index}.txt`, `content-${index}`])) + const entry = await createDirectoryEntry(files) + + const root = new Directory('') + await root.addChild(entry) + + const nested = (root.getChild(entry.name) as Directory).getChild('nested') as Directory + expect(nested.children).toHaveLength(150) + }) +}) diff --git a/lib/upload/utils/fileTree.ts b/lib/upload/utils/fileTree.ts index b9cbbd651..b9f7f37f0 100644 --- a/lib/upload/utils/fileTree.ts +++ b/lib/upload/utils/fileTree.ts @@ -73,7 +73,15 @@ export class Directory extends File { file = await new Promise((resolve, reject) => (file as FileSystemFileEntry).file(resolve, reject)) } else if (isFileSystemDirectoryEntry(file)) { const reader = file.createReader() - const entries = await new Promise((resolve, reject) => reader.readEntries(resolve, reject)) + // `readEntries` does not necessarily return all entries at once, + // e.g. Chromium returns at most 100 entries per call, + // so we need to call it until it returns an empty array. + const entries: FileSystemEntry[] = [] + let batch: FileSystemEntry[] + do { + batch = await new Promise((resolve, reject) => reader.readEntries(resolve, reject)) + entries.push(...batch) + } while (batch.length > 0) // Create a new child directory and add the entries const child = new Directory(`${rootPath}${file.name}`) diff --git a/lib/upload/utils/filesystem.ts b/lib/upload/utils/filesystem.ts index 0602cc49c..df05f7e94 100644 --- a/lib/upload/utils/filesystem.ts +++ b/lib/upload/utils/filesystem.ts @@ -3,10 +3,37 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ // Helpers for the File and Directory API +// +// The interfaces of the (legacy) File and Directory Entries API are not exposed as global +// constructors by Chromium, so `instanceof` can not be used and we check the shape instead. +// This also works for browsers that do not support the API at all. -// Helper to support browser that do not support the API -export const isFileSystemDirectoryEntry = (o: unknown): o is FileSystemDirectoryEntry => 'FileSystemDirectoryEntry' in window && o instanceof FileSystemDirectoryEntry +/** + * Check whether the given object is a `FileSystemEntry` + * + * @param o - The object to check + */ +export function isFileSystemEntry(o: unknown): o is FileSystemEntry { + return typeof o === 'object' && o !== null + && 'isFile' in o && typeof o.isFile === 'boolean' + && 'isDirectory' in o && typeof o.isDirectory === 'boolean' + && 'fullPath' in o && typeof o.fullPath === 'string' +} -export const isFileSystemFileEntry = (o: unknown): o is FileSystemFileEntry => 'FileSystemFileEntry' in window && o instanceof FileSystemFileEntry +/** + * Check whether the given object is a `FileSystemDirectoryEntry` + * + * @param o - The object to check + */ +export function isFileSystemDirectoryEntry(o: unknown): o is FileSystemDirectoryEntry { + return isFileSystemEntry(o) && o.isDirectory && typeof (o as FileSystemDirectoryEntry).createReader === 'function' +} -export const isFileSystemEntry = (o: unknown): o is FileSystemEntry => 'FileSystemEntry' in window && o instanceof FileSystemEntry +/** + * Check whether the given object is a `FileSystemFileEntry` + * + * @param o - The object to check + */ +export function isFileSystemFileEntry(o: unknown): o is FileSystemFileEntry { + return isFileSystemEntry(o) && o.isFile && typeof (o as FileSystemFileEntry).file === 'function' +} diff --git a/tsconfig.json b/tsconfig.json index 9032b449d..2f73a3eb1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,5 +1,6 @@ { "include": ["lib"], + "exclude": ["**/*.spec.ts"], "compilerOptions": { "allowSyntheticDefaultImports": true, "allowImportingTsExtensions": true, @@ -13,5 +14,9 @@ "outDir": "./dist", "rootDir": "./lib", "noEmit": true, + "paths": { + "~/*": ["./*"], + "@/*": ["./lib/*"] + } } } diff --git a/vitest.config.ts b/vitest.config.ts index d363c88bf..4ae9d887f 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -42,7 +42,8 @@ export default defineConfig({ }, resolve: { alias: { - '~': resolve(__dirname, 'lib'), + '@': resolve(__dirname, 'lib'), + '~': __dirname, }, }, server: {