Skip to content

Commit aaa5855

Browse files
authored
perf(browser): do wdio context switching only once per file (#7549)
1 parent 7660723 commit aaa5855

File tree

10 files changed

+86
-54
lines changed

10 files changed

+86
-54
lines changed

packages/browser/src/client/tester/context.ts

+6-9
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import type { Options as TestingLibraryOptions, UserEvent as TestingLibraryUserEvent } from '@testing-library/user-event'
2-
import type { BrowserRPC } from '@vitest/browser/client'
32
import type { RunnerTask } from 'vitest'
43
import type {
54
BrowserPage,
@@ -19,15 +18,11 @@ import { convertElementToCssSelector, ensureAwaited, getBrowserState, getWorkerS
1918
const state = () => getWorkerState()
2019
// @ts-expect-error not typed global
2120
const provider = __vitest_browser_runner__.provider
22-
function filepath() {
23-
return getWorkerState().filepath || getWorkerState().current?.file?.filepath || undefined
24-
}
25-
const rpc = () => getWorkerState().rpc as any as BrowserRPC
2621
const sessionId = getBrowserState().sessionId
2722
const channel = new BroadcastChannel(`vitest:${sessionId}`)
2823

2924
function triggerCommand<T>(command: string, ...args: any[]) {
30-
return rpc().triggerCommand<T>(sessionId, command, filepath(), args)
25+
return getBrowserState().commands.triggerCommand<T>(command, args)
3126
}
3227

3328
export function createUserEvent(__tl_user_event_base__?: TestingLibraryUserEvent, options?: TestingLibraryOptions): UserEvent {
@@ -52,6 +47,10 @@ export function createUserEvent(__tl_user_event_base__?: TestingLibraryUserEvent
5247
return createUserEvent()
5348
},
5449
async cleanup() {
50+
// avoid cleanup rpc call if there is nothing to cleanup
51+
if (!keyboard.unreleased.length) {
52+
return
53+
}
5554
return ensureAwaited(async () => {
5655
await triggerCommand('__vitest_cleanup', keyboard)
5756
keyboard.unreleased = []
@@ -106,9 +105,7 @@ export function createUserEvent(__tl_user_event_base__?: TestingLibraryUserEvent
106105
})
107106
},
108107
tab(options: UserEventTabOptions = {}) {
109-
return ensureAwaited(() => {
110-
return triggerCommand('__vitest_tab', options)
111-
})
108+
return ensureAwaited(() => triggerCommand('__vitest_tab', options))
112109
},
113110
async keyboard(text: string) {
114111
return ensureAwaited(async () => {

packages/browser/src/client/tester/locators/index.ts

+3-23
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import type { BrowserRPC } from '@vitest/browser/client'
21
import type {
32
LocatorByRoleOptions,
43
LocatorOptions,
@@ -8,8 +7,6 @@ import type {
87
UserEventFillOptions,
98
UserEventHoverOptions,
109
} from '@vitest/browser/context'
11-
import type { WorkerGlobalState } from 'vitest'
12-
import type { BrowserRunnerState } from '../../utils'
1310
import { page, server } from '@vitest/browser/context'
1411
import {
1512
getByAltTextSelector,
@@ -22,7 +19,7 @@ import {
2219
Ivya,
2320
type ParsedSelector,
2421
} from 'ivya'
25-
import { ensureAwaited, getBrowserState, getWorkerState } from '../../utils'
22+
import { ensureAwaited, getBrowserState } from '../../utils'
2623
import { getElementError } from '../public-utils'
2724

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

208-
private get state(): BrowserRunnerState {
209-
return getBrowserState()
210-
}
211-
212-
private get worker(): WorkerGlobalState {
213-
return getWorkerState()
214-
}
215-
216-
private get rpc(): BrowserRPC {
217-
return this.worker.rpc as any as BrowserRPC
218-
}
219-
220205
protected triggerCommand<T>(command: string, ...args: any[]): Promise<T> {
221-
const filepath = this.worker.filepath
222-
|| this.worker.current?.file?.filepath
223-
|| undefined
224-
225-
return ensureAwaited(() => this.rpc.triggerCommand<T>(
226-
this.state.sessionId,
206+
const commands = getBrowserState().commands
207+
return ensureAwaited(() => commands.triggerCommand<T>(
227208
command,
228-
filepath,
229209
args,
230210
))
231211
}

packages/browser/src/client/tester/tester.ts

+32-3
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { channel, client, onCancel } from '@vitest/browser/client'
2-
import { page, userEvent } from '@vitest/browser/context'
2+
import { page, server, userEvent } from '@vitest/browser/context'
33
import { collectTests, setupCommonEnv, SpyModule, startCoverageInsideWorker, startTests, stopCoverageInsideWorker } from 'vitest/browser'
4-
import { executor, getBrowserState, getConfig, getWorkerState } from '../utils'
4+
import { CommandsManager, executor, getBrowserState, getConfig, getWorkerState } from '../utils'
55
import { setupDialogsSpy } from './dialog'
66
import { setupExpectDom } from './expect-element'
77
import { setupConsoleLogSpy } from './logger'
@@ -34,6 +34,8 @@ async function prepareTestEnvironment(files: string[]) {
3434
state.onCancel = onCancel
3535
state.rpc = rpc as any
3636

37+
getBrowserState().commands = new CommandsManager()
38+
3739
// TODO: expose `worker`
3840
const interceptor = createModuleMockerInterceptor()
3941
const mocker = new VitestBrowserClientMocker(
@@ -69,6 +71,8 @@ async function prepareTestEnvironment(files: string[]) {
6971
runner,
7072
config,
7173
state,
74+
rpc,
75+
commands: getBrowserState().commands,
7276
}
7377
}
7478

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

114118
debug('runner resolved successfully')
115119

116-
const { config, runner, state } = preparedData
120+
const { config, runner, state, commands, rpc } = preparedData
117121

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

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

126+
let contextSwitched = false
127+
128+
// webdiverio context depends on the iframe state, so we need to switch the context,
129+
// we delay this in case the user doesn't use any userEvent commands to avoid the overhead
130+
if (server.provider === 'webdriverio') {
131+
let switchPromise: Promise<void> | null = null
132+
133+
commands.onCommand(async () => {
134+
if (switchPromise) {
135+
await switchPromise
136+
}
137+
// if this is the first command, make sure we switched the command context to an iframe
138+
if (!contextSwitched) {
139+
switchPromise = rpc.wdioSwitchContext('iframe').finally(() => {
140+
switchPromise = null
141+
contextSwitched = true
142+
})
143+
await switchPromise
144+
}
145+
})
146+
}
147+
122148
try {
123149
await Promise.all([
124150
setupCommonEnv(config),
@@ -151,6 +177,9 @@ async function executeTests(method: 'run' | 'collect', files: string[]) {
151177
// need to cleanup for each tester
152178
// since playwright keyboard API is stateful on page instance level
153179
await userEvent.cleanup()
180+
if (contextSwitched) {
181+
await rpc.wdioSwitchContext('parent')
182+
}
154183
}
155184
catch (error: any) {
156185
await client.rpc.onUnhandledError({

packages/browser/src/client/utils.ts

+21
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { SerializedConfig, WorkerGlobalState } from 'vitest'
2+
import type { BrowserRPC } from './client'
23

34
export async function importId(id: string): Promise<any> {
45
const name = `/@id/${id}`.replace(/\\/g, '/')
@@ -77,6 +78,7 @@ export interface BrowserRunnerState {
7778
method: 'run' | 'collect'
7879
runTests?: (tests: string[]) => Promise<void>
7980
createTesters?: (files: string[]) => Promise<void>
81+
commands: CommandsManager
8082
cdp?: {
8183
on: (event: string, listener: (payload: any) => void) => void
8284
once: (event: string, listener: (payload: any) => void) => void
@@ -194,3 +196,22 @@ function getParent(el: Element) {
194196
}
195197
return parent
196198
}
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+
}

packages/browser/src/node/commands/keyboard.ts

+3
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,9 @@ export const keyboardCleanup: UserEventCommand<(state: KeyboardState) => Promise
5454
state,
5555
) => {
5656
const { provider, sessionId } = context
57+
if (!state.unreleased) {
58+
return
59+
}
5760
if (provider instanceof PlaywrightBrowserProvider) {
5861
const page = provider.getPage(sessionId)
5962
for (const key of state.unreleased) {

packages/browser/src/node/plugins/pluginContext.ts

+1-6
Original file line numberDiff line numberDiff line change
@@ -32,15 +32,13 @@ async function generateContextFile(
3232
globalServer: ParentBrowserProject,
3333
) {
3434
const commands = Object.keys(globalServer.commands)
35-
const filepathCode
36-
= '__vitest_worker__.filepath || __vitest_worker__.current?.file?.filepath || undefined'
3735
const provider = [...globalServer.children][0].provider || { name: 'preview' }
3836
const providerName = provider.name
3937

4038
const commandsCode = commands
4139
.filter(command => !command.startsWith('__vitest'))
4240
.map((command) => {
43-
return ` ["${command}"]: (...args) => rpc().triggerCommand(sessionId, "${command}", filepath(), args),`
41+
return ` ["${command}"]: (...args) => __vitest_browser_runner__.commands.triggerCommand("${command}", args),`
4442
})
4543
.join('\n')
4644

@@ -53,9 +51,6 @@ async function generateContextFile(
5351
return `
5452
import { page, createUserEvent, cdp } from '${distContextPath}'
5553
${userEventNonProviderImport}
56-
const filepath = () => ${filepathCode}
57-
const rpc = () => __vitest_worker__.rpc
58-
const sessionId = __vitest_browser_runner__.sessionId
5954
6055
export const server = {
6156
platform: ${JSON.stringify(process.platform)},

packages/browser/src/node/providers/webdriver.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ export class WebdriverBrowserProvider implements BrowserProvider {
3737
this.options = options as RemoteOptions
3838
}
3939

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

49-
async afterCommand(): Promise<void> {
49+
async switchToMainFrame(): Promise<void> {
5050
await this.browser!.switchToParentFrame()
5151
}
5252

packages/browser/src/node/rpc.ts

+17-9
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { ErrorWithDiff } from 'vitest'
33
import type { BrowserCommandContext, ResolveSnapshotPathHandlerContext, TestProject } from 'vitest/node'
44
import type { WebSocket } from 'ws'
55
import type { ParentBrowserProject } from './projectParent'
6+
import type { WebdriverBrowserProvider } from './providers/webdriver'
67
import type { BrowserServerState } from './state'
78
import type { WebSocketBrowserEvents, WebSocketBrowserHandlers } from './types'
89
import { existsSync, promises as fs } from 'node:fs'
@@ -203,6 +204,21 @@ export function setupBrowserRpc(globalServer: ParentBrowserProject): void {
203204
getCountOfFailedTests() {
204205
return vitest.state.getCountOfFailedTests()
205206
},
207+
async wdioSwitchContext(direction) {
208+
const provider = project.browser!.provider as WebdriverBrowserProvider
209+
if (!provider) {
210+
throw new Error('Commands are only available for browser tests.')
211+
}
212+
if (provider.name !== 'webdriverio') {
213+
throw new Error('Switch context is only available for WebDriverIO provider.')
214+
}
215+
if (direction === 'iframe') {
216+
await provider.switchToTestFrame()
217+
}
218+
else {
219+
await provider.switchToMainFrame()
220+
}
221+
},
206222
async triggerCommand(sessionId, command, testPath, payload) {
207223
debug?.('[%s] Triggering command "%s"', sessionId, command)
208224
const provider = project.browser!.provider
@@ -213,7 +229,6 @@ export function setupBrowserRpc(globalServer: ParentBrowserProject): void {
213229
if (!commands || !commands[command]) {
214230
throw new Error(`Unknown command "${command}".`)
215231
}
216-
await provider.beforeCommand?.(command, payload)
217232
const context = Object.assign(
218233
{
219234
testPath,
@@ -224,14 +239,7 @@ export function setupBrowserRpc(globalServer: ParentBrowserProject): void {
224239
},
225240
provider.getCommandsContext(sessionId),
226241
) as any as BrowserCommandContext
227-
let result
228-
try {
229-
result = await commands[command](context, ...payload)
230-
}
231-
finally {
232-
await provider.afterCommand?.(command, payload)
233-
}
234-
return result
242+
return await commands[command](context, ...payload)
235243
},
236244
finishBrowserTests(sessionId: string) {
237245
debug?.('[%s] Finishing browser tests for session', sessionId)

packages/browser/src/node/types.ts

+1
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ export interface WebSocketBrowserHandlers {
3939
getBrowserFileSourceMap: (
4040
id: string
4141
) => SourceMap | null | { mappings: '' } | undefined
42+
wdioSwitchContext: (direction: 'iframe' | 'parent') => void
4243

4344
// cdp
4445
sendCdpEvent: (sessionId: string, event: string, payload?: Record<string, unknown>) => unknown

packages/vitest/src/node/types/browser.ts

-2
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,6 @@ export interface BrowserProvider {
2424
*/
2525
supportsParallelism: boolean
2626
getSupportedBrowsers: () => readonly string[]
27-
beforeCommand?: (command: string, args: unknown[]) => Awaitable<void>
28-
afterCommand?: (command: string, args: unknown[]) => Awaitable<void>
2927
getCommandsContext: (sessionId: string) => Record<string, unknown>
3028
openPage: (sessionId: string, url: string, beforeNavigate?: () => Promise<void>) => Promise<void>
3129
getCDPSession?: (sessionId: string) => Promise<CDPSession>

0 commit comments

Comments
 (0)