diff --git a/.changeset/pr-176.md b/.changeset/pr-176.md new file mode 100644 index 0000000..c0ee13a --- /dev/null +++ b/.changeset/pr-176.md @@ -0,0 +1,6 @@ +--- +"@wdio/browserstack-service": minor +--- + +- Accessibility scans now run for driver commands issued from your WDIO config's hooks — `before()` and `beforeSuite` before the run starts, and `afterSuite`/`after` during teardown — so screens visited outside your tests are covered. +- `browser.performScan()` called from a config hook is no longer attributed to a test that has not started. diff --git a/packages/browserstack-service/src/accessibility-handler.ts b/packages/browserstack-service/src/accessibility-handler.ts index 69c3350..1055bb5 100644 --- a/packages/browserstack-service/src/accessibility-handler.ts +++ b/packages/browserstack-service/src/accessibility-handler.ts @@ -84,6 +84,15 @@ class _AccessibilityHandler { * cucumber goes through beforeScenario/afterScenario instead. */ private static readonly TEST_HOOK_FRAMEWORKS = ['mocha', 'jasmine'] + // Frameworks whose config-level hooks are covered by the pre-test window. Jasmine is + // excluded deliberately — App Accessibility is not supported there and its behaviour must + // not change; multiremote is excluded in the guard below, having no session id to gate on. + private static readonly PRE_TEST_SCAN_FRAMEWORKS = ['mocha', 'cucumber'] + + // Latched at the first framework hook or test of the session and never reset. Before it, a + // scan can only have come from a WDIO config hook; after it everything behaves as it always + // has, so nothing downstream of the first test changes. + private _testContextSeen = false private _platformA11yMeta: PlatformA11yMeta private _caps: Capabilities.ResolvedTestrunnerCapabilities private _suiteFile?: string @@ -243,7 +252,9 @@ class _AccessibilityHandler { } browserWithA11y.performScan = async () => { - const results = await performA11yScan(this.isAppAutomate, (this._browser as WebdriverIO.Browser), isBrowserstackSession(this._browser), this._accessibility) + // Same parentage rule as the auto path, and the hook uuid the manual path never carried + // — a manual scan inside a framework hook used to land as a NULL hook row. + const results = await performA11yScan(this.isAppAutomate, (this._browser as WebdriverIO.Browser), isBrowserstackSession(this._browser), this._accessibility, undefined, undefined, this._currentHookRunUuid, this.hasNoParent) if (results) { this._testMetadata[this._testIdentifier as string] = { scanTestForAccessibility : true, @@ -282,6 +293,19 @@ class _AccessibilityHandler { if (!this._accessibility) { return } + + // WDIO's config-level hooks run before any test exists, so the per-test gate below has + // not been computed yet and driver commands issued there went unscanned. Every other + // validation still applies — an a11y-capable session (returned above), autoScanning, a + // supported framework, a real session id. The include/exclude tag filter is the one + // exception: it matches on suite and test titles, and neither exists yet. + // + // The framework allowlist is about SCANNING, not about attribution: App Accessibility is + // not supported on jasmine, so it must gain no scans it did not have before. + if (this._autoScanning && this.supportsPreTestWindow() && sessionId) { + AccessibilityHandler._a11yScanSessionMap[sessionId] = true + BStackLogger.debug('Accessibility scan gate opened ahead of the first test') + } if (!('overwriteCommand' in this._browser && Array.isArray(accessibilityScripts.commandsToWrap))) { return } @@ -304,8 +328,20 @@ class _AccessibilityHandler { } + // Nothing can own a scan before the framework has started anything. Defined once: the auto + // path and the user-facing performScan() must not answer this differently. + private get hasNoParent(): boolean { + return !this._currentHookRunUuid && !this._testContextSeen + } + + private supportsPreTestWindow(): boolean { + return AccessibilityHandler.PRE_TEST_SCAN_FRAMEWORKS.includes(this._framework as string) && + !this._browser?.isMultiremote + } + async beforeTest (suiteTitle: string | undefined, test: Frameworks.Test) { try { + this._testContextSeen = true if ( !AccessibilityHandler.TEST_HOOK_FRAMEWORKS.includes(this._framework as string) || !this.shouldRunTestHooks(this._browser, this._accessibility) @@ -385,6 +421,7 @@ class _AccessibilityHandler { * Cucumber Only */ async beforeScenario (world: ITestCaseHookParameter) { + this._testContextSeen = true const pickleData = world.pickle const gherkinDocument = world.gherkinDocument const featureData = gherkinDocument.feature @@ -465,6 +502,7 @@ class _AccessibilityHandler { */ async beforeHook (test: Frameworks.Test | undefined, context: unknown, hookRunUuid?: string | null) { try { + this._testContextSeen = true if (!this._accessibility || !this.shouldRunTestHooks(this._browser, this._accessibility)) { return } @@ -513,7 +551,16 @@ class _AccessibilityHandler { ) ) { BStackLogger.debug(`Performing scan for ${command.class} ${command.name}`) - await performA11yScan(this.isAppAutomate, this._browser, true, true, command.name, undefined, this._currentHookRunUuid) + // Parentless only before the framework has started anything: no hook run to own the + // scan and no test seen yet in this session. Once either has happened the latch stays + // set, so every later hook keeps the attribution it has always had. + // See the CLI module: the gate outlives the session now, and a scan attempted after + // the session is gone logs an error where main was silent. + if (!(this._browser as WebdriverIO.Browser)?.sessionId) { + BStackLogger.debug('Skipping accessibility scan: the session has ended') + } else { + await performA11yScan(this.isAppAutomate, this._browser, true, true, command.name, undefined, this._currentHookRunUuid, this.hasNoParent) + } } else if (skipScanForBidiWindowCommand) { BStackLogger.debug(`SDK-5047: skipping accessibility scan for BiDi window/context command '${command.name}' to avoid racing the WebdriverIO ContextManager during session-start window churn`) } diff --git a/packages/browserstack-service/src/cli/modules/accessibilityModule.ts b/packages/browserstack-service/src/cli/modules/accessibilityModule.ts index 8e44b11..6c38c7e 100644 --- a/packages/browserstack-service/src/cli/modules/accessibilityModule.ts +++ b/packages/browserstack-service/src/cli/modules/accessibilityModule.ts @@ -27,6 +27,8 @@ export default class AccessibilityModule extends BaseModule { scriptInstance: typeof accessibilityScripts accessibility: boolean = false autoScanning: boolean = true + // Latched at the first framework hook or test and never reset — see the classic handler. + testContextSeen: boolean = false isAppAccessibility: boolean isNonBstackA11y: boolean accessibilityConfig: Accessibility @@ -76,6 +78,7 @@ export default class AccessibilityModule extends BaseModule { // capture on autoInstance would silently drop app hook-scan stamping. const hookRunUuid = testInstance ? (TestFramework.getState(testInstance, TestFrameworkConstants.KEY_HOOK_ID) as string | undefined) : undefined this.currentHookRunUuid = hookRunUuid || null + this.testContextSeen = true const autoInstance: AutomationFrameworkInstance = AutomationFramework.getTrackedInstance() if (!testInstance || !autoInstance) { @@ -216,7 +219,7 @@ export default class AccessibilityModule extends BaseModule { return } // If invoked from inside a hook, currentHookRunUuid stamps the scan for the hook. - return await this.performScanCli(browser, undefined, this.currentHookRunUuid) + return await this.performScanCli(browser, undefined, this.currentHookRunUuid, this.hasNoParent) } (browser as WebdriverIO.Browser).startA11yScanning = async () => { @@ -263,6 +266,19 @@ export default class AccessibilityModule extends BaseModule { }) } + // WDIO's config-level hooks (before, beforeSuite) run before any test or framework + // hook exists, so onHookStart — which returns early without a test instance — cannot + // cover them, and driver commands issued there went unscanned. Every validation + // onHookStart applies still applies here: an a11y-capable session (returned above) + // and autoScanning. The include/exclude tag filter is the one exception — it matches + // on suite and test titles, and in this window neither exists yet. onBeforeTest + // re-computes the per-test gate, tags included, so this only affects the window. + const preTestSessionId = this.currentSessionId() + if (this.autoScanning && preTestSessionId !== undefined && preTestSessionId !== null) { + this.accessibilityMap.set(preTestSessionId, true) + this.logger.debug('Accessibility scan gate opened ahead of the first test') + } + } catch (error) { this.logger.error(`Error in onBeforeExecute: ${error}`) } @@ -276,13 +292,25 @@ export default class AccessibilityModule extends BaseModule { if (sessionId && this.accessibilityMap.get(sessionId)) { const browser = AutomationFramework.getDriver(autoInstance) as WebdriverIO.Browser + // The gate now outlives the last test, so a command issued once the session has + // been deleted (afterSession) would reach the scan and fail with "A sessionId is + // required for this command" — an error log where there used to be silence. The + // driver is the only thing that knows: no sessionId, no session. + if (!browser?.sessionId) { + this.logger.debug('Skipping accessibility scan: the session has ended') + return await originFunction(...args) + } + // Perform accessibility scan before command if script is available if ( !command.name.includes('execute') || !this.shouldPatchExecuteScript(args.length ? args[0] as string : null) ) { try { - await this.performScanCli(browser, command.name, this.currentHookRunUuid) + // Parentless only before the framework has started anything: no hook run + // and no test seen yet. The wrapper never knows which hook it is in, and + // does not need to. + await this.performScanCli(browser, command.name, this.currentHookRunUuid, this.hasNoParent) this.logger.debug(`Accessibility scan performed after ${command.name} command`) } catch (scanError) { this.logger.debug(`Error performing accessibility scan after ${command.name}: ${scanError}`) @@ -309,6 +337,7 @@ export default class AccessibilityModule extends BaseModule { const test = (args.test && typeof args.test === 'object' ? args.test as { title?: string } : {}) || {} this.currentTestName = test.title || null + this.testContextSeen = true const autoInstance: AutomationFrameworkInstance = AutomationFramework.getTrackedInstance() const testInstance: TestFrameworkInstance = TestFramework.getTrackedInstance() @@ -354,7 +383,7 @@ export default class AccessibilityModule extends BaseModule { if (!this.accessibility && !this.isAppAccessibility){ return } - const results = await this.performScanCli(browser, undefined, this.currentHookRunUuid) + const results = await this.performScanCli(browser, undefined, this.currentHookRunUuid, this.hasNoParent) if (results){ const testIdentifier = String(testInstance.getContext().getId()) this.testMetadata[testIdentifier] = { @@ -391,7 +420,6 @@ export default class AccessibilityModule extends BaseModule { const autoInstance: AutomationFrameworkInstance = AutomationFramework.getTrackedInstance() const testInstance: TestFrameworkInstance = TestFramework.getTrackedInstance() - const sessionId = AutomationFramework.getState(autoInstance, AutomationFrameworkConstants.KEY_FRAMEWORK_SESSION_ID) if (!autoInstance || !testInstance) { this.logger.error('No tracked instances found for accessibility after test') @@ -435,7 +463,11 @@ export default class AccessibilityModule extends BaseModule { } else { this.logger.warn('No driver found to send accessibility test stop event') } - this.accessibilityMap.delete(sessionId) + // The gate deliberately stays as the test left it. Deleting it here stopped every + // scan between tests — afterSuite, after, and the next suite's beforeSuite all went + // unscanned on this flow, while the classic flow (which never deleted) scanned them. + // Leaving the entry rather than forcing it true keeps a tag-excluded test and a + // user's stopA11yScanning() in effect. // Clean up test metadata TestFramework.setState(testInstance, `accessibility_metadata_${testIdentifier}`, null) @@ -495,10 +527,16 @@ export default class AccessibilityModule extends BaseModule { return false } + // See the classic handler: one definition, used at every scan site. + private get hasNoParent(): boolean { + return !this.currentHookRunUuid && !this.testContextSeen + } + private async performScanCli( browser: WebdriverIO.Browser | WebdriverIO.MultiRemoteBrowser, commandName?: string, - hookRunUuid?: string | null + hookRunUuid?: string | null, + isGlobalHook?: boolean ): Promise | undefined> { return await PerformanceTester.measureWrapper( PERFORMANCE_SDK_EVENTS.A11Y_EVENTS.PERFORM_SCAN, @@ -511,7 +549,7 @@ export default class AccessibilityModule extends BaseModule { if (this.isAppAccessibility) { const testName=this.currentTestName || undefined const results: unknown = await (browser as WebdriverIO.Browser).execute( - formatString(this.scriptInstance.performScan, JSON.stringify(_getParamsForAppAccessibility(commandName, testName, hookRunUuid))) as string, + formatString(this.scriptInstance.performScan, JSON.stringify(_getParamsForAppAccessibility(commandName, testName, hookRunUuid, isGlobalHook))) as string, {} ) BStackLogger.debug(util.format(results as string)) diff --git a/packages/browserstack-service/src/util.ts b/packages/browserstack-service/src/util.ts index 8c381e9..c78ac37 100644 --- a/packages/browserstack-service/src/util.ts +++ b/packages/browserstack-service/src/util.ts @@ -595,9 +595,12 @@ export const formatString = (template: (string | null), ...values: (string | nul } // eslint-disable-next-line @typescript-eslint/no-explicit-any -export const _getParamsForAppAccessibility = ( commandName?: string, testName?: string, hookRunUuid?: string | null ): { thTestRunUuid: any, thHookRunUuid: any, thBuildUuid: any, thJwtToken: any, authHeader: any, scanTimestamp: number, method: string | undefined, testName: string | undefined } => { +export const _getParamsForAppAccessibility = ( commandName?: string, testName?: string, hookRunUuid?: string | null, isGlobalHook?: boolean ): { thTestRunUuid: any, thHookRunUuid: any, thBuildUuid: any, thJwtToken: any, authHeader: any, scanTimestamp: number, method: string | undefined, testName: string | undefined } => { return { - 'thTestRunUuid': process.env.TEST_ANALYTICS_ID, + // A scan from a WDIO config-level hook belongs to no test. TEST_ANALYTICS_ID in that + // window holds a uuid the framework minted at instance creation — a test that has not + // started — so sending it would attribute the scan to a test it did not come from. + 'thTestRunUuid': isGlobalHook ? undefined : process.env.TEST_ANALYTICS_ID, // Present only when the scan fires inside a hook (dropped by JSON.stringify when undefined, // so in-test scans are unchanged). SeleniumHub appAllyHandler relays this as `hook_run_uuid`. 'thHookRunUuid': hookRunUuid || undefined, @@ -611,7 +614,7 @@ export const _getParamsForAppAccessibility = ( commandName?: string, testName?: } /* eslint-disable @typescript-eslint/no-explicit-any */ -export const performA11yScan = async (isAppAutomate: boolean, browser: WebdriverIO.Browser | WebdriverIO.MultiRemoteBrowser, isBrowserStackSession?: boolean, isAccessibility?: boolean | string, commandName?: string, testName?: string, hookRunUuid?: string | null,) : Promise<{ [key: string]: any; } | undefined> => { +export const performA11yScan = async (isAppAutomate: boolean, browser: WebdriverIO.Browser | WebdriverIO.MultiRemoteBrowser, isBrowserStackSession?: boolean, isAccessibility?: boolean | string, commandName?: string, testName?: string, hookRunUuid?: string | null, isGlobalHook?: boolean,) : Promise<{ [key: string]: any; } | undefined> => { if (!isAccessibilityAutomationSession(isAccessibility)) { BStackLogger.warn('Not an Accessibility Automation session, cannot perform Accessibility scan.') @@ -620,7 +623,7 @@ export const performA11yScan = async (isAppAutomate: boolean, browser: Webdriver try { if (isAppAccessibilityAutomationSession(isAccessibility, isAppAutomate)) { - const results: unknown = await (browser as WebdriverIO.Browser).execute(formatString(AccessibilityScripts.performScan, JSON.stringify(_getParamsForAppAccessibility(commandName, testName, hookRunUuid))) as string, {}) + const results: unknown = await (browser as WebdriverIO.Browser).execute(formatString(AccessibilityScripts.performScan, JSON.stringify(_getParamsForAppAccessibility(commandName, testName, hookRunUuid, isGlobalHook))) as string, {}) BStackLogger.debug(util.format(results as string)) return ( results as { [key: string]: any; } | undefined ) } diff --git a/packages/browserstack-service/tests/accessibility-handler.test.ts b/packages/browserstack-service/tests/accessibility-handler.test.ts index 1d75f54..8f2f692 100644 --- a/packages/browserstack-service/tests/accessibility-handler.test.ts +++ b/packages/browserstack-service/tests/accessibility-handler.test.ts @@ -433,6 +433,123 @@ describe('afterScenario', () => { }) }) +describe('scans ahead of the first test (config-level hooks)', () => { + const handlerFor = (framework: string) => { + const handler = new AccessibilityHandler(browser, caps, options, false, config, framework, true, false, accessibilityOpts) + vi.spyOn(utils, 'isBrowserstackSession').mockReturnValue(true) + vi.spyOn(utils, 'isAccessibilityAutomationSession').mockReturnValue(true) + return handler + } + + const scanArgs = async (handler: any) => { + vi.spyOn(utils, 'shouldScanTestForAccessibility').mockReturnValue(true) + const scanSpy = vi.spyOn(utils, 'performA11yScan').mockResolvedValue(undefined) + const orig = vi.fn().mockResolvedValue('ok') + await handler['commandWrapper']({ name: 'click', class: 'Element' } as any, undefined as any, orig, 'arg') + expect(scanSpy).toHaveBeenCalled() + return scanSpy.mock.calls[scanSpy.mock.calls.length - 1] + } + + it('opens the scan gate in before(), so config-level hook commands are scanned', async () => { + const handler = handlerFor('mocha') + + await handler.before('session-window') + + expect(AccessibilityHandler['_a11yScanSessionMap']['session-window']).toBe(true) + }) + + it('covers cucumber too', async () => { + const handler = handlerFor('cucumber') + + await handler.before('session-cuke') + + expect(AccessibilityHandler['_a11yScanSessionMap']['session-cuke']).toBe(true) + }) + + it('leaves jasmine exactly as it was — App Accessibility is not supported there', async () => { + const handler = handlerFor('jasmine') + + await handler.before('session-jasmine') + + expect(AccessibilityHandler['_a11yScanSessionMap']['session-jasmine']).toBeUndefined() + }) + + it('skips multiremote, which has no session id to gate on', async () => { + const handler = handlerFor('mocha') + handler['_browser'] = { ...browser, isMultiremote: true } as any + + await handler.before('session-multi') + + expect(AccessibilityHandler['_a11yScanSessionMap']['session-multi']).toBeUndefined() + }) + + // Stateless rule: parentless only when neither a framework hook run nor a test can own it. + it('sends no test run uuid when no hook run and no test can own the scan', async () => { + const handler = handlerFor('mocha') + handler['_sessionId'] = 'session-noparent' + await handler.before('session-noparent') + + expect((await scanArgs(handler))[7]).toBe(true) + }) + + it('sends no test run uuid for a MANUAL performScan() from a config hook', async () => { + const handler = handlerFor('mocha') + handler['_sessionId'] = 'session-manual' + const scanSpy = vi.spyOn(utils, 'performA11yScan').mockResolvedValue(undefined) + await handler.before('session-manual') + + await (browser as any).performScan() + + const call = scanSpy.mock.calls[scanSpy.mock.calls.length - 1] + expect(call[7]).toBe(true) + }) + + it('gives a MANUAL performScan() inside a framework hook its hook uuid', async () => { + const handler = handlerFor('mocha') + handler['_sessionId'] = 'session-manual-hook' + const scanSpy = vi.spyOn(utils, 'performA11yScan').mockResolvedValue(undefined) + await handler.before('session-manual-hook') + await handler.beforeHook({ title: '"before all" hook', parent: 'suite' } as any, {}, 'hook-uuid-manual') + + await (browser as any).performScan() + + const call = scanSpy.mock.calls[scanSpy.mock.calls.length - 1] + expect(call[6]).toBe('hook-uuid-manual') + expect(call[7]).toBe(false) + }) + + it('keeps the test run uuid once a framework hook is running', async () => { + const handler = handlerFor('mocha') + handler['_sessionId'] = 'session-fwhook' + await handler.before('session-fwhook') + await handler.beforeHook({ title: '"before all" hook', parent: 'suite' } as any, {}, 'hook-uuid-fw') + + expect((await scanArgs(handler))[7]).toBe(false) + }) + + it('keeps the test run uuid once a test is running', async () => { + const handler = handlerFor('mocha') + handler['_sessionId'] = 'session-intest' + await handler.before('session-intest') + vi.spyOn(utils, 'shouldScanTestForAccessibility').mockReturnValue(true) + await handler.beforeTest('suite', { title: 'test-1' } as any) + + expect((await scanArgs(handler))[7]).toBe(false) + }) + + it('stays attributed after the first test, so later hooks behave exactly as before', async () => { + const handler = handlerFor('mocha') + handler['_sessionId'] = 'session-between' + await handler.before('session-between') + vi.spyOn(utils, 'shouldScanTestForAccessibility').mockReturnValue(true) + await handler.beforeTest('suite', { title: 'test-1' } as any) + await handler.afterTest('suite', { title: 'test-1' } as any) + + // the latch never resets: a scan between tests keeps the attribution main gives it + expect((await scanArgs(handler))[7]).toBe(false) + }) +}) + describe('beforeHook / afterHook (hook scans)', () => { beforeEach(() => { accessibilityHandler = new AccessibilityHandler(browser, caps, options, false, config, 'mocha', true, false, accessibilityOpts) @@ -483,8 +600,9 @@ describe('beforeHook / afterHook (hook scans)', () => { await accessibilityHandler['commandWrapper']({ name: 'click', class: 'Element' } as any, undefined as any, orig, 'arg') expect(scanSpy).toHaveBeenCalled() const lastCall = scanSpy.mock.calls[scanSpy.mock.calls.length - 1] - // performA11yScan(isAppAutomate, browser, isBS, isA11y, commandName, testName, hookRunUuid) - expect(lastCall[lastCall.length - 1]).toBe('hook-uuid-42') + // performA11yScan(isAppAutomate, browser, isBS, isA11y, commandName, testName, hookRunUuid, isGlobalHook) + // asserted BY POSITION: reading the last argument silently follows anything appended here + expect(lastCall[6]).toBe('hook-uuid-42') }) it('does NOT stamp a hook uuid on a test-body scan (afterHook cleared it)', async () => { @@ -500,7 +618,7 @@ describe('beforeHook / afterHook (hook scans)', () => { await accessibilityHandler['commandWrapper']({ name: 'click', class: 'Element' } as any, undefined as any, orig, 'arg') expect(scanSpy).toHaveBeenCalled() const lastCall = scanSpy.mock.calls[scanSpy.mock.calls.length - 1] - expect(lastCall[lastCall.length - 1]).toBeNull() + expect(lastCall[6]).toBeNull() }) it('_getParamsForAppAccessibility puts the hook uuid on the scan payload as thHookRunUuid', () => { diff --git a/packages/browserstack-service/tests/cli/modules/accessibilityModule.test.ts b/packages/browserstack-service/tests/cli/modules/accessibilityModule.test.ts index 0c77511..11cf8a9 100644 --- a/packages/browserstack-service/tests/cli/modules/accessibilityModule.test.ts +++ b/packages/browserstack-service/tests/cli/modules/accessibilityModule.test.ts @@ -77,6 +77,8 @@ describe('AccessibilityModule', () => { } mockBrowser = { + // a live session: the wrapper skips scanning when the driver has no sessionId + sessionId: 'session-w', executeAsync: vi.fn().mockResolvedValue([]), execute: vi.fn().mockResolvedValue({}), overwriteCommand: vi.fn() @@ -162,6 +164,118 @@ describe('AccessibilityModule', () => { }) }) + describe('scan gate ahead of the first test', () => { + // afterEach's vi.resetAllMocks() drops the factory's mockReturnValue, so the caps + // validators return undefined and onBeforeExecute bails before the gate. Re-arm them. + const withA11yCaps = () => { + vi.mocked(validateCapsWithA11y).mockReturnValue(true) + vi.mocked(validateCapsWithAppA11y).mockReturnValue(true) + return vi.mocked(AutomationFramework.getState).mockImplementation((instance, key) => { + if (key.includes('INPUT_CAPABILITIES')) { + return {} + } + if (key.includes('CAPABILITIES')) { + return { browserName: 'chrome' } + } + return 'session-w' + }) + } + + const fireWrappedCommand = async () => { + const orig = vi.fn().mockResolvedValue('ok') + await (accessibilityModule as any).commandWrapper({ name: 'click', class: 'Element' }, orig, 'arg') + } + + it('opens the scan gate at driver creation, before any test exists', async () => { + withA11yCaps() + + await accessibilityModule.onBeforeExecute() + + expect(accessibilityModule.accessibilityMap.get('session-w')).toBe(true) + }) + + it('respects autoScanning — the one validation the window still owns', async () => { + withA11yCaps() + accessibilityModule.autoScanning = false + + await accessibilityModule.onBeforeExecute() + + expect(accessibilityModule.accessibilityMap.get('session-w')).toBeUndefined() + }) + + // The rule is stateless: a scan is parentless only when no framework hook run and no test + // can own it. These drive the REAL call site (commandWrapper), so they pin the wiring. + it('sends no test run uuid for a scan with no hook run and no test', async () => { + withA11yCaps() + accessibilityModule.isAppAccessibility = true + await accessibilityModule.onBeforeExecute() + + await fireWrappedCommand() + + expect(_getParamsForAppAccessibility).toHaveBeenCalledWith('click', undefined, null, true) + }) + + it('keeps the test run uuid once a framework hook is running', async () => { + withA11yCaps() + accessibilityModule.isAppAccessibility = true + await accessibilityModule.onBeforeExecute() + vi.mocked(TestFramework.getState).mockReturnValue('hook-uuid-1') + await accessibilityModule.onHookStart({ instance: mockTestInstance }) + + await fireWrappedCommand() + + const call = vi.mocked(_getParamsForAppAccessibility).mock.calls.at(-1) + expect(call?.[2]).toBe('hook-uuid-1') + expect(call?.[3]).toBe(false) + }) + + it('skips the scan once the session is gone, instead of logging a failure', async () => { + withA11yCaps() + await accessibilityModule.onBeforeExecute() + const orig = vi.fn().mockResolvedValue('ok') + mockBrowser.sessionId = undefined + + await (accessibilityModule as any).commandWrapper({ name: 'click', class: 'Element' }, orig, 'arg') + + // the command still runs; only the scan is skipped + expect(orig).toHaveBeenCalled() + expect(_getParamsForAppAccessibility).not.toHaveBeenCalled() + }) + + it('leaves the gate open after a test, so afterSuite/after still scan', async () => { + withA11yCaps() + vi.mocked(shouldScanTestForAccessibility).mockReturnValue(true) + await accessibilityModule.onBeforeExecute() + await accessibilityModule.onBeforeTest({ suiteTitle: 'suite', test: { title: 'a test' } }) + expect(accessibilityModule.accessibilityMap.get('session-w')).toBe(true) + + // drive onAfterTest all the way to the end: its guards, and then the stop-event + // internals, which throw against these mocks and would swallow the line under test + vi.mocked(mockTestInstance.getData).mockReturnValue({ + accessibilityScanStarted: true, + scanTestForAccessibility: true + }) + vi.spyOn(accessibilityModule as any, 'getDriverExecuteParams').mockResolvedValue({}) + vi.spyOn(accessibilityModule as any, 'sendTestStopEvent').mockResolvedValue(undefined) + await accessibilityModule.onAfterTest() + + // deleting it here used to silence every scan between tests and after the last one + expect(accessibilityModule.accessibilityMap.get('session-w')).toBe(true) + }) + + it('keeps the test run uuid once a test is running', async () => { + withA11yCaps() + accessibilityModule.isAppAccessibility = true + await accessibilityModule.onBeforeExecute() + vi.mocked(shouldScanTestForAccessibility).mockReturnValue(true) + await accessibilityModule.onBeforeTest({ suiteTitle: 'suite', test: { title: 'a test' } }) + + await fireWrappedCommand() + + expect(_getParamsForAppAccessibility).toHaveBeenCalledWith('click', 'a test', null, false) + }) + }) + describe('onBeforeExecute', () => { it('should patch browser methods when automation instance exists', async () => { vi.mocked(AutomationFramework.getState).mockImplementation((instance, key) => { @@ -570,7 +684,7 @@ describe('AccessibilityModule', () => { await (accessibilityModule as any).performScanCli(mockBrowser, 'click', 'hook-uuid-99') - expect(_getParamsForAppAccessibility).toHaveBeenCalledWith('click', undefined, 'hook-uuid-99') + expect(_getParamsForAppAccessibility).toHaveBeenCalledWith('click', undefined, 'hook-uuid-99', undefined) }) it('passes no hook uuid for an ordinary (non-hook) app scan', async () => { @@ -580,7 +694,7 @@ describe('AccessibilityModule', () => { await (accessibilityModule as any).performScanCli(mockBrowser, 'click') - expect(_getParamsForAppAccessibility).toHaveBeenCalledWith('click', undefined, undefined) + expect(_getParamsForAppAccessibility).toHaveBeenCalledWith('click', undefined, undefined, undefined) }) }) -}) \ No newline at end of file +}) diff --git a/packages/browserstack-service/tests/util.test.ts b/packages/browserstack-service/tests/util.test.ts index 6ef86c8..64b02c0 100644 --- a/packages/browserstack-service/tests/util.test.ts +++ b/packages/browserstack-service/tests/util.test.ts @@ -2156,6 +2156,19 @@ describe('_getParamsForAppAccessibility', () => { }) }) + it('omits the test run uuid for a global-hook scan, keeping the hook uuid', () => { + const result = _getParamsForAppAccessibility('click', undefined, 'hook-uuid-1', true) + + expect(result.thTestRunUuid).toBeUndefined() + expect(result.thHookRunUuid).toBe('hook-uuid-1') + expect(result.thBuildUuid).toBe('build-456') + }) + + it('sends the test run uuid when the scan is not from a global hook', () => { + expect(_getParamsForAppAccessibility('click', undefined, null, false).thTestRunUuid).toBe('test-123') + expect(_getParamsForAppAccessibility('click').thTestRunUuid).toBe('test-123') + }) + it('should handle missing environment variables', () => { process.env = {}