Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/port-friday-parity-fixes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@wdio/browserstack-service": patch
---

Port the 2026-07-10 monorepo accessibility/CLI fixes to keep the standalone at parity (webdriverio/webdriverio#15380, #15383, #15382, #15381, #15376): skip the accessibility scan for BiDi `window`/`context` commands, route WDIO CLI-flow App Automate sessions to app-accessibility, finalize orphaned test runs on an interrupted exit, coerce stringified boolean accessibility options, and report mocha hooks in the CLI/testHub flow. No user-facing API change.
48 changes: 48 additions & 0 deletions packages/browserstack-service/src/accessibility-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -423,15 +423,19 @@ class _AccessibilityHandler {
*/

private async commandWrapper (command: CommandInfo, prevImpl: Function, origFunction: Function, ...args: unknown[]) {
const skipScanForBidiWindowCommand = AccessibilityHandler.shouldSkipScanForBidiWindowCommand(this._browser, command)
if (
this._sessionId && AccessibilityHandler._a11yScanSessionMap[this._sessionId] &&
!skipScanForBidiWindowCommand &&
(
!command.name.includes('execute') ||
!AccessibilityHandler.shouldPatchExecuteScript(args.length ? args[0] as string : null)
)
) {
BStackLogger.debug(`Performing scan for ${command.class} ${command.name}`)
await performA11yScan(this.isAppAutomate, this._browser, true, true, command.name)
} else if (skipScanForBidiWindowCommand) {
BStackLogger.debug(`SDK-5047: skipping accessibility scan for BiDi window/context command '${command.name}' to avoid racing the WebdriverIO ContextManager during session-start window churn`)
}
const impl = prevImpl || origFunction
return impl(...args)
Expand Down Expand Up @@ -503,6 +507,50 @@ class _AccessibilityHandler {
)
}

/**
* SDK-5047: Window/context-management commands whose surrounding injected
* accessibility scan (a browser.execute) races the WebdriverIO v9 core
* ContextManager while it (re)binds the browsing context during
* session-start window churn on BiDi sessions (e.g. Chrome), surfacing
* "no such window: target window already closed / web view not found".
*/
private static readonly BIDI_WINDOW_CONTEXT_COMMANDS = new Set<string>([
'getWindowHandle', 'getWindowHandles', 'switchToWindow', 'switchWindow',
'newWindow', 'closeWindow', 'switchFrame', 'switchToFrame', 'switchToParentFrame'
])

/**
* BiDi detection that also covers MultiRemote: the aggregate multiremote
* object does not expose `isBidi` itself — the flag lives on each child
* instance — so the session counts as BiDi when the top-level flag is set
* OR any multiremote child instance reports `isBidi`.
*/
private static isBidiSession(browser: WebdriverIO.Browser | WebdriverIO.MultiRemoteBrowser | undefined): boolean {
const b = browser as (WebdriverIO.Browser & { isBidi?: boolean, instances?: string[] }) | undefined
if (b?.isBidi === true) {
return true
}
if (Array.isArray(b?.instances)) {
const children = b as unknown as Record<string, { isBidi?: boolean } | undefined>
return b.instances.some((name) => children[name]?.isBidi === true)
}
return false
}

/**
* Returns true when the injected pre-command accessibility scan must be
* skipped: only on BiDi sessions, and only for window/context-management
* commands. Non-BiDi sessions and all other commands are unaffected and
* keep scanning exactly as before.
*/
private static shouldSkipScanForBidiWindowCommand(browser: WebdriverIO.Browser | WebdriverIO.MultiRemoteBrowser | undefined, command: CommandInfo): boolean {
return Boolean(
command?.name &&
AccessibilityHandler.isBidiSession(browser) &&
AccessibilityHandler.BIDI_WINDOW_CONTEXT_COMMANDS.has(command.name)
)
}

private async _setAnnotation(message: string) {
if (this._accessibility && isBrowserstackSession(this._browser)) {
await (this._browser as WebdriverIO.Browser).executeScript(`browserstack_executor: ${JSON.stringify({
Expand Down
4 changes: 4 additions & 0 deletions packages/browserstack-service/src/cleanup.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { getErrorString, stopBuildUpstream } from './util.js'
import { finalizeOrphanedRuns } from './testOps/openRunsJournal.js'
import { BStackLogger } from './bstackLogger.js'
import fs from 'node:fs'
import util from 'node:util'
Expand Down Expand Up @@ -45,6 +46,9 @@ export default class BStackCleanup {
}
BStackLogger.debug('Executing Test Reporting and Analytics cleanup')
try {
// SDK-4671: finalize any test runs orphaned by the interrupted process
// before the build itself is stopped.
await finalizeOrphanedRuns()
const result = await stopBuildUpstream()
if ((process.env[BROWSERSTACK_OBSERVABILITY]) && process.env[BROWSERSTACK_TESTHUB_UUID]) {
BStackLogger.info(`\nVisit https://automation.browserstack.com/builds/${process.env[BROWSERSTACK_TESTHUB_UUID]} to view build report, insights, and many more debugging information all at one place!\n`)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,11 @@ export default class WdioMochaTestFramework extends TestFramework {
}

try {
if (CLIUtils.matchHookRegex(testFrameworkState.toString()) && hookState === HookState.PRE) {
// matchHookRegex expects the short state name (e.g. AFTER_EACH); `toString()` yields
// the fully-qualified `TestFrameworkState.AFTER_EACH`, which never matches `^(BEFORE_|AFTER_)`.
// Without this, KEY_HOOK_ID is never set, hook_run.uuid ends up empty, and the backend
// (BigQuery) drops the hook events despite them being sent.
if (CLIUtils.matchHookRegex(testFrameworkState.toString().split('.')[1]) && hookState === HookState.PRE) {
instance.updateMultipleEntries({
[TestFrameworkConstants.KEY_HOOK_ID]: uuidv4(),
})
Expand Down Expand Up @@ -76,7 +80,14 @@ export default class WdioMochaTestFramework extends TestFramework {
this.loadTestResult(instance, args)
}

await this.trackHookEvents(instance, testFrameworkState, hookState, args)
// Only real hook states accumulate into test_hooks_started/finished. trackEvent runs
// for every state (TEST/INIT_TEST/LOG/...); without this gate those push pseudo-hook
// entries which — now that the Map serializer actually emits them — get flat-mapped into
// TestRunFinished.hooks (duplicating the last hook id / adding '') and accumulate in
// test_hooks_started forever (never popped).
if (CLIUtils.matchHookRegex(testFrameworkState.toString().split('.')[1])) {
await this.trackHookEvents(instance, testFrameworkState, hookState, args)
}
logger.debug(`trackEvent: tracked instance data=${JSON.stringify(Object.fromEntries(instance.getAllData()))}`)
} catch (error) {
logger.error(`trackEvent: Error in tracking events: ${error} hookState=${hookState} testFrameworkState=${testFrameworkState}`)
Expand All @@ -93,13 +104,49 @@ export default class WdioMochaTestFramework extends TestFramework {
* @returns {TestFrameworkInstance}
*/
resolveInstance(testFrameworkState: State, hookState: State, args: Record<string, unknown> = {}): TestFrameworkInstance|null {
let instance = null
logger.info(`resolveInstance: resolving instance for testFrameworkState=${testFrameworkState} hookState=${hookState}`)
if (testFrameworkState === TestFrameworkState.INIT_TEST || testFrameworkState === TestFrameworkState.NONE) {
const shortState = testFrameworkState.toString().split('.')[1]
const isHook = CLIUtils.matchHookRegex(shortState)
let instance = TestFramework.getTrackedInstance()

// Whether the current tracked instance has already run its test body.
const hasRunTest = !!(instance && TestFramework.getState(instance, TestFrameworkConstants.KEY_TEST_ID))

// New-test boundary (ports Junit5Framework.resolveInstance): the previous method was a
// test / after-hook (POST) and the current is a before-hook / init (PRE) — the next test
// is starting. At entry, getCurrentTestState()/getCurrentHookState() still hold the PREVIOUS
// method's state (updateInstanceState has not run yet).
const prevState = instance ? instance.getCurrentTestState().toString() : ''
const prevWasTerminal = instance ? (instance.getCurrentTestState() === TestFrameworkState.TEST || prevState.includes('AFTER')) : false
const isNewTestBoundary = instance ? (prevWasTerminal && instance.getCurrentHookState() === HookState.POST && hookState === HookState.PRE) : false

if (testFrameworkState === TestFrameworkState.NONE) {
this.trackWdioMochaInstance(testFrameworkState, args)
} else if (testFrameworkState === TestFrameworkState.INIT_TEST) {
// WDIO fires `before each` BEFORE `beforeTest` (INIT_TEST). Reuse the instance a
// preceding before-hook already opened for this same upcoming test; only start a new
// one if the current instance has already run a test (or none exists).
if (!instance || hasRunTest) {
this.trackWdioMochaInstance(testFrameworkState, args)
}
} else if (isHook && hookState === HookState.PRE) {
// Suite-level (`before all`) and the first `before each` fire before any INIT_TEST, so
// no instance exists yet — create one. Also, a BEFORE hook right after a completed test
// (new-test boundary) starts a new test. The boundary restriction must be limited to
// BEFORE hooks: `after each`/`after all` also fire at a POST->PRE boundary (afterTest
// emits TEST POST just before `after each`), but they belong to the just-finished test.
// Creating a fresh (uuid-less) instance for them would drop test_run_id and orphan the
// hook from TestRunFinished.hooks — so let after-hooks reuse the finished test's instance.
if (!instance || (isNewTestBoundary && shortState.startsWith('BEFORE_'))) {
this.trackWdioMochaInstance(testFrameworkState, args)
}
}

instance = TestFramework.getTrackedInstance()
if (!instance) {
logger.error(`resolveInstance: unable to resolve/create instance for testFrameworkState=${testFrameworkState} hookState=${hookState}`)
return null
}
this.updateInstanceState(instance, testFrameworkState, hookState)

return instance
Expand Down Expand Up @@ -205,7 +252,7 @@ export default class WdioMochaTestFramework extends TestFramework {
const logRecord: Record<string, unknown> = {}
const { level, message, timestamp } = logEntry

if (CLIUtils.matchHookRegex(instance.getCurrentTestState().toString())) {
if (CLIUtils.matchHookRegex(instance.getCurrentTestState().toString().split('.')[1])) {
logRecord[TestFrameworkConstants.KEY_HOOK_ID] = TestFramework.getState(instance, TestFrameworkConstants.KEY_HOOK_ID)
}
logRecord.kind = TestFrameworkConstants.KIND_LOG
Expand Down Expand Up @@ -321,7 +368,10 @@ export default class WdioMochaTestFramework extends TestFramework {
) {
const testResult = args.result as Frameworks.TestResult
const test = args.test as Frameworks.Test
const key = testFrameworkState.toString()
// Key hooks by the short state name (e.g. AFTER_EACH), matching how the binary looks them
// up via `event.test_hooks_started[request.testFrameworkState]`. `toString()` yields the
// fully-qualified `TestFrameworkState.AFTER_EACH`, which would never match.
const key = testFrameworkState.toString().split('.')[1]

const hooksStarted = TestFramework.getState(instance, TestFrameworkConstants.KEY_HOOKS_STARTED) as Map<string, unknown[]>
if (!hooksStarted.has(key)) {
Expand Down
12 changes: 12 additions & 0 deletions packages/browserstack-service/src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export class BrowserstackCLI {
process: ChildProcess | null = null
isMainConnected = false
isChildConnected = false
modulesLoaded = false
binSessionId: string | null = null
modules: Record<string, BaseModule> = {}
testFramework: WdioMochaTestFramework|null = null
Expand Down Expand Up @@ -130,6 +131,17 @@ export class BrowserstackCLI {
this.binSessionId = startBinResponse.binSessionId
this.logger.info(`loadModules: binSessionId=${this.binSessionId}`)

// Idempotency guard: startMain() and startChild() can both run in the same process
// (e.g. local runner, single instance). Each call constructs the modules again, whose
// constructors register observers on the shared eventDispatcher singleton — and
// registerObserver does not dedupe. Loading twice therefore double-registers every
// observer, so each test/hook event is dispatched (and uploaded) twice. Load once.
if (this.modulesLoaded) {
this.logger.info('loadModules: modules already loaded in this process; skipping to avoid duplicate observer registration')
return
}
this.modulesLoaded = true

this.setConfig(startBinResponse)

// Surface any build errors the binary populated on testhub.errors
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,15 @@ export default class AccessibilityModule extends BaseModule {
platform_name: browserCaps?.platformName,
platform_version: this.getCapability(browserCaps, 'appium:platformVersion', 'platformVersion'),
}

// App Automate sessions must run the app-accessibility flow, not the
// Chrome-only web path. The binary's isAppAccessibility flag is not
// guaranteed to be set on the CLI path, so derive app-ness from caps the
// same way the classic flow does (service._isAppAutomate).
if (this.isAppAutomateSession(inputCaps, browserCaps)) {
this.isAppAccessibility = true
}

if (this.isAppAccessibility) {
this.accessibility = validateCapsWithAppA11y(platformA11yMeta)
} else {
Expand Down Expand Up @@ -129,12 +138,11 @@ export default class AccessibilityModule extends BaseModule {
return
}

if (!('overwriteCommand' in browser && Array.isArray(this.scriptInstance.commandsToWrap))) {
return
}

// Wrap commands if accessibility scripts are available
if (this.scriptInstance.commandsToWrap && this.scriptInstance.commandsToWrap.length > 0) {
// Web command wrapping (overwriteCommand) only applies to the web a11y
// flow. App Automate a11y scans run via the performScan/test-lifecycle
// path, and appium drivers don't register these commands, so
// overwriteCommand would throw and abort onBeforeExecute.
if (!this.isAppAccessibility && 'overwriteCommand' in browser && Array.isArray(this.scriptInstance.commandsToWrap)) {
this.scriptInstance.commandsToWrap
.filter((command) => command.name && command.class)
.forEach((command) => {
Expand Down Expand Up @@ -365,6 +373,18 @@ export default class AccessibilityModule extends BaseModule {

}

// Mirrors service._isAppAutomate: an App Automate session is identified by the
// presence of an app capability (appium:app / appium:options.app).
private isAppAutomateSession(inputCaps?: WebdriverIO.Capabilities, browserCaps?: WebdriverIO.Capabilities): boolean {
for (const caps of [inputCaps, browserCaps]) {
const c = (caps ?? {}) as Record<string, unknown>
if (c['appium:app'] || (c['appium:options'] as { app?: unknown } | undefined)?.app) {
return true
}
}
return false
}

private async performScanCli(
browser: WebdriverIO.Browser | WebdriverIO.MultiRemoteBrowser,
commandName?: string
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,10 @@ export default class TestHubModule extends BaseModule {
this.logger.debug(`sendTestFrameworkEvent for testState: ${testFrameworkState} hookState: ${testHookState}`)
const platformIndex = process.env.WDIO_WORKER_ID ? parseInt(process.env.WDIO_WORKER_ID.split('-')[0]) : 0
const uuid = TestFramework.getState(instance, TestFrameworkConstants.KEY_TEST_UUID) || instance.getRef()
const eventJson = Buffer.from(JSON.stringify(Object.fromEntries(testData)))
// Nested values such as test_hooks_started/test_hooks_finished are JS Maps, which
// JSON.stringify would serialise to `{}` and strip the hook data. Convert any Map to
// a plain object so the binary receives populated hook maps.
const eventJson = Buffer.from(JSON.stringify(Object.fromEntries(testData), (_key, value) => value instanceof Map ? Object.fromEntries(value) : value))
const executionContext = { hash: trackedContext.getId(), threadId: trackedContext.getThreadId().toString(), processId: trackedContext.getProcessId().toString() }
const payload: Omit<TestFrameworkEventRequest, 'binSessionId'> = {
platformIndex,
Expand Down
14 changes: 11 additions & 3 deletions packages/browserstack-service/src/launcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,11 @@ import {
validateCapsWithNonBstackA11y,
mergeChromeOptions,
isValidEnabledValue,
isMultiRemoteCaps
isMultiRemoteCaps,
coerceStringBooleans
} from './util.js'
import CrashReporter from './crash-reporter.js'
import { finalizeOrphanedRuns } from './testOps/openRunsJournal.js'
import { BStackLogger } from './bstackLogger.js'
import { PercyLogger } from './Percy/PercyLogger.js'
import type Percy from './Percy/Percy.js'
Expand Down Expand Up @@ -438,14 +440,17 @@ export default class BrowserstackLauncherService implements Services.ServiceInst
this.browserStackConfig.accessibility = this._accessibilityAutomation

if (this._accessibilityAutomation && this._options.accessibilityOptions) {
const filteredOpts = Object.keys(this._options.accessibilityOptions)
// SDK-3737: coerce stringified booleans (e.g. autoScanning: 'false') to real
// booleans so boolean-typed accessibility options are honoured instead of
// being dropped by W3C caps validation.
const filteredOpts = coerceStringBooleans(Object.keys(this._options.accessibilityOptions)
.filter(key => !NOT_ALLOWED_KEYS_IN_CAPS.includes(key))
.reduce((opts, key) => {
return {
...opts,
[key]: this._options.accessibilityOptions?.[key]
}
}, {})
}, {} as Record<string, unknown>))

this._updateObjectTypeCaps(capabilities as Capabilities.TestrunnerCapabilities, 'accessibilityOptions', filteredOpts)
} else if (isAccessibilityAutomationSession(this._accessibilityAutomation)) {
Expand Down Expand Up @@ -588,6 +593,9 @@ export default class BrowserstackLauncherService implements Services.ServiceInst
const isCLIEnabled = BrowserstackCLI.getInstance().isRunning()
BStackLogger.debug('Inside OnComplete hook..')
BStackLogger.debug('Sending stop launch event')
// SDK-4671: before stopping the build, synthesize TestRunFinished for any
// test runs whose worker died mid-test, else they stay 'in progress' on TRA.
await finalizeOrphanedRuns()
try {
await (isCLIEnabled ? BrowserstackCLI.getInstance().stop() : stopBuildUpstream())
PerformanceTester.end(PERFORMANCE_SDK_EVENTS.FRAMEWORK_EVENTS.STOP)
Expand Down
Loading
Loading