[READ-ONLY] Mirror of https://github.com/vitest-dev/vitest. Next generation testing framework powered by Vite. vitest.dev
test testing-tools vite
12

Configure Feed

Select the types of activity you want to include in your feed.

fix(browser): fail playwright timeouts earlier than a test timeout (#7565)

Co-authored-by: Hiroshi Ogawa <hi.ogawa.zz@gmail.com>

authored by

Vladimir
Hiroshi Ogawa
and committed by
GitHub
(Mar 7, 2025, 9:54 AM +0100) 5eb4cd1f b7f55261

+549 -338
+6 -4
packages/browser/context.d.ts
··· 142 142 * @see {@link https://webdriver.io/docs/api/element/clearValue} WebdriverIO API 143 143 * @see {@link https://testing-library.com/docs/user-event/utility/#clear} testing-library API 144 144 */ 145 - clear: (element: Element | Locator) => Promise<void> 145 + clear: (element: Element | Locator, options?: UserEventClearOptions) => Promise<void> 146 146 /** 147 147 * Sends a `Tab` key event. Uses provider's API under the hood. 148 148 * @see {@link https://playwright.dev/docs/api/class-keyboard} Playwright API ··· 171 171 * @see {@link https://playwright.dev/docs/api/class-locator#locator-set-input-files} Playwright API 172 172 * @see {@link https://testing-library.com/docs/user-event/utility#upload} testing-library API 173 173 */ 174 - upload: (element: Element | Locator, files: File | File[] | string | string[]) => Promise<void> 174 + upload: (element: Element | Locator, files: File | File[] | string | string[], options?: UserEventUploadOptions) => Promise<void> 175 175 /** 176 176 * Copies the selected content. 177 177 * @see {@link https://playwright.dev/docs/api/class-keyboard} Playwright API ··· 218 218 export interface UserEventHoverOptions {} 219 219 export interface UserEventSelectOptions {} 220 220 export interface UserEventClickOptions {} 221 + export interface UserEventClearOptions {} 221 222 export interface UserEventDoubleClickOptions {} 222 223 export interface UserEventTripleClickOptions {} 223 224 export interface UserEventDragAndDropOptions {} 225 + export interface UserEventUploadOptions {} 224 226 225 227 export interface LocatorOptions { 226 228 /** ··· 358 360 * Clears the input element content 359 361 * @see {@link https://vitest.dev/guide/browser/interactivity-api#userevent-clear} 360 362 */ 361 - clear(): Promise<void> 363 + clear(options?: UserEventClearOptions): Promise<void> 362 364 /** 363 365 * Moves the cursor position to the selected element 364 366 * @see {@link https://vitest.dev/guide/browser/interactivity-api#userevent-hover} ··· 391 393 * Change a file input element to have the specified files. Uses provider's API under the hood. 392 394 * @see {@link https://vitest.dev/guide/browser/interactivity-api#userevent-upload} 393 395 */ 394 - upload(files: File | File[] | string | string[]): Promise<void> 396 + upload(files: File | File[] | string | string[], options?: UserEventUploadOptions): Promise<void> 395 397 396 398 /** 397 399 * Make a screenshot of an element matching the locator.
+2 -1
packages/browser/matchers.d.ts
··· 19 19 interface ExpectStatic { 20 20 /** 21 21 * `expect.element(locator)` is a shorthand for `expect.poll(() => locator.element())`. 22 - * You can set default timeout via `expect.poll.timeout` config. 22 + * You can set default timeout via `expect.poll.timeout` option in the config. 23 + * @see {@link https://vitest.dev/api/expect#poll} 23 24 */ 24 25 element: <T extends Element | Locator>(element: T, options?: ExpectPollOptions) => PromisifyDomAssertion<Awaited<Element | null>> 25 26 }
+1
test/browser/package.json
··· 10 10 "test:safaridriver": "PROVIDER=webdriverio BROWSER=safari pnpm run test:unit", 11 11 "test-fixtures": "vitest", 12 12 "test-mocking": "vitest --root ./fixtures/mocking", 13 + "test-timeout": "vitest --root ./fixtures/timeout", 13 14 "test-mocking-watch": "vitest --root ./fixtures/mocking-watch", 14 15 "test-locators": "vitest --root ./fixtures/locators", 15 16 "test-different-configs": "vitest --root ./fixtures/multiple-different-configs",
+2 -1
docs/guide/browser/interactivity-api.md
··· 292 292 ## userEvent.clear 293 293 294 294 ```ts 295 - function clear(element: Element | Locator): Promise<void> 295 + function clear(element: Element | Locator, options?: UserEventClearOptions): Promise<void> 296 296 ``` 297 297 298 298 This method clears the input element content. ··· 451 451 function upload( 452 452 element: Element | Locator, 453 453 files: string[] | string | File[] | File, 454 + options?: UserEventUploadOptions, 454 455 ): Promise<void> 455 456 ``` 456 457
+2 -2
docs/guide/browser/locators.md
··· 3 3 outline: [2, 3] 4 4 --- 5 5 6 - # Locators <Version>2.1.0</Version> 6 + # Locators 7 7 8 8 A locator is a representation of an element or a number of elements. Every locator is defined by a string called a selector. Vitest abstracts this selector by providing convenient methods that generate those selectors behind the scenes. 9 9 ··· 505 505 ### clear 506 506 507 507 ```ts 508 - function clear(): Promise<void> 508 + function clear(options?: UserEventClearOptions): Promise<void> 509 509 ``` 510 510 511 511 Clears the input element content.
+2
packages/browser/providers/playwright.d.ts
··· 41 41 type PWScreenshotOptions = NonNullable<Parameters<Page['screenshot']>[0]> 42 42 type PWSelectOptions = NonNullable<Parameters<Page['selectOption']>[2]> 43 43 type PWDragAndDropOptions = NonNullable<Parameters<Page['dragAndDrop']>[2]> 44 + type PWSetInputFiles = NonNullable<Parameters<Page['setInputFiles']>[2]> 44 45 45 46 declare module '@vitest/browser/context' { 46 47 export interface UserEventHoverOptions extends PWHoverOptions {} ··· 50 51 export interface UserEventFillOptions extends PWFillOptions {} 51 52 export interface UserEventSelectOptions extends PWSelectOptions {} 52 53 export interface UserEventDragAndDropOptions extends PWDragAndDropOptions {} 54 + export interface UserEventUploadOptions extends PWSetInputFiles {} 53 55 54 56 export interface ScreenshotOptions extends PWScreenshotOptions {} 55 57
+3 -1
packages/runner/src/suite.ts
··· 303 303 initSuite(true) 304 304 305 305 const task = function (name = '', options: TaskCustomOptions = {}) { 306 + const timeout = options?.timeout ?? runner.config.testTimeout 306 307 const task: Test = { 307 308 id: '', 308 309 name, ··· 312 313 context: undefined!, 313 314 type: 'test', 314 315 file: undefined!, 316 + timeout, 315 317 retry: options.retry ?? runner.config.retry, 316 318 repeats: options.repeats, 317 319 mode: options.only ··· 345 347 task, 346 348 withTimeout( 347 349 withAwaitAsyncAssertions(withFixtures(handler, context), task), 348 - options?.timeout ?? runner.config.testTimeout, 350 + timeout, 349 351 ), 350 352 ) 351 353 }
+15 -2
test/browser/specs/runner.test.ts
··· 175 175 176 176 expect(stderr).toMatch(/bundled-lib\/src\/b.js:2:(8|18)/) 177 177 expect(stderr).toMatch(/bundled-lib\/src\/index.js:5:(15|17)/) 178 - expect(stderr).toMatch(/test\/failing.test.ts:25:(2|8)/) 178 + 179 + if (provider === 'playwright') { 180 + // page.getByRole('code').click() 181 + expect(stderr).toContain('locator.click: Timeout') 182 + // playwright error is proxied from the server to the client and back correctly 183 + expect(stderr).toContain('waiting for locator(\'[data-vitest="true"]\').contentFrame().getByRole(\'code\')') 184 + expect(stderr).toMatch(/test\/failing.test.ts:27:(33|39)/) 185 + // await expect.element().toBeVisible() 186 + expect(stderr).toContain('Cannot find element with locator: getByRole(\'code\')') 187 + expect(stderr).toMatch(/test\/failing.test.ts:31:(49|61)/) 188 + } 189 + 190 + // index() is called from a bundled file 191 + expect(stderr).toMatch(/test\/failing.test.ts:36:(2|8)/) 179 192 }) 180 193 181 194 test('popup apis should log a warning', () => { ··· 210 223 const { stderr } = await runBrowserTests({ 211 224 root: './fixtures/timeout', 212 225 }) 213 - expect(stderr).toContain('Matcher did not succeed in 500ms') 226 + expect(stderr).toContain('Matcher did not succeed in time.') 214 227 if (provider === 'playwright') { 215 228 expect(stderr).toContain('locator.click: Timeout 500ms exceeded.') 216 229 expect(stderr).toContain('locator.click: Timeout 345ms exceeded.')
+13 -2
test/browser/test/failing.test.ts
··· 1 - import { page } from '@vitest/browser/context' 1 + import { page, server } from '@vitest/browser/context' 2 2 import { index } from '@vitest/bundled-lib' 3 - import { expect, it } from 'vitest' 3 + import { describe, expect, it } from 'vitest' 4 4 import { throwError } from '../src/error' 5 5 6 6 document.body.innerHTML = ` ··· 19 19 page.getByRole('button').dblClick() 20 20 page.getByRole('button').click() 21 21 page.getByRole('button').tripleClick() 22 + }) 23 + 24 + describe.runIf(server.provider === 'playwright')('timeouts are failing correctly', () => { 25 + it('click on non-existing element fails', async () => { 26 + await new Promise(r => setTimeout(r, 100)) 27 + await page.getByRole('code').click() 28 + }, 1000) 29 + 30 + it('expect.element on non-existing element fails', async () => { 31 + await expect.element(page.getByRole('code')).toBeVisible() 32 + }, 1000) 22 33 }) 23 34 24 35 it('correctly prints error from a bundled file', () => {
+5 -5
test/core/test/expect-poll.test.ts
··· 37 37 await expect(async () => { 38 38 await expect.poll(() => false, { timeout: 100, interval: 10 }).toBe(true) 39 39 }).rejects.toThrowError(expect.objectContaining({ 40 - message: 'Matcher did not succeed in 100ms', 40 + message: 'Matcher did not succeed in time.', 41 41 stack: expect.stringContaining('expect-poll.test.ts:38:68'), 42 42 cause: expect.objectContaining({ 43 43 message: 'expected false to be true // Object.is equality', ··· 60 60 vi.useFakeTimers() 61 61 await expect(async () => { 62 62 await expect.poll(() => false, { timeout: 100 }).toBe(true) 63 - }).rejects.toThrowError('Matcher did not succeed in 100ms') 63 + }).rejects.toThrowError('Matcher did not succeed in time.') 64 64 vi.useRealTimers() 65 65 const diff = Date.now() - now 66 66 expect(diff >= 100).toBe(true) ··· 91 91 await expect(() => 92 92 expect.poll(() => 1, { timeout: 100, interval: 10 }).not.toBeDefined(), 93 93 ).rejects.toThrowError(expect.objectContaining({ 94 - message: 'Matcher did not succeed in 100ms', 94 + message: 'Matcher did not succeed in time.', 95 95 cause: expect.objectContaining({ 96 96 message: 'expected 1 to be undefined', 97 97 }), ··· 100 100 await expect(() => 101 101 expect.poll(() => undefined, { timeout: 100, interval: 10 }).toBeDefined(), 102 102 ).rejects.toThrowError(expect.objectContaining({ 103 - message: 'Matcher did not succeed in 100ms', 103 + message: 'Matcher did not succeed in time.', 104 104 cause: expect.objectContaining({ 105 105 message: 'expected undefined to be defined', 106 106 }), ··· 140 140 await expect(async () => { 141 141 await expect.poll(fn, { interval: 10, timeout: 100 }).toBe(1) 142 142 }).rejects.toThrowError(expect.objectContaining({ 143 - message: 'Matcher did not succeed in 100ms', 143 + message: 'Matcher did not succeed in time.', 144 144 cause: expect.objectContaining({ 145 145 // makes sure cause message reflects the last attempt value 146 146 message: 'expected 3 to be 1 // Object.is equality',
+10
test/reporters/src/data.ts
··· 49 49 suite, 50 50 meta: {}, 51 51 file: passedFile, 52 + timeout: 0, 52 53 result: { 53 54 state: 'pass', 54 55 duration: 1.4422860145568848, ··· 95 96 column: 32, 96 97 line: 8, 97 98 }, 99 + timeout: 0, 98 100 context: null as any, 99 101 }, 100 102 { ··· 104 106 mode: 'run', 105 107 suite, 106 108 fails: undefined, 109 + timeout: 0, 107 110 meta: {}, 108 111 file, 109 112 result: { state: 'pass', duration: 1.0237109661102295 }, ··· 117 120 suite, 118 121 fails: undefined, 119 122 meta: {}, 123 + timeout: 0, 120 124 file, 121 125 result: undefined, 122 126 context: null as any, ··· 129 133 suite, 130 134 fails: undefined, 131 135 meta: {}, 136 + timeout: 0, 132 137 file, 133 138 result: { state: 'pass', duration: 100.50598406791687 }, 134 139 context: null as any, ··· 141 146 suite, 142 147 fails: undefined, 143 148 meta: {}, 149 + timeout: 0, 144 150 file, 145 151 result: { state: 'pass', duration: 20.184875011444092 }, 146 152 context: null as any, ··· 153 159 suite, 154 160 fails: undefined, 155 161 meta: {}, 162 + timeout: 0, 156 163 file, 157 164 result: { state: 'pass', duration: 0.33245420455932617 }, 158 165 context: null as any, ··· 165 172 suite, 166 173 fails: undefined, 167 174 meta: {}, 175 + timeout: 0, 168 176 file, 169 177 result: { state: 'pass', duration: 19.738605976104736 }, 170 178 context: null as any, ··· 177 185 suite, 178 186 fails: undefined, 179 187 meta: {}, 188 + timeout: 0, 180 189 file, 181 190 result: { state: 'pass', duration: 0.1923508644104004 }, 182 191 context: null as any, ··· 195 204 name: 'todo test', 196 205 mode: 'todo', 197 206 suite, 207 + timeout: 0, 198 208 fails: undefined, 199 209 meta: {}, 200 210 file,
+1
test/reporters/tests/junit.test.ts
··· 36 36 mode: 'run', 37 37 result, 38 38 file, 39 + timeout: 0, 39 40 context: null as any, 40 41 suite, 41 42 meta: {},
+5 -117
packages/browser/src/client/utils.ts
··· 1 1 import type { SerializedConfig, WorkerGlobalState } from 'vitest' 2 - import type { BrowserRPC } from './client' 2 + import type { CommandsManager } from './tester/utils' 3 3 4 4 export async function importId(id: string): Promise<any> { 5 5 const name = `/@id/${id}`.replace(/\\/g, '/') ··· 26 26 return getBrowserState().config 27 27 } 28 28 29 - export function ensureAwaited<T>(promise: () => Promise<T>): Promise<T> { 29 + export function ensureAwaited<T>(promise: (error?: Error) => Promise<T>): Promise<T> { 30 30 const test = getWorkerState().current 31 31 if (!test || test.type !== 'test') { 32 32 return promise() ··· 48 48 return { 49 49 then(onFulfilled, onRejected) { 50 50 awaited = true 51 - return (promiseResult ||= promise()).then(onFulfilled, onRejected) 51 + return (promiseResult ||= promise(sourceError)).then(onFulfilled, onRejected) 52 52 }, 53 53 catch(onRejected) { 54 - return (promiseResult ||= promise()).catch(onRejected) 54 + return (promiseResult ||= promise(sourceError)).catch(onRejected) 55 55 }, 56 56 finally(onFinally) { 57 - return (promiseResult ||= promise()).finally(onFinally) 57 + return (promiseResult ||= promise(sourceError)).finally(onFinally) 58 58 }, 59 59 [Symbol.toStringTag]: 'Promise', 60 60 } satisfies Promise<T> ··· 102 102 throw new Error('Worker state is not found. This is an issue with Vitest. Please, open an issue.') 103 103 } 104 104 return state 105 - } 106 - 107 - /* @__NO_SIDE_EFFECTS__ */ 108 - export function convertElementToCssSelector(element: Element): string { 109 - if (!element || !(element instanceof Element)) { 110 - throw new Error( 111 - `Expected DOM element to be an instance of Element, received ${typeof element}`, 112 - ) 113 - } 114 - 115 - return getUniqueCssSelector(element) 116 - } 117 - 118 - function escapeIdForCSSSelector(id: string) { 119 - return id 120 - .split('') 121 - .map((char) => { 122 - const code = char.charCodeAt(0) 123 - 124 - if (char === ' ' || char === '#' || char === '.' || char === ':' || char === '[' || char === ']' || char === '>' || char === '+' || char === '~' || char === '\\') { 125 - // Escape common special characters with backslashes 126 - return `\\${char}` 127 - } 128 - else if (code >= 0x10000) { 129 - // Unicode escape for characters outside the BMP 130 - return `\\${code.toString(16).toUpperCase().padStart(6, '0')} ` 131 - } 132 - else if (code < 0x20 || code === 0x7F) { 133 - // Non-printable ASCII characters (0x00-0x1F and 0x7F) are escaped 134 - return `\\${code.toString(16).toUpperCase().padStart(2, '0')} ` 135 - } 136 - else if (code >= 0x80) { 137 - // Non-ASCII characters (0x80 and above) are escaped 138 - return `\\${code.toString(16).toUpperCase().padStart(2, '0')} ` 139 - } 140 - else { 141 - // Allowable characters are used directly 142 - return char 143 - } 144 - }) 145 - .join('') 146 - } 147 - 148 - function getUniqueCssSelector(el: Element) { 149 - const path = [] 150 - let parent: null | ParentNode 151 - let hasShadowRoot = false 152 - // eslint-disable-next-line no-cond-assign 153 - while (parent = getParent(el)) { 154 - if ((parent as Element).shadowRoot) { 155 - hasShadowRoot = true 156 - } 157 - 158 - const tag = el.tagName 159 - if (el.id) { 160 - path.push(`#${escapeIdForCSSSelector(el.id)}`) 161 - } 162 - else if (!el.nextElementSibling && !el.previousElementSibling) { 163 - path.push(tag.toLowerCase()) 164 - } 165 - else { 166 - let index = 0 167 - let sameTagSiblings = 0 168 - let elementIndex = 0 169 - 170 - for (const sibling of parent.children) { 171 - index++ 172 - if (sibling.tagName === tag) { 173 - sameTagSiblings++ 174 - } 175 - if (sibling === el) { 176 - elementIndex = index 177 - } 178 - } 179 - 180 - if (sameTagSiblings > 1) { 181 - path.push(`${tag.toLowerCase()}:nth-child(${elementIndex})`) 182 - } 183 - else { 184 - path.push(tag.toLowerCase()) 185 - } 186 - } 187 - el = parent as Element 188 - }; 189 - return `${getBrowserState().provider === 'webdriverio' && hasShadowRoot ? '>>>' : ''}${path.reverse().join(' > ')}` 190 - } 191 - 192 - function getParent(el: Element) { 193 - const parent = el.parentNode 194 - if (parent instanceof ShadowRoot) { 195 - return parent.host 196 - } 197 - return parent 198 - } 199 - 200 - export class CommandsManager { 201 - private _listeners: ((command: string, args: any[]) => void)[] = [] 202 - 203 - public onCommand(listener: (command: string, args: any[]) => void): void { 204 - this._listeners.push(listener) 205 - } 206 - 207 - public async triggerCommand<T>(command: string, args: any[]): Promise<T> { 208 - const state = getWorkerState() 209 - const rpc = state.rpc as any as BrowserRPC 210 - const { sessionId } = getBrowserState() 211 - const filepath = state.filepath || state.current?.file?.filepath 212 - if (this._listeners.length) { 213 - await Promise.all(this._listeners.map(listener => listener(command, args))) 214 - } 215 - return rpc.triggerCommand<T>(sessionId, command, filepath, args) 216 - } 217 105 }
+4
packages/runner/src/types/tasks.ts
··· 247 247 * Test context that will be passed to the test function. 248 248 */ 249 249 context: TestContext & ExtraContext 250 + /** 251 + * The test timeout in milliseconds. 252 + */ 253 + timeout: number 250 254 } 251 255 252 256 /**
+3
packages/vitest/src/node/error.ts
··· 264 264 'actual', 265 265 'expected', 266 266 'diffOptions', 267 + 'sourceURL', 268 + 'column', 269 + 'line', 267 270 'VITEST_TEST_NAME', 268 271 'VITEST_TEST_PATH', 269 272 'VITEST_AFTER_ENV_TEARDOWN',
+4
packages/vitest/src/runtime/config.ts
··· 126 126 testIdAttribute: string 127 127 } 128 128 screenshotFailures: boolean 129 + providerOptions: { 130 + // for playwright 131 + actionTimeout?: number 132 + } 129 133 } 130 134 standalone: boolean 131 135 logHeapUsage: boolean | undefined
+1
packages/vitest/src/typecheck/collect.ts
··· 202 202 suite: latestSuite, 203 203 file, 204 204 mode, 205 + timeout: 0, 205 206 context: {} as any, // not used in typecheck 206 207 name: definition.name, 207 208 end: definition.end,
+3
test/reporters/tests/__snapshots__/html.test.ts.snap
··· 68 68 "startTime": 0, 69 69 "state": "fail", 70 70 }, 71 + "timeout": 5000, 71 72 "type": "test", 72 73 }, 73 74 ], ··· 148 149 "startTime": 0, 149 150 "state": "pass", 150 151 }, 152 + "timeout": 5000, 151 153 "type": "test", 152 154 }, 153 155 { ··· 160 162 "meta": {}, 161 163 "mode": "skip", 162 164 "name": "3 + 3 = 6", 165 + "timeout": 5000, 163 166 "type": "test", 164 167 }, 165 168 ],
+53 -182
packages/browser/src/client/tester/context.ts
··· 4 4 BrowserPage, 5 5 Locator, 6 6 UserEvent, 7 - UserEventClickOptions, 8 - UserEventDragAndDropOptions, 9 - UserEventHoverOptions, 10 - UserEventTabOptions, 11 - UserEventTypeOptions, 12 7 } from '../../../context' 13 8 import type { BrowserRunnerState } from '../utils' 14 - import { convertElementToCssSelector, ensureAwaited, getBrowserState, getWorkerState } from '../utils' 9 + import { ensureAwaited, getBrowserState, getWorkerState } from '../utils' 10 + import { convertElementToCssSelector, processTimeoutOptions } from './utils' 15 11 16 12 // this file should not import anything directly, only types and utils 17 13 18 - const state = () => getWorkerState() 19 14 // @ts-expect-error not typed global 20 15 const provider = __vitest_browser_runner__.provider 21 16 const sessionId = getBrowserState().sessionId 22 17 const channel = new BroadcastChannel(`vitest:${sessionId}`) 23 18 24 - function triggerCommand<T>(command: string, ...args: any[]) { 25 - return getBrowserState().commands.triggerCommand<T>(command, args) 19 + function triggerCommand<T>(command: string, args: any[], error?: Error) { 20 + return getBrowserState().commands.triggerCommand<T>(command, args, error) 26 21 } 27 22 28 23 export function createUserEvent(__tl_user_event_base__?: TestingLibraryUserEvent, options?: TestingLibraryOptions): UserEvent { ··· 51 46 if (!keyboard.unreleased.length) { 52 47 return 53 48 } 54 - return ensureAwaited(async () => { 55 - await triggerCommand('__vitest_cleanup', keyboard) 49 + return ensureAwaited(async (error) => { 50 + await triggerCommand('__vitest_cleanup', [keyboard], error) 56 51 keyboard.unreleased = [] 57 52 }) 58 53 }, 59 - click(element: Element | Locator, options: UserEventClickOptions = {}) { 60 - return convertToLocator(element).click(processClickOptions(options)) 54 + click(element, options) { 55 + return convertToLocator(element).click(options) 61 56 }, 62 - dblClick(element: Element | Locator, options: UserEventClickOptions = {}) { 63 - return convertToLocator(element).dblClick(processClickOptions(options)) 57 + dblClick(element, options) { 58 + return convertToLocator(element).dblClick(options) 64 59 }, 65 - tripleClick(element: Element | Locator, options: UserEventClickOptions = {}) { 66 - return convertToLocator(element).tripleClick(processClickOptions(options)) 60 + tripleClick(element, options) { 61 + return convertToLocator(element).tripleClick(options) 67 62 }, 68 - selectOptions(element, value) { 69 - return convertToLocator(element).selectOptions(value) 63 + selectOptions(element, value, options) { 64 + return convertToLocator(element).selectOptions(value, options) 70 65 }, 71 - clear(element: Element | Locator) { 72 - return convertToLocator(element).clear() 66 + clear(element, options) { 67 + return convertToLocator(element).clear(options) 73 68 }, 74 - hover(element: Element | Locator, options: UserEventHoverOptions = {}) { 75 - return convertToLocator(element).hover(processHoverOptions(options)) 69 + hover(element, options) { 70 + return convertToLocator(element).hover(options) 76 71 }, 77 - unhover(element: Element | Locator, options: UserEventHoverOptions = {}) { 72 + unhover(element, options) { 78 73 return convertToLocator(element).unhover(options) 79 74 }, 80 - upload(element: Element | Locator, files: string | string[] | File | File[]) { 81 - return convertToLocator(element).upload(files) 75 + upload(element, files: string | string[] | File | File[], options) { 76 + return convertToLocator(element).upload(files, options) 82 77 }, 83 78 84 79 // non userEvent events, but still useful 85 - fill(element: Element | Locator, text: string, options) { 80 + fill(element, text, options) { 86 81 return convertToLocator(element).fill(text, options) 87 82 }, 88 - dragAndDrop(source: Element | Locator, target: Element | Locator, options = {}) { 83 + dragAndDrop(source, target, options) { 89 84 const sourceLocator = convertToLocator(source) 90 85 const targetLocator = convertToLocator(target) 91 - return sourceLocator.dropTo(targetLocator, processDragAndDropOptions(options)) 86 + return sourceLocator.dropTo(targetLocator, options) 92 87 }, 93 88 94 89 // testing-library user-event 95 - async type(element: Element | Locator, text: string, options: UserEventTypeOptions = {}) { 96 - return ensureAwaited(async () => { 90 + async type(element, text, options) { 91 + return ensureAwaited(async (error) => { 97 92 const selector = convertToSelector(element) 98 93 const { unreleased } = await triggerCommand<{ unreleased: string[] }>( 99 94 '__vitest_type', 100 - selector, 101 - text, 102 - { ...options, unreleased: keyboard.unreleased }, 95 + [ 96 + selector, 97 + text, 98 + { ...options, unreleased: keyboard.unreleased }, 99 + ], 100 + error, 103 101 ) 104 102 keyboard.unreleased = unreleased 105 103 }) 106 104 }, 107 - tab(options: UserEventTabOptions = {}) { 108 - return ensureAwaited(() => triggerCommand('__vitest_tab', options)) 105 + tab(options = {}) { 106 + return ensureAwaited(error => triggerCommand('__vitest_tab', [options], error)) 109 107 }, 110 - async keyboard(text: string) { 111 - return ensureAwaited(async () => { 108 + async keyboard(text) { 109 + return ensureAwaited(async (error) => { 112 110 const { unreleased } = await triggerCommand<{ unreleased: string[] }>( 113 111 '__vitest_keyboard', 114 - text, 115 - keyboard, 112 + [text, keyboard], 113 + error, 116 114 ) 117 115 keyboard.unreleased = unreleased 118 116 }) ··· 175 173 async unhover(element: Element | Locator) { 176 174 await userEvent.unhover(toElement(element)) 177 175 }, 178 - async upload(element: Element | Locator, files: string | string[] | File | File[]) { 176 + async upload(element, files: string | string[] | File | File[]) { 179 177 const uploadPromise = (Array.isArray(files) ? files : [files]).map(async (file) => { 180 178 if (typeof file !== 'string') { 181 179 return file ··· 185 183 content: string 186 184 basename: string 187 185 mime: string 188 - }>('__vitest_fileInfo', file, 'base64') 186 + }>('__vitest_fileInfo', [file, 'base64']) 189 187 190 188 const fileInstance = fetch(`data:${mime};base64,${base64}`) 191 189 .then(r => r.blob()) ··· 196 194 return userEvent.upload(toElement(element) as HTMLElement, uploadFiles) 197 195 }, 198 196 199 - async fill(element: Element | Locator, text: string) { 197 + async fill(element, text) { 200 198 await userEvent.clear(toElement(element)) 201 199 return userEvent.type(toElement(element), text) 202 200 }, ··· 204 202 throw new Error(`The "preview" provider doesn't support 'userEvent.dragAndDrop'`) 205 203 }, 206 204 207 - async type(element: Element | Locator, text: string, options: UserEventTypeOptions = {}) { 205 + async type(element, text, options) { 208 206 await userEvent.type(toElement(element), text, options) 209 207 }, 210 - async tab(options: UserEventTabOptions = {}) { 208 + async tab(options) { 211 209 await userEvent.tab(options) 212 210 }, 213 211 async keyboard(text: string) { ··· 282 280 const name 283 281 = options.path || `${taskName.replace(/[^a-z0-9]/gi, '-')}-${number}.png` 284 282 285 - return ensureAwaited(() => triggerCommand('__vitest_screenshot', name, { 283 + return ensureAwaited(error => triggerCommand('__vitest_screenshot', [name, processTimeoutOptions({ 286 284 ...options, 287 285 element: options.element 288 286 ? convertToSelector(options.element) 289 287 : undefined, 290 - })) 288 + })], error)) 291 289 }, 292 290 getByRole() { 293 - throw new Error('Method "getByRole" is not implemented in the current provider.') 291 + throw new Error(`Method "getByRole" is not implemented in the "${provider}" provider.`) 294 292 }, 295 293 getByLabelText() { 296 - throw new Error('Method "getByLabelText" is not implemented in the current provider.') 294 + throw new Error(`Method "getByLabelText" is not implemented in the "${provider}" provider.`) 297 295 }, 298 296 getByTestId() { 299 - throw new Error('Method "getByTestId" is not implemented in the current provider.') 297 + throw new Error(`Method "getByTestId" is not implemented in the "${provider}" provider.`) 300 298 }, 301 299 getByAltText() { 302 - throw new Error('Method "getByAltText" is not implemented in the current provider.') 300 + throw new Error(`Method "getByAltText" is not implemented in the "${provider}" provider.`) 303 301 }, 304 302 getByPlaceholder() { 305 - throw new Error('Method "getByPlaceholder" is not implemented in the current provider.') 303 + throw new Error(`Method "getByPlaceholder" is not implemented in the "${provider}" provider.`) 306 304 }, 307 305 getByText() { 308 - throw new Error('Method "getByText" is not implemented in the current provider.') 306 + throw new Error(`Method "getByText" is not implemented in the "${provider}" provider.`) 309 307 }, 310 308 getByTitle() { 311 - throw new Error('Method "getByTitle" is not implemented in the current provider.') 309 + throw new Error(`Method "getByTitle" is not implemented in the "${provider}" provider.`) 312 310 }, 313 311 elementLocator() { 314 - throw new Error('Method "elementLocator" is not implemented in the current provider.') 312 + throw new Error(`Method "elementLocator" is not implemented in the "${provider}" provider.`) 315 313 }, 316 314 extend(methods) { 317 315 for (const key in methods) { ··· 343 341 344 342 function getTaskFullName(task: RunnerTask): string { 345 343 return task.suite ? `${getTaskFullName(task.suite)} ${task.name}` : task.name 346 - } 347 - 348 - function processClickOptions(options_?: UserEventClickOptions) { 349 - // only ui scales the iframe, so we need to adjust the position 350 - if (!options_ || !state().config.browser.ui) { 351 - return options_ 352 - } 353 - if (provider === 'playwright') { 354 - const options = options_ as NonNullable< 355 - Parameters<import('playwright').Page['click']>[1] 356 - > 357 - if (options.position) { 358 - options.position = processPlaywrightPosition(options.position) 359 - } 360 - } 361 - if (provider === 'webdriverio') { 362 - const options = options_ as import('webdriverio').ClickOptions 363 - if (options.x != null || options.y != null) { 364 - const cache = {} 365 - if (options.x != null) { 366 - options.x = scaleCoordinate(options.x, cache) 367 - } 368 - if (options.y != null) { 369 - options.y = scaleCoordinate(options.y, cache) 370 - } 371 - } 372 - } 373 - return options_ 374 - } 375 - 376 - function processHoverOptions(options_?: UserEventHoverOptions) { 377 - // only ui scales the iframe, so we need to adjust the position 378 - if (!options_ || !state().config.browser.ui) { 379 - return options_ 380 - } 381 - 382 - if (provider === 'playwright') { 383 - const options = options_ as NonNullable< 384 - Parameters<import('playwright').Page['hover']>[1] 385 - > 386 - if (options.position) { 387 - options.position = processPlaywrightPosition(options.position) 388 - } 389 - } 390 - if (provider === 'webdriverio') { 391 - const options = options_ as import('webdriverio').MoveToOptions 392 - const cache = {} 393 - if (options.xOffset != null) { 394 - options.xOffset = scaleCoordinate(options.xOffset, cache) 395 - } 396 - if (options.yOffset != null) { 397 - options.yOffset = scaleCoordinate(options.yOffset, cache) 398 - } 399 - } 400 - return options_ 401 - } 402 - 403 - function processDragAndDropOptions(options_?: UserEventDragAndDropOptions) { 404 - // only ui scales the iframe, so we need to adjust the position 405 - if (!options_ || !state().config.browser.ui) { 406 - return options_ 407 - } 408 - if (provider === 'playwright') { 409 - const options = options_ as NonNullable< 410 - Parameters<import('playwright').Page['dragAndDrop']>[2] 411 - > 412 - if (options.sourcePosition) { 413 - options.sourcePosition = processPlaywrightPosition(options.sourcePosition) 414 - } 415 - if (options.targetPosition) { 416 - options.targetPosition = processPlaywrightPosition(options.targetPosition) 417 - } 418 - } 419 - if (provider === 'webdriverio') { 420 - const cache = {} 421 - const options = options_ as import('webdriverio').DragAndDropOptions & { 422 - targetX?: number 423 - targetY?: number 424 - sourceX?: number 425 - sourceY?: number 426 - } 427 - if (options.sourceX != null) { 428 - options.sourceX = scaleCoordinate(options.sourceX, cache) 429 - } 430 - if (options.sourceY != null) { 431 - options.sourceY = scaleCoordinate(options.sourceY, cache) 432 - } 433 - if (options.targetX != null) { 434 - options.targetX = scaleCoordinate(options.targetX, cache) 435 - } 436 - if (options.targetY != null) { 437 - options.targetY = scaleCoordinate(options.targetY, cache) 438 - } 439 - } 440 - return options_ 441 - } 442 - 443 - function scaleCoordinate(coordinate: number, cache: any) { 444 - return Math.round(coordinate * getCachedScale(cache)) 445 - } 446 - 447 - function getCachedScale(cache: { scale: number | undefined }) { 448 - return cache.scale ??= getIframeScale() 449 - } 450 - 451 - function processPlaywrightPosition(position: { x: number; y: number }) { 452 - const scale = getIframeScale() 453 - if (position.x != null) { 454 - position.x *= scale 455 - } 456 - if (position.y != null) { 457 - position.y *= scale 458 - } 459 - return position 460 - } 461 - 462 - function getIframeScale() { 463 - const testerUi = window.parent.document.querySelector('#tester-ui') as HTMLElement | null 464 - if (!testerUi) { 465 - throw new Error(`Cannot find Tester element. This is a bug in Vitest. Please, open a new issue with reproduction.`) 466 - } 467 - const scaleAttribute = testerUi.getAttribute('data-scale') 468 - const scale = Number(scaleAttribute) 469 - if (Number.isNaN(scale)) { 470 - throw new TypeError(`Cannot parse scale value from Tester element (${scaleAttribute}). This is a bug in Vitest. Please, open a new issue with reproduction.`) 471 - } 472 - return scale 473 344 }
+2 -1
packages/browser/src/client/tester/expect-element.ts
··· 2 2 import type { ExpectPollOptions } from 'vitest' 3 3 import * as matchers from '@testing-library/jest-dom/matchers' 4 4 import { chai, expect } from 'vitest' 5 + import { processTimeoutOptions } from './utils' 5 6 6 7 export async function setupExpectDom(): Promise<void> { 7 8 expect.extend(matchers) ··· 38 39 } 39 40 40 41 return result 41 - }, options) 42 + }, processTimeoutOptions(options)) 42 43 } 43 44 }
+2 -1
packages/browser/src/client/tester/tester.ts
··· 1 1 import { channel, client, onCancel } from '@vitest/browser/client' 2 2 import { page, server, userEvent } from '@vitest/browser/context' 3 3 import { collectTests, setupCommonEnv, SpyModule, startCoverageInsideWorker, startTests, stopCoverageInsideWorker } from 'vitest/browser' 4 - import { CommandsManager, executor, getBrowserState, getConfig, getWorkerState } from '../utils' 4 + import { executor, getBrowserState, getConfig, getWorkerState } from '../utils' 5 5 import { setupDialogsSpy } from './dialog' 6 6 import { setupExpectDom } from './expect-element' 7 7 import { setupConsoleLogSpy } from './logger' ··· 9 9 import { createModuleMockerInterceptor } from './msw' 10 10 import { createSafeRpc } from './rpc' 11 11 import { browserHashMap, initiateRunner } from './runner' 12 + import { CommandsManager } from './utils' 12 13 13 14 const cleanupSymbol = Symbol.for('vitest:component-cleanup') 14 15
+180
packages/browser/src/client/tester/utils.ts
··· 1 + import type { BrowserRPC } from '../client' 2 + import { getBrowserState, getWorkerState } from '../utils' 3 + 4 + const provider = getBrowserState().provider 5 + 6 + /* @__NO_SIDE_EFFECTS__ */ 7 + export function convertElementToCssSelector(element: Element): string { 8 + if (!element || !(element instanceof Element)) { 9 + throw new Error( 10 + `Expected DOM element to be an instance of Element, received ${typeof element}`, 11 + ) 12 + } 13 + 14 + return getUniqueCssSelector(element) 15 + } 16 + 17 + function escapeIdForCSSSelector(id: string) { 18 + return id 19 + .split('') 20 + .map((char) => { 21 + const code = char.charCodeAt(0) 22 + 23 + if (char === ' ' || char === '#' || char === '.' || char === ':' || char === '[' || char === ']' || char === '>' || char === '+' || char === '~' || char === '\\') { 24 + // Escape common special characters with backslashes 25 + return `\\${char}` 26 + } 27 + else if (code >= 0x10000) { 28 + // Unicode escape for characters outside the BMP 29 + return `\\${code.toString(16).toUpperCase().padStart(6, '0')} ` 30 + } 31 + else if (code < 0x20 || code === 0x7F) { 32 + // Non-printable ASCII characters (0x00-0x1F and 0x7F) are escaped 33 + return `\\${code.toString(16).toUpperCase().padStart(2, '0')} ` 34 + } 35 + else if (code >= 0x80) { 36 + // Non-ASCII characters (0x80 and above) are escaped 37 + return `\\${code.toString(16).toUpperCase().padStart(2, '0')} ` 38 + } 39 + else { 40 + // Allowable characters are used directly 41 + return char 42 + } 43 + }) 44 + .join('') 45 + } 46 + 47 + function getUniqueCssSelector(el: Element) { 48 + const path = [] 49 + let parent: null | ParentNode 50 + let hasShadowRoot = false 51 + // eslint-disable-next-line no-cond-assign 52 + while (parent = getParent(el)) { 53 + if ((parent as Element).shadowRoot) { 54 + hasShadowRoot = true 55 + } 56 + 57 + const tag = el.tagName 58 + if (el.id) { 59 + path.push(`#${escapeIdForCSSSelector(el.id)}`) 60 + } 61 + else if (!el.nextElementSibling && !el.previousElementSibling) { 62 + path.push(tag.toLowerCase()) 63 + } 64 + else { 65 + let index = 0 66 + let sameTagSiblings = 0 67 + let elementIndex = 0 68 + 69 + for (const sibling of parent.children) { 70 + index++ 71 + if (sibling.tagName === tag) { 72 + sameTagSiblings++ 73 + } 74 + if (sibling === el) { 75 + elementIndex = index 76 + } 77 + } 78 + 79 + if (sameTagSiblings > 1) { 80 + path.push(`${tag.toLowerCase()}:nth-child(${elementIndex})`) 81 + } 82 + else { 83 + path.push(tag.toLowerCase()) 84 + } 85 + } 86 + el = parent as Element 87 + }; 88 + return `${getBrowserState().provider === 'webdriverio' && hasShadowRoot ? '>>>' : ''}${path.reverse().join(' > ')}` 89 + } 90 + 91 + function getParent(el: Element) { 92 + const parent = el.parentNode 93 + if (parent instanceof ShadowRoot) { 94 + return parent.host 95 + } 96 + return parent 97 + } 98 + 99 + export class CommandsManager { 100 + private _listeners: ((command: string, args: any[]) => void)[] = [] 101 + 102 + public onCommand(listener: (command: string, args: any[]) => void): void { 103 + this._listeners.push(listener) 104 + } 105 + 106 + public async triggerCommand<T>( 107 + command: string, 108 + args: any[], 109 + // error makes sure the stack trace is correct on webkit, 110 + // if we make the error here, it looses the context 111 + clientError: Error = new Error('empty'), 112 + ): Promise<T> { 113 + const state = getWorkerState() 114 + const rpc = state.rpc as any as BrowserRPC 115 + const { sessionId } = getBrowserState() 116 + const filepath = state.filepath || state.current?.file?.filepath 117 + args = args.filter(arg => arg !== undefined) // remove optional fields 118 + if (this._listeners.length) { 119 + await Promise.all(this._listeners.map(listener => listener(command, args))) 120 + } 121 + return rpc.triggerCommand<T>(sessionId, command, filepath, args).catch((err) => { 122 + // rethrow an error to keep the stack trace in browser 123 + // const clientError = new Error(err.message) 124 + clientError.message = err.message 125 + clientError.name = err.name 126 + clientError.stack = clientError.stack?.replace(clientError.message, err.message) 127 + throw clientError 128 + }) 129 + } 130 + } 131 + 132 + const now = Date.now 133 + 134 + export function processTimeoutOptions<T extends { timeout?: number }>(options_?: T): T | undefined { 135 + if ( 136 + // if timeout is set, keep it 137 + (options_ && options_.timeout != null) 138 + // timeout can only be set for playwright commands 139 + || provider !== 'playwright' 140 + ) { 141 + return options_ 142 + } 143 + // if there is a default action timeout, use it 144 + if (getWorkerState().config.browser.providerOptions.actionTimeout != null) { 145 + return options_ 146 + } 147 + const currentTest = getWorkerState().current 148 + const startTime = currentTest?.result?.startTime 149 + // ignore timeout if this is called outside of a test 150 + if (!currentTest || currentTest.type === 'suite' || !startTime) { 151 + return options_ 152 + } 153 + const timeout = currentTest.timeout 154 + if (timeout === 0 || timeout === Number.POSITIVE_INFINITY) { 155 + return options_ 156 + } 157 + options_ = options_ || {} as T 158 + const currentTime = now() 159 + const endTime = startTime + timeout 160 + const remainingTime = endTime - currentTime 161 + if (remainingTime <= 0) { 162 + return options_ 163 + } 164 + // give us some time to process the timeout 165 + options_.timeout = remainingTime - 100 166 + return options_ 167 + } 168 + 169 + export function getIframeScale(): number { 170 + const testerUi = window.parent.document.querySelector('#tester-ui') as HTMLElement | null 171 + if (!testerUi) { 172 + throw new Error(`Cannot find Tester element. This is a bug in Vitest. Please, open a new issue with reproduction.`) 173 + } 174 + const scaleAttribute = testerUi.getAttribute('data-scale') 175 + const scale = Number(scaleAttribute) 176 + if (Number.isNaN(scale)) { 177 + throw new TypeError(`Cannot parse scale value from Tester element (${scaleAttribute}). This is a bug in Vitest. Please, open a new issue with reproduction.`) 178 + } 179 + return scale 180 + }
+1 -1
packages/browser/src/node/commands/click.ts
··· 59 59 await browser 60 60 .action('pointer', { parameters: { pointerType: 'mouse' } }) 61 61 // move the pointer over the button 62 - .move({ origin: await browser.$(selector) }) 62 + .move({ origin: browser.$(selector) }) 63 63 // simulate 3 clicks 64 64 .down() 65 65 .up()
+1 -1
packages/browser/src/node/commands/fill.ts
··· 19 19 await browser.$(selector).setValue(text) 20 20 } 21 21 else { 22 - throw new TypeError(`Provider "${context.provider.name}" does not support clearing elements`) 22 + throw new TypeError(`Provider "${context.provider.name}" does not support filling inputs`) 23 23 } 24 24 }
+1 -1
packages/browser/src/node/commands/keyboard.ts
··· 202 202 203 203 function selectAll() { 204 204 const element = document.activeElement as HTMLInputElement 205 - if (element && element.select) { 205 + if (element && typeof element.select === 'function') { 206 206 element.select() 207 207 } 208 208 }
+1 -1
packages/browser/src/node/commands/type.ts
··· 45 45 text, 46 46 () => browser.execute(() => { 47 47 const element = document.activeElement as HTMLInputElement 48 - if (element) { 48 + if (element && typeof element.select === 'function') { 49 49 element.select() 50 50 } 51 51 }),
+1 -1
packages/vitest/src/integrations/chai/poll.ts
··· 91 91 const rejectWithCause = (cause: any) => { 92 92 reject( 93 93 copyStackTrace( 94 - new Error(`Matcher did not succeed in ${timeout}ms`, { 94 + new Error('Matcher did not succeed in time.', { 95 95 cause, 96 96 }), 97 97 STACK_TRACE_ERROR,
+5
packages/vitest/src/node/config/serializeConfig.ts
··· 156 156 locators: { 157 157 testIdAttribute: browser.locators.testIdAttribute, 158 158 }, 159 + providerOptions: browser.provider === 'playwright' 160 + ? { 161 + actionTimeout: (browser.providerOptions as any)?.context?.actionTimeout, 162 + } 163 + : {}, 159 164 } 160 165 })(config.browser), 161 166 standalone: config.standalone,
+1
packages/vitest/src/node/reporters/junit.ts
··· 317 317 mode: 'run', 318 318 result: file.result, 319 319 meta: {}, 320 + timeout: 0, 320 321 // NOTE: not used in JUnitReporter 321 322 context: null as any, 322 323 suite: null as any,
+1
test/cli/fixtures/custom-pool/pool/custom-pool.ts
··· 42 42 suite: taskFile, 43 43 mode: 'run', 44 44 meta: {}, 45 + timeout: 0, 45 46 file: taskFile, 46 47 result: { 47 48 state: 'pass',
+16 -9
packages/browser/src/client/tester/locators/index.ts
··· 2 2 LocatorByRoleOptions, 3 3 LocatorOptions, 4 4 LocatorScreenshotOptions, 5 + UserEventClearOptions, 5 6 UserEventClickOptions, 6 7 UserEventDragAndDropOptions, 7 8 UserEventFillOptions, 8 9 UserEventHoverOptions, 10 + UserEventSelectOptions, 11 + UserEventUploadOptions, 9 12 } from '@vitest/browser/context' 10 13 import { page, server } from '@vitest/browser/context' 11 14 import { ··· 57 60 return this.triggerCommand<void>('__vitest_tripleClick', this.selector, options) 58 61 } 59 62 60 - public clear(): Promise<void> { 61 - return this.triggerCommand<void>('__vitest_clear', this.selector) 63 + public clear(options?: UserEventClearOptions): Promise<void> { 64 + return this.triggerCommand<void>('__vitest_clear', this.selector, options) 62 65 } 63 66 64 - public hover(options: UserEventHoverOptions): Promise<void> { 67 + public hover(options?: UserEventHoverOptions): Promise<void> { 65 68 return this.triggerCommand<void>('__vitest_hover', this.selector, options) 66 69 } 67 70 68 - public unhover(options: UserEventHoverOptions): Promise<void> { 71 + public unhover(options?: UserEventHoverOptions): Promise<void> { 69 72 return this.triggerCommand<void>('__vitest_hover', 'html > body', options) 70 73 } 71 74 ··· 73 76 return this.triggerCommand<void>('__vitest_fill', this.selector, text, options) 74 77 } 75 78 76 - public async upload(files: string | string[] | File | File[]): Promise<void> { 79 + public async upload(files: string | string[] | File | File[], options?: UserEventUploadOptions): Promise<void> { 77 80 const filesPromise = (Array.isArray(files) ? files : [files]).map(async (file) => { 78 81 if (typeof file === 'string') { 79 82 return file ··· 91 94 base64: bas64String, 92 95 } 93 96 }) 94 - return this.triggerCommand<void>('__vitest_upload', this.selector, await Promise.all(filesPromise)) 97 + return this.triggerCommand<void>('__vitest_upload', this.selector, await Promise.all(filesPromise), options) 95 98 } 96 99 97 100 public dropTo(target: Locator, options: UserEventDragAndDropOptions = {}): Promise<void> { ··· 103 106 ) 104 107 } 105 108 106 - public selectOptions(value: HTMLElement | HTMLElement[] | Locator | Locator[] | string | string[]): Promise<void> { 109 + public selectOptions( 110 + value: HTMLElement | HTMLElement[] | Locator | Locator[] | string | string[], 111 + options?: UserEventSelectOptions, 112 + ): Promise<void> { 107 113 const values = (Array.isArray(value) ? value : [value]).map((v) => { 108 114 if (typeof v !== 'string') { 109 115 const selector = 'element' in v ? v.selector : selectorEngine.generateSelectorSimple(v) ··· 111 117 } 112 118 return v 113 119 }) 114 - return this.triggerCommand('__vitest_selectOptions', this.selector, values) 120 + return this.triggerCommand('__vitest_selectOptions', this.selector, values, options) 115 121 } 116 122 117 123 public screenshot(options: Omit<LocatorScreenshotOptions, 'base64'> & { base64: true }): Promise<{ ··· 204 210 205 211 protected triggerCommand<T>(command: string, ...args: any[]): Promise<T> { 206 212 const commands = getBrowserState().commands 207 - return ensureAwaited(() => commands.triggerCommand<T>( 213 + return ensureAwaited(error => commands.triggerCommand<T>( 208 214 command, 209 215 args, 216 + error, 210 217 )) 211 218 } 212 219 }
+103
packages/browser/src/client/tester/locators/playwright.ts
··· 1 + import type { UserEventClearOptions, UserEventClickOptions, UserEventDragAndDropOptions, UserEventFillOptions, UserEventHoverOptions, UserEventSelectOptions, UserEventUploadOptions } from '@vitest/browser/context' 1 2 import { page, server } from '@vitest/browser/context' 2 3 import { 3 4 getByAltTextSelector, ··· 8 9 getByTextSelector, 9 10 getByTitleSelector, 10 11 } from 'ivya' 12 + import { getBrowserState } from '../../utils' 13 + import { getIframeScale, processTimeoutOptions } from '../utils' 11 14 import { Locator, selectorEngine } from './index' 12 15 13 16 page.extend({ ··· 46 49 super() 47 50 } 48 51 52 + public override click(options?: UserEventClickOptions) { 53 + return super.click(processTimeoutOptions(processClickOptions(options))) 54 + } 55 + 56 + public override dblClick(options?: UserEventClickOptions): Promise<void> { 57 + return super.dblClick(processTimeoutOptions(processClickOptions(options))) 58 + } 59 + 60 + public override tripleClick(options?: UserEventClickOptions): Promise<void> { 61 + return super.tripleClick(processTimeoutOptions(processClickOptions(options))) 62 + } 63 + 64 + public override selectOptions( 65 + value: HTMLElement | HTMLElement[] | Locator | Locator[] | string | string[], 66 + options?: UserEventSelectOptions, 67 + ): Promise<void> { 68 + return super.selectOptions(value, processTimeoutOptions(options)) 69 + } 70 + 71 + public override clear(options?: UserEventClearOptions): Promise<void> { 72 + return super.clear(processTimeoutOptions(options)) 73 + } 74 + 75 + public override hover(options?: UserEventHoverOptions): Promise<void> { 76 + return super.hover(processTimeoutOptions(processHoverOptions(options))) 77 + } 78 + 79 + public override upload( 80 + files: string | string[] | File | File[], 81 + options?: UserEventUploadOptions, 82 + ): Promise<void> { 83 + return super.upload(files, processTimeoutOptions(options)) 84 + } 85 + 86 + public override fill(text: string, options?: UserEventFillOptions): Promise<void> { 87 + return super.fill(text, processTimeoutOptions(options)) 88 + } 89 + 90 + public override dropTo(target: Locator, options?: UserEventDragAndDropOptions): Promise<void> { 91 + return super.dropTo(target, processTimeoutOptions( 92 + processDragAndDropOptions(options), 93 + )) 94 + } 95 + 49 96 protected locator(selector: string) { 50 97 return new PlaywrightLocator(`${this.selector} >> ${selector}`, this._container) 51 98 } ··· 56 103 element, 57 104 ) 58 105 } 106 + } 107 + 108 + function processDragAndDropOptions(options_?: UserEventDragAndDropOptions) { 109 + // only ui scales the iframe, so we need to adjust the position 110 + if (!options_ || !getBrowserState().config.browser.ui) { 111 + return options_ 112 + } 113 + const options = options_ as NonNullable< 114 + Parameters<import('playwright').Page['dragAndDrop']>[2] 115 + > 116 + if (options.sourcePosition) { 117 + options.sourcePosition = processPlaywrightPosition(options.sourcePosition) 118 + } 119 + if (options.targetPosition) { 120 + options.targetPosition = processPlaywrightPosition(options.targetPosition) 121 + } 122 + return options_ 123 + } 124 + 125 + function processHoverOptions(options_?: UserEventHoverOptions) { 126 + // only ui scales the iframe, so we need to adjust the position 127 + if (!options_ || !getBrowserState().config.browser.ui) { 128 + return options_ 129 + } 130 + const options = options_ as NonNullable< 131 + Parameters<import('playwright').Page['hover']>[1] 132 + > 133 + if (options.position) { 134 + options.position = processPlaywrightPosition(options.position) 135 + } 136 + return options_ 137 + } 138 + 139 + function processClickOptions(options_?: UserEventClickOptions) { 140 + // only ui scales the iframe, so we need to adjust the position 141 + if (!options_ || !getBrowserState().config.browser.ui) { 142 + return options_ 143 + } 144 + const options = options_ as NonNullable< 145 + Parameters<import('playwright').Page['click']>[1] 146 + > 147 + if (options.position) { 148 + options.position = processPlaywrightPosition(options.position) 149 + } 150 + return options 151 + } 152 + 153 + function processPlaywrightPosition(position: { x: number; y: number }) { 154 + const scale = getIframeScale() 155 + if (position.x != null) { 156 + position.x *= scale 157 + } 158 + if (position.y != null) { 159 + position.y *= scale 160 + } 161 + return position 59 162 }
+1 -1
packages/browser/src/client/tester/locators/preview.ts
··· 8 8 getByTextSelector, 9 9 getByTitleSelector, 10 10 } from 'ivya' 11 - import { convertElementToCssSelector } from '../../utils' 12 11 import { getElementError } from '../public-utils' 12 + import { convertElementToCssSelector } from '../utils' 13 13 import { Locator, selectorEngine } from './index' 14 14 15 15 page.extend({
+98 -4
packages/browser/src/client/tester/locators/webdriverio.ts
··· 1 + import type { UserEventClickOptions, UserEventDragAndDropOptions, UserEventHoverOptions, UserEventSelectOptions } from '@vitest/browser/context' 1 2 import { page, server } from '@vitest/browser/context' 2 3 import { 3 4 getByAltTextSelector, ··· 8 9 getByTextSelector, 9 10 getByTitleSelector, 10 11 } from 'ivya' 11 - import { convertElementToCssSelector } from '../../utils' 12 + import { getBrowserState } from '../../utils' 12 13 import { getElementError } from '../public-utils' 14 + import { convertElementToCssSelector, getIframeScale } from '../utils' 13 15 import { Locator, selectorEngine } from './index' 14 16 15 17 page.extend({ ··· 45 47 super() 46 48 } 47 49 48 - override get selector() { 50 + override get selector(): string { 49 51 const selectors = this.elements().map(element => convertElementToCssSelector(element)) 50 52 if (!selectors.length) { 51 53 throw getElementError(this._pwSelector, this._container || document.body) ··· 53 55 return selectors.join(', ') 54 56 } 55 57 56 - public selectOptions(value: HTMLElement | HTMLElement[] | Locator | Locator[] | string | string[]): Promise<void> { 58 + public override click(options?: UserEventClickOptions): Promise<void> { 59 + return super.click(processClickOptions(options)) 60 + } 61 + 62 + public override dblClick(options?: UserEventClickOptions): Promise<void> { 63 + return super.dblClick(processClickOptions(options)) 64 + } 65 + 66 + public override tripleClick(options?: UserEventClickOptions): Promise<void> { 67 + return super.tripleClick(processClickOptions(options)) 68 + } 69 + 70 + public selectOptions( 71 + value: HTMLElement | HTMLElement[] | Locator | Locator[] | string | string[], 72 + options?: UserEventSelectOptions, 73 + ): Promise<void> { 57 74 const values = getWebdriverioSelectOptions(this.element(), value) 58 - return this.triggerCommand('__vitest_selectOptions', this.selector, values) 75 + return this.triggerCommand('__vitest_selectOptions', this.selector, values, options) 76 + } 77 + 78 + public override hover(options?: UserEventHoverOptions): Promise<void> { 79 + return super.hover(processHoverOptions(options)) 80 + } 81 + 82 + public override dropTo(target: Locator, options?: UserEventDragAndDropOptions): Promise<void> { 83 + return super.dropTo(target, processDragAndDropOptions(options)) 59 84 } 60 85 61 86 protected locator(selector: string) { ··· 106 131 } 107 132 108 133 return [{ index: labelIndex }] 134 + } 135 + 136 + function processClickOptions(options_?: UserEventClickOptions) { 137 + // only ui scales the iframe, so we need to adjust the position 138 + if (!options_ || !getBrowserState().config.browser.ui) { 139 + return options_ 140 + } 141 + const options = options_ as import('webdriverio').ClickOptions 142 + if (options.x != null || options.y != null) { 143 + const cache = {} 144 + if (options.x != null) { 145 + options.x = scaleCoordinate(options.x, cache) 146 + } 147 + if (options.y != null) { 148 + options.y = scaleCoordinate(options.y, cache) 149 + } 150 + } 151 + return options_ 152 + } 153 + 154 + function processHoverOptions(options_?: UserEventHoverOptions) { 155 + // only ui scales the iframe, so we need to adjust the position 156 + if (!options_ || !getBrowserState().config.browser.ui) { 157 + return options_ 158 + } 159 + const options = options_ as import('webdriverio').MoveToOptions 160 + const cache = {} 161 + if (options.xOffset != null) { 162 + options.xOffset = scaleCoordinate(options.xOffset, cache) 163 + } 164 + if (options.yOffset != null) { 165 + options.yOffset = scaleCoordinate(options.yOffset, cache) 166 + } 167 + return options_ 168 + } 169 + 170 + function processDragAndDropOptions(options_?: UserEventDragAndDropOptions) { 171 + // only ui scales the iframe, so we need to adjust the position 172 + if (!options_ || !getBrowserState().config.browser.ui) { 173 + return options_ 174 + } 175 + const cache = {} 176 + const options = options_ as import('webdriverio').DragAndDropOptions & { 177 + targetX?: number 178 + targetY?: number 179 + sourceX?: number 180 + sourceY?: number 181 + } 182 + if (options.sourceX != null) { 183 + options.sourceX = scaleCoordinate(options.sourceX, cache) 184 + } 185 + if (options.sourceY != null) { 186 + options.sourceY = scaleCoordinate(options.sourceY, cache) 187 + } 188 + if (options.targetX != null) { 189 + options.targetX = scaleCoordinate(options.targetX, cache) 190 + } 191 + if (options.targetY != null) { 192 + options.targetY = scaleCoordinate(options.targetY, cache) 193 + } 194 + return options_ 195 + } 196 + 197 + function scaleCoordinate(coordinate: number, cache: any) { 198 + return Math.round(coordinate * getCachedScale(cache)) 199 + } 200 + 201 + function getCachedScale(cache: { scale: number | undefined }) { 202 + return cache.scale ??= getIframeScale() 109 203 }