From 2a6185e32a12a73cd3b34d989963a6cc6f8617c7 Mon Sep 17 00:00:00 2001 From: skjnldsv Date: Tue, 2 Apr 2024 11:28:59 +0200 Subject: [PATCH] chore: add drag and drop recursion and FilesystemAPI testing Signed-off-by: skjnldsv --- __tests__/FileSystemAPIUtils.ts | 124 +++++++++ .../src/components/DragAndDropNotice.vue | 7 +- apps/files/src/components/FileEntryMixin.ts | 2 +- apps/files/src/services/DropService.ts | 178 +------------ .../src/services/DropServiceUtils.spec.ts | 142 ++++++++++ apps/files/src/services/DropServiceUtils.ts | 195 ++++++++++++++ cypress/e2e/files/FileSystemAPIUtils.ts | 59 ----- cypress/e2e/files/drag-n-drop.cy.ts | 2 +- jest.config.ts | 7 + package-lock.json | 242 +++++++++++++----- package.json | 3 +- 11 files changed, 664 insertions(+), 297 deletions(-) create mode 100644 __tests__/FileSystemAPIUtils.ts create mode 100644 apps/files/src/services/DropServiceUtils.spec.ts create mode 100644 apps/files/src/services/DropServiceUtils.ts delete mode 100644 cypress/e2e/files/FileSystemAPIUtils.ts diff --git a/__tests__/FileSystemAPIUtils.ts b/__tests__/FileSystemAPIUtils.ts new file mode 100644 index 00000000000..efb273e9dcd --- /dev/null +++ b/__tests__/FileSystemAPIUtils.ts @@ -0,0 +1,124 @@ +import { basename } from 'node:path' +import mime from 'mime' + +class FileSystemEntry { + + private _isFile: boolean + private _fullPath: string + + constructor(isFile: boolean, fullPath: string) { + this._isFile = isFile + this._fullPath = fullPath + } + + get isFile() { + return !!this._isFile + } + + get isDirectory() { + return !this.isFile + } + + get name() { + return basename(this._fullPath) + } + +} + +export class FileSystemFileEntry extends FileSystemEntry { + + private _contents: string + private _lastModified: number + + constructor(fullPath: string, contents: string, lastModified = Date.now()) { + super(true, fullPath) + this._contents = contents + this._lastModified = lastModified + } + + file(success: (file: File) => void) { + const lastModified = this._lastModified + // Faking the mime by using the file extension + const type = mime.getType(this.name) || '' + success(new File([this._contents], this.name, { lastModified, type })) + } + +} + +export class FileSystemDirectoryEntry extends FileSystemEntry { + + private _entries: FileSystemEntry[] + + constructor(fullPath: string, entries: FileSystemEntry[]) { + super(false, fullPath) + this._entries = entries || [] + } + + createReader() { + let read = false + return { + readEntries: (success: (entries: FileSystemEntry[]) => void) => { + if (read) { + return success([]) + } + read = true + success(this._entries) + }, + } + } + +} + +/** + * This mocks the File API's File class + * It will allow us to test the Filesystem API as well as the + * File API in the same test suite. + */ +export class DataTransferItem { + + private _type: string + private _entry: FileSystemEntry + + getAsEntry?: () => FileSystemEntry + + constructor(type = '', entry: FileSystemEntry, isFileSystemAPIAvailable = true) { + this._type = type + this._entry = entry + + // Only when the Files API is available we are + // able to get the entry + if (isFileSystemAPIAvailable) { + this.getAsEntry = () => this._entry + } + } + + get kind() { + return 'file' + } + + get type() { + return this._type + } + + getAsFile(): File|null { + if (this._entry.isFile && this._entry instanceof FileSystemFileEntry) { + let file: File | null = null + this._entry.file((f) => { + file = f + }) + return file + } + + // The browser will return an empty File object if the entry is a directory + return new File([], this._entry.name, { type: '' }) + } + +} + +export const fileSystemEntryToDataTransferItem = (entry: FileSystemEntry, isFileSystemAPIAvailable = true): DataTransferItem => { + return new DataTransferItem( + entry.isFile ? 'text/plain' : 'httpd/unix-directory', + entry, + isFileSystemAPIAvailable, + ) +} diff --git a/apps/files/src/components/DragAndDropNotice.vue b/apps/files/src/components/DragAndDropNotice.vue index d591f6ee934..c036c86fb64 100644 --- a/apps/files/src/components/DragAndDropNotice.vue +++ b/apps/files/src/components/DragAndDropNotice.vue @@ -180,7 +180,7 @@ export default defineComponent({ // If another button is pressed, cancel it. This // allows cancelling the drag with the right click. - if (event.button !== 0) { + if (event.button) { return } @@ -192,9 +192,12 @@ export default defineComponent({ // Scroll to last successful upload in current directory if terminated const lastUpload = uploads.findLast((upload) => upload.status !== UploadStatus.FAILED && !upload.file.webkitRelativePath.includes('/') - && upload.response?.headers?.['oc-fileid']) + && upload.response?.headers?.['oc-fileid'] + // Only use the last ID if it's in the current folder + && upload.source.replace(folder.source, '').split('/').length === 2) if (lastUpload !== undefined) { + logger.debug('Scrolling to last upload in current folder', { lastUpload }) this.$router.push({ ...this.$route, params: { diff --git a/apps/files/src/components/FileEntryMixin.ts b/apps/files/src/components/FileEntryMixin.ts index a85a033b916..3c4b0c5d1bd 100644 --- a/apps/files/src/components/FileEntryMixin.ts +++ b/apps/files/src/components/FileEntryMixin.ts @@ -329,7 +329,7 @@ export default defineComponent({ // If another button is pressed, cancel it. This // allows cancelling the drag with the right click. - if (!this.canDrop || event.button !== 0) { + if (!this.canDrop || event.button) { return } diff --git a/apps/files/src/services/DropService.ts b/apps/files/src/services/DropService.ts index e5f806d9f0b..d3711741753 100644 --- a/apps/files/src/services/DropService.ts +++ b/apps/files/src/services/DropService.ts @@ -22,186 +22,21 @@ */ import type { Upload } from '@nextcloud/upload' -import type { FileStat, ResponseDataDetailed } from 'webdav' +import type { RootDirectory } from './DropServiceUtils' -import { emit } from '@nextcloud/event-bus' -import { Folder, Node, NodeStatus, davGetClient, davGetDefaultPropfind, davResultToNode, davRootPath } from '@nextcloud/files' -import { getUploader, hasConflict, openConflictPicker } from '@nextcloud/upload' +import { Folder, Node, NodeStatus, davRootPath } from '@nextcloud/files' +import { getUploader, hasConflict } from '@nextcloud/upload' import { join } from 'path' import { joinPaths } from '@nextcloud/paths' import { showError, showInfo, showSuccess, showWarning } from '@nextcloud/dialogs' import { translate as t } from '@nextcloud/l10n' import Vue from 'vue' +import { Directory, traverseTree, resolveConflict, createDirectoryIfNotExists } from './DropServiceUtils' import { handleCopyMoveNodeTo } from '../actions/moveOrCopyAction' import { MoveCopyAction } from '../actions/moveOrCopyActionUtils' import logger from '../logger.js' -/** - * This represents a Directory in the file tree - * We extend the File class to better handling uploading - * and stay as close as possible as the Filesystem API. - * This also allow us to hijack the size or lastModified - * properties to compute them dynamically. - */ -class Directory extends File { - - /* eslint-disable no-use-before-define */ - _contents: (Directory|File)[] - - constructor(name, contents: (Directory|File)[] = []) { - super([], name, { type: 'httpd/unix-directory' }) - this._contents = contents - } - - set contents(contents: (Directory|File)[]) { - this._contents = contents - } - - get contents(): (Directory|File)[] { - return this._contents - } - - get size() { - return this._computeDirectorySize(this) - } - - get lastModified() { - if (this._contents.length === 0) { - return Date.now() - } - return this._computeDirectoryMtime(this) - } - - /** - * Get the last modification time of a file tree - * This is not perfect, but will get us a pretty good approximation - * @param directory the directory to traverse - */ - _computeDirectoryMtime(directory: Directory): number { - return directory.contents.reduce((acc, file) => { - return file.lastModified > acc - // If the file is a directory, the lastModified will - // also return the results of its _computeDirectoryMtime method - // Fancy recursion, huh? - ? file.lastModified - : acc - }, 0) - } - - /** - * Get the size of a file tree - * @param directory the directory to traverse - */ - _computeDirectorySize(directory: Directory): number { - return directory.contents.reduce((acc: number, entry: Directory|File) => { - // If the file is a directory, the size will - // also return the results of its _computeDirectorySize method - // Fancy recursion, huh? - return acc + entry.size - }, 0) - } - -} - -type RootDirectory = Directory & { - name: 'root' -} - -/** - * Traverse a file tree using the Filesystem API - * @param entry the entry to traverse - */ -const traverseTree = async (entry: FileSystemEntry): Promise => { - // Handle file - if (entry.isFile) { - return new Promise((resolve, reject) => { - (entry as FileSystemFileEntry).file(resolve, reject) - }) - } - - // Handle directory - logger.debug('Handling recursive file tree', { entry: entry.name }) - const directory = entry as FileSystemDirectoryEntry - const entries = await readDirectory(directory) - const contents = (await Promise.all(entries.map(traverseTree))).flat() - return new Directory(directory.name, contents) -} - -/** - * Read a directory using Filesystem API - * @param directory the directory to read - */ -const readDirectory = (directory: FileSystemDirectoryEntry): Promise => { - const dirReader = directory.createReader() - - return new Promise((resolve, reject) => { - const entries = [] as FileSystemEntry[] - const getEntries = () => { - dirReader.readEntries((results) => { - if (results.length) { - entries.push(...results) - getEntries() - } else { - resolve(entries) - } - }, (error) => { - reject(error) - }) - } - - getEntries() - }) -} - -const createDirectoryIfNotExists = async (absolutePath: string) => { - const davClient = davGetClient() - const dirExists = await davClient.exists(absolutePath) - if (!dirExists) { - logger.debug('Directory does not exist, creating it', { absolutePath }) - await davClient.createDirectory(absolutePath, { recursive: true }) - const stat = await davClient.stat(absolutePath, { details: true, data: davGetDefaultPropfind() }) as ResponseDataDetailed - emit('files:node:created', davResultToNode(stat.data)) - } -} - -const resolveConflict = async (files: Array, destination: Folder, contents: Node[]): Promise => { - try { - // List all conflicting files - const conflicts = files.filter((file: File|Node) => { - return contents.find((node: Node) => node.basename === (file instanceof File ? file.name : file.basename)) - }).filter(Boolean) as (File|Node)[] - - // List of incoming files that are NOT in conflict - const uploads = files.filter((file: File|Node) => { - return !conflicts.includes(file) - }) - - // Let the user choose what to do with the conflicting files - const { selected, renamed } = await openConflictPicker(destination.path, conflicts, contents) - - logger.debug('Conflict resolution', { uploads, selected, renamed }) - - // If the user selected nothing, we cancel the upload - if (selected.length === 0 && renamed.length === 0) { - // User skipped - showInfo(t('files', 'Conflicts resolution skipped')) - logger.info('User skipped the conflict resolution') - return [] - } - - // Update the list of files to upload - return [...uploads, ...selected, ...renamed] as (typeof files) - } catch (error) { - console.error(error) - // User cancelled - showError(t('files', 'Upload cancelled')) - logger.error('User cancelled the upload') - } - - return [] -} - /** * This function converts a list of DataTransferItems to a file tree. * It uses the Filesystem API if available, otherwise it falls back to the File API. @@ -225,7 +60,7 @@ export const dataTransferToFileTree = async (items: DataTransferItem[]): Promise }).map((item) => { // MDN recommends to try both, as it might be renamed in the future return (item as unknown as { getAsEntry?: () => FileSystemEntry|undefined })?.getAsEntry?.() - ?? item.webkitGetAsEntry() + ?? item?.webkitGetAsEntry?.() ?? item }) as (FileSystemEntry | DataTransferItem)[] @@ -249,7 +84,8 @@ export const dataTransferToFileTree = async (items: DataTransferItem[]): Promise // we therefore cannot upload directories recursively. if (file.type === 'httpd/unix-directory' || !file.type) { if (!warned) { - showWarning(t('files', 'Your browser does not support the Filesystem API. Directories will not be uploaded.')) + logger.warn('Browser does not support Filesystem API. Directories will not be uploaded') + showWarning(t('files', 'Your browser does not support the Filesystem API. Directories will not be uploaded')) warned = true } continue diff --git a/apps/files/src/services/DropServiceUtils.spec.ts b/apps/files/src/services/DropServiceUtils.spec.ts new file mode 100644 index 00000000000..1502d83d9ce --- /dev/null +++ b/apps/files/src/services/DropServiceUtils.spec.ts @@ -0,0 +1,142 @@ +import { describe, it, expect } from '@jest/globals' + +import { FileSystemDirectoryEntry, FileSystemFileEntry, fileSystemEntryToDataTransferItem, DataTransferItem as DataTransferItemMock } from '../../../../__tests__/FileSystemAPIUtils' +import { join } from 'node:path' +import { Directory, traverseTree } from './DropServiceUtils' +import { dataTransferToFileTree } from './DropService' +import logger from '../logger' + +const dataTree = { + 'file0.txt': ['Hello, world!', 1234567890], + dir1: { + 'file1.txt': ['Hello, world!', 4567891230], + 'file2.txt': ['Hello, world!', 7891234560], + }, + dir2: { + 'file3.txt': ['Hello, world!', 1234567890], + }, +} + +// This is mocking a file tree using the FileSystem API +const buildFileSystemDirectoryEntry = (path: string, tree: any): FileSystemDirectoryEntry => { + const entries = Object.entries(tree).map(([name, contents]) => { + const fullPath = join(path, name) + if (Array.isArray(contents)) { + return new FileSystemFileEntry(fullPath, contents[0], contents[1]) + } else { + return buildFileSystemDirectoryEntry(fullPath, contents) + } + }) + return new FileSystemDirectoryEntry(path, entries) +} + +const buildDataTransferItemArray = (path: string, tree: any, isFileSystemAPIAvailable = true): DataTransferItemMock[] => { + return Object.entries(tree).map(([name, contents]) => { + const fullPath = join(path, name) + if (Array.isArray(contents)) { + const entry = new FileSystemFileEntry(fullPath, contents[0], contents[1]) + return fileSystemEntryToDataTransferItem(entry, isFileSystemAPIAvailable) + } + + const entry = buildFileSystemDirectoryEntry(fullPath, contents) + return fileSystemEntryToDataTransferItem(entry, isFileSystemAPIAvailable) + }) +} + +describe('Filesystem API traverseTree', () => { + it('Should traverse a file tree from root', async () => { + // Fake a FileSystemEntry tree + const root = buildFileSystemDirectoryEntry('root', dataTree) + const tree = await traverseTree(root as unknown as FileSystemEntry) as Directory + + expect(tree.name).toBe('root') + expect(tree).toBeInstanceOf(Directory) + expect(tree.contents).toHaveLength(3) + expect(tree.size).toBe(13 * 4) // 13 bytes from 'Hello, world!' + }) + + it('Should traverse a file tree from a subdirectory', async () => { + // Fake a FileSystemEntry tree + const dir2 = buildFileSystemDirectoryEntry('dir2', dataTree.dir2) + const tree = await traverseTree(dir2 as unknown as FileSystemEntry) as Directory + + expect(tree.name).toBe('dir2') + expect(tree).toBeInstanceOf(Directory) + expect(tree.contents).toHaveLength(1) + expect(tree.contents[0].name).toBe('file3.txt') + expect(tree.size).toBe(13) // 13 bytes from 'Hello, world!' + }) + + it('Should properly compute the last modified', async () => { + // Fake a FileSystemEntry tree + const root = buildFileSystemDirectoryEntry('root', dataTree) + const rootTree = await traverseTree(root as unknown as FileSystemEntry) as Directory + + expect(rootTree.lastModified).toBe(7891234560) + + // Fake a FileSystemEntry tree + const dir2 = buildFileSystemDirectoryEntry('root', dataTree.dir2) + const dir2Tree = await traverseTree(dir2 as unknown as FileSystemEntry) as Directory + expect(dir2Tree.lastModified).toBe(1234567890) + }) +}) + +describe('DropService dataTransferToFileTree', () => { + + beforeAll(() => { + // DataTransferItem doesn't exists in jsdom, let's mock + // a dumb one so we can check the instanceof + // @ts-expect-error jsdom doesn't have DataTransferItem + window.DataTransferItem = DataTransferItemMock + }) + + afterAll(() => { + // @ts-expect-error jsdom doesn't have DataTransferItem + delete window.DataTransferItem + }) + + it('Should return a RootDirectory with Filesystem API', async () => { + jest.spyOn(logger, 'error').mockImplementation(() => jest.fn()) + jest.spyOn(logger, 'warn').mockImplementation(() => jest.fn()) + + const dataTransferItems = buildDataTransferItemArray('root', dataTree) + const fileTree = await dataTransferToFileTree(dataTransferItems as unknown as DataTransferItem[]) + + expect(fileTree.name).toBe('root') + expect(fileTree).toBeInstanceOf(Directory) + expect(fileTree.contents).toHaveLength(3) + + // The file tree should be recursive when using the Filesystem API + expect(fileTree.contents[1]).toBeInstanceOf(Directory) + expect((fileTree.contents[1] as Directory).contents).toHaveLength(2) + expect(fileTree.contents[2]).toBeInstanceOf(Directory) + expect((fileTree.contents[2] as Directory).contents).toHaveLength(1) + + expect(logger.error).not.toBeCalled() + expect(logger.warn).not.toBeCalled() + }) + + it('Should return a RootDirectory with legacy File API ignoring recursive directories', async () => { + jest.spyOn(logger, 'error').mockImplementation(() => jest.fn()) + jest.spyOn(logger, 'warn').mockImplementation(() => jest.fn()) + + const dataTransferItems = buildDataTransferItemArray('root', dataTree, false) + + const fileTree = await dataTransferToFileTree(dataTransferItems as unknown as DataTransferItem[]) + + expect(fileTree.name).toBe('root') + expect(fileTree).toBeInstanceOf(Directory) + expect(fileTree.contents).toHaveLength(1) + + // The file tree should be recursive when using the Filesystem API + expect(fileTree.contents[0]).not.toBeInstanceOf(Directory) + expect((fileTree.contents[0].name)).toBe('file0.txt') + + expect(logger.error).not.toBeCalled() + expect(logger.warn).toHaveBeenNthCalledWith(1, 'Could not get FilesystemEntry of item, falling back to file') + expect(logger.warn).toHaveBeenNthCalledWith(2, 'Could not get FilesystemEntry of item, falling back to file') + expect(logger.warn).toHaveBeenNthCalledWith(3, 'Browser does not support Filesystem API. Directories will not be uploaded') + expect(logger.warn).toHaveBeenNthCalledWith(4, 'Could not get FilesystemEntry of item, falling back to file') + expect(logger.warn).toHaveBeenCalledTimes(4) + }) +}) diff --git a/apps/files/src/services/DropServiceUtils.ts b/apps/files/src/services/DropServiceUtils.ts new file mode 100644 index 00000000000..6fd051f9dae --- /dev/null +++ b/apps/files/src/services/DropServiceUtils.ts @@ -0,0 +1,195 @@ +/** + * @copyright Copyright (c) 2024 John Molakvoæ + * + * @author John Molakvoæ + * + * @license AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ +import type { FileStat, ResponseDataDetailed } from 'webdav' + +import { emit } from '@nextcloud/event-bus' +import { Folder, Node, davGetClient, davGetDefaultPropfind, davResultToNode } from '@nextcloud/files' +import { openConflictPicker } from '@nextcloud/upload' +import { showError, showInfo } from '@nextcloud/dialogs' +import { translate as t } from '@nextcloud/l10n' + +import logger from '../logger.js' + +/** + * This represents a Directory in the file tree + * We extend the File class to better handling uploading + * and stay as close as possible as the Filesystem API. + * This also allow us to hijack the size or lastModified + * properties to compute them dynamically. + */ +export class Directory extends File { + + /* eslint-disable no-use-before-define */ + _contents: (Directory|File)[] + + constructor(name, contents: (Directory|File)[] = []) { + super([], name, { type: 'httpd/unix-directory' }) + this._contents = contents + } + + set contents(contents: (Directory|File)[]) { + this._contents = contents + } + + get contents(): (Directory|File)[] { + return this._contents + } + + get size() { + return this._computeDirectorySize(this) + } + + get lastModified() { + if (this._contents.length === 0) { + return Date.now() + } + return this._computeDirectoryMtime(this) + } + + /** + * Get the last modification time of a file tree + * This is not perfect, but will get us a pretty good approximation + * @param directory the directory to traverse + */ + _computeDirectoryMtime(directory: Directory): number { + return directory.contents.reduce((acc, file) => { + return file.lastModified > acc + // If the file is a directory, the lastModified will + // also return the results of its _computeDirectoryMtime method + // Fancy recursion, huh? + ? file.lastModified + : acc + }, 0) + } + + /** + * Get the size of a file tree + * @param directory the directory to traverse + */ + _computeDirectorySize(directory: Directory): number { + return directory.contents.reduce((acc: number, entry: Directory|File) => { + // If the file is a directory, the size will + // also return the results of its _computeDirectorySize method + // Fancy recursion, huh? + return acc + entry.size + }, 0) + } + +} + +export type RootDirectory = Directory & { + name: 'root' +} + +/** + * Traverse a file tree using the Filesystem API + * @param entry the entry to traverse + */ +export const traverseTree = async (entry: FileSystemEntry): Promise => { + // Handle file + if (entry.isFile) { + return new Promise((resolve, reject) => { + (entry as FileSystemFileEntry).file(resolve, reject) + }) + } + + // Handle directory + logger.debug('Handling recursive file tree', { entry: entry.name }) + const directory = entry as FileSystemDirectoryEntry + const entries = await readDirectory(directory) + const contents = (await Promise.all(entries.map(traverseTree))).flat() + return new Directory(directory.name, contents) +} + +/** + * Read a directory using Filesystem API + * @param directory the directory to read + */ +const readDirectory = (directory: FileSystemDirectoryEntry): Promise => { + const dirReader = directory.createReader() + + return new Promise((resolve, reject) => { + const entries = [] as FileSystemEntry[] + const getEntries = () => { + dirReader.readEntries((results) => { + if (results.length) { + entries.push(...results) + getEntries() + } else { + resolve(entries) + } + }, (error) => { + reject(error) + }) + } + + getEntries() + }) +} + +export const createDirectoryIfNotExists = async (absolutePath: string) => { + const davClient = davGetClient() + const dirExists = await davClient.exists(absolutePath) + if (!dirExists) { + logger.debug('Directory does not exist, creating it', { absolutePath }) + await davClient.createDirectory(absolutePath, { recursive: true }) + const stat = await davClient.stat(absolutePath, { details: true, data: davGetDefaultPropfind() }) as ResponseDataDetailed + emit('files:node:created', davResultToNode(stat.data)) + } +} + +export const resolveConflict = async (files: Array, destination: Folder, contents: Node[]): Promise => { + try { + // List all conflicting files + const conflicts = files.filter((file: File|Node) => { + return contents.find((node: Node) => node.basename === (file instanceof File ? file.name : file.basename)) + }).filter(Boolean) as (File|Node)[] + + // List of incoming files that are NOT in conflict + const uploads = files.filter((file: File|Node) => { + return !conflicts.includes(file) + }) + + // Let the user choose what to do with the conflicting files + const { selected, renamed } = await openConflictPicker(destination.path, conflicts, contents) + + logger.debug('Conflict resolution', { uploads, selected, renamed }) + + // If the user selected nothing, we cancel the upload + if (selected.length === 0 && renamed.length === 0) { + // User skipped + showInfo(t('files', 'Conflicts resolution skipped')) + logger.info('User skipped the conflict resolution') + return [] + } + + // Update the list of files to upload + return [...uploads, ...selected, ...renamed] as (typeof files) + } catch (error) { + console.error(error) + // User cancelled + showError(t('files', 'Upload cancelled')) + logger.error('User cancelled the upload') + } + + return [] +} diff --git a/cypress/e2e/files/FileSystemAPIUtils.ts b/cypress/e2e/files/FileSystemAPIUtils.ts deleted file mode 100644 index 995aef2ced5..00000000000 --- a/cypress/e2e/files/FileSystemAPIUtils.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { basename } from 'node:path' - -class FileSystemEntry { - - private _isFile: boolean - private _fullPath: string - - constructor(isFile: boolean, fullPath: string) { - this._isFile = isFile - this._fullPath = fullPath - } - - get isFile() { - return !!this._isFile - } - - get isDirectory() { - return !this.isFile - } - - get name() { - return basename(this._fullPath) - } - -} - -export class FileSystemFileEntry extends FileSystemEntry { - - private _contents: string - - constructor(fullPath: string, contents: string) { - super(true, fullPath) - this._contents = contents - } - - file(success: (file: File) => void) { - success(new File([this._contents], this.name)) - } - -} - -export class FileSystemDirectoryEntry extends FileSystemEntry { - - private _entries: FileSystemEntry[] - - constructor(fullPath: string, entries: FileSystemEntry[]) { - super(false, fullPath) - this._entries = entries || [] - } - - createReader() { - return { - readEntries: (success: (entries: FileSystemEntry[]) => void) => { - success(this._entries) - }, - } - } - -} diff --git a/cypress/e2e/files/drag-n-drop.cy.ts b/cypress/e2e/files/drag-n-drop.cy.ts index 4474f5b634c..86a3bcfb571 100644 --- a/cypress/e2e/files/drag-n-drop.cy.ts +++ b/cypress/e2e/files/drag-n-drop.cy.ts @@ -1,6 +1,6 @@ import { getRowForFile } from './FilesUtils.ts' -describe('files: Drag and Drop legacy', { testIsolation: true }, () => { +describe('files: Drag and Drop', { testIsolation: true }, () => { beforeEach(() => { cy.createRandomUser().then((user) => { cy.login(user) diff --git a/jest.config.ts b/jest.config.ts index f18589bce51..68dd68a36ce 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -26,18 +26,25 @@ const ignorePatterns = [ '@buttercup/fetch', '@juliushaertl', '@mdi/svg', + '@nextcloud/upload', '@nextcloud/vue', 'ansi-regex', 'camelcase', 'char-regex', 'hot-patcher', 'is-svg', + 'mime', + 'p-cancelable', + 'p-limit', + 'p-queue', + 'p-timeout', 'splitpanes', 'string-length', 'strip-ansi', 'tributejs', 'vue-material-design-icons', 'webdav', + 'yocto-queue', ] const config: Config = { diff --git a/package-lock.json b/package-lock.json index cdd69ca440d..2dd1e215c32 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,7 +29,7 @@ "@nextcloud/paths": "^2.1.0", "@nextcloud/router": "^2.2.1", "@nextcloud/sharing": "^0.1.0", - "@nextcloud/upload": "^1.0.5", + "@nextcloud/upload": "^1.1.0", "@nextcloud/vue": "^8.10.0", "@skjnldsv/sanitize-svg": "^1.0.2", "@vueuse/components": "^10.7.2", @@ -143,6 +143,7 @@ "karma-jasmine-sinon": "^1.0.4", "karma-spec-reporter": "^0.0.36", "karma-viewport": "^1.0.9", + "mime": "^4.0.1", "node-polyfill-webpack-plugin": "^2.0.1", "puppeteer": "^21.4.1", "raw-loader": "^4.0.2", @@ -1952,9 +1953,9 @@ "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==" }, "node_modules/@buttercup/fetch": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/@buttercup/fetch/-/fetch-0.1.2.tgz", - "integrity": "sha512-mDBtsysQ0Gnrp4FamlRJGpu7HUHwbyLC4uUav1I7QAqThFAa/4d1cdZCxrV5gKvh6zO1fu95bILNJi4Y2hALhQ==", + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@buttercup/fetch/-/fetch-0.2.1.tgz", + "integrity": "sha512-sCgECOx8wiqY8NN1xN22BqqKzXYIG2AicNLlakOAI4f0WgyLVUbAigMf8CZhBtJxdudTcB1gD5lciqi44jwJvg==", "optionalDependencies": { "node-fetch": "^3.3.0" } @@ -3817,30 +3818,31 @@ } }, "node_modules/@nextcloud/dialogs": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/@nextcloud/dialogs/-/dialogs-5.1.2.tgz", - "integrity": "sha512-JhWUdjjJwjY2K2O2d5CMgcSn+46RMt28Uv1ToFpm1lcdwP7swOp7u9tE8P4p1vA7hZBOKgRGEuTWI/qz/3dxHQ==", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@nextcloud/dialogs/-/dialogs-5.2.0.tgz", + "integrity": "sha512-+nO9/obNXGZUc0AJzzGbK4kniJborfbTeohN17owffFHGHB5TzDE0P1wiMimM3ki4Itfx+9aYuHyMCbv+43E1Q==", "dependencies": { "@mdi/js": "^7.4.47", + "@nextcloud/auth": "^2.2.1", "@nextcloud/axios": "^2.4.0", "@nextcloud/event-bus": "^3.1.0", "@nextcloud/files": "^3.1.0", "@nextcloud/initial-state": "^2.1.0", "@nextcloud/l10n": "^2.2.0", "@nextcloud/router": "^3.0.0", - "@nextcloud/typings": "^1.7.0", + "@nextcloud/typings": "^1.8.0", "@types/toastify-js": "^1.12.3", - "@vueuse/core": "^10.7.2", + "@vueuse/core": "^10.9.0", "toastify-js": "^1.12.0", "vue-frag": "^1.4.3", - "webdav": "^5.3.2" + "webdav": "^5.4.0" }, "engines": { "node": "^20.0.0", "npm": "^10.0.0" }, "peerDependencies": { - "@nextcloud/vue": "^8.2.0", + "@nextcloud/vue": "^8.9.1", "vue": "^2.7.16" } }, @@ -3856,6 +3858,89 @@ "npm": "^10.0.0" } }, + "node_modules/@nextcloud/dialogs/node_modules/@vueuse/core": { + "version": "10.9.0", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-10.9.0.tgz", + "integrity": "sha512-/1vjTol8SXnx6xewDEKfS0Ra//ncg4Hb0DaZiwKf7drgfMsKFExQ+FnnENcN6efPen+1kIzhLQoGSy0eDUVOMg==", + "dependencies": { + "@types/web-bluetooth": "^0.0.20", + "@vueuse/metadata": "10.9.0", + "@vueuse/shared": "10.9.0", + "vue-demi": ">=0.14.7" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@nextcloud/dialogs/node_modules/@vueuse/core/node_modules/vue-demi": { + "version": "0.14.7", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.7.tgz", + "integrity": "sha512-EOG8KXDQNwkJILkx/gPcoL/7vH+hORoBaKgGe+6W7VFMvCYJfmF2dGbvgDroVnI8LU7/kTu8mbjRZGBU1z9NTA==", + "hasInstallScript": true, + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/@nextcloud/dialogs/node_modules/@vueuse/metadata": { + "version": "10.9.0", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-10.9.0.tgz", + "integrity": "sha512-iddNbg3yZM0X7qFY2sAotomgdHK7YJ6sKUvQqbvwnf7TmaVPxS4EJydcNsVejNdS8iWCtDk+fYXr7E32nyTnGA==", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@nextcloud/dialogs/node_modules/@vueuse/shared": { + "version": "10.9.0", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-10.9.0.tgz", + "integrity": "sha512-Uud2IWncmAfJvRaFYzv5OHDli+FbOzxiVEQdLCKQKLyhz94PIyFC3CHcH7EDMwIn8NPtD06+PNbC/PiO0LGLtw==", + "dependencies": { + "vue-demi": ">=0.14.7" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@nextcloud/dialogs/node_modules/@vueuse/shared/node_modules/vue-demi": { + "version": "0.14.7", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.7.tgz", + "integrity": "sha512-EOG8KXDQNwkJILkx/gPcoL/7vH+hORoBaKgGe+6W7VFMvCYJfmF2dGbvgDroVnI8LU7/kTu8mbjRZGBU1z9NTA==", + "hasInstallScript": true, + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, "node_modules/@nextcloud/eslint-config": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/@nextcloud/eslint-config/-/eslint-config-8.3.0.tgz", @@ -3943,23 +4028,35 @@ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" }, "node_modules/@nextcloud/files": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@nextcloud/files/-/files-3.1.0.tgz", - "integrity": "sha512-i0g9L5HRBJ2vr/gXYb0Gtg379u6nYZJFL30W50OV0F0qlf8OtkAlNpfOVOg3sJf9zklARE2lVY9g2Y9sv/iQ3g==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@nextcloud/files/-/files-3.1.1.tgz", + "integrity": "sha512-PwGxh/AcKeDehYSf/L+OpYNzZ2eK5xA1l/lVjufwa7I+u2onCo6qjYSqvc9Dh4Myzixjmt5YiA+Um/gx/Kq4NA==", "dependencies": { "@nextcloud/auth": "^2.2.1", "@nextcloud/l10n": "^2.2.0", "@nextcloud/logger": "^2.7.0", "@nextcloud/paths": "^2.1.0", - "@nextcloud/router": "^2.2.0", + "@nextcloud/router": "^3.0.0", "is-svg": "^5.0.0", - "webdav": "^5.3.1" + "webdav": "^5.4.0" }, "engines": { "node": "^20.0.0", "npm": "^9.0.0" } }, + "node_modules/@nextcloud/files/node_modules/@nextcloud/router": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@nextcloud/router/-/router-3.0.0.tgz", + "integrity": "sha512-RlPrOPw94yT9rmt3+2sUs2cmWzqhX5eFW+i/EHymJEKgURVtnqCcXjIcAiLTfgsCCdAS1hGapBL8j8rhHk1FHQ==", + "dependencies": { + "@nextcloud/typings": "^1.7.0" + }, + "engines": { + "node": "^20.0.0", + "npm": "^10.0.0" + } + }, "node_modules/@nextcloud/initial-state": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@nextcloud/initial-state/-/initial-state-2.1.0.tgz", @@ -3999,14 +4096,12 @@ } }, "node_modules/@nextcloud/moment": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@nextcloud/moment/-/moment-1.2.2.tgz", - "integrity": "sha512-66jJJurd4JdqqlGIpqfxMWOvpG7i6dMibkNCPcpe8i+C+bGSFRMxMe74m1abehcaysj164is4juiT2ikVbZ4yg==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@nextcloud/moment/-/moment-1.3.1.tgz", + "integrity": "sha512-+1CtYlc4Lu4soa1RKXvUsTJdsHS0kHUCzNBtb02BADMY5PMGUTCiCQx5xf1Ez15h2ehuwg0vESr8VyKem9sGAQ==", "dependencies": { "@nextcloud/l10n": "^2.2.0", - "core-js": "^3.21.1", - "jed": "^1.1.1", - "moment": "^2.29.2", + "moment": "^2.30.1", "node-gettext": "^3.0.0" }, "engines": { @@ -4077,33 +4172,34 @@ } }, "node_modules/@nextcloud/typings": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/@nextcloud/typings/-/typings-1.7.0.tgz", - "integrity": "sha512-fK1i09FYTfSUBdXswyiCr8ng5MwdWjEWOF7hRvNvq5i+XFUSmGjSsRmpQZFM2AONroHqGGQBkvQqpONUshFBJQ==", + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@nextcloud/typings/-/typings-1.8.0.tgz", + "integrity": "sha512-q9goE0wc+1BCI9Ku0MebCHmqOMwz2K7ESKQrcHDs6O+HqbKA8zGiEtXL5XGrMS7Ovtl1YOIwxlP9kEvgvXt52Q==", "dependencies": { "@types/jquery": "3.5.16", - "vue": "^2.7.14", + "vue": "^2.7.15", "vue-router": "<4" }, "engines": { - "node": "^16.0.0", - "npm": "^7.0.0 || ^8.0.0" + "node": "^20.0.0", + "npm": "^10.0.0" } }, "node_modules/@nextcloud/upload": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@nextcloud/upload/-/upload-1.0.5.tgz", - "integrity": "sha512-QMojKvnBnxmxiKaFTpFIugaGsVQtjCvOrLdKzpa5IoNhouupI0vrE77aEXZuoOrhUHga9unN1YSA2hY0n8WrOQ==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@nextcloud/upload/-/upload-1.1.0.tgz", + "integrity": "sha512-SRBNKrPWZNMLwCkIiDfSvcDlbGisaliAbUDW0p7D0s4nA1zAG8Xfew87NQxmxNeqVeAM7IP8O83jd5MSPjKYDw==", "dependencies": { "@nextcloud/auth": "^2.2.1", "@nextcloud/axios": "^2.4.0", - "@nextcloud/dialogs": "^5.0.0-beta.6", - "@nextcloud/files": "^3.0.0", + "@nextcloud/dialogs": "^5.2.0", + "@nextcloud/files": "^3.1.1", "@nextcloud/l10n": "^2.2.0", "@nextcloud/logger": "^2.7.0", + "@nextcloud/moment": "^1.3.1", "@nextcloud/paths": "^2.1.0", - "@nextcloud/router": "^2.2.0", - "axios": "^1.6.2", + "@nextcloud/router": "^3.0.0", + "axios": "^1.6.8", "buffer": "^6.0.3", "crypto-browserify": "^3.12.0", "p-cancelable": "^4.0.1", @@ -4120,6 +4216,18 @@ "vue": "^2.7.16" } }, + "node_modules/@nextcloud/upload/node_modules/@nextcloud/router": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@nextcloud/router/-/router-3.0.0.tgz", + "integrity": "sha512-RlPrOPw94yT9rmt3+2sUs2cmWzqhX5eFW+i/EHymJEKgURVtnqCcXjIcAiLTfgsCCdAS1hGapBL8j8rhHk1FHQ==", + "dependencies": { + "@nextcloud/typings": "^1.7.0" + }, + "engines": { + "node": "^20.0.0", + "npm": "^10.0.0" + } + }, "node_modules/@nextcloud/upload/node_modules/eventemitter3": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", @@ -7181,11 +7289,11 @@ } }, "node_modules/axios": { - "version": "1.6.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.2.tgz", - "integrity": "sha512-7i24Ri4pmDRfJTR7LDBhsOTtcm+9kjX5WiY1X3wIisx6G9So3pfMkEiU7emUBe46oceVImccTEM3k6C5dbVW8A==", + "version": "1.6.8", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.8.tgz", + "integrity": "sha512-v/ZHtJDU39mDpyBoFVkETcd/uNdxrWRrg3bKpOKzXFA6Bvqopts6ALSMU3y6ijYxbw2B+wPrIv46egTzJXCLGQ==", "dependencies": { - "follow-redirects": "^1.15.0", + "follow-redirects": "^1.15.6", "form-data": "^4.0.0", "proxy-from-env": "^1.1.0" } @@ -13038,9 +13146,9 @@ } }, "node_modules/follow-redirects": { - "version": "1.15.5", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.5.tgz", - "integrity": "sha512-vSFWUON1B+yAw1VN4xMfxgn5fTUiaOzAJCKBwIIgT/+7CuGy9+r+5gITvP62j3RmaD5Ph65UaERdOSRGUzZtgw==", + "version": "1.15.6", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", + "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==", "funding": [ { "type": "individual", @@ -15364,11 +15472,6 @@ "node": ">=0.1.103" } }, - "node_modules/jed": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/jed/-/jed-1.1.1.tgz", - "integrity": "sha512-z35ZSEcXHxLW4yumw0dF6L464NT36vmx3wxJw8MDpraBcWuNVgUPZgPJKcu1HekNgwlMFNqol7i/IpSbjhqwqA==" - }, "node_modules/jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", @@ -17863,6 +17966,18 @@ "wrap-ansi": "^7.0.0" } }, + "node_modules/karma/node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/karma/node_modules/mkdirp": { "version": "0.5.6", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", @@ -19507,15 +19622,18 @@ "integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==" }, "node_modules/mime": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", - "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/mime/-/mime-4.0.1.tgz", + "integrity": "sha512-5lZ5tyrIfliMXzFtkYyekWbtRXObT9OWa8IwQ5uxTBDHucNNwniRqo0yInflj+iYi5CBa6qxadGzGarDfuEOxA==", "dev": true, + "funding": [ + "https://github.com/sponsors/broofa" + ], "bin": { - "mime": "cli.js" + "mime": "bin/cli.js" }, "engines": { - "node": ">=4.0.0" + "node": ">=16" } }, "node_modules/mime-db": { @@ -19714,9 +19832,9 @@ } }, "node_modules/moment": { - "version": "2.29.4", - "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz", - "integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==", + "version": "2.30.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", + "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", "engines": { "node": "*" } @@ -26616,20 +26734,20 @@ } }, "node_modules/web-streams-polyfill": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.2.1.tgz", - "integrity": "sha512-e0MO3wdXWKrLbL0DgGnUV7WHVuw9OUvL4hjgnPkIeEvESk74gAITi5G606JtZPp39cd8HA9VQzCIvA49LpPN5Q==", + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", "optional": true, "engines": { "node": ">= 8" } }, "node_modules/webdav": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/webdav/-/webdav-5.3.2.tgz", - "integrity": "sha512-wfUh68rccDcH1A9W5gdcAflBm0EOeXrX3LwKbdDLWR0SDFE5QTPfLXPkDit+zGC0tRihCD9qzPfIVEUFoc7MwA==", + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/webdav/-/webdav-5.5.0.tgz", + "integrity": "sha512-SHSDe6n8lBuwwyX+uePB1N1Yn35ebd3locl/LbADMWpcEoowyFdIbnH3fv17T4Jf2tOa1Vwjr/Lld3t0dOio1w==", "dependencies": { - "@buttercup/fetch": "^0.1.1", + "@buttercup/fetch": "^0.2.1", "base-64": "^1.0.0", "byte-length": "^1.0.2", "fast-xml-parser": "^4.2.4", diff --git a/package.json b/package.json index 36b6e0de812..711f0c05453 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,7 @@ "@nextcloud/paths": "^2.1.0", "@nextcloud/router": "^2.2.1", "@nextcloud/sharing": "^0.1.0", - "@nextcloud/upload": "^1.0.5", + "@nextcloud/upload": "^1.1.0", "@nextcloud/vue": "^8.10.0", "@skjnldsv/sanitize-svg": "^1.0.2", "@vueuse/components": "^10.7.2", @@ -170,6 +170,7 @@ "karma-jasmine-sinon": "^1.0.4", "karma-spec-reporter": "^0.0.36", "karma-viewport": "^1.0.9", + "mime": "^4.0.1", "node-polyfill-webpack-plugin": "^2.0.1", "puppeteer": "^21.4.1", "raw-loader": "^4.0.2",