From 79da950f01c9e0e35a5d9b1525bd1478590ef1a9 Mon Sep 17 00:00:00 2001 From: Bhargavi Vaidya Date: Wed, 1 Jul 2026 11:59:00 +0530 Subject: [PATCH 1/4] fix(browserstack-service): rebind per-test session after reloadSession (SDK-6767) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In the CLI/binary flow the device session id was captured once per worker (KEY_FRAMEWORK_SESSION_ID) and read from that cache for every test's TestHub session event, so tests after browser.reloadSession() stayed stamped with the first session's id — collapsing multiple real device sessions onto one session/video on the Observability dashboard. Fix: in testHubModule.sendTestSessionEvent read the live driver.sessionId (fallback to the cached state) and also (re)send the per-test session binding at TEST/POST so it lands after any beforeTest/in-body reload finalizes the session; retain the onReload CREATE re-fire. Verified end-to-end on App Automate: per-test frameworkSessionId distinct 1 -> 3, each test bound to its own session (in-body and beforeTest-hook reload variants). Co-Authored-By: Claude Opus 4.8 --- .../src/cli/modules/testHubModule.ts | 42 ++++++++++++++++--- .../wdio-browserstack-service/src/service.ts | 11 +++++ 2 files changed, 47 insertions(+), 6 deletions(-) diff --git a/packages/wdio-browserstack-service/src/cli/modules/testHubModule.ts b/packages/wdio-browserstack-service/src/cli/modules/testHubModule.ts index a2787f0ef17..16b58fca3f0 100644 --- a/packages/wdio-browserstack-service/src/cli/modules/testHubModule.ts +++ b/packages/wdio-browserstack-service/src/cli/modules/testHubModule.ts @@ -36,6 +36,12 @@ export default class TestHubModule extends BaseModule { this.testhubConfig = testhubConfig TestFramework.registerObserver(TestFrameworkState.TEST, HookState.PRE, this.onBeforeTest.bind(this)) + // SDK-6767: also (re)send the per-test session binding at TEST/POST. The PRE binding runs + // before a beforeTest/in-body browser.reloadSession() finalizes the session, so on its own it + // binds the stale (pre-reload) session — collapsing reloaded sessions. The POST binding lands + // after the reload; the binary does last-write-wins per test_run uuid, so each test links to + // the session it actually ran on. + TestFramework.registerObserver(TestFrameworkState.TEST, HookState.POST, this.onAfterTestSession.bind(this)) Object.values(TestFrameworkState).forEach(state => { Object.values(HookState).forEach(hook => { @@ -60,6 +66,16 @@ export default class TestHubModule extends BaseModule { this.sendTestSessionEvent(args) } + // SDK-6767: re-bind the per-test session at TEST/POST, after any beforeTest/in-body + // browser.reloadSession() has finalized the session. Pairs with the live-read in + // sendTestSessionEvent so the post-reload session overwrites the stale PRE binding. + onAfterTestSession(args: Record) { + this.logger.debug('onAfterTestSession: re-binding per-test session at TEST/POST (post reloadSession)') + const autoInstance = AutomationFramework.getTrackedInstance() as AutomationFrameworkInstance + args.autoInstance = [autoInstance] + this.sendTestSessionEvent(args) + } + onAllTestEvents(args: Record) { this.logger.debug('onAllTestEvents: Called after all test events from cli configured module!!!') const instance = args.instance as TestFrameworkInstance @@ -211,12 +227,26 @@ export default class TestHubModule extends BaseModule { // ltsSessionId) ternary on line 215 has a chance to fall through to // ltsSessionId. Default to '' so the ternary path stays untouched // and the non-LTS fall-through is still safe. - const driverFrameworkSessionId = ( - AutomationFramework.getState( - autoInstance, - AutomationFrameworkConstants.KEY_FRAMEWORK_SESSION_ID, - )?.toString() ?? '' - ) + // SDK-6767: prefer the LIVE driver session id over the once-captured + // KEY_FRAMEWORK_SESSION_ID. The cached id is set at driver-create and goes stale after + // browser.reloadSession(), collapsing reloaded sessions onto the first. getDriver() is + // the same live handle used for capabilities above. Fall back to the cached state if the + // live read is unavailable (e.g. LTS local-Selenium instances without a live driver). + let driverFrameworkSessionId = '' + try { + const liveDriver = AutomationFramework.getDriver(autoInstance) as WebdriverIO.Browser | undefined + driverFrameworkSessionId = liveDriver?.sessionId ? liveDriver.sessionId.toString() : '' + } catch (sessionErr) { + this.logger.debug(`sendTestSessionEvent: live sessionId read failed, falling back to cached state: ${sessionErr}`) + } + if (!driverFrameworkSessionId) { + driverFrameworkSessionId = ( + AutomationFramework.getState( + autoInstance, + AutomationFrameworkConstants.KEY_FRAMEWORK_SESSION_ID, + )?.toString() ?? '' + ) + } const automationSession: AutomationSession = { provider: sessionProvider, diff --git a/packages/wdio-browserstack-service/src/service.ts b/packages/wdio-browserstack-service/src/service.ts index 17cf501978f..ddcdd82139b 100644 --- a/packages/wdio-browserstack-service/src/service.ts +++ b/packages/wdio-browserstack-service/src/service.ts @@ -731,6 +731,17 @@ export default class BrowserstackService implements Services.ServiceInstance { this._reloadHappened = true + // SDK-6767: re-anchor the per-worker session identity to the NEW session after a reload. + // In the CLI/binary flow the device session id is captured once in before() (the CREATE/POST + // trackEvent below) into KEY_FRAMEWORK_SESSION_ID and never refreshed; without this, every + // post-reload test's TestHub event is stamped with the FIRST session's hashed_id, collapsing + // multiple real device sessions onto one session/video on the dashboard. Re-firing CREATE/POST + // re-invokes WebdriverIOModule.onDriverCreated, which overwrites the stored session id (and the + // new device's capabilities) with the live post-reload browser.sessionId. + if (BrowserstackCLI.getInstance().isRunning()) { + await BrowserstackCLI.getInstance().getAutomationFramework()!.trackEvent(AutomationFrameworkState.CREATE, HookState.POST, { browser: this._browser, hubUrl: this._config.hostname }) + } + const { setSessionName, setSessionStatus } = this._options const ignoreHooksStatus = this._options.testObservabilityOptions?.ignoreHooksStatus === true From db93ccab1feb14b13c699d128e14c0e287d7927a Mon Sep 17 00:00:00 2001 From: Bhargavi Vaidya Date: Wed, 1 Jul 2026 17:21:57 +0530 Subject: [PATCH 2/4] test(browserstack-service): add UTs for SDK-6767 reloadSession session rebind - testHubModule: live driver.sessionId wins over the stale cached id; a TEST/POST observer is registered; onAfterTestSession re-sends the per-test session event (post-reload rebind). - service.onReload: re-fires the CLI session capture when the CLI is running, and does not when it is not. - Update the observer-count assertion (+1 -> +2) for the new TEST/POST registration. Co-Authored-By: Claude Opus 4.8 --- .../tests/cli/modules/testHubModule.test.ts | 75 ++++++++++++++++++- .../tests/service.test.ts | 35 +++++++++ 2 files changed, 108 insertions(+), 2 deletions(-) diff --git a/packages/wdio-browserstack-service/tests/cli/modules/testHubModule.test.ts b/packages/wdio-browserstack-service/tests/cli/modules/testHubModule.test.ts index 47316f20aa8..9d03006c2aa 100644 --- a/packages/wdio-browserstack-service/tests/cli/modules/testHubModule.test.ts +++ b/packages/wdio-browserstack-service/tests/cli/modules/testHubModule.test.ts @@ -117,17 +117,51 @@ describe('TestHubModule', () => { expect.any(Function) ) - // Should register for all test states and hook states (11*3) + 1 specific registration for onBeforeTest - const expectedCalls = Object.values(TestFrameworkState).length * Object.values(HookState).length + 1 + // Should register for all test states and hook states (11*3) + 2 specific registrations: + // onBeforeTest (TEST/PRE) and onAfterTestSession (TEST/POST, SDK-6767 post-reload rebind) + const expectedCalls = Object.values(TestFrameworkState).length * Object.values(HookState).length + 2 expect(TestFramework.registerObserver).toHaveBeenCalledTimes(expectedCalls) }) + it('should register a TEST/POST observer to re-bind the session after reload (SDK-6767)', () => { + registerObserverSpy.mockClear() + new TestHubModule(mockTesthubConfig) + + expect(TestFramework.registerObserver).toHaveBeenCalledWith( + TestFrameworkState.TEST, + HookState.POST, + expect.any(Function) + ) + }) + it('should have correct module name', () => { expect(testHubModule.getModuleName()).toBe('TestHubModule') expect(TestHubModule.MODULE_NAME).toBe('TestHubModule') }) }) + describe('onAfterTestSession', () => { + it('should re-send the session event at TEST/POST for post-reload rebind (SDK-6767)', async () => { + const mockAutomationInstance = { + getRef: vi.fn(() => 'auto-ref'), + frameworkName: 'webdriverio', + frameworkVersion: '9.0.0' + } + + vi.mocked(AutomationFramework.getTrackedInstance).mockReturnValue(mockAutomationInstance) + const sendTestSessionEventSpy = vi.spyOn(testHubModule, 'sendTestSessionEvent').mockResolvedValue() + + const mockArgs = { test: { title: 'Test After Reload' } as Frameworks.Test } + + await testHubModule.onAfterTestSession(mockArgs) + + expect(sendTestSessionEventSpy).toHaveBeenCalledWith({ + ...mockArgs, + autoInstance: [mockAutomationInstance] + }) + }) + }) + describe('onBeforeTest', () => { it('should call sendTestSessionEvent with automation instance', async () => { const mockAutomationInstance = { @@ -381,6 +415,43 @@ describe('TestHubModule', () => { }) }) + it('binds to the LIVE driver.sessionId, not the stale cached id (SDK-6767)', async () => { + const mockInstance = { + getCurrentTestState: vi.fn(() => TestFrameworkState.TEST), + getCurrentHookState: vi.fn(() => HookState.POST) + } + const mockAutomationInstance = { + getRef: vi.fn(() => 'auto-ref'), + frameworkName: 'webdriverio', + frameworkVersion: '9.0.0' + } + + vi.mocked(TestFramework.getState).mockImplementation((instance, key) => + key === TestFrameworkConstants.KEY_TEST_UUID ? 'test-uuid-123' : 'mocha' + ) + // Cached state is STALE (the first session, captured once per worker and never refreshed) + vi.mocked(AutomationFramework.getState).mockImplementation((instance, key) => { + if (key === AutomationFrameworkConstants.KEY_IS_BROWSERSTACK_HUB) { + return true + } + if (key === AutomationFrameworkConstants.KEY_FRAMEWORK_SESSION_ID) { + return 'stale-first-session' + } + return 'default-value' + }) + // Live driver reports the CURRENT (post-reload) session + vi.mocked(AutomationFramework.getDriver).mockReturnValue({ + sessionId: 'live-current-session', + capabilities: { browserName: 'chrome' } + } as any) + + await testHubModule.sendTestSessionEvent({ instance: mockInstance, autoInstance: [mockAutomationInstance] }) + + const sent = mockGrpcClient.testSessionEvent.mock.calls[0][0] + expect(sent.automationSessions[0].frameworkSessionId).toBe('live-current-session') + expect(sent.automationSessions[0].frameworkSessionId).not.toBe('stale-first-session') + }) + it('should handle gRPC error and throw', async () => { const error = new Error('gRPC connection failed') mockGrpcClient.testSessionEvent.mockRejectedValue(error) diff --git a/packages/wdio-browserstack-service/tests/service.test.ts b/packages/wdio-browserstack-service/tests/service.test.ts index 416e1f07c23..dba30f65474 100644 --- a/packages/wdio-browserstack-service/tests/service.test.ts +++ b/packages/wdio-browserstack-service/tests/service.test.ts @@ -9,6 +9,8 @@ import * as utils from '../src/util.js' import InsightsHandler from '../src/insights-handler.js' import * as bstackLogger from '../src/bstackLogger.js' import { BrowserstackCLI } from '../src/cli/index.js' +import { AutomationFrameworkState } from '../src/cli/states/automationFrameworkState.js' +import { HookState } from '../src/cli/states/hookState.js' import TestFramework from '../src/cli/frameworks/testFramework.js' import { TestFrameworkConstants } from '../src/cli/frameworks/constants/testFrameworkConstants.js' @@ -233,6 +235,39 @@ describe('onReload()', () => { expect.objectContaining({ reloadHappened: true }) ) }) + + it('re-fires the CLI session capture on reload so the new session is rebound (SDK-6767)', async () => { + const trackEventSpy = vi.fn().mockResolvedValue(undefined) + vi.spyOn(BrowserstackCLI, 'getInstance').mockReturnValue({ + isRunning: () => true, + getTestFramework: () => null, + getAutomationFramework: () => ({ trackEvent: trackEventSpy }) + } as any) + service['_browser'] = browser as WebdriverIO.Browser + + await service.onReload('old-session', 'new-session') + + // Re-captures the driver so KEY_FRAMEWORK_SESSION_ID is refreshed to the post-reload session + expect(trackEventSpy).toHaveBeenCalledWith( + AutomationFrameworkState.CREATE, + HookState.POST, + expect.objectContaining({ browser: service['_browser'] }) + ) + }) + + it('does NOT re-fire the CLI session capture on reload when the CLI is not running (SDK-6767)', async () => { + const trackEventSpy = vi.fn().mockResolvedValue(undefined) + vi.spyOn(BrowserstackCLI, 'getInstance').mockReturnValue({ + isRunning: () => false, + getTestFramework: () => null, + getAutomationFramework: () => ({ trackEvent: trackEventSpy }) + } as any) + service['_browser'] = browser as WebdriverIO.Browser + + await service.onReload('old-session', 'new-session') + + expect(trackEventSpy).not.toHaveBeenCalled() + }) }) describe('beforeSession', () => { From 22ca853ec070e78ca39960ff28b1c8dd6d82a56e Mon Sep 17 00:00:00 2001 From: Bhargavi Vaidya Date: Mon, 6 Jul 2026 15:10:03 +0530 Subject: [PATCH 3/4] fix(browserstack-service): address review feedback + fix UT (SDK-6767) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - service.onReload: null-guard getAutomationFramework() before calling trackEvent (greptile P1) — avoids a TypeError if it returns null while isRunning() is true. - testHubModule: only re-bind the per-test session at TEST/POST when the live session actually changed during the test (a reloadSession happened), tracked via _lastReportedSessionId (greptile P2) — avoids a redundant session event on the common no-reload path. - tests: add the missing getContext() to the live-read test mock (fixes the failing UT: "instance.getContext is not a function"); cover both the re-send-on-change and skip-on-no-change branches of onAfterTestSession. Co-Authored-By: Claude Opus 4.8 --- .../src/cli/modules/testHubModule.ts | 30 ++++++++++++++++--- .../wdio-browserstack-service/src/service.ts | 5 +++- .../tests/cli/modules/testHubModule.test.ts | 27 ++++++++++++++++- 3 files changed, 56 insertions(+), 6 deletions(-) diff --git a/packages/wdio-browserstack-service/src/cli/modules/testHubModule.ts b/packages/wdio-browserstack-service/src/cli/modules/testHubModule.ts index 16b58fca3f0..65c69c7016c 100644 --- a/packages/wdio-browserstack-service/src/cli/modules/testHubModule.ts +++ b/packages/wdio-browserstack-service/src/cli/modules/testHubModule.ts @@ -26,6 +26,10 @@ export default class TestHubModule extends BaseModule { testhubConfig: unknown name: string static MODULE_NAME = 'TestHubModule' + // SDK-6767: session id sent in the most recent session event (per worker). Used at TEST/POST to + // detect whether browser.reloadSession() changed the session during the test, so the post-reload + // re-bind only fires when it is actually needed (not for every test). + private _lastReportedSessionId?: string /** * Create a new TestHubModule @@ -66,12 +70,26 @@ export default class TestHubModule extends BaseModule { this.sendTestSessionEvent(args) } - // SDK-6767: re-bind the per-test session at TEST/POST, after any beforeTest/in-body - // browser.reloadSession() has finalized the session. Pairs with the live-read in - // sendTestSessionEvent so the post-reload session overwrites the stale PRE binding. + // SDK-6767: re-bind the per-test session at TEST/POST, but ONLY when the session changed during + // the test (i.e. a beforeTest/in-body browser.reloadSession() happened). This pairs with the + // live-read in sendTestSessionEvent so the post-reload session overwrites the stale PRE binding, + // while avoiding a redundant session event on the common no-reload path. onAfterTestSession(args: Record) { - this.logger.debug('onAfterTestSession: re-binding per-test session at TEST/POST (post reloadSession)') const autoInstance = AutomationFramework.getTrackedInstance() as AutomationFrameworkInstance + + let liveSessionId = '' + try { + liveSessionId = (AutomationFramework.getDriver(autoInstance) as WebdriverIO.Browser | undefined)?.sessionId?.toString() ?? '' + } catch (error) { + this.logger.debug(`onAfterTestSession: could not read live sessionId: ${error}`) + } + + // No reload => the PRE binding already carries the correct session; skip the extra event. + if (!liveSessionId || liveSessionId === this._lastReportedSessionId) { + return + } + + this.logger.debug('onAfterTestSession: session changed during test (reloadSession) — re-binding at TEST/POST') args.autoInstance = [autoInstance] this.sendTestSessionEvent(args) } @@ -248,6 +266,10 @@ export default class TestHubModule extends BaseModule { ) } + // SDK-6767: remember the live driver session we bound, so onAfterTestSession can tell + // whether a reloadSession() changed it during the test and skip the redundant re-bind. + this._lastReportedSessionId = driverFrameworkSessionId + const automationSession: AutomationSession = { provider: sessionProvider, ref: autoInstance.getRef(), diff --git a/packages/wdio-browserstack-service/src/service.ts b/packages/wdio-browserstack-service/src/service.ts index ddcdd82139b..a03e292fc7f 100644 --- a/packages/wdio-browserstack-service/src/service.ts +++ b/packages/wdio-browserstack-service/src/service.ts @@ -739,7 +739,10 @@ export default class BrowserstackService implements Services.ServiceInstance { // re-invokes WebdriverIOModule.onDriverCreated, which overwrites the stored session id (and the // new device's capabilities) with the live post-reload browser.sessionId. if (BrowserstackCLI.getInstance().isRunning()) { - await BrowserstackCLI.getInstance().getAutomationFramework()!.trackEvent(AutomationFrameworkState.CREATE, HookState.POST, { browser: this._browser, hubUrl: this._config.hostname }) + const automationFramework = BrowserstackCLI.getInstance().getAutomationFramework() + if (automationFramework) { + await automationFramework.trackEvent(AutomationFrameworkState.CREATE, HookState.POST, { browser: this._browser, hubUrl: this._config.hostname }) + } } const { setSessionName, setSessionStatus } = this._options diff --git a/packages/wdio-browserstack-service/tests/cli/modules/testHubModule.test.ts b/packages/wdio-browserstack-service/tests/cli/modules/testHubModule.test.ts index 9d03006c2aa..f9e3a062313 100644 --- a/packages/wdio-browserstack-service/tests/cli/modules/testHubModule.test.ts +++ b/packages/wdio-browserstack-service/tests/cli/modules/testHubModule.test.ts @@ -141,7 +141,7 @@ describe('TestHubModule', () => { }) describe('onAfterTestSession', () => { - it('should re-send the session event at TEST/POST for post-reload rebind (SDK-6767)', async () => { + it('should re-send the session event at TEST/POST when the session changed (reload) (SDK-6767)', async () => { const mockAutomationInstance = { getRef: vi.fn(() => 'auto-ref'), frameworkName: 'webdriverio', @@ -149,6 +149,9 @@ describe('TestHubModule', () => { } vi.mocked(AutomationFramework.getTrackedInstance).mockReturnValue(mockAutomationInstance) + // live session differs from the last reported one => a reloadSession() happened + vi.mocked(AutomationFramework.getDriver).mockReturnValue({ sessionId: 'reloaded-session' } as any) + testHubModule['_lastReportedSessionId'] = 'first-session' const sendTestSessionEventSpy = vi.spyOn(testHubModule, 'sendTestSessionEvent').mockResolvedValue() const mockArgs = { test: { title: 'Test After Reload' } as Frameworks.Test } @@ -160,6 +163,23 @@ describe('TestHubModule', () => { autoInstance: [mockAutomationInstance] }) }) + + it('should NOT re-send at TEST/POST when the session is unchanged (no reload) (SDK-6767)', async () => { + const mockAutomationInstance = { + getRef: vi.fn(() => 'auto-ref'), + frameworkName: 'webdriverio', + frameworkVersion: '9.0.0' + } + + vi.mocked(AutomationFramework.getTrackedInstance).mockReturnValue(mockAutomationInstance) + vi.mocked(AutomationFramework.getDriver).mockReturnValue({ sessionId: 'same-session' } as any) + testHubModule['_lastReportedSessionId'] = 'same-session' + const sendTestSessionEventSpy = vi.spyOn(testHubModule, 'sendTestSessionEvent').mockResolvedValue() + + await testHubModule.onAfterTestSession({ test: { title: 'No Reload' } as Frameworks.Test }) + + expect(sendTestSessionEventSpy).not.toHaveBeenCalled() + }) }) describe('onBeforeTest', () => { @@ -417,6 +437,11 @@ describe('TestHubModule', () => { it('binds to the LIVE driver.sessionId, not the stale cached id (SDK-6767)', async () => { const mockInstance = { + getContext: vi.fn(() => ({ + getId: vi.fn(() => 'ctx-id'), + getThreadId: vi.fn(() => 'thread-123'), + getProcessId: vi.fn(() => 'process-456') + })), getCurrentTestState: vi.fn(() => TestFrameworkState.TEST), getCurrentHookState: vi.fn(() => HookState.POST) } From 8ba1abad21ec4f8e5e44c2cb0e6a0859b513b82b Mon Sep 17 00:00:00 2001 From: Bhargavi Vaidya Date: Tue, 7 Jul 2026 03:57:45 +0530 Subject: [PATCH 4/4] fix(browserstack-service): address review feedback on SDK-6767 - Remove the onReload CREATE/POST re-fire. That lifecycle event has six observers (Percy, Accessibility, Observability, CustomTags, WebdriverIO, ...), so re-firing it on every reloadSession() risked re-running all of them, and the await was unguarded. The o11y session fix does not need it: sendTestSessionEvent already reads the live driver session directly. (addresses the "unexpected side effects" + "unguarded await" comments) - Key _lastReportedSessionId per automation-instance ref (Map) so concurrent multiremote instances in one worker no longer clobber each other's last-known session. (addresses the "single scalar" comment) - Extract getLiveOrCachedSessionId() (live driver -> cached-state fallback) and use it in both sendTestSessionEvent and onAfterTestSession, so the TEST/POST path now has the cached fallback it was missing and the two sites can't drift. (addresses the "silent skip / no fallback" + "duplicated logic" comments) - tests: drop the removed onReload re-fire tests; add per-ref keying and cached-fallback coverage. Full wdio-browserstack-service suite green (39 files, 1020 tests). Co-Authored-By: Claude Opus 4.8 --- .../src/cli/modules/testHubModule.ts | 74 ++++++++----------- .../wdio-browserstack-service/src/service.ts | 14 ---- .../tests/cli/modules/testHubModule.test.ts | 25 ++++++- .../tests/service.test.ts | 34 --------- 4 files changed, 54 insertions(+), 93 deletions(-) diff --git a/packages/wdio-browserstack-service/src/cli/modules/testHubModule.ts b/packages/wdio-browserstack-service/src/cli/modules/testHubModule.ts index 65c69c7016c..6abd3834027 100644 --- a/packages/wdio-browserstack-service/src/cli/modules/testHubModule.ts +++ b/packages/wdio-browserstack-service/src/cli/modules/testHubModule.ts @@ -26,10 +26,11 @@ export default class TestHubModule extends BaseModule { testhubConfig: unknown name: string static MODULE_NAME = 'TestHubModule' - // SDK-6767: session id sent in the most recent session event (per worker). Used at TEST/POST to + // SDK-6767: session id sent in the most recent session event, keyed by automation-instance ref so + // concurrent multiremote instances in one worker don't clobber each other. Used at TEST/POST to // detect whether browser.reloadSession() changed the session during the test, so the post-reload // re-bind only fires when it is actually needed (not for every test). - private _lastReportedSessionId?: string + private _lastReportedSessionId = new Map() /** * Create a new TestHubModule @@ -70,22 +71,35 @@ export default class TestHubModule extends BaseModule { this.sendTestSessionEvent(args) } + // SDK-6767: resolve the session id for an automation instance, preferring the LIVE driver session + // over the cached KEY_FRAMEWORK_SESSION_ID (captured once at driver-create and stale after + // browser.reloadSession()). Falls back to the cached state when the live read is unavailable. + // Shared by sendTestSessionEvent and onAfterTestSession so the two never drift apart. + private getLiveOrCachedSessionId(autoInstance: AutomationFrameworkInstance): string { + let sessionId = '' + try { + sessionId = (AutomationFramework.getDriver(autoInstance) as WebdriverIO.Browser | undefined)?.sessionId?.toString() ?? '' + } catch (error) { + this.logger.debug(`getLiveOrCachedSessionId: live sessionId read failed, falling back to cached state: ${error}`) + } + if (!sessionId) { + sessionId = ( + AutomationFramework.getState(autoInstance, AutomationFrameworkConstants.KEY_FRAMEWORK_SESSION_ID)?.toString() ?? '' + ) + } + return sessionId + } + // SDK-6767: re-bind the per-test session at TEST/POST, but ONLY when the session changed during // the test (i.e. a beforeTest/in-body browser.reloadSession() happened). This pairs with the // live-read in sendTestSessionEvent so the post-reload session overwrites the stale PRE binding, // while avoiding a redundant session event on the common no-reload path. onAfterTestSession(args: Record) { const autoInstance = AutomationFramework.getTrackedInstance() as AutomationFrameworkInstance + const liveSessionId = this.getLiveOrCachedSessionId(autoInstance) - let liveSessionId = '' - try { - liveSessionId = (AutomationFramework.getDriver(autoInstance) as WebdriverIO.Browser | undefined)?.sessionId?.toString() ?? '' - } catch (error) { - this.logger.debug(`onAfterTestSession: could not read live sessionId: ${error}`) - } - - // No reload => the PRE binding already carries the correct session; skip the extra event. - if (!liveSessionId || liveSessionId === this._lastReportedSessionId) { + // No usable session id, or unchanged since the PRE binding for THIS instance => nothing to fix. + if (!liveSessionId || liveSessionId === this._lastReportedSessionId.get(autoInstance.getRef())) { return } @@ -237,38 +251,12 @@ export default class TestHubModule extends BaseModule { ? 'browserstack' : 'unknown_grid') - // Null-safe: under LTS the local-Selenium AutomationFrameworkInstance - // may not have KEY_FRAMEWORK_SESSION_ID populated yet (the binary's - // hub assigns sessionId later than KEY_IS_BROWSERSTACK_HUB). Without - // the guard, AutomationFramework.getState returns undefined and the - // chained .toString() throws TypeError before the (ltsActive && - // ltsSessionId) ternary on line 215 has a chance to fall through to - // ltsSessionId. Default to '' so the ternary path stays untouched - // and the non-LTS fall-through is still safe. - // SDK-6767: prefer the LIVE driver session id over the once-captured - // KEY_FRAMEWORK_SESSION_ID. The cached id is set at driver-create and goes stale after - // browser.reloadSession(), collapsing reloaded sessions onto the first. getDriver() is - // the same live handle used for capabilities above. Fall back to the cached state if the - // live read is unavailable (e.g. LTS local-Selenium instances without a live driver). - let driverFrameworkSessionId = '' - try { - const liveDriver = AutomationFramework.getDriver(autoInstance) as WebdriverIO.Browser | undefined - driverFrameworkSessionId = liveDriver?.sessionId ? liveDriver.sessionId.toString() : '' - } catch (sessionErr) { - this.logger.debug(`sendTestSessionEvent: live sessionId read failed, falling back to cached state: ${sessionErr}`) - } - if (!driverFrameworkSessionId) { - driverFrameworkSessionId = ( - AutomationFramework.getState( - autoInstance, - AutomationFrameworkConstants.KEY_FRAMEWORK_SESSION_ID, - )?.toString() ?? '' - ) - } - - // SDK-6767: remember the live driver session we bound, so onAfterTestSession can tell - // whether a reloadSession() changed it during the test and skip the redundant re-bind. - this._lastReportedSessionId = driverFrameworkSessionId + // SDK-6767: prefer the LIVE driver session id over the once-captured, now-possibly-stale + // cached KEY_FRAMEWORK_SESSION_ID (helper is null-safe for LTS local-Selenium instances + // where the cached id may be unset). Record it per instance ref so onAfterTestSession can + // tell whether a reloadSession() changed the session during the test. + const driverFrameworkSessionId = this.getLiveOrCachedSessionId(autoInstance) + this._lastReportedSessionId.set(autoInstance.getRef(), driverFrameworkSessionId) const automationSession: AutomationSession = { provider: sessionProvider, diff --git a/packages/wdio-browserstack-service/src/service.ts b/packages/wdio-browserstack-service/src/service.ts index a03e292fc7f..17cf501978f 100644 --- a/packages/wdio-browserstack-service/src/service.ts +++ b/packages/wdio-browserstack-service/src/service.ts @@ -731,20 +731,6 @@ export default class BrowserstackService implements Services.ServiceInstance { this._reloadHappened = true - // SDK-6767: re-anchor the per-worker session identity to the NEW session after a reload. - // In the CLI/binary flow the device session id is captured once in before() (the CREATE/POST - // trackEvent below) into KEY_FRAMEWORK_SESSION_ID and never refreshed; without this, every - // post-reload test's TestHub event is stamped with the FIRST session's hashed_id, collapsing - // multiple real device sessions onto one session/video on the dashboard. Re-firing CREATE/POST - // re-invokes WebdriverIOModule.onDriverCreated, which overwrites the stored session id (and the - // new device's capabilities) with the live post-reload browser.sessionId. - if (BrowserstackCLI.getInstance().isRunning()) { - const automationFramework = BrowserstackCLI.getInstance().getAutomationFramework() - if (automationFramework) { - await automationFramework.trackEvent(AutomationFrameworkState.CREATE, HookState.POST, { browser: this._browser, hubUrl: this._config.hostname }) - } - } - const { setSessionName, setSessionStatus } = this._options const ignoreHooksStatus = this._options.testObservabilityOptions?.ignoreHooksStatus === true diff --git a/packages/wdio-browserstack-service/tests/cli/modules/testHubModule.test.ts b/packages/wdio-browserstack-service/tests/cli/modules/testHubModule.test.ts index f9e3a062313..f3ddfd00529 100644 --- a/packages/wdio-browserstack-service/tests/cli/modules/testHubModule.test.ts +++ b/packages/wdio-browserstack-service/tests/cli/modules/testHubModule.test.ts @@ -151,7 +151,7 @@ describe('TestHubModule', () => { vi.mocked(AutomationFramework.getTrackedInstance).mockReturnValue(mockAutomationInstance) // live session differs from the last reported one => a reloadSession() happened vi.mocked(AutomationFramework.getDriver).mockReturnValue({ sessionId: 'reloaded-session' } as any) - testHubModule['_lastReportedSessionId'] = 'first-session' + testHubModule['_lastReportedSessionId'].set('auto-ref', 'first-session') const sendTestSessionEventSpy = vi.spyOn(testHubModule, 'sendTestSessionEvent').mockResolvedValue() const mockArgs = { test: { title: 'Test After Reload' } as Frameworks.Test } @@ -173,13 +173,34 @@ describe('TestHubModule', () => { vi.mocked(AutomationFramework.getTrackedInstance).mockReturnValue(mockAutomationInstance) vi.mocked(AutomationFramework.getDriver).mockReturnValue({ sessionId: 'same-session' } as any) - testHubModule['_lastReportedSessionId'] = 'same-session' + testHubModule['_lastReportedSessionId'].set('auto-ref', 'same-session') const sendTestSessionEventSpy = vi.spyOn(testHubModule, 'sendTestSessionEvent').mockResolvedValue() await testHubModule.onAfterTestSession({ test: { title: 'No Reload' } as Frameworks.Test }) expect(sendTestSessionEventSpy).not.toHaveBeenCalled() }) + + it('falls back to the cached session id when the live driver read is empty (SDK-6767)', async () => { + const mockAutomationInstance = { + getRef: vi.fn(() => 'auto-ref'), + frameworkName: 'webdriverio', + frameworkVersion: '9.0.0' + } + + vi.mocked(AutomationFramework.getTrackedInstance).mockReturnValue(mockAutomationInstance) + // live read unavailable → helper must fall back to cached KEY_FRAMEWORK_SESSION_ID + vi.mocked(AutomationFramework.getDriver).mockReturnValue(undefined as any) + vi.mocked(AutomationFramework.getState).mockImplementation((instance, key) => + key === AutomationFrameworkConstants.KEY_FRAMEWORK_SESSION_ID ? 'cached-reloaded-session' : 'x' + ) + testHubModule['_lastReportedSessionId'].set('auto-ref', 'first-session') + const sendTestSessionEventSpy = vi.spyOn(testHubModule, 'sendTestSessionEvent').mockResolvedValue() + + await testHubModule.onAfterTestSession({ test: { title: 'Reload, live read failed' } as Frameworks.Test }) + + expect(sendTestSessionEventSpy).toHaveBeenCalled() + }) }) describe('onBeforeTest', () => { diff --git a/packages/wdio-browserstack-service/tests/service.test.ts b/packages/wdio-browserstack-service/tests/service.test.ts index dba30f65474..28ff3c79e31 100644 --- a/packages/wdio-browserstack-service/tests/service.test.ts +++ b/packages/wdio-browserstack-service/tests/service.test.ts @@ -9,8 +9,6 @@ import * as utils from '../src/util.js' import InsightsHandler from '../src/insights-handler.js' import * as bstackLogger from '../src/bstackLogger.js' import { BrowserstackCLI } from '../src/cli/index.js' -import { AutomationFrameworkState } from '../src/cli/states/automationFrameworkState.js' -import { HookState } from '../src/cli/states/hookState.js' import TestFramework from '../src/cli/frameworks/testFramework.js' import { TestFrameworkConstants } from '../src/cli/frameworks/constants/testFrameworkConstants.js' @@ -236,38 +234,6 @@ describe('onReload()', () => { ) }) - it('re-fires the CLI session capture on reload so the new session is rebound (SDK-6767)', async () => { - const trackEventSpy = vi.fn().mockResolvedValue(undefined) - vi.spyOn(BrowserstackCLI, 'getInstance').mockReturnValue({ - isRunning: () => true, - getTestFramework: () => null, - getAutomationFramework: () => ({ trackEvent: trackEventSpy }) - } as any) - service['_browser'] = browser as WebdriverIO.Browser - - await service.onReload('old-session', 'new-session') - - // Re-captures the driver so KEY_FRAMEWORK_SESSION_ID is refreshed to the post-reload session - expect(trackEventSpy).toHaveBeenCalledWith( - AutomationFrameworkState.CREATE, - HookState.POST, - expect.objectContaining({ browser: service['_browser'] }) - ) - }) - - it('does NOT re-fire the CLI session capture on reload when the CLI is not running (SDK-6767)', async () => { - const trackEventSpy = vi.fn().mockResolvedValue(undefined) - vi.spyOn(BrowserstackCLI, 'getInstance').mockReturnValue({ - isRunning: () => false, - getTestFramework: () => null, - getAutomationFramework: () => ({ trackEvent: trackEventSpy }) - } as any) - service['_browser'] = browser as WebdriverIO.Browser - - await service.onReload('old-session', 'new-session') - - expect(trackEventSpy).not.toHaveBeenCalled() - }) }) describe('beforeSession', () => {