From 2a1fee626f4063b9bdbf137ac602e76676ae7186 Mon Sep 17 00:00:00 2001 From: DudaGod Date: Thu, 24 Sep 2026 14:12:30 +0300 Subject: [PATCH 1/3] fix(session): handle context navigation errors --- packages/webdriverio/src/session/context.ts | 13 ++- .../webdriverio/tests/session/session.test.ts | 96 ++++++++++++++++++- 2 files changed, 106 insertions(+), 3 deletions(-) diff --git a/packages/webdriverio/src/session/context.ts b/packages/webdriverio/src/session/context.ts index f0e65b03ff0..0a942a730ba 100644 --- a/packages/webdriverio/src/session/context.ts +++ b/packages/webdriverio/src/session/context.ts @@ -41,7 +41,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) => { + void this.#navigationStarted(nav).catch((err) => { + log.warn(`Failed to update current context after navigation: ${err}`) + }) + } /** * Listens for the 'closeWindow' browser command to handle context changes. @@ -103,7 +107,12 @@ 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) { + log.warn('Failed to update current context after navigation: browsingContextGetTree returned no result') + return + } + const { contexts } = contextTree /** * check if the context is still in the tree, if not, switch to... */ diff --git a/packages/webdriverio/tests/session/session.test.ts b/packages/webdriverio/tests/session/session.test.ts index 940fd067e3b..9a117492a70 100644 --- a/packages/webdriverio/tests/session/session.test.ts +++ b/packages/webdriverio/tests/session/session.test.ts @@ -1,8 +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' +vi.mock('@testplane/wdio-logger', () => import(path.join(process.cwd(), '__mocks__', '@testplane/wdio-logger'))) + describe('SessionManager', () => { const browser ={ sessionId: '123', @@ -11,6 +16,7 @@ describe('SessionManager', () => { beforeEach(()=>{ vi.mocked(browser.on).mockClear() + logMock.warn.mockClear() }) it('should listener registered', ()=>{ @@ -51,7 +57,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 +76,92 @@ 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 ignore an empty context tree result without an unhandled rejection', async () => { + const browser = createBidiBrowser(undefined) + const cm = createEnabledContextManager(browser) + cm.setCurrentContext('old-context') + + const listener = getNavigationStartedListener(browser) + expect(listener({ context: 'new-context' })).toBeUndefined() + + await vi.waitFor(() => expect(logMock.warn).toHaveBeenCalledWith( + expect.stringContaining('browsingContextGetTree returned no result') + )) + expect(browser.switchToWindow).not.toHaveBeenCalled() + }) + + it('should handle navigation errors without an unhandled rejection', 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 vi.waitFor(() => expect(logMock.warn).toHaveBeenCalledWith( + expect.stringContaining('Failed to update current context after navigation: Error: get tree failed') + )) + expect(browser.switchToWindow).not.toHaveBeenCalled() + }) }) + +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 + } + } +} From 8a72421f4399b078bb77e7b6028e0173a665f4f9 Mon Sep 17 00:00:00 2001 From: DudaGod Date: Thu, 24 Sep 2026 20:25:06 +0300 Subject: [PATCH 2/3] fix(session): propagate background context errors --- packages/webdriverio/src/index.ts | 8 ++- packages/webdriverio/src/session/context.ts | 5 +- .../webdriverio/src/session/errorHandler.ts | 64 +++++++++++++++++++ .../tests/session/errorHandler.test.ts | 41 ++++++++++++ .../webdriverio/tests/session/session.test.ts | 21 +++--- 5 files changed, 126 insertions(+), 13 deletions(-) create mode 100644 packages/webdriverio/src/session/errorHandler.ts create mode 100644 packages/webdriverio/tests/session/errorHandler.test.ts diff --git a/packages/webdriverio/src/index.ts b/packages/webdriverio/src/index.ts index 1ced5cd575f..722c723ea1b 100644 --- a/packages/webdriverio/src/index.ts +++ b/packages/webdriverio/src/index.ts @@ -14,6 +14,7 @@ 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' @@ -21,6 +22,7 @@ import type * as elementCommands from './commands/element.js' export * from './types.js' export const Key = KeyConstant export const SevereServiceError = SevereServiceErrorImport +const wrapCommandWithErrors = wrapCommandWithSessionManagerErrors(wrapCommand) /** * A method to create a new session with WebdriverIO. @@ -62,7 +64,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 +104,7 @@ export const attach = async function (attachOptions: AttachOptions): Promise { void this.#navigationStarted(nav).catch((err) => { log.warn(`Failed to update current context after navigation: ${err}`) + reportSessionManagerError(this.#browser, err) }) } @@ -109,8 +111,7 @@ export class ContextManager extends SessionManager { */ const contextTree = await this.#browser.browsingContextGetTree({}) if (!contextTree) { - log.warn('Failed to update current context after navigation: browsingContextGetTree returned no result') - return + throw new Error('Failed to update current context after navigation: browsingContextGetTree returned no result') } const { contexts } = contextTree /** diff --git a/packages/webdriverio/src/session/errorHandler.ts b/packages/webdriverio/src/session/errorHandler.ts new file mode 100644 index 00000000000..eb0a80a37e0 --- /dev/null +++ b/packages/webdriverio/src/session/errorHandler.ts @@ -0,0 +1,64 @@ +type CommandWrapper = (commandName: string, command: Function) => (...args: unknown[]) => unknown + +type SessionManagerErrorState = { + pendingError?: Error + rejectActiveCommands: Set<(error: Error) => void> +} + +const sessionManagerErrors = new WeakMap() + +function getErrorState(browser: object): SessionManagerErrorState { + let state = sessionManagerErrors.get(browser) + if (!state) { + state = { rejectActiveCommands: new Set() } + sessionManagerErrors.set(browser, state) + } + + return state +} + +export function reportSessionManagerError(browser: object, error: unknown): void { + const state = getErrorState(browser) + const commandError = error instanceof Error ? error : new Error(String(error)) + + if (!state.rejectActiveCommands.size) { + state.pendingError = commandError + return + } + + for (const reject of state.rejectActiveCommands) { + reject(commandError) + } +} + +/** + * Session manager event listeners run outside of the command promise chain. Race their + * background work with active commands so runners can attribute failures to the command + * and test that were executing. Errors reported between commands fail the next command. + */ +async function runWithSessionManagerErrors(browser: object, command: () => T | Promise): Promise { + const state = getErrorState(browser) + if (state.pendingError) { + const error = state.pendingError + state.pendingError = undefined + throw error + } + + let rejectCommand!: (error: Error) => void + const sessionManagerError = new Promise((_resolve, reject) => { + rejectCommand = reject + }) + state.rejectActiveCommands.add(rejectCommand) + + try { + return await Promise.race([command(), sessionManagerError]) + } finally { + state.rejectActiveCommands.delete(rejectCommand) + } +} + +export function wrapCommandWithSessionManagerErrors(wrapCommand: CommandWrapper): CommandWrapper { + return (commandName, command) => wrapCommand(commandName, function (this: object, ...args: unknown[]) { + return runWithSessionManagerErrors(this, () => 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..f4c2c943750 --- /dev/null +++ b/packages/webdriverio/tests/session/errorHandler.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest' + +import { + reportSessionManagerError, + 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 reject an active command when a session manager reports an error', async () => { + const browser = {} + let completeCommand!: () => void + const command = wrapCommandWithErrors('test', () => new Promise((resolve) => { + completeCommand = resolve + })) + + const commandResult = command.call(browser) + reportSessionManagerError(browser, new Error('context update failed')) + completeCommand() + + await expect(commandResult).rejects.toThrow('context update failed') + }) + + 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(command.call(browser)).resolves.toBe('result') + }) +}) diff --git a/packages/webdriverio/tests/session/session.test.ts b/packages/webdriverio/tests/session/session.test.ts index 9a117492a70..b844d780c9b 100644 --- a/packages/webdriverio/tests/session/session.test.ts +++ b/packages/webdriverio/tests/session/session.test.ts @@ -5,6 +5,7 @@ import { logMock } from '@testplane/wdio-logger' import { SessionManager } from '../../src/session/session.js' import { ContextManager } from '../../src/session/context.js' +import { wrapCommandWithSessionManagerErrors } from '../../src/session/errorHandler.js' vi.mock('@testplane/wdio-logger', () => import(path.join(process.cwd(), '__mocks__', '@testplane/wdio-logger'))) @@ -91,7 +92,7 @@ describe('SessionManager', () => { expect(logMock.warn).not.toHaveBeenCalled() }) - it('should ignore an empty context tree result without an unhandled rejection', async () => { + 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') @@ -99,13 +100,11 @@ describe('SessionManager', () => { const listener = getNavigationStartedListener(browser) expect(listener({ context: 'new-context' })).toBeUndefined() - await vi.waitFor(() => expect(logMock.warn).toHaveBeenCalledWith( - expect.stringContaining('browsingContextGetTree returned no result') - )) + await expect(runCommand(browser)).rejects.toThrow('browsingContextGetTree returned no result') expect(browser.switchToWindow).not.toHaveBeenCalled() }) - it('should handle navigation errors without an unhandled rejection', async () => { + 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') @@ -113,9 +112,7 @@ describe('SessionManager', () => { const listener = getNavigationStartedListener(browser) expect(listener({ context: 'new-context' })).toBeUndefined() - await vi.waitFor(() => expect(logMock.warn).toHaveBeenCalledWith( - expect.stringContaining('Failed to update current context after navigation: Error: get tree failed') - )) + await expect(runCommand(browser)).rejects.toThrow('get tree failed') expect(browser.switchToWindow).not.toHaveBeenCalled() }) }) @@ -165,3 +162,11 @@ function createEnabledContextManager(browser: WebdriverIO.Browser) { } } } + +function runCommand(browser: WebdriverIO.Browser) { + const command = wrapCommandWithSessionManagerErrors( + (_commandName: string, command: Function) => command + )('test', () => new Promise((resolve) => setTimeout(resolve, 0))) + + return command.call(browser) +} From f3f916a51aec4d9942de8ee6aaf0a059244c711d Mon Sep 17 00:00:00 2001 From: DudaGod Date: Fri, 25 Sep 2026 00:15:15 +0300 Subject: [PATCH 3/3] fix(session): retain context errors until test completion --- packages/webdriverio/src/index.cts | 6 ++ packages/webdriverio/src/index.ts | 1 + packages/webdriverio/src/session/context.ts | 5 +- .../webdriverio/src/session/errorHandler.ts | 85 +++++++++++++------ .../tests/session/errorHandler.test.ts | 47 +++++++++- .../webdriverio/tests/session/session.test.ts | 12 ++- 6 files changed, 123 insertions(+), 33 deletions(-) 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 722c723ea1b..841bb74a146 100644 --- a/packages/webdriverio/src/index.ts +++ b/packages/webdriverio/src/index.ts @@ -20,6 +20,7 @@ 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) diff --git a/packages/webdriverio/src/session/context.ts b/packages/webdriverio/src/session/context.ts index af631321609..8bc55841fa8 100644 --- a/packages/webdriverio/src/session/context.ts +++ b/packages/webdriverio/src/session/context.ts @@ -2,7 +2,7 @@ import type { local } from '@testplane/webdriver' import logger from '@testplane/wdio-logger' import { SessionManager } from './session.js' -import { reportSessionManagerError } from './errorHandler.js' +import { trackSessionManagerTask } from './errorHandler.js' import { getMobileContext, getNativeContext } from '../utils/mobile.js' const log = logger('webdriverio:context') @@ -43,9 +43,8 @@ export class ContextManager extends SessionManager { this.#onCommandListener = this.#onCommand.bind(this) this.#onCommandResultMobileListener = this.#onCommandResultMobile.bind(this) this.#navigationStartedListener = (nav) => { - void this.#navigationStarted(nav).catch((err) => { + trackSessionManagerTask(this.#browser, this.#navigationStarted(nav), (err) => { log.warn(`Failed to update current context after navigation: ${err}`) - reportSessionManagerError(this.#browser, err) }) } diff --git a/packages/webdriverio/src/session/errorHandler.ts b/packages/webdriverio/src/session/errorHandler.ts index eb0a80a37e0..e4214b2c94f 100644 --- a/packages/webdriverio/src/session/errorHandler.ts +++ b/packages/webdriverio/src/session/errorHandler.ts @@ -1,8 +1,8 @@ type CommandWrapper = (commandName: string, command: Function) => (...args: unknown[]) => unknown type SessionManagerErrorState = { - pendingError?: Error - rejectActiveCommands: Set<(error: Error) => void> + errors: Error[] + pendingTasks: Set> } const sessionManagerErrors = new WeakMap() @@ -10,7 +10,7 @@ const sessionManagerErrors = new WeakMap() function getErrorState(browser: object): SessionManagerErrorState { let state = sessionManagerErrors.get(browser) if (!state) { - state = { rejectActiveCommands: new Set() } + state = { errors: [], pendingTasks: new Set() } sessionManagerErrors.set(browser, state) } @@ -19,46 +19,77 @@ function getErrorState(browser: object): SessionManagerErrorState { export function reportSessionManagerError(browser: object, error: unknown): void { const state = getErrorState(browser) - const commandError = error instanceof Error ? error : new Error(String(error)) + 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('; ')}`) + } +} - if (!state.rejectActiveCommands.size) { - state.pendingError = commandError - return +/** 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) } - for (const reject of state.rejectActiveCommands) { - reject(commandError) + const error = getReportedError(state.errors.splice(0)) + if (error) { + throw error } } /** - * Session manager event listeners run outside of the command promise chain. Race their - * background work with active commands so runners can attribute failures to the command - * and test that were executing. Errors reported between commands fail the next command. + * 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, command: () => T | Promise): Promise { +async function runWithSessionManagerErrors(browser: object, commandName: string, command: () => T | Promise): Promise { + if (commandName === 'deleteSession') { + return command() + } + const state = getErrorState(browser) - if (state.pendingError) { - const error = state.pendingError - state.pendingError = undefined + const error = getReportedError(state.errors) + if (error) { throw error } - let rejectCommand!: (error: Error) => void - const sessionManagerError = new Promise((_resolve, reject) => { - rejectCommand = reject - }) - state.rejectActiveCommands.add(rejectCommand) - - try { - return await Promise.race([command(), sessionManagerError]) - } finally { - state.rejectActiveCommands.delete(rejectCommand) + 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, () => command.apply(this, args)) + 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 index f4c2c943750..f84c81afa81 100644 --- a/packages/webdriverio/tests/session/errorHandler.test.ts +++ b/packages/webdriverio/tests/session/errorHandler.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from 'vitest' import { + flushSessionManagerErrors, reportSessionManagerError, + trackSessionManagerTask, wrapCommandWithSessionManagerErrors } from '../../src/session/errorHandler.js' @@ -16,18 +18,25 @@ describe('session manager error handler', () => { await expect(command.call(browser)).resolves.toBe('result') }) - it('should reject an active command when a session manager reports an error', async () => { + 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 = 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 () => { @@ -36,6 +45,40 @@ describe('session manager error handler', () => { 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 b844d780c9b..dc12972ceb2 100644 --- a/packages/webdriverio/tests/session/session.test.ts +++ b/packages/webdriverio/tests/session/session.test.ts @@ -5,7 +5,7 @@ import { logMock } from '@testplane/wdio-logger' import { SessionManager } from '../../src/session/session.js' import { ContextManager } from '../../src/session/context.js' -import { wrapCommandWithSessionManagerErrors } from '../../src/session/errorHandler.js' +import { flushSessionManagerErrors, wrapCommandWithSessionManagerErrors } from '../../src/session/errorHandler.js' vi.mock('@testplane/wdio-logger', () => import(path.join(process.cwd(), '__mocks__', '@testplane/wdio-logger'))) @@ -115,6 +115,16 @@ describe('SessionManager', () => { 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