diff --git a/packages/webdriverio/src/index.cts b/packages/webdriverio/src/index.cts index 5a25d5c70df..f7b7901c7cb 100644 --- a/packages/webdriverio/src/index.cts +++ b/packages/webdriverio/src/index.cts @@ -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). diff --git a/packages/webdriverio/src/index.ts b/packages/webdriverio/src/index.ts index 1ced5cd575f..841bb74a146 100644 --- a/packages/webdriverio/src/index.ts +++ b/packages/webdriverio/src/index.ts @@ -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. @@ -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 @@ -102,7 +105,7 @@ export const attach = async function (attachOptions: AttachOptions): Promise { + 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. @@ -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... */ diff --git a/packages/webdriverio/src/session/errorHandler.ts b/packages/webdriverio/src/session/errorHandler.ts new file mode 100644 index 00000000000..e4214b2c94f --- /dev/null +++ b/packages/webdriverio/src/session/errorHandler.ts @@ -0,0 +1,95 @@ +type CommandWrapper = (commandName: string, command: Function) => (...args: unknown[]) => unknown + +type SessionManagerErrorState = { + errors: Error[] + pendingTasks: Set> +} + +const sessionManagerErrors = new WeakMap() + +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, 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 { + 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(browser: object, commandName: string, command: () => T | Promise): Promise { + 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)) + }) +} diff --git a/packages/webdriverio/tests/session/errorHandler.test.ts b/packages/webdriverio/tests/session/errorHandler.test.ts new file mode 100644 index 00000000000..f84c81afa81 --- /dev/null +++ b/packages/webdriverio/tests/session/errorHandler.test.ts @@ -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((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((_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') + }) +}) diff --git a/packages/webdriverio/tests/session/session.test.ts b/packages/webdriverio/tests/session/session.test.ts index 940fd067e3b..dc12972ceb2 100644 --- a/packages/webdriverio/tests/session/session.test.ts +++ b/packages/webdriverio/tests/session/session.test.ts @@ -1,7 +1,13 @@ +import path from 'node:path' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { logMock } from '@testplane/wdio-logger' + import { SessionManager } from '../../src/session/session.js' import { ContextManager } from '../../src/session/context.js' +import { flushSessionManagerErrors, wrapCommandWithSessionManagerErrors } from '../../src/session/errorHandler.js' + +vi.mock('@testplane/wdio-logger', () => import(path.join(process.cwd(), '__mocks__', '@testplane/wdio-logger'))) describe('SessionManager', () => { const browser ={ @@ -11,6 +17,7 @@ describe('SessionManager', () => { beforeEach(()=>{ vi.mocked(browser.on).mockClear() + logMock.warn.mockClear() }) it('should listener registered', ()=>{ @@ -51,7 +58,7 @@ describe('SessionManager', () => { switchToWindow: vi.fn(), } as any as WebdriverIO.Browser - const cm = new ContextManager(browser) + const cm = createEnabledContextManager(browser) const onCalls = vi.mocked(browser.on).mock.calls const commandListeners = onCalls.filter(([event]) => event === 'command').map(([, listener]) => listener) @@ -70,4 +77,106 @@ describe('SessionManager', () => { expect(browser.off).toHaveBeenCalledWith('command', contextCommandListener) expect(browser.off).toHaveBeenCalledWith('result', resultListeners[0]) }) + + it('should update the current context after navigation without returning a promise to EventEmitter', async () => { + const browser = createBidiBrowser({ + contexts: [{ context: 'new-context' }] + }) + const cm = createEnabledContextManager(browser) + cm.setCurrentContext('old-context') + + const listener = getNavigationStartedListener(browser) + expect(listener({ context: 'new-context' })).toBeUndefined() + + await vi.waitFor(() => expect(browser.switchToWindow).toHaveBeenCalledWith('new-context')) + expect(logMock.warn).not.toHaveBeenCalled() + }) + + it('should propagate an empty context tree result to the active command', async () => { + const browser = createBidiBrowser(undefined) + const cm = createEnabledContextManager(browser) + cm.setCurrentContext('old-context') + + const listener = getNavigationStartedListener(browser) + expect(listener({ context: 'new-context' })).toBeUndefined() + + await expect(runCommand(browser)).rejects.toThrow('browsingContextGetTree returned no result') + expect(browser.switchToWindow).not.toHaveBeenCalled() + }) + + it('should propagate navigation errors to the active command', async () => { + const browser = createBidiBrowser(new Error('get tree failed')) + const cm = createEnabledContextManager(browser) + cm.setCurrentContext('old-context') + + const listener = getNavigationStartedListener(browser) + expect(listener({ context: 'new-context' })).toBeUndefined() + + await expect(runCommand(browser)).rejects.toThrow('get tree failed') + expect(browser.switchToWindow).not.toHaveBeenCalled() + }) + + it('should report a navigation error after the final browser command', async () => { + const browser = createBidiBrowser(undefined) + const cm = createEnabledContextManager(browser) + cm.setCurrentContext('old-context') + + getNavigationStartedListener(browser)({ context: 'new-context' }) + + await expect(flushSessionManagerErrors(browser)).rejects.toThrow('browsingContextGetTree returned no result') + }) }) + +let bidiSessionCounter = 0 + +function createBidiBrowser(contextTree: unknown): WebdriverIO.Browser { + return { + sessionId: `bidi-${++bidiSessionCounter}`, + capabilities: {}, + isBidi: true, + isMobile: false, + isAndroid: false, + on: vi.fn(), + off: vi.fn(), + sessionSubscribe: vi.fn(), + browsingContextGetTree: vi.fn().mockImplementation(() => contextTree instanceof Error + ? Promise.reject(contextTree) + : Promise.resolve(contextTree)), + switchToWindow: vi.fn(), + } as any as WebdriverIO.Browser +} + +function getNavigationStartedListener(browser: WebdriverIO.Browser) { + const listener = vi.mocked(browser.on).mock.calls.find( + ([event]) => event === 'browsingContext.navigationStarted' + )?.[1] + + expect( + vi.mocked(browser.on).mock.calls.map(([event]) => event) + ).toContain('browsingContext.navigationStarted') + expect(listener).toBeTypeOf('function') + return listener as (event: { context: string }) => unknown +} + +function createEnabledContextManager(browser: WebdriverIO.Browser) { + const unitTestFlag = process.env.WDIO_UNIT_TESTS + delete process.env.WDIO_UNIT_TESTS + + try { + return new ContextManager(browser) + } finally { + if (unitTestFlag === undefined) { + delete process.env.WDIO_UNIT_TESTS + } else { + process.env.WDIO_UNIT_TESTS = unitTestFlag + } + } +} + +function runCommand(browser: WebdriverIO.Browser) { + const command = wrapCommandWithSessionManagerErrors( + (_commandName: string, command: Function) => command + )('test', () => new Promise((resolve) => setTimeout(resolve, 0))) + + return command.call(browser) +}