diff --git a/.changeset/pr-163.md b/.changeset/pr-163.md new file mode 100644 index 0000000..32c7f2f --- /dev/null +++ b/.changeset/pr-163.md @@ -0,0 +1,5 @@ +--- +"@wdio/browserstack-service": patch +--- + +- 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. diff --git a/packages/browserstack-service/src/cli/modules/automateModule.ts b/packages/browserstack-service/src/cli/modules/automateModule.ts index 712b8b2..fcdc336 100644 --- a/packages/browserstack-service/src/cli/modules/automateModule.ts +++ b/packages/browserstack-service/src/cli/modules/automateModule.ts @@ -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 } @@ -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) } diff --git a/packages/browserstack-service/tests/cli/modules/automateModule.test.ts b/packages/browserstack-service/tests/cli/modules/automateModule.test.ts index d3f47c4..3fca63a 100644 --- a/packages/browserstack-service/tests/cli/modules/automateModule.test.ts +++ b/packages/browserstack-service/tests/cli/modules/automateModule.test.ts @@ -210,6 +210,114 @@ describe('AutomateModule', () => { expect(TestFramework.setState).toHaveBeenCalledTimes(2) // status and reason }) + it('names the session created by a mid-test reload (SDK-7270)', async () => { + // browser.reloadSession() from the test body runs AFTER mocha's EVENT_TEST_BEGIN, so + // onBeforeTest named the old session and the replacement was never registered. + vi.mocked(fetch).mockResolvedValue({ + json: vi.fn().mockResolvedValue({ success: true }) + } as any) + + const mockArgs = { + instance: mockTestInstance, + result: { error: null, passed: true }, + test: { title: 'test title', parent: 'suite title' }, + suiteTitle: 'suite title' + } + + await automateModule.onBeforeTest(mockArgs) + + // service.onReload repoints KEY_FRAMEWORK_SESSION_ID at the replacement session. + vi.mocked(AutomationFramework.getState).mockImplementation((instance, key) => { + if (key === 'framework_session_id') {return 'reloaded-session-id'} + if (key.includes('CAPABILITIES')) {return { browserName: 'chrome' }} + return {} + }) + vi.mocked(fetch).mockClear() + + await automateModule.onAfterTest(mockArgs) + + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining('sessions/reloaded-session-id.json'), + expect.objectContaining({ method: 'PUT', body: JSON.stringify({ name: 'suite title - test title' }) }) + ) + }) + + it('issues no extra name call when the session is unchanged across the test (SDK-7270 de-dupe)', async () => { + vi.mocked(fetch).mockResolvedValue({ + json: vi.fn().mockResolvedValue({ success: true }) + } as any) + + const mockArgs = { + instance: mockTestInstance, + result: { error: null, passed: true }, + test: { title: 'test title', parent: 'suite title' }, + suiteTitle: 'suite title' + } + + await automateModule.onBeforeTest(mockArgs) + vi.mocked(fetch).mockClear() + + await automateModule.onAfterTest(mockArgs) + + // appliedName de-dupes — steady state must cost zero additional API calls. + expect(fetch).not.toHaveBeenCalled() + }) + + it('still names a mid-test-reload session when skipSessionStatus is true (SDK-7270 guard independence)', async () => { + // Naming and status are independent options; a status flag must not gate the name repair. + (automateModule.config as any).testContextOptions.skipSessionStatus = true + vi.mocked(fetch).mockResolvedValue({ + json: vi.fn().mockResolvedValue({ success: true }) + } as any) + + const mockArgs = { + instance: mockTestInstance, + result: { error: null, passed: true }, + test: { title: 'test title', parent: 'suite title' }, + suiteTitle: 'suite title' + } + + await automateModule.onBeforeTest(mockArgs) + + vi.mocked(AutomationFramework.getState).mockImplementation((instance, key) => { + if (key === 'framework_session_id') {return 'reloaded-session-id'} + if (key.includes('CAPABILITIES')) {return { browserName: 'chrome' }} + return {} + }) + vi.mocked(fetch).mockClear() + + await automateModule.onAfterTest(mockArgs) + + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining('sessions/reloaded-session-id.json'), + expect.objectContaining({ method: 'PUT', body: JSON.stringify({ name: 'suite title - test title' }) }) + ) + }) + + it('adds no status traffic for skipSessionName users (SDK-7270 inverse-leak guard)', async () => { + // With naming off, onBeforeTest never registers the session. onAfterTest must not adopt it + // either, or onAfterExecute would start status-marking sessions it previously ignored. + (automateModule.config as any).testContextOptions.skipSessionName = true + vi.mocked(fetch).mockResolvedValue({ + json: vi.fn().mockResolvedValue({ success: true }) + } as any) + + const mockArgs = { + instance: mockTestInstance, + result: { error: null, passed: true }, + test: { title: 'test title', parent: 'suite title' }, + suiteTitle: 'suite title' + } + + await automateModule.onBeforeTest(mockArgs) + await automateModule.onAfterTest(mockArgs) + vi.mocked(fetch).mockClear() + + await automateModule.onAfterExecute() + + expect(fetch).not.toHaveBeenCalled() + }) + it('should skip session status update when skipSessionStatus is true', async () => { (automateModule.config as any).testContextOptions.skipSessionStatus = true const mockArgs = {