Skip to content

Commit

Permalink
perf(browser): do wdio context switching only once per file (#7549)
Browse files Browse the repository at this point in the history
  • Loading branch information
sheremet-va authored Feb 24, 2025
1 parent 7660723 commit aaa5855
Show file tree
Hide file tree
Showing 10 changed files with 86 additions and 54 deletions.
15 changes: 6 additions & 9 deletions packages/browser/src/client/tester/context.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import type { Options as TestingLibraryOptions, UserEvent as TestingLibraryUserEvent } from '@testing-library/user-event'
import type { BrowserRPC } from '@vitest/browser/client'
import type { RunnerTask } from 'vitest'
import type {
BrowserPage,
Expand All @@ -19,15 +18,11 @@ import { convertElementToCssSelector, ensureAwaited, getBrowserState, getWorkerS
const state = () => getWorkerState()
// @ts-expect-error not typed global
const provider = __vitest_browser_runner__.provider
function filepath() {
return getWorkerState().filepath || getWorkerState().current?.file?.filepath || undefined
}
const rpc = () => getWorkerState().rpc as any as BrowserRPC
const sessionId = getBrowserState().sessionId
const channel = new BroadcastChannel(`vitest:${sessionId}`)

function triggerCommand<T>(command: string, ...args: any[]) {
return rpc().triggerCommand<T>(sessionId, command, filepath(), args)
return getBrowserState().commands.triggerCommand<T>(command, args)
}

export function createUserEvent(__tl_user_event_base__?: TestingLibraryUserEvent, options?: TestingLibraryOptions): UserEvent {
Expand All @@ -52,6 +47,10 @@ export function createUserEvent(__tl_user_event_base__?: TestingLibraryUserEvent
return createUserEvent()
},
async cleanup() {
// avoid cleanup rpc call if there is nothing to cleanup
if (!keyboard.unreleased.length) {
return
}
return ensureAwaited(async () => {
await triggerCommand('__vitest_cleanup', keyboard)
keyboard.unreleased = []
Expand Down Expand Up @@ -106,9 +105,7 @@ export function createUserEvent(__tl_user_event_base__?: TestingLibraryUserEvent
})
},
tab(options: UserEventTabOptions = {}) {
return ensureAwaited(() => {
return triggerCommand('__vitest_tab', options)
})
return ensureAwaited(() => triggerCommand('__vitest_tab', options))
},
async keyboard(text: string) {
return ensureAwaited(async () => {
Expand Down
26 changes: 3 additions & 23 deletions packages/browser/src/client/tester/locators/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import type { BrowserRPC } from '@vitest/browser/client'
import type {
LocatorByRoleOptions,
LocatorOptions,
Expand All @@ -8,8 +7,6 @@ import type {
UserEventFillOptions,
UserEventHoverOptions,
} from '@vitest/browser/context'
import type { WorkerGlobalState } from 'vitest'
import type { BrowserRunnerState } from '../../utils'
import { page, server } from '@vitest/browser/context'
import {
getByAltTextSelector,
Expand All @@ -22,7 +19,7 @@ import {
Ivya,
type ParsedSelector,
} from 'ivya'
import { ensureAwaited, getBrowserState, getWorkerState } from '../../utils'
import { ensureAwaited, getBrowserState } from '../../utils'
import { getElementError } from '../public-utils'

// we prefer using playwright locators because they are more powerful and support Shadow DOM
Expand Down Expand Up @@ -205,27 +202,10 @@ export abstract class Locator {
return this.selector
}

private get state(): BrowserRunnerState {
return getBrowserState()
}

private get worker(): WorkerGlobalState {
return getWorkerState()
}

private get rpc(): BrowserRPC {
return this.worker.rpc as any as BrowserRPC
}

protected triggerCommand<T>(command: string, ...args: any[]): Promise<T> {
const filepath = this.worker.filepath
|| this.worker.current?.file?.filepath
|| undefined

return ensureAwaited(() => this.rpc.triggerCommand<T>(
this.state.sessionId,
const commands = getBrowserState().commands
return ensureAwaited(() => commands.triggerCommand<T>(
command,
filepath,
args,
))
}
Expand Down
35 changes: 32 additions & 3 deletions packages/browser/src/client/tester/tester.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { channel, client, onCancel } from '@vitest/browser/client'
import { page, userEvent } from '@vitest/browser/context'
import { page, server, userEvent } from '@vitest/browser/context'
import { collectTests, setupCommonEnv, SpyModule, startCoverageInsideWorker, startTests, stopCoverageInsideWorker } from 'vitest/browser'
import { executor, getBrowserState, getConfig, getWorkerState } from '../utils'
import { CommandsManager, executor, getBrowserState, getConfig, getWorkerState } from '../utils'
import { setupDialogsSpy } from './dialog'
import { setupExpectDom } from './expect-element'
import { setupConsoleLogSpy } from './logger'
Expand Down Expand Up @@ -34,6 +34,8 @@ async function prepareTestEnvironment(files: string[]) {
state.onCancel = onCancel
state.rpc = rpc as any

getBrowserState().commands = new CommandsManager()

// TODO: expose `worker`
const interceptor = createModuleMockerInterceptor()
const mocker = new VitestBrowserClientMocker(
Expand Down Expand Up @@ -69,6 +71,8 @@ async function prepareTestEnvironment(files: string[]) {
runner,
config,
state,
rpc,
commands: getBrowserState().commands,
}
}

Expand Down Expand Up @@ -113,12 +117,34 @@ async function executeTests(method: 'run' | 'collect', files: string[]) {

debug('runner resolved successfully')

const { config, runner, state } = preparedData
const { config, runner, state, commands, rpc } = preparedData

state.durations.prepare = performance.now() - state.durations.prepare

debug('prepare time', state.durations.prepare, 'ms')

let contextSwitched = false

// webdiverio context depends on the iframe state, so we need to switch the context,
// we delay this in case the user doesn't use any userEvent commands to avoid the overhead
if (server.provider === 'webdriverio') {
let switchPromise: Promise<void> | null = null

commands.onCommand(async () => {
if (switchPromise) {
await switchPromise
}
// if this is the first command, make sure we switched the command context to an iframe
if (!contextSwitched) {
switchPromise = rpc.wdioSwitchContext('iframe').finally(() => {
switchPromise = null
contextSwitched = true
})
await switchPromise
}
})
}

try {
await Promise.all([
setupCommonEnv(config),
Expand Down Expand Up @@ -151,6 +177,9 @@ async function executeTests(method: 'run' | 'collect', files: string[]) {
// need to cleanup for each tester
// since playwright keyboard API is stateful on page instance level
await userEvent.cleanup()
if (contextSwitched) {
await rpc.wdioSwitchContext('parent')
}
}
catch (error: any) {
await client.rpc.onUnhandledError({
Expand Down
21 changes: 21 additions & 0 deletions packages/browser/src/client/utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { SerializedConfig, WorkerGlobalState } from 'vitest'
import type { BrowserRPC } from './client'

export async function importId(id: string): Promise<any> {
const name = `/@id/${id}`.replace(/\\/g, '/')
Expand Down Expand Up @@ -77,6 +78,7 @@ export interface BrowserRunnerState {
method: 'run' | 'collect'
runTests?: (tests: string[]) => Promise<void>
createTesters?: (files: string[]) => Promise<void>
commands: CommandsManager
cdp?: {
on: (event: string, listener: (payload: any) => void) => void
once: (event: string, listener: (payload: any) => void) => void
Expand Down Expand Up @@ -194,3 +196,22 @@ function getParent(el: Element) {
}
return parent
}

export class CommandsManager {
private _listeners: ((command: string, args: any[]) => void)[] = []

public onCommand(listener: (command: string, args: any[]) => void): void {
this._listeners.push(listener)
}

public async triggerCommand<T>(command: string, args: any[]): Promise<T> {
const state = getWorkerState()
const rpc = state.rpc as any as BrowserRPC
const { sessionId } = getBrowserState()
const filepath = state.filepath || state.current?.file?.filepath
if (this._listeners.length) {
await Promise.all(this._listeners.map(listener => listener(command, args)))
}
return rpc.triggerCommand<T>(sessionId, command, filepath, args)
}
}
3 changes: 3 additions & 0 deletions packages/browser/src/node/commands/keyboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ export const keyboardCleanup: UserEventCommand<(state: KeyboardState) => Promise
state,
) => {
const { provider, sessionId } = context
if (!state.unreleased) {
return
}
if (provider instanceof PlaywrightBrowserProvider) {
const page = provider.getPage(sessionId)
for (const key of state.unreleased) {
Expand Down
7 changes: 1 addition & 6 deletions packages/browser/src/node/plugins/pluginContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,15 +32,13 @@ async function generateContextFile(
globalServer: ParentBrowserProject,
) {
const commands = Object.keys(globalServer.commands)
const filepathCode
= '__vitest_worker__.filepath || __vitest_worker__.current?.file?.filepath || undefined'
const provider = [...globalServer.children][0].provider || { name: 'preview' }
const providerName = provider.name

const commandsCode = commands
.filter(command => !command.startsWith('__vitest'))
.map((command) => {
return ` ["${command}"]: (...args) => rpc().triggerCommand(sessionId, "${command}", filepath(), args),`
return ` ["${command}"]: (...args) => __vitest_browser_runner__.commands.triggerCommand("${command}", args),`
})
.join('\n')

Expand All @@ -53,9 +51,6 @@ async function generateContextFile(
return `
import { page, createUserEvent, cdp } from '${distContextPath}'
${userEventNonProviderImport}
const filepath = () => ${filepathCode}
const rpc = () => __vitest_worker__.rpc
const sessionId = __vitest_browser_runner__.sessionId
export const server = {
platform: ${JSON.stringify(process.platform)},
Expand Down
4 changes: 2 additions & 2 deletions packages/browser/src/node/providers/webdriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export class WebdriverBrowserProvider implements BrowserProvider {
this.options = options as RemoteOptions
}

async beforeCommand(): Promise<void> {
async switchToTestFrame(): Promise<void> {
const page = this.browser!
const iframe = await page.findElement(
'css selector',
Expand All @@ -46,7 +46,7 @@ export class WebdriverBrowserProvider implements BrowserProvider {
await page.switchToFrame(iframe)
}

async afterCommand(): Promise<void> {
async switchToMainFrame(): Promise<void> {
await this.browser!.switchToParentFrame()
}

Expand Down
26 changes: 17 additions & 9 deletions packages/browser/src/node/rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { ErrorWithDiff } from 'vitest'
import type { BrowserCommandContext, ResolveSnapshotPathHandlerContext, TestProject } from 'vitest/node'
import type { WebSocket } from 'ws'
import type { ParentBrowserProject } from './projectParent'
import type { WebdriverBrowserProvider } from './providers/webdriver'
import type { BrowserServerState } from './state'
import type { WebSocketBrowserEvents, WebSocketBrowserHandlers } from './types'
import { existsSync, promises as fs } from 'node:fs'
Expand Down Expand Up @@ -203,6 +204,21 @@ export function setupBrowserRpc(globalServer: ParentBrowserProject): void {
getCountOfFailedTests() {
return vitest.state.getCountOfFailedTests()
},
async wdioSwitchContext(direction) {
const provider = project.browser!.provider as WebdriverBrowserProvider
if (!provider) {
throw new Error('Commands are only available for browser tests.')
}
if (provider.name !== 'webdriverio') {
throw new Error('Switch context is only available for WebDriverIO provider.')
}
if (direction === 'iframe') {
await provider.switchToTestFrame()
}
else {
await provider.switchToMainFrame()
}
},
async triggerCommand(sessionId, command, testPath, payload) {
debug?.('[%s] Triggering command "%s"', sessionId, command)
const provider = project.browser!.provider
Expand All @@ -213,7 +229,6 @@ export function setupBrowserRpc(globalServer: ParentBrowserProject): void {
if (!commands || !commands[command]) {
throw new Error(`Unknown command "${command}".`)
}
await provider.beforeCommand?.(command, payload)
const context = Object.assign(
{
testPath,
Expand All @@ -224,14 +239,7 @@ export function setupBrowserRpc(globalServer: ParentBrowserProject): void {
},
provider.getCommandsContext(sessionId),
) as any as BrowserCommandContext
let result
try {
result = await commands[command](context, ...payload)
}
finally {
await provider.afterCommand?.(command, payload)
}
return result
return await commands[command](context, ...payload)
},
finishBrowserTests(sessionId: string) {
debug?.('[%s] Finishing browser tests for session', sessionId)
Expand Down
1 change: 1 addition & 0 deletions packages/browser/src/node/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export interface WebSocketBrowserHandlers {
getBrowserFileSourceMap: (
id: string
) => SourceMap | null | { mappings: '' } | undefined
wdioSwitchContext: (direction: 'iframe' | 'parent') => void

// cdp
sendCdpEvent: (sessionId: string, event: string, payload?: Record<string, unknown>) => unknown
Expand Down
2 changes: 0 additions & 2 deletions packages/vitest/src/node/types/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,6 @@ export interface BrowserProvider {
*/
supportsParallelism: boolean
getSupportedBrowsers: () => readonly string[]
beforeCommand?: (command: string, args: unknown[]) => Awaitable<void>
afterCommand?: (command: string, args: unknown[]) => Awaitable<void>
getCommandsContext: (sessionId: string) => Record<string, unknown>
openPage: (sessionId: string, url: string, beforeNavigate?: () => Promise<void>) => Promise<void>
getCDPSession?: (sessionId: string) => Promise<CDPSession>
Expand Down

0 comments on commit aaa5855

Please sign in to comment.