Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/webdriverio/src/index.cts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,12 @@ exports.attach = async function(attachOptions: any) {
return attach(attachOptions)
}

/** Report and clear background session manager errors at the end of a test. */
exports.flushSessionManagerErrors = async function(browser: object) {
const { flushSessionManagerErrors } = await import('./node.js')
return flushSessionManagerErrors(browser)
}

/**
* WebdriverIO allows you to run multiple automated sessions in a single test.
* This is handy when you're testing features that require multiple users (for example, chat or WebRTC applications).
Expand Down
9 changes: 6 additions & 3 deletions packages/webdriverio/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,16 @@ import { getProtocolDriver } from './utils/driver.js'
import { WDIO_DEFAULTS, Key as KeyConstant } from './constants.js'
import { getPrototype, addLocatorStrategyHandler, isStub } from './utils/index.js'
import { registerSessionManager } from './session/index.js'
import { wrapCommandWithSessionManagerErrors } from './session/errorHandler.js'

import type { AttachOptions } from './types.js'
import type * as elementCommands from './commands/element.js'

export * from './types.js'
export { flushSessionManagerErrors } from './session/errorHandler.js'
export const Key = KeyConstant
export const SevereServiceError = SevereServiceErrorImport
const wrapCommandWithErrors = wrapCommandWithSessionManagerErrors(wrapCommand)

/**
* A method to create a new session with WebdriverIO.
Expand Down Expand Up @@ -62,7 +65,7 @@ export const remote = async function(

const { Driver, options } = await getProtocolDriver({ ...params, ...config })
const prototype = getPrototype('browser')
const instance = await Driver.newSession(options, modifier, prototype, wrapCommand) as WebdriverIO.Browser
const instance = await Driver.newSession(options, modifier, prototype, wrapCommandWithErrors) as WebdriverIO.Browser

/**
* we need to overwrite the original addCommand and overwriteCommand
Expand Down Expand Up @@ -102,7 +105,7 @@ export const attach = async function (attachOptions: AttachOptions): Promise<Web
params,
undefined,
prototype,
wrapCommand
wrapCommandWithErrors
) as WebdriverIO.Browser
driver.addLocatorStrategy = addLocatorStrategyHandler(driver)

Expand Down Expand Up @@ -169,7 +172,7 @@ export const multiremote = async function (
sessionParams,
multibrowser.modifier.bind(multibrowser),
prototype,
wrapCommand
wrapCommandWithErrors
) as WebdriverIO.MultiRemoteBrowser

/**
Expand Down
13 changes: 11 additions & 2 deletions packages/webdriverio/src/session/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { local } from '@testplane/webdriver'
import logger from '@testplane/wdio-logger'

import { SessionManager } from './session.js'
import { trackSessionManagerTask } from './errorHandler.js'
import { getMobileContext, getNativeContext } from '../utils/mobile.js'

const log = logger('webdriverio:context')
Expand Down Expand Up @@ -41,7 +42,11 @@ export class ContextManager extends SessionManager {

this.#onCommandListener = this.#onCommand.bind(this)
this.#onCommandResultMobileListener = this.#onCommandResultMobile.bind(this)
this.#navigationStartedListener = this.#navigationStarted.bind(this)
this.#navigationStartedListener = (nav) => {
trackSessionManagerTask(this.#browser, this.#navigationStarted(nav), (err) => {
log.warn(`Failed to update current context after navigation: ${err}`)
})
}

/**
* Listens for the 'closeWindow' browser command to handle context changes.
Expand Down Expand Up @@ -103,7 +108,11 @@ export class ContextManager extends SessionManager {
* current tree and see if our context is still there, if not, we need to reset
* the context to the first context in the tree.
*/
const { contexts } = await this.#browser.browsingContextGetTree({})
const contextTree = await this.#browser.browsingContextGetTree({})
if (!contextTree) {
throw new Error('Failed to update current context after navigation: browsingContextGetTree returned no result')
}
const { contexts } = contextTree
/**
* check if the context is still in the tree, if not, switch to...
*/
Expand Down
95 changes: 95 additions & 0 deletions packages/webdriverio/src/session/errorHandler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
type CommandWrapper = (commandName: string, command: Function) => (...args: unknown[]) => unknown

type SessionManagerErrorState = {
errors: Error[]
pendingTasks: Set<Promise<void>>
}

const sessionManagerErrors = new WeakMap<object, SessionManagerErrorState>()

function getErrorState(browser: object): SessionManagerErrorState {
let state = sessionManagerErrors.get(browser)
if (!state) {
state = { errors: [], pendingTasks: new Set() }
sessionManagerErrors.set(browser, state)
}

return state
}

export function reportSessionManagerError(browser: object, error: unknown): void {
const state = getErrorState(browser)
state.errors.push(error instanceof Error ? error : new Error(String(error)))
}

/** Keep event-listener work observable until the runner finishes the current test. */
export function trackSessionManagerTask(browser: object, task: Promise<unknown>, onError?: (error: unknown) => void): void {
const state = getErrorState(browser)
const tracked = task.then(
() => undefined,
(error) => {
reportSessionManagerError(browser, error)
try {
onError?.(error)
} catch {
// A logging callback must not introduce another unhandled rejection.
}
}
)
state.pendingTasks.add(tracked)
void tracked.finally(() => state.pendingTasks.delete(tracked))
}

function getReportedError(errors: Error[]): Error | undefined {
if (errors.length === 1) {
return errors[0]
}

if (errors.length > 1) {
return new AggregateError(errors, `Session manager errors: ${errors.map((error) => error.message).join('; ')}`)
}
}

/** Wait for background listeners, then consume every error they reported. */
export async function flushSessionManagerErrors(browser: object): Promise<void> {
const state = getErrorState(browser)
while (state.pendingTasks.size) {
await Promise.all(state.pendingTasks)
}

const error = getReportedError(state.errors.splice(0))
if (error) {
throw error
}
}

/**
* Session manager event listeners run outside of the command promise chain. A command
* must settle before reporting their errors so that it cannot continue in the background.
* Errors remain recorded until the runner checks them at the end of the test.
*/
async function runWithSessionManagerErrors<T>(browser: object, commandName: string, command: () => T | Promise<T>): Promise<T> {
if (commandName === 'deleteSession') {
return command()
}

const state = getErrorState(browser)
const error = getReportedError(state.errors)
if (error) {
throw error
}

const result = await command()
const reportedError = getReportedError(state.errors)
if (reportedError) {
throw reportedError
}

return result
}

export function wrapCommandWithSessionManagerErrors(wrapCommand: CommandWrapper): CommandWrapper {
return (commandName, command) => wrapCommand(commandName, function (this: object, ...args: unknown[]) {
return runWithSessionManagerErrors(this, commandName, () => command.apply(this, args))
})
}
84 changes: 84 additions & 0 deletions packages/webdriverio/tests/session/errorHandler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { describe, expect, it } from 'vitest'

import {
flushSessionManagerErrors,
reportSessionManagerError,
trackSessionManagerTask,
wrapCommandWithSessionManagerErrors
} from '../../src/session/errorHandler.js'

describe('session manager error handler', () => {
const wrapCommand = (_commandName: string, command: Function) => command
const wrapCommandWithErrors = wrapCommandWithSessionManagerErrors(wrapCommand)

it('should resolve a command when no session manager error occurs', async () => {
const browser = {}
const command = wrapCommandWithErrors('test', async () => 'result')

await expect(command.call(browser)).resolves.toBe('result')
})

it('should wait for an active command before reporting a session manager error', async () => {
const browser = {}
let completeCommand!: () => void
let commandCompleted = false
const command = wrapCommandWithErrors('test', () => new Promise<void>((resolve) => {
completeCommand = () => {
commandCompleted = true
resolve()
}
}))

const commandResult = command.call(browser)
reportSessionManagerError(browser, new Error('context update failed'))
await Promise.resolve()
expect(commandCompleted).toBe(false)
completeCommand()

await expect(commandResult).rejects.toThrow('context update failed')
expect(commandCompleted).toBe(true)
})

it('should reject the next command when an error occurs between commands', async () => {
const browser = {}
reportSessionManagerError(browser, new Error('context update failed'))
const command = wrapCommandWithErrors('test', async () => 'result')

await expect(command.call(browser)).rejects.toThrow('context update failed')
await expect(flushSessionManagerErrors(browser)).rejects.toThrow('context update failed')
await expect(command.call(browser)).resolves.toBe('result')
})

it('should wait for background work and fail the test even after its last command', async () => {
const browser = {}
let rejectTask!: (error: Error) => void
trackSessionManagerTask(browser, new Promise<void>((_resolve, reject) => {
rejectTask = reject
}))

const result = flushSessionManagerErrors(browser)
rejectTask(new Error('late context failure'))

await expect(result).rejects.toThrow('late context failure')
await expect(flushSessionManagerErrors(browser)).resolves.toBeUndefined()
})

it('should preserve multiple session manager errors', async () => {
const browser = {}
reportSessionManagerError(browser, new Error('first failure'))
reportSessionManagerError(browser, new Error('second failure'))

const error = await flushSessionManagerErrors(browser).catch(err => err)
expect(error).toBeInstanceOf(AggregateError)
expect(error.errors.map((err: Error) => err.message)).toEqual(['first failure', 'second failure'])
})

it('should allow deleteSession even when a session manager error is pending', async () => {
const browser = {}
const deleteSession = wrapCommandWithErrors('deleteSession', async () => 'deleted')
reportSessionManagerError(browser, new Error('context update failed'))

await expect(deleteSession.call(browser)).resolves.toBe('deleted')
await expect(flushSessionManagerErrors(browser)).rejects.toThrow('context update failed')
})
})
Loading
Loading