Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
2b7f7e9
fix(automate): name the session created by a mid-test reload (SDK-7270)
harshit-browserstack Aug 24, 2026
d2a0326
fix(app-a11y): register scans for pre-test and reloaded sessions, cap…
kamal-kaur04 Aug 26, 2026
2354ae4
chore(changeset): auto-generate from PR template (patch)
github-actions[bot] Aug 26, 2026
e943a78
fix(config-capture): expand an alias wildcard by slicing, not String.…
kamal-kaur04 Aug 26, 2026
f2271ca
feat(accessibility): give pre-test scans a hook run TRA can resolve
kamal-kaur04 Aug 26, 2026
cca3e9d
feat(hooks): log the start and finish of every session-scoped config …
kamal-kaur04 Aug 26, 2026
ef9953a
revert(config-capture): drop alias-import following, log hook sources…
kamal-kaur04 Aug 26, 2026
516cae0
chore(changeset): auto-generate from PR template (patch)
github-actions[bot] Aug 26, 2026
2ddef19
fix(hooks): redact captured hook sources before they reach the log
kamal-kaur04 Aug 26, 2026
71b3966
revert(hooks): move hook-source logging out of this PR
kamal-kaur04 Aug 26, 2026
4803e0f
chore(changeset): auto-generate from PR template (patch)
github-actions[bot] Aug 26, 2026
5da67af
fix(app-a11y): migrate the classic scan state only in the non-CLI flow
kamal-kaur04 Aug 26, 2026
cec234c
fix(app-a11y): resolve the session at call time, not at capture time
kamal-kaur04 Aug 26, 2026
bd75537
fix(service): keep @Measure bound to onReload, and guard the placement
kamal-kaur04 Aug 26, 2026
23d45bb
Merge branch 'fix/sdk-7270-mid-test-reload-session-name' into release…
harshit-browserstack Aug 27, 2026
d39b110
Update pr-165.md
harshit-browserstack Aug 27, 2026
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 .changeset/pr-165.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@wdio/browserstack-service": patch
---

- Fixed accessibility scanning stopping for the remainder of a test after `browser.reloadSession()`.
- Fixed App Automate and Automate session names staying on the static `sessionName` capability when a test reloads the session part-way through for example a deep-link or app-relaunch step. Such sessions now show the test title, and also carry their own pass/fail result.
29 changes: 27 additions & 2 deletions packages/browserstack-service/src/accessibility-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,28 @@ class _AccessibilityHandler {
}
}

/**
* A reload replaces the session id under a driver object that otherwise carries on
* unchanged. `_sessionId` is captured once in `before()`, so without this every later
* scan-gate lookup and every results call keeps addressing the session that already ended.
*/
onSessionReload(oldSessionId: string, newSessionId: string) {
try {
if (!newSessionId || oldSessionId === newSessionId) {
return
}
if (oldSessionId in AccessibilityHandler._a11yScanSessionMap) {
AccessibilityHandler._a11yScanSessionMap[newSessionId] = AccessibilityHandler._a11yScanSessionMap[oldSessionId]
delete AccessibilityHandler._a11yScanSessionMap[oldSessionId]
}
if (this._sessionId === oldSessionId) {
this._sessionId = newSessionId
}
} catch (error) {
BStackLogger.debug(`Exception while migrating accessibility state across session reload: ${error}`)
}
}

async before(sessionId: string) {
PerformanceTester.start(PERFORMANCE_SDK_EVENTS.CONFIG_EVENTS.ACCESSIBILITY)

Expand Down Expand Up @@ -237,7 +259,10 @@ class _AccessibilityHandler {
BStackLogger.warn('Accessibility scanning cannot be started from outside the test')
return
}
AccessibilityHandler._a11yScanSessionMap[sessionId] = true
// `this._sessionId` rather than the captured `sessionId`: commandWrapper reads the
// former, and a reload moves it, so writing the captured id would leave the user's
// start/stop addressing a session that has ended.
AccessibilityHandler._a11yScanSessionMap[this._sessionId ?? sessionId] = true
this._testMetadata[this._testIdentifier as string] = {
scanTestForAccessibility : true,
accessibilityScanStarted : true
Expand All @@ -250,7 +275,7 @@ class _AccessibilityHandler {
BStackLogger.warn('Accessibility scanning cannot be stopped from outside the test')
return
}
AccessibilityHandler._a11yScanSessionMap[sessionId] = false
AccessibilityHandler._a11yScanSessionMap[this._sessionId ?? sessionId] = false
await this._setAnnotation('Accessibility scanning has stopped')
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@ export default class AccessibilityModule extends BaseModule {
isNonBstackA11y: boolean
accessibilityConfig: Accessibility
static MODULE_NAME = 'AccessibilityModule'
accessibilityMap: Map<number, boolean>
LOG_DISABLED_SHOWN: Map<number, boolean>
accessibilityMap: Map<string, boolean>
LOG_DISABLED_SHOWN: Map<string, boolean>
testMetadata: Record<string, { [key: string]: unknown; }> = {}
currentTestName: string | null = null
// The run uuid of the hook currently executing (set at hook PRE, cleared at hook POST).
Expand Down Expand Up @@ -102,6 +102,56 @@ export default class AccessibilityModule extends BaseModule {
this.currentHookRunUuid = null
}

/**
* browser.reloadSession() hands the worker a NEW session id while the driver object, the
* wrapped commands and the currently running test all stay exactly the same. The scan gate
* is keyed on the session id, so the entry registered for the old id is orphaned the moment
* the reload lands: every command issued for the rest of that test looks up a key that does
* not exist and is silently not scanned. Nothing re-registers until the NEXT onBeforeTest,
* so a test that reloads mid-way loses all coverage after the reload.
*
* Migrating the entry rather than re-deriving it preserves whatever the gate currently says,
* including a stopA11yScanning() the user called before reloading.
*/
onSessionReload(oldSessionId: string, newSessionId: string) {
try {
if (!oldSessionId || !newSessionId || oldSessionId === newSessionId) {
return
}

if (this.accessibilityMap.has(oldSessionId)) {
this.accessibilityMap.set(newSessionId, this.accessibilityMap.get(oldSessionId) as boolean)
this.accessibilityMap.delete(oldSessionId)
this.logger.debug(`Accessibility scan gate migrated across session reload to ${newSessionId}`)
}
if (this.LOG_DISABLED_SHOWN.has(oldSessionId)) {
this.LOG_DISABLED_SHOWN.set(newSessionId, this.LOG_DISABLED_SHOWN.get(oldSessionId) as boolean)
this.LOG_DISABLED_SHOWN.delete(oldSessionId)
}
} catch (error) {
this.logger.error(`Exception in accessibility onSessionReload: ${error}`)
}
}

/**
* The session id as of RIGHT NOW, read from framework state rather than captured.
*
* Anything installed on the driver — the scanning toggles, the results getters — outlives the
* session it was created in, because `browser.reloadSession()` swaps the session underneath a
* driver object that carries on unchanged. A captured id makes those closures address a
* session that has ended, while `commandWrapper` re-reads state and addresses the live one.
*/
private currentSessionId(): string | undefined {
try {
const autoInstance: AutomationFrameworkInstance = AutomationFramework.getTrackedInstance()
return autoInstance
? AutomationFramework.getState(autoInstance, AutomationFrameworkConstants.KEY_FRAMEWORK_SESSION_ID) as string
: undefined
} catch {
return undefined
}
}

async onBeforeExecute() {
try {
const autoInstance: AutomationFrameworkInstance = AutomationFramework.getTrackedInstance()
Expand All @@ -121,7 +171,6 @@ export default class AccessibilityModule extends BaseModule {
const isBrowserstackSession = AutomationFramework.getState(autoInstance, AutomationFrameworkConstants.KEY_IS_BROWSERSTACK_HUB)
const browserCaps = AutomationFramework.getState(autoInstance, AutomationFrameworkConstants.KEY_CAPABILITIES)
const inputCaps = AutomationFramework.getState(autoInstance, AutomationFrameworkConstants.KEY_INPUT_CAPABILITIES)
const sessionId = AutomationFramework.getState(autoInstance, AutomationFrameworkConstants.KEY_FRAMEWORK_SESSION_ID)
const platformA11yMeta = {
browser_name: browserCaps.browserName,
browser_version: browserCaps?.browserVersion || 'latest',
Expand All @@ -148,15 +197,15 @@ export default class AccessibilityModule extends BaseModule {
//patching getA11yResultsSummary
(browser as WebdriverIO.Browser).getAccessibilityResultsSummary = async () => {
if (this.isAppAccessibility) {
return await getAppA11yResultsSummary(true, browser, this.currentTestName, isBrowserstackSession, this.accessibility, sessionId)
return await getAppA11yResultsSummary(true, browser, this.currentTestName, isBrowserstackSession, this.accessibility, this.currentSessionId())
}
return await this.getA11yResultsSummary(browser)
}

//patching getA11yResults
(browser as WebdriverIO.Browser).getAccessibilityResults = async () => {
if (this.isAppAccessibility) {
return await getAppA11yResults(true, browser, this.currentTestName, isBrowserstackSession, this.accessibility, sessionId)
return await getAppA11yResults(true, browser, this.currentTestName, isBrowserstackSession, this.accessibility, this.currentSessionId())
}
return await this.getA11yResults(browser)
}
Expand Down Expand Up @@ -282,7 +331,9 @@ export default class AccessibilityModule extends BaseModule {
if (!this.accessibility && !this.isAppAccessibility){
return
}
this.accessibilityMap.set(sessionId, true)
// Resolved at call time: a reload mid-test moves the gate to a new key, and
// writing to the captured one would leave the user's start/stop with no effect.
this.accessibilityMap.set(this.currentSessionId() ?? sessionId, true)
this.testMetadata[testIdentifier] = {
scanTestForAccessibility : true,
accessibilityScanStarted : true
Expand All @@ -295,7 +346,7 @@ export default class AccessibilityModule extends BaseModule {
if (!this.accessibility && !this.isAppAccessibility){
return
}
this.accessibilityMap.set(sessionId, false)
this.accessibilityMap.set(this.currentSessionId() ?? sessionId, false)
await this._setAnnotation('Accessibility scanning has stopped')
}

Expand Down
39 changes: 32 additions & 7 deletions packages/browserstack-service/src/cli/modules/automateModule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ export default class AutomateModule extends BaseModule {
const suiteTitle = args.suiteTitle as string
const testContextOptions = this.config.testContextOptions as TestContextOptions

if (testContextOptions.skipSessionStatus || !isBrowserstackSession(browser)) {
if (!isBrowserstackSession(browser)) {
this.logger.info('Skipping session status update as per configuration')
return
}
Expand All @@ -176,14 +176,39 @@ export default class AutomateModule extends BaseModule {
name = `${pre}${test.parent}${post}`
}

// SDK-7270 (residual): the session can be REPLACED mid-test. @wdio/mocha-framework binds
// beforeTest to the test function itself (wrapGlobalTestMethod), so onBeforeTest is the one
// and only per-test naming opportunity and it has already passed by the time the test body
// calls browser.reloadSession(). The replacement session is therefore never registered in
// sessionMap and keeps its creation-time `sessionName` capability. (A reload in a beforeEach
// hook is fine — that runs before beforeTest.) Re-resolve the live session id here
// (service.onReload has already pointed KEY_FRAMEWORK_SESSION_ID at it) and adopt it while
// it is still open.
//
// Deliberately gated on skipSessionName, NOT skipSessionStatus: naming and status are
// independent options, so a `setSessionStatus: false` user must still get the name repair,
// and a `setSessionName: false` user must not be pulled into sessionMap — that would hand
// onAfterExecute a session to status-mark where it previously had none.
if (sessionId && !testContextOptions.skipSessionName && !this.sessionMap.has(sessionId)) {
this.sessionMap.set(sessionId, { lastTestName: name, testResults: new Map() })
}
// No-op for the steady state: when no mid-test reload happened, onBeforeTest already
// applied this exact name and `appliedName` de-dupes it away — no extra API call.
await this.flushSessionName(sessionId)

if (testContextOptions.skipSessionStatus) {
this.logger.info('Skipping session status update as per configuration')
return
}

const testResult: TestResult = {
testName: name,
status: status,
reason: reason
}

const sessionData = this.sessionMap.get(sessionId)
if (sessionData) {
const testResult: TestResult = {
testName: name,
status: status,
reason: reason
}

sessionData.testResults.set(name, testResult)
this.sessionMap.set(sessionId, sessionData)
}
Expand Down
35 changes: 34 additions & 1 deletion packages/browserstack-service/src/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import { AutomationFrameworkConstants } from './cli/frameworks/constants/automat
import TestFramework from './cli/frameworks/testFramework.js'
import { TestFrameworkState } from './cli/states/testFrameworkState.js'
import { TestFrameworkConstants } from './cli/frameworks/constants/testFrameworkConstants.js'
import AccessibilityModule from './cli/modules/accessibilityModule.js'

import util from 'node:util'

Expand Down Expand Up @@ -288,7 +289,7 @@ export default class BrowserstackService implements Services.ServiceInstance {
this._options.accessibilityOptions
)

if (isBrowserstackSession(this._browser) && BrowserstackCLI.getInstance().isRunning()){
if (this._isCliAccessibilityFlow()){
BStackLogger.info(`CLI is running, tracking accessibility event for before: ${sessionId}`)
// BrowserstackCLI.getInstance().getTestFramework()!.trackEvent(AutomationFrameworkState.CREATE, HookState.POST, { sessionId })
} else {
Expand Down Expand Up @@ -884,6 +885,26 @@ export default class BrowserstackService implements Services.ServiceInstance {
await this._insightsHandler?.afterStep(step, scenario, result)
}

/**
* Whether accessibility is being driven by the binary rather than the classic handler.
*
* Precisely: it decides whether the CLASSIC handler owns the session's accessibility state.
* It is the same condition that gates `AccessibilityHandler.before()` in the `before` hook, so
* keeping it in one place is what stops the two drifting.
*
* The two paths are not strictly exclusive. `AccessibilityModule` is registered on
* `startBinResponse.accessibility?.success` alone, independent of provider — `isNonBstackA11y`
* exists for exactly the turboscale / non-BrowserStack case — so with the binary up against a
* non-BrowserStack provider the module can hold real state AND the handler's `before()` will
* have run. Both then need migrating, which is what the caller does.
*
* `isRunning()` alone is NOT equivalent, and that is the trap: under a non-BrowserStack
* provider it is true while the classic handler is the one holding the live state.
*/
private _isCliAccessibilityFlow (): boolean {
return Boolean(isBrowserstackSession(this._browser)) && BrowserstackCLI.getInstance().isRunning()
}

@PerformanceTester.Measure(PERFORMANCE_SDK_EVENTS.EVENTS.SDK_HOOK, { hookType: 'onReload' })
async onReload(oldSessionId: string, newSessionId: string) {
if (!this._browser) {
Expand All @@ -896,6 +917,18 @@ export default class BrowserstackService implements Services.ServiceInstance {
if (instance) {
AutomationFramework.setState(instance, AutomationFrameworkConstants.KEY_FRAMEWORK_SESSION_ID, newSessionId)
}
// Carry the accessibility scan gate over to the new session id. Updating the state
// above without this is what orphans it: the gate is keyed on the session id, so the
// rest of the reloading test would go unscanned.
const accessibilityModule = BrowserstackCLI.getInstance().modules?.[AccessibilityModule.MODULE_NAME] as AccessibilityModule | undefined
accessibilityModule?.onSessionReload(oldSessionId, newSessionId)
}

// The classic handler holds state exactly when its `before()` ran, which is the inverse of
// _isCliAccessibilityFlow() — not of isRunning(). Gating on the same predicate as that
// call site is what keeps the binary-up-but-non-BrowserStack-provider case covered.
if (!this._isCliAccessibilityFlow()) {
this._accessibilityHandler?.onSessionReload(oldSessionId, newSessionId)
}

const { setSessionName, setSessionStatus } = this._options
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -689,6 +689,49 @@ describe('afterTest', () => {
})
})

describe('onSessionReload', () => {
beforeEach(() => {
accessibilityHandler = new AccessibilityHandler(browser, caps, options, false, config, 'framework', true, false, accessibilityOpts)
})

it('moves the scan flag and the tracked session id onto the reloaded session', () => {
accessibilityHandler['_sessionId'] = 'old-session'
AccessibilityHandler['_a11yScanSessionMap']['old-session'] = true

accessibilityHandler.onSessionReload('old-session', 'new-session')

expect(AccessibilityHandler['_a11yScanSessionMap']['new-session']).toBe(true)
expect(AccessibilityHandler['_a11yScanSessionMap']['old-session']).toBeUndefined()
expect(accessibilityHandler['_sessionId']).toBe('new-session')
})

it('makes the scanning toggles address the reloaded session', async () => {
// commandWrapper reads `this._sessionId`, so a toggle writing the id captured in before()
// would set the dead key — stop would not stop, start would not start.
// before() is what installs the toggles on the driver, so it has to run first
vi.spyOn(utils, 'isBrowserstackSession').mockReturnValue(true)
await accessibilityHandler.before('old-session')
accessibilityHandler['_testIdentifier'] = 'test-1'
AccessibilityHandler['_a11yScanSessionMap']['old-session'] = true

accessibilityHandler.onSessionReload('old-session', 'new-session')
const browserWithA11y = browser as unknown as { stopA11yScanning: () => Promise<void> }
await browserWithA11y.stopA11yScanning()

expect(AccessibilityHandler['_a11yScanSessionMap']['new-session']).toBe(false)
})

it('is a no-op for a reload that changes nothing', () => {
accessibilityHandler['_sessionId'] = 'same'
AccessibilityHandler['_a11yScanSessionMap']['same'] = true

accessibilityHandler.onSessionReload('same', 'same')

expect(AccessibilityHandler['_a11yScanSessionMap']['same']).toBe(true)
expect(accessibilityHandler['_sessionId']).toBe('same')
})
})

describe('getIdentifier', () => {
let getUniqueIdentifierSpy: any
let getUniqueIdentifierForCucumberSpy: any
Expand Down
Loading
Loading