@uppy/core
Version:
Core module for the extensible JavaScript file upload widget with support for drag&drop, resumable uploads, previews, restrictions, file processing/encoding, remote providers like Instagram, Dropbox, Google Drive, S3 and more :dog:
1,761 lines (1,539 loc) • 79.7 kB
text/typescript
import assert from 'node:assert'
import fs from 'node:fs'
import path from 'node:path'
import prettierBytes from '@transloadit/prettier-bytes'
import type { Body, Meta } from '@uppy/core'
import type { Locale } from '@uppy/utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import BasePlugin, {
type DefinePluginOpts,
type PluginOpts,
} from './BasePlugin.js'
import Core from './index.js'
import { debugLogger } from './loggers.js'
import AcquirerPlugin1 from './mocks/acquirerPlugin1.js'
import AcquirerPlugin2 from './mocks/acquirerPlugin2.js'
// @ts-expect-error untyped
import DeepFrozenStore from './mocks/DeepFrozenStore.mjs'
import InvalidPlugin from './mocks/invalidPlugin.js'
import InvalidPluginWithoutId from './mocks/invalidPluginWithoutId.js'
import InvalidPluginWithoutType from './mocks/invalidPluginWithoutType.js'
import { RestrictionError } from './Restricter.js'
import UIPlugin from './UIPlugin.js'
import type { State } from './Uppy.js'
const sampleImage = fs.readFileSync(
path.join(__dirname, '../../compressor/fixtures/image.jpg'),
)
// @ts-expect-error type object can be second argument
const testImage = new File([sampleImage], { type: 'image/jpeg' })
describe('src/Core', () => {
const RealCreateObjectUrl = globalThis.URL.createObjectURL
beforeEach(() => {
globalThis.URL.createObjectURL = vi.fn().mockReturnValue('newUrl')
})
afterEach(() => {
globalThis.URL.createObjectURL = RealCreateObjectUrl
})
it('should expose a class', () => {
const core = new Core()
expect(core.constructor.name).toEqual('Uppy')
})
it('should have a string `id` option that defaults to "uppy"', () => {
const core = new Core()
expect(core.getID()).toEqual('uppy')
const core2 = new Core({ id: 'profile' })
expect(core2.getID()).toEqual('profile')
})
describe('plugins', () => {
it('should add a plugin to the plugin stack', () => {
const core = new Core()
core.use(AcquirerPlugin1)
expect(
// @ts-expect-error untyped
Object.keys(core[Symbol.for('uppy test: getPlugins')]('acquirer'))
.length,
).toEqual(1)
})
it('should be able to .use() without passing generics again', () => {
{
interface TestOpts extends PluginOpts {
foo?: string
bar: string
}
class TestPlugin<M extends Meta, B extends Body> extends BasePlugin<
TestOpts,
M,
B
> {
foo: string
bar: string
constructor(uppy: Core<M, B>, opts: TestOpts) {
super(uppy, opts)
this.id = 'Test'
this.type = 'acquirer'
this.foo = this.opts.foo ?? 'defaultFoo'
this.bar = this.opts.bar
}
}
// @ts-expect-error missing mandatory option foo
new Core().use(TestPlugin)
new Core().use(TestPlugin, { foo: '', bar: '' })
// @ts-expect-error boolean not allowed
new Core().use(TestPlugin, { bar: false })
// @ts-expect-error missing option
new Core().use(TestPlugin, { foo: '' })
}
{
interface TestOpts extends PluginOpts {
foo?: string
bar?: string
}
const defaultOptions = {
foo: 'defaultFoo',
}
class TestPlugin<M extends Meta, B extends Body> extends BasePlugin<
DefinePluginOpts<TestOpts, keyof typeof defaultOptions>,
M,
B
> {
constructor(uppy: Core<M, B>, opts?: TestOpts) {
super(uppy, { ...defaultOptions, ...opts })
this.id = this.opts.id ?? 'Test'
this.type = 'acquirer'
}
}
new Core().use(TestPlugin)
new Core().use(TestPlugin, { foo: '', bar: '' })
new Core().use(TestPlugin, { foo: '' })
new Core().use(TestPlugin, { bar: '' })
// @ts-expect-error boolean not allowed
new Core().use(TestPlugin, { foo: false })
}
})
it('should prevent the same plugin from being added more than once', () => {
const core = new Core()
core.use(AcquirerPlugin1)
expect(() => {
core.use(AcquirerPlugin1)
}).toThrowErrorMatchingSnapshot()
})
it('should not be able to add an invalid plugin', () => {
const core = new Core()
expect(() => {
// @ts-expect-error expected
core.use(InvalidPlugin)
}).toThrowErrorMatchingSnapshot()
})
it('should not be able to add a plugin that has no id', () => {
const core = new Core()
expect(() =>
core.use(InvalidPluginWithoutId),
).toThrowErrorMatchingSnapshot()
})
it('should not be able to add a plugin that has no type', () => {
const core = new Core()
expect(() =>
core.use(InvalidPluginWithoutType),
).toThrowErrorMatchingSnapshot()
})
it('should return the plugin that matches the specified name', () => {
const core = new Core()
expect(core.getPlugin('foo')).toEqual(undefined)
core.use(AcquirerPlugin1)
const plugin = core.getPlugin('TestSelector1')
expect(plugin!.id).toEqual('TestSelector1')
expect(plugin instanceof UIPlugin)
})
it('should call the specified method on all the plugins', () => {
const core = new Core()
core.use(AcquirerPlugin1)
core.use(AcquirerPlugin2)
core.iteratePlugins((plugin) => {
// @ts-ignore
plugin.run('hello')
})
expect(
// @ts-ignore
core[Symbol.for('uppy test: getPlugins')]('acquirer')[0].mocks.run.mock
.calls.length,
).toEqual(1)
expect(
// @ts-ignore
core[Symbol.for('uppy test: getPlugins')]('acquirer')[0].mocks.run.mock
.calls[0],
).toEqual(['hello'])
expect(
// @ts-ignore
core[Symbol.for('uppy test: getPlugins')]('acquirer')[1].mocks.run.mock
.calls.length,
).toEqual(1)
expect(
// @ts-ignore
core[Symbol.for('uppy test: getPlugins')]('acquirer')[1].mocks.run.mock
.calls[0],
).toEqual(['hello'])
})
it('should uninstall and the remove the specified plugin', () => {
const core = new Core()
core.use(AcquirerPlugin1)
core.use(AcquirerPlugin2)
expect(
// @ts-ignore
Object.keys(core[Symbol.for('uppy test: getPlugins')]('acquirer'))
.length,
).toEqual(2)
const plugin = core.getPlugin('TestSelector1')
core.removePlugin(plugin!)
expect(
// @ts-ignore
Object.keys(core[Symbol.for('uppy test: getPlugins')]('acquirer'))
.length,
).toEqual(1)
// @ts-ignore
expect(plugin.mocks.uninstall.mock.calls.length).toEqual(1)
expect(
// @ts-ignore
core[Symbol.for('uppy test: getPlugins')]('acquirer')[0].mocks.run.mock
.calls.length,
).toEqual(0)
})
})
describe('state', () => {
it('should update all the plugins with the new state when the updateAll method is called', () => {
const core = new Core()
core.use(AcquirerPlugin1)
core.use(AcquirerPlugin2)
core.updateAll({ foo: 'bar' })
expect(
// @ts-ignore
core[Symbol.for('uppy test: getPlugins')]('acquirer')[0].mocks.update
.mock.calls.length,
).toEqual(1)
expect(
// @ts-ignore
core[Symbol.for('uppy test: getPlugins')]('acquirer')[0].mocks.update
.mock.calls[0],
).toEqual([{ foo: 'bar' }])
expect(
// @ts-ignore
core[Symbol.for('uppy test: getPlugins')]('acquirer')[1].mocks.update
.mock.calls.length,
).toEqual(1)
expect(
// @ts-ignore
core[Symbol.for('uppy test: getPlugins')]('acquirer')[1].mocks.update
.mock.calls[0],
).toEqual([{ foo: 'bar' }])
})
it('should update the state', () => {
const core = new Core()
const stateUpdateEventMock = vi.fn()
core.on('state-update', stateUpdateEventMock)
core.use(AcquirerPlugin1)
core.use(AcquirerPlugin2)
core.setState({ foo: 'bar', bee: 'boo' })
core.setState({ foo: 'baar' })
const newState = {
bee: 'boo',
capabilities: {
individualCancellation: true,
uploadProgress: true,
resumableUploads: false,
},
files: {},
currentUploads: {},
error: null,
allowNewUpload: true,
foo: 'baar',
info: [],
meta: {},
plugins: {},
totalProgress: 0,
recoveredState: null,
}
expect(core.getState()).toEqual(newState)
expect(
// @ts-ignore
core[Symbol.for('uppy test: getPlugins')]('acquirer')[0].mocks.update
.mock.calls[1],
).toEqual([newState])
expect(
// @ts-ignore
core[Symbol.for('uppy test: getPlugins')]('acquirer')[1].mocks.update
.mock.calls[1],
).toEqual([newState])
expect(stateUpdateEventMock.mock.calls.length).toEqual(2)
// current state
expect(stateUpdateEventMock.mock.calls[1][0]).toEqual({
bee: 'boo',
capabilities: {
individualCancellation: true,
uploadProgress: true,
resumableUploads: false,
},
files: {},
currentUploads: {},
error: null,
allowNewUpload: true,
foo: 'bar',
info: [],
meta: {},
plugins: {},
totalProgress: 0,
recoveredState: null,
})
// new state
expect(stateUpdateEventMock.mock.calls[1][1]).toEqual({
bee: 'boo',
capabilities: {
individualCancellation: true,
uploadProgress: true,
resumableUploads: false,
},
files: {},
currentUploads: {},
error: null,
allowNewUpload: true,
foo: 'baar',
info: [],
meta: {},
plugins: {},
totalProgress: 0,
recoveredState: null,
})
})
it('should get the state', () => {
const core = new Core()
core.setState({ foo: 'bar' })
expect(core.getState()).toMatchObject({ foo: 'bar' })
})
})
it('should cancel all when the `cancelAll` method is called', () => {
// use DeepFrozenStore in some tests to make sure we are not mutating things
const core = new Core({
store: DeepFrozenStore(),
})
// const corePauseEventMock = vi.fn()
const coreCancelEventMock = vi.fn()
const coreStateUpdateEventMock = vi.fn()
core.on('cancel-all', coreCancelEventMock)
core.on('state-update', coreStateUpdateEventMock)
core.setState({ foo: 'bar', totalProgress: 30 })
core.cancelAll()
expect(coreCancelEventMock).toHaveBeenCalledWith(
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
)
expect(coreStateUpdateEventMock.mock.calls.length).toEqual(2)
expect(coreStateUpdateEventMock.mock.calls[1][1]).toEqual({
capabilities: {
individualCancellation: true,
uploadProgress: true,
resumableUploads: false,
},
files: {},
currentUploads: {},
allowNewUpload: true,
error: null,
foo: 'bar',
info: [],
meta: {},
plugins: {},
totalProgress: 0,
recoveredState: null,
})
})
it('should clear all uploads and files on cancelAll()', () => {
const core = new Core()
core.addFile({
source: 'vi',
name: 'foo1.jpg',
type: 'image/jpeg',
data: testImage,
})
core.addFile({
source: 'vi',
name: 'foo2.jpg',
type: 'image/jpeg',
data: testImage,
})
const fileIDs = Object.keys(core.getState().files)
// @ts-ignore
const id = core[Symbol.for('uppy test: createUpload')](fileIDs)
expect(core.getState().currentUploads[id]).toBeDefined()
expect(Object.keys(core.getState().files).length).toEqual(2)
core.cancelAll()
expect(core.getState().currentUploads[id]).toBeUndefined()
expect(Object.keys(core.getState().files).length).toEqual(0)
})
it('should allow remove all uploads when individualCancellation is disabled', () => {
const core = new Core()
const { capabilities } = core.getState()
core.setState({
capabilities: {
...capabilities,
individualCancellation: false,
},
})
core.addFile({
source: 'vi',
name: 'foo1.jpg',
type: 'image/jpeg',
data: testImage,
})
core.addFile({
source: 'vi',
name: 'foo2.jpg',
type: 'image/jpeg',
data: testImage,
})
const fileIDs = Object.keys(core.getState().files)
// @ts-ignore
const id = core[Symbol.for('uppy test: createUpload')](fileIDs)
expect(core.getState().currentUploads[id]).toBeDefined()
expect(Object.keys(core.getState().files).length).toEqual(2)
core.removeFiles(fileIDs)
expect(core.getState().currentUploads[id]).toBeUndefined()
expect(Object.keys(core.getState().files).length).toEqual(0)
})
it('should disallow remove one upload when individualCancellation is disabled', () => {
const core = new Core()
const { capabilities } = core.getState()
core.setState({
capabilities: {
...capabilities,
individualCancellation: false,
},
})
core.addFile({
source: 'vi',
name: 'foo1.jpg',
type: 'image/jpeg',
data: testImage,
})
core.addFile({
source: 'vi',
name: 'foo2.jpg',
type: 'image/jpeg',
data: testImage,
})
const fileIDs = Object.keys(core.getState().files)
// @ts-ignore
const id = core[Symbol.for('uppy test: createUpload')](fileIDs)
expect(core.getState().currentUploads[id]).toBeDefined()
expect(Object.keys(core.getState().files).length).toEqual(2)
assert.throws(
() => core.removeFile(fileIDs[0]),
/The installed uploader plugin does not allow removing files during an upload/,
)
expect(core.getState().currentUploads[id]).toBeDefined()
expect(Object.keys(core.getState().files).length).toEqual(2)
})
it('should allow remove one upload when individualCancellation is enabled', () => {
const core = new Core()
const { capabilities } = core.getState()
core.setState({
capabilities: {
...capabilities,
individualCancellation: true,
},
})
core.addFile({
source: 'vi',
name: 'foo1.jpg',
type: 'image/jpeg',
data: testImage,
})
core.addFile({
source: 'vi',
name: 'foo2.jpg',
type: 'image/jpeg',
data: testImage,
})
const fileIDs = Object.keys(core.getState().files)
// @ts-ignore
const id = core[Symbol.for('uppy test: createUpload')](fileIDs)
expect(core.getState().currentUploads[id]).toBeDefined()
expect(Object.keys(core.getState().files).length).toEqual(2)
core.removeFile(fileIDs[0])
expect(core.getState().currentUploads[id]).toBeDefined()
expect(Object.keys(core.getState().files).length).toEqual(1)
})
it('should close, reset and uninstall when the close method is called', () => {
// use DeepFrozenStore in some tests to make sure we are not mutating things
const core = new Core({
store: DeepFrozenStore(),
})
core.use(AcquirerPlugin1)
const coreCancelEventMock = vi.fn()
const coreStateUpdateEventMock = vi.fn()
// @ts-ignore
const plugin = core[Symbol.for('uppy test: getPlugins')]('acquirer')[0]
core.on('cancel-all', coreCancelEventMock)
core.on('state-update', coreStateUpdateEventMock)
core.destroy()
expect(coreCancelEventMock).toHaveBeenCalledWith(
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
)
expect(coreStateUpdateEventMock.mock.calls.length).toEqual(1)
expect(coreStateUpdateEventMock.mock.calls[0][1]).toEqual({
capabilities: {
individualCancellation: true,
uploadProgress: true,
resumableUploads: false,
},
files: {},
currentUploads: {},
allowNewUpload: true,
error: null,
info: [],
meta: {},
plugins: {},
totalProgress: 0,
recoveredState: null,
})
expect(plugin.mocks.uninstall.mock.calls.length).toEqual(1)
const pluginIteration = vi.fn()
core.iteratePlugins(pluginIteration)
expect(pluginIteration.mock.calls.length).toEqual(0)
})
describe('upload hooks', () => {
it('should add data returned from upload hooks to the .upload() result', () => {
const core = new Core()
core.addPreProcessor((_, uploadID) => {
core.addResultData(uploadID, { pre: 'ok' })
})
core.addPostProcessor((_, uploadID) => {
core.addResultData(uploadID, { post: 'ok' })
})
core.addUploader((_, uploadID) => {
core.addResultData(uploadID, { upload: 'ok' })
})
return core.upload().then((result) => {
if (result) {
expect(result.pre).toBe('ok')
expect(result.upload).toBe('ok')
expect(result.post).toBe('ok')
}
})
})
})
describe('preprocessors', () => {
it('should add and remove preprocessor', () => {
const core = new Core()
const preprocessor = () => {}
expect(core.removePreProcessor(preprocessor)).toBe(false)
core.addPreProcessor(preprocessor)
expect(core.removePreProcessor(preprocessor)).toBe(true)
expect(core.removePreProcessor(preprocessor)).toBe(false)
})
it('should execute all the preprocessors when uploading a file', () => {
const core = new Core()
const preprocessor1 = vi.fn()
const preprocessor2 = vi.fn()
core.addPreProcessor(preprocessor1)
core.addPreProcessor(preprocessor2)
core.addFile({
source: 'vi',
name: 'foo.jpg',
type: 'image/jpeg',
data: testImage,
})
return core.upload().then(() => {
const fileId = Object.keys(core.getState().files)[0]
expect(preprocessor1.mock.calls.length).toEqual(1)
expect(preprocessor1.mock.calls[0][0].length).toEqual(1)
expect(preprocessor1.mock.calls[0][0][0]).toEqual(fileId)
expect(preprocessor2.mock.calls[0][0].length).toEqual(1)
expect(preprocessor2.mock.calls[0][0][0]).toEqual(fileId)
})
})
it('should not pass removed file IDs to next step', async () => {
const core = new Core()
const uploader = vi.fn()
core.addPreProcessor((fileIDs) => {
core.removeFile(fileIDs[0])
})
core.addUploader(uploader)
core.addFile({
source: 'vi',
name: 'rmd.jpg',
type: 'image/jpeg',
data: testImage,
})
core.addFile({
source: 'vi',
name: 'kept.jpg',
type: 'image/jpeg',
data: testImage,
})
await core.upload()
expect(uploader.mock.calls.length).toEqual(1)
expect(uploader.mock.calls[0][0].length).toEqual(1)
expect(core.getFile(uploader.mock.calls[0][0][0]).name).toEqual(
'kept.jpg',
)
})
it('should update the file progress state when preprocess-progress event is fired', () => {
const core = new Core()
core.addFile({
source: 'vi',
name: 'foo.jpg',
type: 'image/jpeg',
data: testImage,
})
const fileId = Object.keys(core.getState().files)[0]
const file = core.getFile(fileId)
core.emit('preprocess-progress', file, {
mode: 'determinate',
message: 'something',
value: 0,
})
expect(core.getFile(fileId).progress).toEqual({
percentage: 0,
bytesUploaded: false,
bytesTotal: 17175,
uploadComplete: false,
uploadStarted: null,
preprocess: { mode: 'determinate', message: 'something', value: 0 },
})
})
it('should update the file progress state when preprocess-complete event is fired', () => {
const core = new Core()
core.addFile({
source: 'vi',
name: 'foo.jpg',
type: 'image/jpeg',
data: testImage,
})
const fileID = Object.keys(core.getState().files)[0]
const file = core.getFile(fileID)
core.emit('preprocess-complete', file, {
mode: 'determinate',
message: 'something',
value: 0,
})
expect(core.getFile(fileID).progress).toEqual({
percentage: 0,
bytesUploaded: false,
bytesTotal: 17175,
uploadComplete: false,
uploadStarted: null,
})
})
})
describe('postprocessors', () => {
it('should add and remove postprocessor', () => {
const core = new Core()
const postprocessor = () => {}
expect(core.removePostProcessor(postprocessor)).toBe(false)
core.addPostProcessor(postprocessor)
expect(core.removePostProcessor(postprocessor)).toBe(true)
expect(core.removePostProcessor(postprocessor)).toBe(false)
})
it('should execute all the postprocessors when uploading a file', () => {
const core = new Core()
const postprocessor1 = vi.fn()
const postprocessor2 = vi.fn()
core.addPostProcessor(postprocessor1)
core.addPostProcessor(postprocessor2)
core.addFile({
source: 'vi',
name: 'foo.jpg',
type: 'image/jpeg',
data: testImage,
})
return core.upload().then(() => {
expect(postprocessor1.mock.calls.length).toEqual(1)
// const lastModifiedTime = new Date()
// const fileId = 'foojpg' + lastModifiedTime.getTime()
const fileId = 'uppy-foo/jpg-1e-image'
expect(postprocessor1.mock.calls[0][0].length).toEqual(1)
expect(postprocessor1.mock.calls[0][0][0].substring(0, 17)).toEqual(
fileId.substring(0, 17),
)
expect(postprocessor2.mock.calls[0][0].length).toEqual(1)
expect(postprocessor2.mock.calls[0][0][0].substring(0, 17)).toEqual(
fileId.substring(0, 17),
)
})
})
it('should update the file progress state when postprocess-progress event is fired', () => {
const core = new Core()
core.addFile({
source: 'vi',
name: 'foo.jpg',
type: 'image/jpeg',
data: testImage,
})
const fileId = Object.keys(core.getState().files)[0]
const file = core.getFile(fileId)
core.emit('postprocess-progress', file, {
mode: 'determinate',
message: 'something',
value: 0,
})
expect(core.getFile(fileId).progress).toEqual({
percentage: 0,
bytesUploaded: false,
bytesTotal: 17175,
uploadComplete: false,
uploadStarted: null,
postprocess: { mode: 'determinate', message: 'something', value: 0 },
})
})
it('should update the file progress state when postprocess-complete event is fired', () => {
const core = new Core()
core.addFile({
source: 'vi',
name: 'foo.jpg',
type: 'image/jpeg',
data: testImage,
})
const fileId = Object.keys(core.getState().files)[0]
const file = core.getFile(fileId)
core.emit('postprocess-complete', file, {
mode: 'determinate',
message: 'something',
value: 0,
})
expect(core.getFile(fileId).progress).toEqual({
percentage: 0,
bytesUploaded: false,
bytesTotal: 17175,
uploadComplete: false,
uploadStarted: null,
})
})
})
describe('uploaders', () => {
it('should add and remove uploader', () => {
const core = new Core()
const uploader = () => {}
expect(core.removeUploader(uploader)).toBe(false)
core.addUploader(uploader)
expect(core.removeUploader(uploader)).toBe(true)
expect(core.removeUploader(uploader)).toBe(false)
})
})
describe('adding a file', () => {
it('should call onBeforeFileAdded if it was specified in the options when initialising the class', () => {
const onBeforeFileAdded = vi.fn()
const core = new Core({
// need to capture a snapshot of files, because files will change in the next tick, thus failing the expect below
onBeforeFileAdded: (file, files) =>
onBeforeFileAdded(file, { ...files }),
})
core.addFile({
source: 'vi',
name: 'foo.jpg',
type: 'image/jpeg',
data: testImage,
})
expect(onBeforeFileAdded.mock.calls.length).toEqual(1)
expect(onBeforeFileAdded.mock.calls[0][0].name).toEqual('foo.jpg')
expect(onBeforeFileAdded.mock.calls[0][1]).toEqual({})
})
it('should allow uploading duplicate file if explicitly allowed in onBeforeFileAdded', async () => {
const core = new Core({ onBeforeFileAdded: () => true })
const sameFileBlob = testImage
core.addFile({
source: 'vi',
name: 'foo.jpg',
type: 'image/jpeg',
data: sameFileBlob,
})
core.addFile({
source: 'vi',
name: 'foo.jpg',
type: 'image/jpeg',
data: sameFileBlob,
})
})
it('should add a file', () => {
const fileData = testImage
const fileAddedEventMock = vi.fn()
const core = new Core()
core.on('file-added', fileAddedEventMock)
const fileId = core.addFile({
source: 'vi',
name: 'foo.jpg',
type: 'image/jpeg',
data: fileData,
})
const newFile = {
extension: 'jpg',
id: fileId,
isRemote: false,
meta: { name: 'foo.jpg', type: 'image/jpeg' },
name: 'foo.jpg',
preview: undefined,
data: fileData,
isGhost: false,
progress: {
bytesTotal: 17175,
bytesUploaded: false,
percentage: 0,
uploadComplete: false,
uploadStarted: null,
},
remote: undefined,
size: 17175,
source: 'vi',
type: 'image/jpeg',
}
expect(core.getFile(fileId)).toEqual(newFile)
expect(fileAddedEventMock.mock.calls[0][0]).toEqual(newFile)
})
it('should add a file from a File object', () => {
const fileData = testImage
const core = new Core()
const fileId = core.addFile(fileData)
expect(core.getFile(fileId).id).toEqual(fileId)
})
it('should not allow a file that does not meet the restrictions', () => {
const core = new Core({
restrictions: {
allowedFileTypes: ['image/gif', 'video/webm'],
},
})
expect(() => {
core.addFile({
source: 'vi',
name: 'foo.jpg',
type: 'image/jpeg',
data: testImage,
})
}).toThrow('You can only upload: image/gif, video/webm')
expect(() => {
core.addFile({
source: 'vi',
name: 'foo.webm',
type: 'video/webm; codecs="vp8, opus"',
// @ts-ignore
data: new File([sampleImage], {
type: 'video/webm; codecs="vp8, opus"',
}),
})
}).not.toThrow()
})
it('should not allow a dupicate file, a file with the same id', () => {
const core = new Core()
const sameFileBlob = testImage
core.addFile({
source: 'vi',
name: 'foo.jpg',
type: 'image/jpeg',
data: sameFileBlob,
})
expect(() => {
core.addFile({
source: 'vi',
name: 'foo.jpg',
type: 'image/jpeg',
data: sameFileBlob,
meta: {
notARelativePath: 'folder/a',
},
})
}).toThrow("Cannot add the duplicate file 'foo.jpg', it already exists")
expect(core.getFiles().length).toEqual(1)
})
it('should allow a duplicate file if its relativePath is different, thus the id is different', () => {
const core = new Core()
core.addFile({
source: 'vi',
name: 'foo.jpg',
type: 'image/jpeg',
data: testImage,
})
core.addFile({
source: 'vi',
name: 'foo.jpg',
type: 'image/jpeg',
data: testImage,
meta: {
relativePath: 'folder/a',
},
})
expect(core.getFiles().length).toEqual(2)
})
it('should not allow a file if onBeforeFileAdded returned false', () => {
const core = new Core({
onBeforeFileAdded: (file) => {
if (file.source === 'vi') {
return false
}
return undefined
},
})
expect(() => {
core.addFile({
source: 'vi',
name: 'foo.jpg',
type: 'image/jpeg',
data: testImage,
})
}).toThrow(
'Cannot add the file because onBeforeFileAdded returned false.',
)
expect(core.getFiles().length).toEqual(0)
})
describe('with allowMultipleUploadBatches: false', () => {
it('allows no new files after upload', async () => {
const core = new Core({ allowMultipleUploadBatches: false })
core.addFile({
source: 'vi',
name: 'foo.jpg',
type: 'image/jpeg',
data: testImage,
})
await core.upload()
expect(() => {
core.addFile({
source: 'vi',
name: '123.foo',
type: 'image/jpeg',
data: testImage,
})
}).toThrow(/Cannot add more files/)
})
it('allows no new files after upload with legacy allowMultipleUploads option', async () => {
const core = new Core({ allowMultipleUploads: false })
core.addFile({
source: 'vi',
name: 'foo.jpg',
type: 'image/jpeg',
data: testImage,
})
await core.upload()
expect(() => {
core.addFile({
source: 'vi',
name: '123.foo',
type: 'image/jpeg',
data: testImage,
})
}).toThrow(/Cannot add more files/)
})
it('does not allow new files after the removeFile() if some file is still present', async () => {
const core = new Core({ allowMultipleUploadBatches: false })
// adding 2 files
const fileId1 = core.addFile({
source: 'vi',
name: '1.jpg',
type: 'image/jpeg',
data: testImage,
})
core.addFile({
source: 'vi',
name: '2.jpg',
type: 'image/jpeg',
data: testImage,
})
// removing 1 file
core.removeFile(fileId1)
await expect(core.upload()).resolves.toBeDefined()
})
it('allows new files after the last removeFile()', async () => {
const core = new Core({ allowMultipleUploadBatches: false })
// adding 2 files
const fileId1 = core.addFile({
source: 'vi',
name: '1.jpg',
type: 'image/jpeg',
data: testImage,
})
const fileId2 = core.addFile({
source: 'vi',
name: '2.jpg',
type: 'image/jpeg',
data: testImage,
})
// removing 2 files
core.removeFile(fileId1)
core.removeFile(fileId2)
await expect(core.upload()).resolves.toBeDefined()
})
})
it('does not dedupe different files', async () => {
const core = new Core()
const data = new Blob([sampleImage], { type: 'image/jpeg' })
// @ts-ignore
data.lastModified = 1562770350937
core.addFile({
source: 'vi',
name: 'foo.jpg',
type: 'image/jpeg',
data,
})
core.addFile({
source: 'vi',
name: 'foo푸.jpg',
type: 'image/jpeg',
data,
})
expect(core.getFiles()).toHaveLength(2)
expect(
core.getFile('uppy-foo/jpg-1e-image/jpeg-17175-1562770350937'),
).toBeDefined()
expect(
core.getFile('uppy-foo//jpg-1l3o-1e-image/jpeg-17175-1562770350937'),
).toBeDefined()
})
})
describe('uploading a file', () => {
it('should return a { successful, failed } pair containing file objects', () => {
const core = new Core()
core.addUploader(() => Promise.resolve())
core.addFile({
source: 'vi',
name: 'foo.jpg',
type: 'image/jpeg',
data: testImage,
})
core.addFile({
source: 'vi',
name: 'bar.jpg',
type: 'image/jpeg',
data: testImage,
})
return expect(core.upload()).resolves.toMatchObject({
successful: [{ name: 'foo.jpg' }, { name: 'bar.jpg' }],
failed: [],
})
})
it('should return files with errors in the { failed } key', () => {
// use DeepFrozenStore in some tests to make sure we are not mutating things
const core = new Core({
store: DeepFrozenStore(),
})
core.addUploader((fileIDs) => {
fileIDs.forEach((fileID) => {
const file = core.getFile(fileID)
if (file.name != null && /bar/.test(file.name)) {
// @ts-ignore
core.emit(
'upload-error',
file,
new Error('This is bar and I do not like bar'),
)
}
})
return Promise.resolve()
})
core.addFile({
source: 'vi',
name: 'foo.jpg',
type: 'image/jpeg',
data: testImage,
})
core.addFile({
source: 'vi',
name: 'bar.jpg',
type: 'image/jpeg',
data: testImage,
})
return expect(core.upload()).resolves.toMatchObject({
successful: [{ name: 'foo.jpg' }],
failed: [
{ name: 'bar.jpg', error: 'This is bar and I do not like bar' },
],
})
})
it('should only upload files that are not already assigned to another upload id', () => {
const core = new Core()
// @ts-ignore
core.store.state.currentUploads = {
// @ts-ignore
upload1: {
fileIDs: [
'uppy-file1/jpg-1e-image/jpeg',
'uppy-file2/jpg-1e-image/jpeg',
'uppy-file3/jpg-1e-image/jpeg',
],
},
// @ts-ignore
upload2: {
fileIDs: [
'uppy-file4/jpg-1e-image/jpeg',
'uppy-file5/jpg-1e-image/jpeg',
'uppy-file6/jpg-1e-image/jpeg',
],
},
}
core.addUploader(() => Promise.resolve())
core.addFile({
source: 'vi',
name: 'foo.jpg',
type: 'image/jpeg',
// @ts-ignore
data: new Uint8Array(),
})
core.addFile({
source: 'vi',
name: 'bar.jpg',
type: 'image/jpeg',
// @ts-ignore
data: new Uint8Array(),
})
core.addFile({
source: 'file3',
name: 'file3.jpg',
type: 'image/jpeg',
// @ts-ignore
data: new Uint8Array(),
})
// uploadID is random, we don't want randomness in the snapshot
return expect(
core
.upload()
.then((r) =>
typeof r!.uploadID === 'string' && r!.uploadID.length === 21
? { ...r, uploadID: 'cjd09qwxb000dlql4tp4doz8h' }
: r,
),
).resolves.toMatchSnapshot()
})
it('should not upload if onBeforeUpload returned false', () => {
const core = new Core({
onBeforeUpload: (files) => {
for (const fileId in files) {
if (files[fileId].name === '123.foo') {
return false
}
}
return files
},
})
core.addFile({
source: 'vi',
name: 'foo.jpg',
type: 'image/jpeg',
data: testImage,
})
core.addFile({
source: 'vi',
name: 'bar.jpg',
type: 'image/jpeg',
data: testImage,
})
core.addFile({
source: 'vi',
name: '123.foo',
type: 'image/jpeg',
data: testImage,
})
return core.upload().catch((err) => {
expect(err.message).toStrictEqual(
'Not starting the upload because onBeforeUpload returned false',
)
})
})
it('only allows a single upload() batch when allowMultipleUploadBatches: false', async () => {
const core = new Core({ allowMultipleUploadBatches: false })
core.addFile({
source: 'vi',
name: 'foo.jpg',
type: 'image/jpeg',
data: testImage,
})
core.addFile({
source: 'vi',
name: 'bar.jpg',
type: 'image/jpeg',
data: testImage,
})
await expect(core.upload()).resolves.toBeDefined()
await expect(core.upload()).rejects.toThrow(
/Cannot create a new upload: already uploading\./,
)
})
it('allows new files again with allowMultipleUploadBatches: false after cancelAll() was called', async () => {
const core = new Core({ allowMultipleUploadBatches: false })
core.addFile({
source: 'vi',
name: 'bar.jpg',
type: 'image/jpeg',
data: testImage,
})
await expect(core.upload()).resolves.toBeDefined()
core.cancelAll()
core.addFile({
source: 'vi',
name: '123.foo',
type: 'image/jpeg',
data: testImage,
})
await expect(core.upload()).resolves.toBeDefined()
})
it('upload() is idempotent when called multiple times', async () => {
const onUpload = vi.fn()
const onRetryAll = vi.fn()
const onUploadError = vi.fn()
const core = new Core()
let hasError = false
core.addUploader((fileIDs) => {
fileIDs.forEach((fileID) => {
const file = core.getFile(fileID)
if (!hasError) {
// @ts-ignore
core.emit('upload-error', file, new Error('foo'))
hasError = true
}
})
return Promise.resolve()
})
core.on('upload', onUpload)
core.on('retry-all', onRetryAll)
core.on('upload-error', onUploadError)
const firstFileID = core.addFile({
source: 'vi',
name: 'foo.jpg',
type: 'image/jpeg',
data: testImage,
})
// First time 'upload' and 'upload-error' should be emitted
await core.upload()
expect(onRetryAll).not.toHaveBeenCalled()
expect(onUpload).toHaveBeenCalled()
expect(onUploadError).toHaveBeenCalled()
// Reset counters
onRetryAll.mockReset()
onUpload.mockReset()
onUploadError.mockReset()
const secondFileID = core.addFile({
source: 'vi',
name: 'bar.jpg',
type: 'image/jpeg',
data: testImage,
})
const onComplete = vi.fn()
core.on('complete', onComplete)
// Second time two uploads should happen back-to-back.
// First to retry the failed files, which will emit events, and the second upload
// for the new files, which also emits events.
const result = await core.upload()
expect(result?.successful?.[0].id).toBe(firstFileID)
expect(result?.successful?.[1].id).toBe(secondFileID)
expect(onRetryAll).toBeCalledTimes(1)
expect(onUpload).toBeCalledTimes(2)
expect(onUploadError).toBeCalledTimes(0)
expect(onComplete).toBeCalledTimes(1)
const retryResult = onRetryAll.mock.calls[0][0]
expect(retryResult.length).toBe(1)
expect(retryResult[0].id).toBe(firstFileID)
const completeResult = onComplete.mock.calls[0][0]
expect(completeResult.successful?.length).toBe(2)
expect(completeResult.failed?.length).toBe(0)
expect(completeResult.successful?.[0].id).toBe(firstFileID)
expect(completeResult.successful?.[1].id).toBe(secondFileID)
})
})
describe('removing a file', () => {
it('should remove the file', () => {
const fileRemovedEventMock = vi.fn()
const core = new Core()
core.on('file-removed', fileRemovedEventMock)
core.addFile({
source: 'vi',
name: 'foo.jpg',
type: 'image/jpeg',
data: testImage,
})
const fileId = Object.keys(core.getState().files)[0]
expect(core.getFiles().length).toEqual(1)
core.setState({
totalProgress: 50,
})
const file = core.getFile(fileId)
core.removeFile(fileId)
expect(core.getFiles().length).toEqual(0)
expect(fileRemovedEventMock.mock.calls[0][0]).toEqual(file)
expect(core.getState().totalProgress).toEqual(0)
})
})
describe('retries', () => {
it('should start a new upload with failed files', async () => {
const onUpload = vi.fn()
const onRetryAll = vi.fn()
const core = new Core()
core.on('upload', onUpload)
core.on('retry-all', onRetryAll)
const id = core.addFile({
source: 'vi',
name: 'foo.jpg',
type: 'image/jpeg',
data: testImage,
})
core.setFileState(id, {
error: 'something went wrong',
})
await core.retryAll()
expect(onRetryAll).toHaveBeenCalled()
expect(onUpload).toHaveBeenCalled()
})
it('should not start a new upload if there are no failed files', async () => {
const onUpload = vi.fn()
const core = new Core()
core.on('upload', onUpload)
core.addFile({
source: 'vi',
name: 'foo.jpg',
type: 'image/jpeg',
data: testImage,
})
await core.retryAll()
expect(onUpload).not.toHaveBeenCalled()
})
describe('with required metadata', () => {
it('should not retry files that have missing required metadata', async () => {
const onUpload = vi.fn()
const onRetryAll = vi.fn()
const core = new Core({
restrictions: {
requiredMetaFields: ['caption'],
},
})
core.on('upload', onUpload)
core.on('retry-all', onRetryAll)
const fileId = core.addFile({
source: 'vi',
name: 'foo.jpg',
type: 'image/jpeg',
data: testImage,
})
// Simulate an upload attempt which triggers metadata validation
try {
await core.upload()
} catch (error) {
expect(error).toBeInstanceOf(RestrictionError)
expect(error.message).toContain('Missing required meta fields')
}
// File should now have missing metadata error after upload attempt
const file = core.getFile(fileId)
expect(file.missingRequiredMetaFields).toEqual(['caption'])
expect(file.error).toContain('Missing required meta fields in foo.jpg')
// Should not retry files with outstanding metadata issues
await core.retryAll()
expect(onRetryAll.mock.calls[0][0]).toEqual([])
expect(onUpload).toHaveBeenCalledTimes(0)
})
it('should retry files after metadata is corrected', async () => {
const onUpload = vi.fn()
const onRetryAll = vi.fn()
const core = new Core({
restrictions: {
requiredMetaFields: ['caption'],
},
})
core.on('upload', onUpload)
core.on('retry-all', onRetryAll)
const fileId = core.addFile({
source: 'vi',
name: 'foo.jpg',
type: 'image/jpeg',
data: testImage,
})
try {
await core.upload()
} catch (error) {
expect(error).toBeInstanceOf(RestrictionError)
expect(error.message).toContain('Missing required meta fields')
}
// Verify file has missing metadata error after upload attempt
const file = core.getFile(fileId)
expect(file.missingRequiredMetaFields).toEqual(['caption'])
expect(file.error).toContain('Missing required meta fields in foo.jpg')
// Fix the metadata
core.setFileMeta(fileId, { caption: 'Test caption' })
// Trigger the dashboard:file-edit-complete event to update validation state
// @ts-ignore
core.emit('dashboard:file-edit-complete', core.getFile(fileId))
const updatedFile = core.getFile(fileId)
expect(updatedFile.missingRequiredMetaFields).toEqual([])
// Now retry should work
await core.retryAll()
expect(onRetryAll.mock.calls[0][0]).toContainEqual(
expect.objectContaining({ id: fileId }),
)
expect(onUpload).toHaveBeenCalledTimes(1) // Called once during retry (initial upload failed at validation)
})
it('should handle multiple files with mixed metadata states', async () => {
const onUpload = vi.fn()
const onRetryAll = vi.fn()
const core = new Core({
restrictions: {
requiredMetaFields: ['caption'],
},
})
core.on('upload', onUpload)
core.on('retry-all', onRetryAll)
// Add files with missing metadata
const fileId1 = core.addFile({
source: 'vi',
name: 'file1.jpg',
type: 'image/jpeg',
data: testImage,
})
const _fileId2 = core.addFile({
source: 'vi',
name: 'file2.jpg',
type: 'image/jpeg',
data: testImage,
})
const fileId3 = core.addFile({
source: 'vi',
name: 'file3.jpg',
type: 'image/jpeg',
data: testImage,
})
try {
await core.upload()
} catch (error) {
expect(error).toBeInstanceOf(RestrictionError)
expect(error.message).toContain('Missing required meta fields')
}
// Give one file a different error (not metadata-related)
core.setFileState(fileId3, {
error: 'Network error',
missingRequiredMetaFields: [],
})
// Fix metadata for first file only
core.setFileMeta(fileId1, { caption: 'Fixed caption' })
// @ts-ignore
core.emit('dashboard:file-edit-complete', core.getFile(fileId1))
// Add an error to file1 so it can be retried
core.setFileState(fileId1, {
error: 'Upload failed',
})
// Retry should only include file1 and file3 (file2 still has missing metadata)
await core.retryAll()
const retriedFiles = onRetryAll.mock.calls[0][0]
expect(retriedFiles).toContainEqual(
expect.objectContaining({ id: fileId1 }),
)
expect(retriedFiles).toContainEqual(
expect.objectContaining({ id: fileId3 }),
)
expect(onUpload).toHaveBeenCalledTimes(1)
})
})
})
describe('restoring a file', () => {
it.skip('should restore a file')
it.skip("should fail to restore a file if it doesn't exist")
})
describe('get a file', () => {
it('should get the specified file', () => {
const core = new Core()
core.addFile({
source: 'vi',
name: 'foo.jpg',
type: 'image/jpeg',
data: testImage,
})
const fileId = Object.keys(core.getState().files)[0]
expect(core.getFile(fileId).name).toEqual('foo.jpg')
expect(core.getFile('non existent file')).toEqual(undefined)
})
})
describe('getFiles', () => {
it('should return an empty array if there are no files', () => {
const core = new Core()
expect(core.getFiles()).toEqual([])
})
it('should return all files as an array', () => {
const core = new Core()
core.addFile({
source: 'vi',
name: 'foo.jpg',
type: 'image/jpeg',
data: testImage,
})
core.addFile({
source: 'vi',
name: 'empty.dat',
type: 'application/octet-stream',
// @ts-ignore
data: new File([new Uint8Array(1000)], {
type: 'application/octet-stream',
}),
})
expect(core.getFiles()).toHaveLength(2)
expect(
core
.getFiles()
.map((file) => file.name)
.sort(),
).toEqual(['empty.dat', 'foo.jpg'])
})
})
describe('setOptions', () => {
it('should change options on the fly', () => {
const core = new Core()
core.setOptions({
id: 'lolUppy',
autoProceed: true,
allowMultipleUploadBatches: true,
})
expect(core.opts.id).toEqual('lolUppy')
expect(core.opts.autoProceed).toEqual(true)
expect(core.opts.allowMultipleUploadBatches).toEqual(true)
})
it('should change locale on the fly', () => {
const core = new Core()
expect(core.i18n('cancel')).toEqual('Cancel')
core.setOptions({
locale: {
strings: {
cancel: 'Отмена',
},
},
})
expect(core.i18n('cancel')).toEqual('Отмена')
expect(core.i18n('logOut')).toEqual('Log out')
})
it('should change meta on the fly', () => {
const core = new Core<{ foo: string; beep: string }, any>({
meta: { foo: 'bar', beep: '' },
})
expect(core.getState().meta).toMatchObject({
foo: 'bar',
})
core.setOptions({
meta: {
beep: 'boop',
},
})
expect(core.getState().meta).toMatchObject({
foo: 'bar',
beep: 'boop',
})
})
it('should change restrictions on the fly', () => {
const fr_FR: Locale<0 | 1> = {
strings: {
youCanOnlyUploadFileTypes:
'Vous pouvez seulement téléverser: %{types}',
},
pluralize(n) {
if (n <= 1) {
return 0
}
return 1
},
}
const core = new Core({
restrictions: {
allowedFileTypes: ['image/jpeg'],
maxNumber