From cf148645c91619701427bc8321e3a95df7e93292 Mon Sep 17 00:00:00 2001 From: Vladimir Date: Mon, 2 Sep 2024 18:33:22 +0200 Subject: [PATCH] feat(browser): support `userEvent.upload` in playwright provider (#6442) --- docs/guide/browser/interactivity-api.md | 132 +++++++++++++++--- docs/guide/browser/locators.md | 118 +++++++++++++--- packages/browser/context.d.ts | 11 ++ packages/browser/package.json | 1 + packages/browser/rollup.config.js | 1 + packages/browser/src/client/tester/context.ts | 3 + .../src/client/tester/locators/index.ts | 21 +++ .../src/client/tester/locators/preview.ts | 5 + packages/browser/src/node/commands/fs.ts | 14 +- packages/browser/src/node/commands/index.ts | 4 + .../browser/src/node/commands/screenshot.ts | 2 +- packages/browser/src/node/commands/upload.ts | 53 +++++++ .../browser/src/node/plugins/pluginContext.ts | 19 ++- pnpm-lock.yaml | 10 ++ test/browser/test/userEvent.test.ts | 67 +++++++++ test/ui/test/html-report.spec.ts | 23 ++- test/ui/test/ui.spec.ts | 2 +- test/ui/tsconfig.json | 5 +- 18 files changed, 448 insertions(+), 43 deletions(-) create mode 100644 packages/browser/src/node/commands/upload.ts diff --git a/docs/guide/browser/interactivity-api.md b/docs/guide/browser/interactivity-api.md index fd308f8299e2..b82f4a701edb 100644 --- a/docs/guide/browser/interactivity-api.md +++ b/docs/guide/browser/interactivity-api.md @@ -37,7 +37,9 @@ Almost every `userEvent` method inherits its provider options. To see all availa ## userEvent.setup -- **Type:** `() => UserEvent` +```ts +function setup(): UserEvent +``` Creates a new user event instance. This is useful if you need to keep the state of keyboard to press and release buttons correctly. @@ -60,7 +62,12 @@ This behaviour is more useful because we do not emulate the keyboard, we actuall ## userEvent.click -- **Type:** `(element: Element | Locator, options?: UserEventClickOptions) => Promise` +```ts +function click( + element: Element | Locator, + options?: UserEventClickOptions, +): Promise +``` Click on an element. Inherits provider's options. Please refer to your provider's documentation for detailed explanation about how this method works. @@ -84,7 +91,12 @@ References: ## userEvent.dblClick -- **Type:** `(element: Element | Locator, options?: UserEventDoubleClickOptions) => Promise` +```ts +function dblClick( + element: Element | Locator, + options?: UserEventDoubleClickOptions, +): Promise +``` Triggers a double click event on an element. @@ -110,7 +122,12 @@ References: ## userEvent.tripleClick -- **Type:** `(element: Element | Locator, options?: UserEventTripleClickOptions) => Promise` +```ts +function tripleClick( + element: Element | Locator, + options?: UserEventTripleClickOptions, +): Promise +``` Triggers a triple click event on an element. Since there is no `tripleclick` in browser api, this method will fire three click events in a row, and so you must check [click event detail](https://developer.mozilla.org/en-US/docs/Web/API/Element/click_event#usage_notes) to filter the event: `evt.detail === 3`. @@ -144,7 +161,12 @@ References: ## userEvent.fill -- **Type:** `(element: Element | Locator, text: string) => Promise` +```ts +function fill( + element: Element | Locator, + text: string, +): Promise +``` Set a value to the `input/textarea/conteneditable` field. This will remove any existing text in the input before setting the new value. @@ -179,7 +201,9 @@ References: ## userEvent.keyboard -- **Type:** `(text: string) => Promise` +```ts +function keyboard(text: string): Promise +``` The `userEvent.keyboard` allows you to trigger keyboard strokes. If any input has a focus, it will type characters into that input. Otherwise, it will trigger keyboard events on the currently focused element (`document.body` if there are no focused elements). @@ -205,7 +229,9 @@ References: ## userEvent.tab -- **Type:** `(options?: UserEventTabOptions) => Promise` +```ts +function tab(options?: UserEventTabOptions): Promise +``` Sends a `Tab` key event. This is a shorthand for `userEvent.keyboard('{tab}')`. @@ -235,10 +261,16 @@ References: ## userEvent.type -- **Type:** `(element: Element | Locator, text: string, options?: UserEventTypeOptions) => Promise` +```ts +function type( + element: Element | Locator, + text: string, + options?: UserEventTypeOptions, +): Promise +``` ::: warning -If you don't rely on [special characters](https://testing-library.com/docs/user-event/keyboard) (e.g., `{shift}` or `{selectall}`), it is recommended to use [`userEvent.fill`](#userevent-fill) instead. +If you don't rely on [special characters](https://testing-library.com/docs/user-event/keyboard) (e.g., `{shift}` or `{selectall}`), it is recommended to use [`userEvent.fill`](#userevent-fill) instead for better performance. ::: The `type` method implements `@testing-library/user-event`'s [`type`](https://testing-library.com/docs/user-event/utility/#type) utility built on top of [`keyboard`](https://testing-library.com/docs/user-event/keyboard) API. @@ -271,7 +303,9 @@ References: ## userEvent.clear -- **Type:** `(element: Element | Locator) => Promise` +```ts +function clear(element: Element | Locator): Promise +``` This method clears the input element content. @@ -300,7 +334,19 @@ References: ## userEvent.selectOptions -- **Type:** `(element: Element | Locator, values: HTMLElement | HTMLElement[] | Locator | Locator[] | string | string[], options?: UserEventSelectOptions) => Promise` +```ts +function selectOptions( + element: Element | Locator, + values: + | HTMLElement + | HTMLElement[] + | Locator + | Locator[] + | string + | string[], + options?: UserEventSelectOptions, +): Promise +``` The `userEvent.selectOptions` allows selecting a value in a `` element. @@ -495,7 +557,13 @@ await languages.selectOptions([ ### screenshot -- **Type:** `(options?: LocatorScreenshotOptions) => Promise` +```ts +function screenshot(options: LocatorScreenshotOptions & { base64: true }): Promise<{ + path: string + base64: string +}> +function screenshot(options?: LocatorScreenshotOptions & { base64?: false }): Promise +``` Creates a screenshot of the element matching the locator's selector. @@ -520,7 +588,9 @@ const { path, base64 } = await button.screenshot({ ### query -- **Type:** `() => Element | null` +```ts +function query(): Element | null +``` This method returns a single element matching the locator's selector or `null` if no element is found. @@ -552,7 +622,9 @@ page.getByText(/^Hello/).query() // ❌ ### element -- **Type:** `() => Element` +```ts +function element(): Element +``` This method returns a single element matching the locator's selector. @@ -598,7 +670,9 @@ page.getByText('Hello USA').element() // ❌ ### elements -- **Type:** `() => Element[]` +```ts +function elements(): Element[] +``` This method returns an array of elements matching the locator's selector. @@ -623,7 +697,9 @@ page.getByText('Hello USA').elements() // ✅ [] ### all -- **Type:** `() => Locator[]` +```ts +function all(): Locator[] +``` This method returns an array of new locators that match the selector. diff --git a/packages/browser/context.d.ts b/packages/browser/context.d.ts index 4e81c70541d2..706653af9315 100644 --- a/packages/browser/context.d.ts +++ b/packages/browser/context.d.ts @@ -160,6 +160,12 @@ export interface UserEvent { * @see {@link https://testing-library.com/docs/user-event/convenience/#hover} testing-library API */ unhover: (element: Element | Locator, options?: UserEventHoverOptions) => Promise + /** + * Change a file input element to have the specified files. Uses provider's API under the hood. + * @see {@link https://playwright.dev/docs/api/class-locator#locator-set-input-files} Playwright API + * @see {@link https://testing-library.com/docs/user-event/utility#upload} testing-library API + */ + upload: (element: Element | Locator, files: File | File[] | string | string[]) => Promise /** * Fills an input element with text. This will remove any existing text in the input before typing the new text. * Uses provider's API under the hood. @@ -337,6 +343,11 @@ export interface Locator extends LocatorSelectors { values: HTMLElement | HTMLElement[] | Locator | Locator[] | string | string[], options?: UserEventSelectOptions, ): Promise + /** + * Change a file input element to have the specified files. Uses provider's API under the hood. + * @see {@link https://vitest.dev/guide/browser/interactivity-api#userevent-upload} + */ + upload: (files: File | File[] | string | string[]) => Promise /** * Make a screenshot of an element matching the locator. diff --git a/packages/browser/package.json b/packages/browser/package.json index 837a93ba57ba..e8ee6d438ad9 100644 --- a/packages/browser/package.json +++ b/packages/browser/package.json @@ -103,6 +103,7 @@ "birpc": "0.2.17", "flatted": "^3.3.1", "ivya": "^1.1.1", + "mime": "^4.0.4", "pathe": "^1.1.2", "periscopic": "^4.0.2", "playwright": "^1.46.0", diff --git a/packages/browser/rollup.config.js b/packages/browser/rollup.config.js index b1b901bbdeb2..c0d642ae03d8 100644 --- a/packages/browser/rollup.config.js +++ b/packages/browser/rollup.config.js @@ -43,6 +43,7 @@ export default () => format: 'esm', }, external, + context: 'null', plugins: [ { name: 'no-side-effects', diff --git a/packages/browser/src/client/tester/context.ts b/packages/browser/src/client/tester/context.ts index 3ba8c020b7f6..b4331e76f43e 100644 --- a/packages/browser/src/client/tester/context.ts +++ b/packages/browser/src/client/tester/context.ts @@ -79,6 +79,9 @@ function createUserEvent(): UserEvent { unhover(element: Element | Locator, options: UserEventHoverOptions = {}) { return convertToLocator(element).unhover(options) }, + upload(element: Element | Locator, files: string | string[] | File | File[]) { + return convertToLocator(element).upload(files) + }, // non userEvent events, but still useful fill(element: Element | Locator, text: string, options) { diff --git a/packages/browser/src/client/tester/locators/index.ts b/packages/browser/src/client/tester/locators/index.ts index 90779164c698..b1280c64ccf7 100644 --- a/packages/browser/src/client/tester/locators/index.ts +++ b/packages/browser/src/client/tester/locators/index.ts @@ -76,6 +76,27 @@ export abstract class Locator { return this.triggerCommand('__vitest_fill', this.selector, text, options) } + public async upload(files: string | string[] | File | File[]): Promise { + const filesPromise = (Array.isArray(files) ? files : [files]).map(async (file) => { + if (typeof file === 'string') { + return file + } + const bas64String = await new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => resolve(reader.result as string) + reader.onerror = () => reject(new Error(`Failed to read file: ${file.name}`)) + reader.readAsDataURL(file) + }) + + return { + name: file.name, + mimeType: file.type, + base64: bas64String, + } + }) + return this.triggerCommand('__vitest_upload', this.selector, await Promise.all(filesPromise)) + } + public dropTo(target: Locator, options: UserEventDragAndDropOptions = {}): Promise { return this.triggerCommand( '__vitest_dragAndDrop', diff --git a/packages/browser/src/client/tester/locators/preview.ts b/packages/browser/src/client/tester/locators/preview.ts index 567b42122f04..cd2e6fa8b220 100644 --- a/packages/browser/src/client/tester/locators/preview.ts +++ b/packages/browser/src/client/tester/locators/preview.ts @@ -81,6 +81,11 @@ class PreviewLocator extends Locator { return userEvent.type(this.element(), text) } + async upload(file: string | string[] | File | File[]): Promise { + // we override userEvent.upload to support this in pluginContext.ts + return userEvent.upload(this.element() as HTMLElement, file as File[]) + } + selectOptions(options_: string | string[] | HTMLElement | HTMLElement[] | Locator | Locator[]): Promise { const options = (Array.isArray(options_) ? options_ : [options_]).map((option) => { if (typeof option !== 'string' && 'element' in option) { diff --git a/packages/browser/src/node/commands/fs.ts b/packages/browser/src/node/commands/fs.ts index d9275b73f881..3b1961b3a80d 100644 --- a/packages/browser/src/node/commands/fs.ts +++ b/packages/browser/src/node/commands/fs.ts @@ -1,7 +1,8 @@ import fs, { promises as fsp } from 'node:fs' -import { dirname, resolve } from 'node:path' +import { basename, dirname, resolve } from 'node:path' import { isFileServingAllowed } from 'vitest/node' import type { BrowserCommand, WorkspaceProject } from 'vitest/node' +import mime from 'mime/lite' import type { BrowserCommands } from '../../../context' function assertFileAccess(path: string, project: WorkspaceProject) { @@ -46,3 +47,14 @@ export const removeFile: BrowserCommand< assertFileAccess(filepath, project) await fsp.rm(filepath) } + +export const _fileInfo: BrowserCommand<[path: string, encoding: BufferEncoding]> = async ({ project, testPath = process.cwd() }, path, encoding) => { + const filepath = resolve(dirname(testPath), path) + assertFileAccess(filepath, project) + const content = await fsp.readFile(filepath, encoding || 'base64') + return { + content, + basename: basename(filepath), + mime: mime.getType(filepath), + } +} diff --git a/packages/browser/src/node/commands/index.ts b/packages/browser/src/node/commands/index.ts index 429882d5d08c..3568fbcc5f8d 100644 --- a/packages/browser/src/node/commands/index.ts +++ b/packages/browser/src/node/commands/index.ts @@ -7,7 +7,9 @@ import { tab } from './tab' import { keyboard } from './keyboard' import { dragAndDrop } from './dragAndDrop' import { hover } from './hover' +import { upload } from './upload' import { + _fileInfo, readFile, removeFile, writeFile, @@ -18,6 +20,8 @@ export default { readFile, removeFile, writeFile, + __vitest_fileInfo: _fileInfo, + __vitest_upload: upload, __vitest_click: click, __vitest_dblClick: dblClick, __vitest_tripleClick: tripleClick, diff --git a/packages/browser/src/node/commands/screenshot.ts b/packages/browser/src/node/commands/screenshot.ts index 1790bd6a448a..32b3f5522fe6 100644 --- a/packages/browser/src/node/commands/screenshot.ts +++ b/packages/browser/src/node/commands/screenshot.ts @@ -16,7 +16,7 @@ export const screenshot: BrowserCommand<[string, ScreenshotOptions]> = async ( } const path = options.path - ? resolve(context.testPath, options.path) + ? resolve(dirname(context.testPath), options.path) : resolveScreenshotPath( context.testPath, name, diff --git a/packages/browser/src/node/commands/upload.ts b/packages/browser/src/node/commands/upload.ts new file mode 100644 index 000000000000..3846190a8f51 --- /dev/null +++ b/packages/browser/src/node/commands/upload.ts @@ -0,0 +1,53 @@ +import { dirname, resolve } from 'pathe' +import { PlaywrightBrowserProvider } from '../providers/playwright' +import { WebdriverBrowserProvider } from '../providers/webdriver' +import type { UserEventCommand } from './utils' + +export const upload: UserEventCommand<(element: string, files: Array) => void> = async ( + context, + selector, + files, +) => { + const testPath = context.testPath + if (!testPath) { + throw new Error(`Cannot upload files outside of a test`) + } + const testDir = dirname(testPath) + + if (context.provider instanceof PlaywrightBrowserProvider) { + const { iframe } = context + const playwrightFiles = files.map((file) => { + if (typeof file === 'string') { + return resolve(testDir, file) + } + return { + name: file.name, + mimeType: file.mimeType, + buffer: Buffer.from(file.base64, 'base64'), + } + }) + await iframe.locator(selector).setInputFiles(playwrightFiles as string[]) + } + else if (context.provider instanceof WebdriverBrowserProvider) { + for (const file of files) { + if (typeof file !== 'string') { + throw new TypeError(`The "${context.provider.name}" provider doesn't support uploading files objects. Provide a file path instead.`) + } + } + + const element = context.browser.$(selector) + + for (const file of files) { + const filepath = resolve(testDir, file as string) + const remoteFilePath = await context.browser.uploadFile(filepath) + await element.addValue(remoteFilePath) + } + } + else { + throw new TypeError(`Provider "${context.provider.name}" does not support uploading files via userEvent.upload`) + } +} diff --git a/packages/browser/src/node/plugins/pluginContext.ts b/packages/browser/src/node/plugins/pluginContext.ts index 701e1abc0e65..a22b75c261a3 100644 --- a/packages/browser/src/node/plugins/pluginContext.ts +++ b/packages/browser/src/node/plugins/pluginContext.ts @@ -94,15 +94,32 @@ function getUserEvent(provider: BrowserProvider) { return '__userEvent_CDP__' } // TODO: have this in a separate file - return `{ + return String.raw`{ ..._userEventSetup, setup() { const userEvent = __vitest_user_event__.setup() userEvent.setup = this.setup userEvent.fill = this.fill.bind(userEvent) + userEvent._upload = userEvent.upload.bind(userEvent) + userEvent.upload = this.upload.bind(userEvent) userEvent.dragAndDrop = this.dragAndDrop return userEvent }, + async upload(element, file) { + const uploadPromise = (Array.isArray(file) ? file : [file]).map(async (file) => { + if (typeof file !== 'string') { + return file + } + + const { content: base64, basename, mime } = await rpc().triggerCommand(contextId, "__vitest_fileInfo", filepath(), [file, 'base64']) + const fileInstance = fetch(base64) + .then(r => r.blob()) + .then(blob => new File([blob], basename, { type: mime })) + return fileInstance + }) + const uploadFiles = await Promise.all(uploadPromise) + return this._upload(element, uploadFiles) + }, async fill(element, text) { await this.clear(element) await this.type(element, text) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9b0c41f85496..97e34ba78aa1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -482,6 +482,9 @@ importers: ivya: specifier: ^1.1.1 version: 1.1.1 + mime: + specifier: ^4.0.4 + version: 4.0.4 pathe: specifier: ^1.1.2 version: 1.1.2 @@ -7099,6 +7102,11 @@ packages: engines: {node: '>=10.0.0'} hasBin: true + mime@4.0.4: + resolution: {integrity: sha512-v8yqInVjhXyqP6+Kw4fV3ZzeMRqEW6FotRsKXjRS5VMTNIuXsdRoAvklpoRgSqXm6o9VNH4/C0mgedko9DdLsQ==} + engines: {node: '>=16'} + hasBin: true + mimic-fn@2.1.0: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} @@ -16304,6 +16312,8 @@ snapshots: mime@3.0.0: {} + mime@4.0.4: {} + mimic-fn@2.1.0: {} mimic-fn@4.0.0: {} diff --git a/test/browser/test/userEvent.test.ts b/test/browser/test/userEvent.test.ts index 08d4a4182e6f..d4b43802b600 100644 --- a/test/browser/test/userEvent.test.ts +++ b/test/browser/test/userEvent.test.ts @@ -865,6 +865,73 @@ describe.each([ }) }) +describe('uploading files', async () => { + test.skipIf(server.provider === 'webdriverio')('can upload an instance of File', async () => { + const file = new File(['hello'], 'hello.png', { type: 'image/png' }) + const input = document.createElement('input') + input.type = 'file' + document.body.appendChild(input) + await userEvent.upload(input, file) + await expect.poll(() => input.files.length).toBe(1) + + const uploadedFile = input.files[0] + expect(uploadedFile.name).toBe('hello.png') + expect(uploadedFile.type).toBe('image/png') + }) + + test.skipIf(server.provider === 'webdriverio')('can upload several instances of File', async () => { + const file1 = new File(['hello1'], 'hello1.png', { type: 'image/png' }) + const file2 = new File(['hello2'], 'hello2.png', { type: 'image/png' }) + const input = document.createElement('input') + input.type = 'file' + input.multiple = true + document.body.appendChild(input) + await userEvent.upload(input, [file1, file2]) + await expect.poll(() => input.files.length).toBe(2) + + const uploadedFile1 = input.files[0] + expect(uploadedFile1.name).toBe('hello1.png') + expect(uploadedFile1.type).toBe('image/png') + + const uploadedFile2 = input.files[1] + expect(uploadedFile2.name).toBe('hello2.png') + expect(uploadedFile2.type).toBe('image/png') + }) + + test.skipIf( + server.provider === 'webdriverio' && server.browser === 'firefox', + )('can upload a file by filepath relative to test file', async () => { + const input = document.createElement('input') + input.type = 'file' + document.body.appendChild(input) + await userEvent.upload(input, '../src/button.css') + await expect.poll(() => input.files.length).toBe(1) + + const uploadedFile = input.files[0] + expect(uploadedFile.name).toBe('button.css') + expect(uploadedFile.type).toBe('text/css') + }) + + test.skipIf( + server.provider === 'webdriverio' && server.browser === 'firefox', + )('can upload several files by filepath relative to test file', async () => { + const input = document.createElement('input') + input.type = 'file' + input.multiple = true + document.body.appendChild(input) + await userEvent.upload(input, ['../src/button.css', '../package.json']) + await expect.poll(() => input.files.length).toBe(2) + + const uploadedFile1 = input.files[0] + expect(uploadedFile1.name).toBe('button.css') + expect(uploadedFile1.type).toBe('text/css') + + const uploadedFile2 = input.files[1] + expect(uploadedFile2.name).toBe('package.json') + expect(uploadedFile2.type).toBe('application/json') + }) +}) + function createShadowRoot() { const div = document.createElement('div') const shadowRoot = div.attachShadow({ mode: 'open' }) diff --git a/test/ui/test/html-report.spec.ts b/test/ui/test/html-report.spec.ts index 53046a3ff20d..21c01d76ba01 100644 --- a/test/ui/test/html-report.spec.ts +++ b/test/ui/test/html-report.spec.ts @@ -1,3 +1,4 @@ +import { Writable } from 'node:stream' import { expect, test } from '@playwright/test' import type { PreviewServer } from 'vite' import { preview } from 'vite' @@ -10,8 +11,28 @@ test.describe('html report', () => { let previewServer: PreviewServer test.beforeAll(async () => { + // silence Vitest logs + const stdout = new Writable({ write: (_, __, callback) => callback() }) + const stderr = new Writable({ write: (_, __, callback) => callback() }) // generate vitest html report - await startVitest('test', [], { run: true, reporters: 'html', coverage: { enabled: true, reportsDirectory: 'html/coverage' } }) + await startVitest( + 'test', + [], + { + run: true, + reporters: 'html', + coverage: { + enabled: true, + reportsDirectory: 'html/coverage', + reporter: ['html'], + }, + }, + {}, + { + stdout, + stderr, + }, + ) // run vite preview server previewServer = await preview({ build: { outDir: 'html' }, preview: { port, strictPort: true } }) diff --git a/test/ui/test/ui.spec.ts b/test/ui/test/ui.spec.ts index a066a70b5267..d8d293805c57 100644 --- a/test/ui/test/ui.spec.ts +++ b/test/ui/test/ui.spec.ts @@ -17,7 +17,7 @@ test.describe('ui', () => { ui: true, open: false, api: { port }, - coverage: { enabled: true }, + coverage: { enabled: true, reporter: ['html'] }, reporters: [], }, {}, { stdout, diff --git a/test/ui/tsconfig.json b/test/ui/tsconfig.json index 0f32ce2bd569..accaf3ba63ee 100644 --- a/test/ui/tsconfig.json +++ b/test/ui/tsconfig.json @@ -3,7 +3,10 @@ "compilerOptions": { "baseUrl": "../..", "paths": { - "vitest/node": ["./packages/vitest/dist/node.js"] + "vitest/node": [ + "./packages/vitest/node.d.ts", + "./packages/vitest/dist/node.js" + ] } } }