Skip to content
Closed
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
68 changes: 54 additions & 14 deletions packages/wdio-browserstack-service/src/cli/modules/testHubModule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>()

/**
* Create a new TestHubModule
Expand All @@ -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 => {
Expand All @@ -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<string, unknown>) {
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<string, unknown>) {
this.logger.debug('onAllTestEvents: Called after all test events from cli configured module!!!')
const instance = args.instance as TestFrameworkInstance
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions packages/wdio-browserstack-service/tests/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,7 @@ describe('onReload()', () => {
expect.objectContaining({ reloadHappened: true })
)
})

})

describe('beforeSession', () => {
Expand Down
Loading