diff --git a/packages/wdio-browserstack-service/src/cli/modules/testHubModule.ts b/packages/wdio-browserstack-service/src/cli/modules/testHubModule.ts index a2787f0ef17..6abd3834027 100644 --- a/packages/wdio-browserstack-service/src/cli/modules/testHubModule.ts +++ b/packages/wdio-browserstack-service/src/cli/modules/testHubModule.ts @@ -26,6 +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, 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 = new Map() /** * Create a new TestHubModule @@ -36,6 +41,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 +71,43 @@ 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) + + // 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 + } + + this.logger.debug('onAfterTestSession: session changed during test (reloadSession) — re-binding at TEST/POST') + 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 @@ -203,20 +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. - const driverFrameworkSessionId = ( - AutomationFramework.getState( - autoInstance, - AutomationFrameworkConstants.KEY_FRAMEWORK_SESSION_ID, - )?.toString() ?? '' - ) + // 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/tests/cli/modules/testHubModule.test.ts b/packages/wdio-browserstack-service/tests/cli/modules/testHubModule.test.ts index 47316f20aa8..f3ddfd00529 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,92 @@ 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 when the session changed (reload) (SDK-6767)', async () => { + const mockAutomationInstance = { + getRef: vi.fn(() => 'auto-ref'), + frameworkName: 'webdriverio', + frameworkVersion: '9.0.0' + } + + 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'].set('auto-ref', 'first-session') + 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] + }) + }) + + 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'].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', () => { it('should call sendTestSessionEvent with automation instance', async () => { const mockAutomationInstance = { @@ -381,6 +456,48 @@ 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) + } + 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..28ff3c79e31 100644 --- a/packages/wdio-browserstack-service/tests/service.test.ts +++ b/packages/wdio-browserstack-service/tests/service.test.ts @@ -233,6 +233,7 @@ describe('onReload()', () => { expect.objectContaining({ reloadHappened: true }) ) }) + }) describe('beforeSession', () => {