diff --git a/docs/guide/browser/context.md b/docs/guide/browser/context.md index 5780534c0288..744bb37ec597 100644 --- a/docs/guide/browser/context.md +++ b/docs/guide/browser/context.md @@ -20,6 +20,7 @@ The `userEvent` API is explained in detail at [Interactivity API](/guide/browser */ export const userEvent: { setup: () => UserEvent + cleanup: () => Promise click: (element: Element, options?: UserEventClickOptions) => Promise dblClick: (element: Element, options?: UserEventDoubleClickOptions) => Promise tripleClick: (element: Element, options?: UserEventTripleClickOptions) => Promise diff --git a/packages/browser/context.d.ts b/packages/browser/context.d.ts index 95f59074d7a1..c332b1df9de9 100644 --- a/packages/browser/context.d.ts +++ b/packages/browser/context.d.ts @@ -59,6 +59,12 @@ export interface UserEvent { * @see {@link https://vitest.dev/guide/browser/interactivity-api.html#userevent-setup} */ setup: () => UserEvent + /** + * Cleans up the user event instance, releasing any resources or state it holds, + * such as keyboard press state. For the default `userEvent` instance, this method + * is automatically called after each test case. + */ + cleanup: () => Promise /** * Click on an element. Uses provider's API under the hood and supports all its options. * @see {@link https://playwright.dev/docs/api/class-locator#locator-click} Playwright API diff --git a/packages/browser/src/client/tester/context.ts b/packages/browser/src/client/tester/context.ts index f498e89128a2..613740bc9fef 100644 --- a/packages/browser/src/client/tester/context.ts +++ b/packages/browser/src/client/tester/context.ts @@ -1,6 +1,6 @@ import type { RunnerTask } from 'vitest' import type { BrowserRPC } from '@vitest/browser/client' -import type { UserEvent as TestingLibraryUserEvent } from '@testing-library/user-event' +import type { Options as TestingLibraryOptions, UserEvent as TestingLibraryUserEvent } from '@testing-library/user-event' import type { BrowserPage, Locator, @@ -29,14 +29,23 @@ function triggerCommand(command: string, ...args: any[]) { return rpc().triggerCommand(contextId, command, filepath(), args) } -export function createUserEvent(__tl_user_event__?: TestingLibraryUserEvent): UserEvent { +export function createUserEvent(__tl_user_event_base__?: TestingLibraryUserEvent, options?: TestingLibraryOptions): UserEvent { + let __tl_user_event__ = __tl_user_event_base__?.setup(options ?? {}) const keyboard = { unreleased: [] as string[], } return { setup(options?: any) { - return createUserEvent(__tl_user_event__?.setup(options)) + return createUserEvent(__tl_user_event_base__, options) + }, + async cleanup() { + if (typeof __tl_user_event_base__ !== 'undefined') { + __tl_user_event__ = __tl_user_event_base__?.setup(options ?? {}) + return + } + await triggerCommand('__vitest_cleanup', keyboard) + keyboard.unreleased = [] }, click(element: Element | Locator, options: UserEventClickOptions = {}) { return convertToLocator(element).click(processClickOptions(options)) diff --git a/packages/browser/src/client/tester/runner.ts b/packages/browser/src/client/tester/runner.ts index 83bf799cf1b7..370046c3acb4 100644 --- a/packages/browser/src/client/tester/runner.ts +++ b/packages/browser/src/client/tester/runner.ts @@ -4,7 +4,7 @@ import type { VitestExecutor } from 'vitest/execute' import { NodeBenchmarkRunner, VitestTestRunner } from 'vitest/runners' import { loadDiffConfig, loadSnapshotSerializers, takeCoverageInsideWorker } from 'vitest/browser' import { TraceMap, originalPositionFor } from 'vitest/utils' -import { page } from '@vitest/browser/context' +import { page, userEvent } from '@vitest/browser/context' import { globalChannel } from '@vitest/browser/client' import { executor } from '../utils' import { VitestBrowserSnapshotEnvironment } from './snapshot' @@ -41,6 +41,7 @@ export function createBrowserRunner( } onAfterRunTask = async (task: Task) => { + await userEvent.cleanup() await super.onAfterRunTask?.(task) if (this.config.bail && task.result?.state === 'fail') { diff --git a/packages/browser/src/node/commands/index.ts b/packages/browser/src/node/commands/index.ts index 3568fbcc5f8d..bdbd2c7052fd 100644 --- a/packages/browser/src/node/commands/index.ts +++ b/packages/browser/src/node/commands/index.ts @@ -4,7 +4,7 @@ import { clear } from './clear' import { fill } from './fill' import { selectOptions } from './select' import { tab } from './tab' -import { keyboard } from './keyboard' +import { keyboard, keyboardCleanup } from './keyboard' import { dragAndDrop } from './dragAndDrop' import { hover } from './hover' import { upload } from './upload' @@ -34,4 +34,5 @@ export default { __vitest_selectOptions: selectOptions, __vitest_dragAndDrop: dragAndDrop, __vitest_hover: hover, + __vitest_cleanup: keyboardCleanup, } diff --git a/packages/browser/src/node/commands/keyboard.ts b/packages/browser/src/node/commands/keyboard.ts index 4341700876c3..852a2fd2ca51 100644 --- a/packages/browser/src/node/commands/keyboard.ts +++ b/packages/browser/src/node/commands/keyboard.ts @@ -49,6 +49,29 @@ export const keyboard: UserEventCommand<(text: string, state: KeyboardState) => } } +export const keyboardCleanup: UserEventCommand<(state: KeyboardState) => Promise> = async ( + context, + state, +) => { + const { provider, contextId } = context + if (provider instanceof PlaywrightBrowserProvider) { + const page = provider.getPage(contextId) + for (const key of state.unreleased) { + await page.keyboard.up(key) + } + } + else if (provider instanceof WebdriverBrowserProvider) { + const keyboard = provider.browser!.action('key') + for (const key of state.unreleased) { + keyboard.up(key) + } + await keyboard.perform() + } + else { + throw new TypeError(`Provider "${context.provider.name}" does not support keyboard api`) + } +} + export async function keyboardImplementation( pressed: Set, provider: BrowserProvider, diff --git a/packages/browser/src/node/plugins/pluginContext.ts b/packages/browser/src/node/plugins/pluginContext.ts index f431ad399477..1c31f1d13fe9 100644 --- a/packages/browser/src/node/plugins/pluginContext.ts +++ b/packages/browser/src/node/plugins/pluginContext.ts @@ -97,7 +97,8 @@ async function getUserEventImport(provider: BrowserProvider, resolve: (id: strin if (!resolved) { throw new Error(`Failed to resolve user-event package from ${__dirname}`) } - return `import { userEvent as __vitest_user_event__ } from '${slash( - `/@fs/${resolved.id}`, - )}'\nconst _userEventSetup = __vitest_user_event__.setup()\n` + return `\ +import { userEvent as __vitest_user_event__ } from '${slash(`/@fs/${resolved.id}`)}' +const _userEventSetup = __vitest_user_event__ +` } diff --git a/test/browser/fixtures/user-event/cleanup1.test.ts b/test/browser/fixtures/user-event/cleanup1.test.ts new file mode 100644 index 000000000000..9b6ef3996ed6 --- /dev/null +++ b/test/browser/fixtures/user-event/cleanup1.test.ts @@ -0,0 +1,55 @@ +import { expect, onTestFinished, test } from 'vitest' +import { userEvent } from '@vitest/browser/context' + +test('cleanup1', async () => { + let logs: any[] = []; + function handler(e: KeyboardEvent) { + logs.push([e.key, e.altKey]); + }; + document.addEventListener('keydown', handler) + onTestFinished(() => { + document.removeEventListener('keydown', handler); + }) + + await userEvent.keyboard('{Tab}') + await userEvent.keyboard("{Alt>}") + expect(logs).toMatchInlineSnapshot(` + [ + [ + "Tab", + false, + ], + [ + "Alt", + true, + ], + ] + `) +}) + +// test per-test cleanup +test('cleanup1.2', async () => { + let logs: any[] = []; + function handler(e: KeyboardEvent) { + logs.push([e.key, e.altKey]); + }; + document.addEventListener('keydown', handler) + onTestFinished(() => { + document.removeEventListener('keydown', handler); + }) + + await userEvent.keyboard('{Tab}') + await userEvent.keyboard("{Alt>}") + expect(logs).toMatchInlineSnapshot(` + [ + [ + "Tab", + false, + ], + [ + "Alt", + true, + ], + ] + `) +}) diff --git a/test/browser/fixtures/user-event/cleanup2.test.ts b/test/browser/fixtures/user-event/cleanup2.test.ts new file mode 100644 index 000000000000..75e66d124050 --- /dev/null +++ b/test/browser/fixtures/user-event/cleanup2.test.ts @@ -0,0 +1,30 @@ +import { expect, onTestFinished, test } from 'vitest' +import { userEvent } from '@vitest/browser/context' + +// test per-test-file cleanup just in case + +test('cleanup2', async () => { + let logs: any[] = []; + function handler(e: KeyboardEvent) { + logs.push([e.key, e.altKey]); + }; + document.addEventListener('keydown', handler) + onTestFinished(() => { + document.removeEventListener('keydown', handler); + }) + + await userEvent.keyboard('{Tab}') + await userEvent.keyboard("{Alt>}") + expect(logs).toMatchInlineSnapshot(` + [ + [ + "Tab", + false, + ], + [ + "Alt", + true, + ], + ] + `) +}) diff --git a/test/browser/fixtures/user-event/vitest.config.ts b/test/browser/fixtures/user-event/vitest.config.ts new file mode 100644 index 000000000000..c3fe79b6ac9c --- /dev/null +++ b/test/browser/fixtures/user-event/vitest.config.ts @@ -0,0 +1,17 @@ +import { fileURLToPath } from 'node:url' +import { defineConfig } from 'vitest/config' + +const provider = process.env.PROVIDER || 'playwright' +const name = + process.env.BROWSER || (provider === 'playwright' ? 'chromium' : 'chrome') + +export default defineConfig({ + cacheDir: fileURLToPath(new URL("./node_modules/.vite", import.meta.url)), + test: { + browser: { + enabled: true, + provider, + name, + }, + }, +}) diff --git a/test/browser/specs/runner.test.ts b/test/browser/specs/runner.test.ts index 419aa61df9c4..da0a878afc07 100644 --- a/test/browser/specs/runner.test.ts +++ b/test/browser/specs/runner.test.ts @@ -137,3 +137,15 @@ error with a stack expect(stderr).toContain('Access denied to "/inaccesible/path".') }) }) + +test('user-event', async () => { + const { ctx } = await runBrowserTests({ + root: './fixtures/user-event', + }) + expect(Object.fromEntries(ctx.state.getFiles().map(f => [f.name, f.result.state]))).toMatchInlineSnapshot(` + { + "cleanup1.test.ts": "pass", + "cleanup2.test.ts": "pass", + } + `) +})