Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions __tests__/fixtures/filesystem.ts
Original file line number Diff line number Diff line change
@@ -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 `<input webkitdirectory>`.
*
* 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<string, string>): Promise<FileSystemDirectoryEntry> {
const filesystem = await new Promise<FileSystem>((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<FileSystemDirectoryEntry> {
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<void> {
const entry = await new Promise<FileSystemFileEntry>((resolve, reject) => parent.getFile(name, { create: true }, resolve as FileSystemEntryCallback, reject))
const writer = await new Promise<FileWriter>((resolve, reject) => (entry as unknown as { createWriter(success: (writer: FileWriter) => void, error: (error: unknown) => void): void }).createWriter(resolve, reject))
await new Promise<void>((resolve, reject) => {
writer.onwriteend = () => resolve()
writer.onerror = reject
writer.write(new Blob([content], { type: 'text/plain' }))
})
}
2 changes: 1 addition & 1 deletion __tests__/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
4 changes: 1 addition & 3 deletions __tests__/tsconfig.json
Original file line number Diff line number Diff line change
@@ -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/*"]
}
}
}

12 changes: 6 additions & 6 deletions __tests__/ui/actions/fileAction.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 5 additions & 5 deletions __tests__/ui/actions/fileListAction.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions __tests__/ui/filters/listFilter.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
8 changes: 4 additions & 4 deletions __tests__/ui/headers/listHeaders.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down
6 changes: 3 additions & 3 deletions __tests__/ui/navigation/navigation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
4 changes: 2 additions & 2 deletions __tests__/ui/navigation/view.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
14 changes: 7 additions & 7 deletions __tests__/ui/newMenu/newFileMenu.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
6 changes: 3 additions & 3 deletions __tests__/ui/sidebar/sidebar.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 3 additions & 3 deletions __tests__/ui/sidebar/sidebarTab.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
36 changes: 33 additions & 3 deletions __tests__/uploader/upload.e2e.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
Expand Down Expand Up @@ -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<void>((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(() => {})
Expand Down
57 changes: 57 additions & 0 deletions lib/upload/utils/fileTree.spec.ts
Original file line number Diff line number Diff line change
@@ -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<FileSystemEntry>((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)
})
})
10 changes: 9 additions & 1 deletion lib/upload/utils/fileTree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,15 @@ export class Directory extends File {
file = await new Promise<File>((resolve, reject) => (file as FileSystemFileEntry).file(resolve, reject))
} else if (isFileSystemDirectoryEntry(file)) {
const reader = file.createReader()
const entries = await new Promise<FileSystemEntry[]>((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<FileSystemEntry[]>((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}`)
Expand Down
Loading
Loading