From f3e622819d807cd6441f0dde9a0703daf9f79fac Mon Sep 17 00:00:00 2001 From: Philipp Fritsche Date: Thu, 25 Nov 2021 19:00:49 +0000 Subject: [PATCH] feat(upload)!: replace element properties BREAKING CHANGE: `init` parameter has been removed from `userEvent.upload`. --- src/setup/directApi.ts | 4 +- src/utility/upload.ts | 43 +++-------------- src/utils/dataTransfer/FileList.ts | 18 +++++-- src/utils/edit/setFiles.ts | 77 ++++++++++++++++++++++++++++++ tests/setup.ts | 1 + tests/upload.ts | 23 +++------ tests/utils/edit/setFiles.ts | 69 ++++++++++++++++++++++++++ 7 files changed, 175 insertions(+), 60 deletions(-) create mode 100644 src/utils/edit/setFiles.ts create mode 100644 tests/utils/edit/setFiles.ts diff --git a/src/setup/directApi.ts b/src/setup/directApi.ts index 93590cb0..5c70a269 100644 --- a/src/setup/directApi.ts +++ b/src/setup/directApi.ts @@ -1,5 +1,4 @@ import type {PointerOptions} from '../utils' -import type {uploadInit} from '../utility' import type {PointerInput} from '../pointer' import type {UserEventApi} from '.' import {setupDirect} from './setup' @@ -91,10 +90,9 @@ export function unhover(element: Element, options: PointerOptions = {}) { export function upload( element: HTMLElement, fileOrFiles: File | File[], - init?: uploadInit, options: Partial = {}, ) { - return setupDirect(options).upload(element, fileOrFiles, init) + return setupDirect(options).upload(element, fileOrFiles) } export function tab( diff --git a/src/utility/upload.ts b/src/utility/upload.ts index 31c9b731..82b692ab 100644 --- a/src/utility/upload.ts +++ b/src/utility/upload.ts @@ -1,6 +1,7 @@ -import {fireEvent, createEvent} from '@testing-library/dom' -import {blur, focus, isDisabled, isElementType} from '../utils' +import {fireEvent} from '@testing-library/dom' +import {blur, createFileList, focus, isDisabled, isElementType} from '../utils' import {Config, UserEvent} from '../setup' +import {setFiles} from '#src/utils/edit/setFiles' export interface uploadInit { changeInit?: EventInit @@ -10,11 +11,10 @@ export async function upload( this: UserEvent, element: HTMLElement, fileOrFiles: File | File[], - init?: uploadInit, ) { const input = isElementType(element, 'label') ? element.control : element - if (!input || !isElementType(input, 'input', {type: 'file'})) { + if (!input || !isElementType(input, 'input', {type: 'file' as const})) { throw new TypeError( `The ${input === element ? 'given' : 'associated'} ${ input?.tagName @@ -44,38 +44,9 @@ export async function upload( return } - // the event fired in the browser isn't actually an "input" or "change" event - // but a new Event with a type set to "input" and "change" - // Kinda odd... - const inputFiles: FileList & Iterable = { - ...files, - length: files.length, - item: (index: number) => files[index], - [Symbol.iterator]() { - let i = 0 - return { - next: () => ({ - done: i >= files.length, - value: files[i++], - }), - } - }, - } - - fireEvent( - input, - createEvent('input', input, { - target: {files: inputFiles}, - bubbles: true, - cancelable: false, - composed: true, - }), - ) - - fireEvent.change(input, { - target: {files: inputFiles}, - ...init?.changeInit, - }) + setFiles(input, createFileList(files)) + fireEvent.input(input) + fireEvent.change(input) } function isAcceptableFile(file: File, accept: string) { diff --git a/src/utils/dataTransfer/FileList.ts b/src/utils/dataTransfer/FileList.ts index 9484cabd..1de62055 100644 --- a/src/utils/dataTransfer/FileList.ts +++ b/src/utils/dataTransfer/FileList.ts @@ -1,9 +1,19 @@ // FileList can not be created per constructor. export function createFileList(files: File[]): FileList { - const f = [...files] + const list: FileList & Iterable = { + ...files, + length: files.length, + item: (index: number) => list[index], + [Symbol.iterator]: function* nextFile() { + for (let i = 0; i < list.length; i++) { + yield list[i] + } + }, + } + list.constructor = FileList + Object.setPrototypeOf(list, FileList.prototype) + Object.freeze(list) - Object.setPrototypeOf(f, FileList.prototype) - - return f as unknown as FileList + return list } diff --git a/src/utils/edit/setFiles.ts b/src/utils/edit/setFiles.ts new file mode 100644 index 00000000..2a43ce10 --- /dev/null +++ b/src/utils/edit/setFiles.ts @@ -0,0 +1,77 @@ +// It is not possible to create a real FileList programmatically. +// Therefore assigning `files` property with a programmatically created FileList results in an error. +// Just assigning the property (as per fireEvent) breaks the interweaving with the `value` property. + +const fakeFiles = Symbol('files and value properties are mocked') + +declare global { + interface HTMLInputElement { + [fakeFiles]?: { + restore: () => void + } + } +} + +export function setFiles( + el: HTMLInputElement & {type: 'file'}, + files: FileList, +) { + el[fakeFiles]?.restore() + + const objectDescriptors = Object.getOwnPropertyDescriptors(el) + const prototypeDescriptors = Object.getOwnPropertyDescriptors( + Object.getPrototypeOf(el), + ) + + function restore() { + Object.defineProperties(el, { + files: { + ...prototypeDescriptors.files, + ...objectDescriptors.files, + }, + value: { + ...prototypeDescriptors.value, + ...objectDescriptors.value, + }, + type: { + ...prototypeDescriptors.type, + ...objectDescriptors.type, + }, + }) + } + el[fakeFiles] = {restore} + + Object.defineProperties(el, { + files: { + ...prototypeDescriptors.files, + ...objectDescriptors.files, + get: () => files, + }, + value: { + ...prototypeDescriptors.value, + ...objectDescriptors.value, + get: () => (files.length ? `C:\\fakepath\\${files[0].name}` : ''), + set(v: string) { + if (v === '') { + restore() + } else { + objectDescriptors.value.set?.call(el, v) + } + }, + }, + // eslint-disable-next-line accessor-pairs + type: { + ...prototypeDescriptors.type, + ...objectDescriptors.type, + set(v: string) { + if (v !== 'file') { + restore() + // In the browser the value will be empty. + // In Jsdom the value will be the same as + // before this element became file input - which might be empty. + ;(el as HTMLInputElement).type = v + } + }, + }, + }) +} diff --git a/tests/setup.ts b/tests/setup.ts index 03c5e5fa..7de86d61 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -203,6 +203,7 @@ cases( upload: { api: 'upload', elementArg: 0, + args: [null, new File(['foo'], 'foo.txt')], }, }, ) diff --git a/tests/upload.ts b/tests/upload.ts index 2f6d266a..0299296c 100644 --- a/tests/upload.ts +++ b/tests/upload.ts @@ -11,7 +11,7 @@ test('should fire the correct events for input', async () => { // value of the input programmatically. The value in the browser // set by a user would be: `C:\\fakepath\\${file.name}` expect(getEventSnapshot()).toMatchInlineSnapshot(` - Events fired on: input[value=""] + Events fired on: input[value="C:\\\\fakepath\\\\hello.png"] input[value=""] - pointerover input[value=""] - pointerenter @@ -30,8 +30,8 @@ test('should fire the correct events for input', async () => { input[value=""] - focusout input[value=""] - focus input[value=""] - focusin - input[value=""] - input - input[value=""] - change + input[value="C:\\\\fakepath\\\\hello.png"] - input + input[value="C:\\\\fakepath\\\\hello.png"] - change `) }) @@ -64,8 +64,8 @@ test('should fire the correct events with label', async () => { label[for="element"] - click: primary input#element[value=""] - click: primary input#element[value=""] - focusin - input#element[value=""] - input - input#element[value=""] - change + input#element[value="C:\\\\fakepath\\\\hello.png"] - input + input#element[value="C:\\\\fakepath\\\\hello.png"] - change `) }) @@ -187,7 +187,7 @@ test.each([ /> `) - await userEvent.upload(element, files, undefined, {applyAccept}) + await userEvent.upload(element, files, {applyAccept}) expect(element.files).toHaveLength(expectedLength) }, @@ -255,14 +255,3 @@ test('throw error if trying to use upload on an invalid element', async () => { `The associated INPUT element does not accept file uploads`, ) }) - -test('apply init options', async () => { - const {element, getEvents} = setup('') - - await userEvent.upload(element, new File([], 'hello.png'), { - changeInit: {cancelable: true}, - }) - - expect(getEvents('click')[0]).toHaveProperty('shiftKey', false) - expect(getEvents('change')[0]).toHaveProperty('cancelable', true) -}) diff --git a/tests/utils/edit/setFiles.ts b/tests/utils/edit/setFiles.ts new file mode 100644 index 00000000..6c528a5b --- /dev/null +++ b/tests/utils/edit/setFiles.ts @@ -0,0 +1,69 @@ +import {createFileList} from '#src/utils' +import {setFiles} from '#src/utils/edit/setFiles' +import {setup} from '#testHelpers/utils' + +test('set files', () => { + const {element} = setup( + ``, + ) + + const list = createFileList([new File(['foo'], 'foo.txt')]) + setFiles(element, list) + + expect(element).toHaveProperty('files', list) + expect(element).toHaveValue('C:\\fakepath\\foo.txt') +}) + +test('switching type resets value', () => { + const {element} = setup(``) + + element.type = 'file' + + expect(element).toHaveValue('') + + const list = createFileList([new File(['foo'], 'foo.txt')]) + setFiles(element as HTMLInputElement & {type: 'file'}, list) + + element.type = 'file' + + expect(element).toHaveValue('C:\\fakepath\\foo.txt') + + element.type = 'text' + + expect(element).toHaveValue('') + expect(element).toHaveProperty('type', 'text') +}) + +test('setting value resets `files`', () => { + const {element} = setup( + ``, + ) + + const list = createFileList([new File(['foo'], 'foo.txt')]) + setFiles(element, list) + + // Everything but an empty string throws an error in the browser + expect(() => { + element.value = 'foo' + }).toThrow() + + expect(element).toHaveProperty('files', list) + + element.value = '' + + expect(element).toHaveProperty('files', expect.objectContaining({length: 0})) +}) + +test('is save to call multiple times', () => { + const {element} = setup( + ``, + ) + + const list = createFileList([new File(['foo'], 'foo.txt')]) + setFiles(element, list) + setFiles(element, list) + + expect(element).toHaveValue('C:\\fakepath\\foo.txt') + element.value = '' + expect(element).toHaveValue('') +})