diff --git a/.changeset/pr-308.md b/.changeset/pr-308.md new file mode 100644 index 00000000..9df4adf0 --- /dev/null +++ b/.changeset/pr-308.md @@ -0,0 +1,5 @@ +--- +"@wdio/browserstack-service": patch +--- + +- Fixed builds being reported as timed out on Test Reporting & Analytics when a Mocha `before` / `beforeEach` / `afterEach` hook is declared outside any `describe` block. diff --git a/packages/browserstack-service/src/cli/frameworks/wdioMochaTestFramework.ts b/packages/browserstack-service/src/cli/frameworks/wdioMochaTestFramework.ts index ff04c728..802fe1a8 100644 --- a/packages/browserstack-service/src/cli/frameworks/wdioMochaTestFramework.ts +++ b/packages/browserstack-service/src/cli/frameworks/wdioMochaTestFramework.ts @@ -104,6 +104,7 @@ export default class WdioMochaTestFramework extends TestFramework { private owedBailCascades: Array<{ attempt: TestAttempt, result: Frameworks.TestResult }> = [] /** settleTestFinishes() has run its cascades; anything later cascades inline. */ private settled = false + private _suiteFile?: string /** One attempt of a test: mocha retries a test as a new runnable with `_currentRetry` + 1. */ static attemptKey(test: Frameworks.Test): string { @@ -131,6 +132,10 @@ export default class WdioMochaTestFramework extends TestFramework { super(testFrameworks, testFrameworkVersions, binSessionId) } + setSuiteFile(filename?: string) { + this._suiteFile = filename + } + /** * Find instance and track any state for the test framework * @param {TestFrameworkState} testFrameworkState @@ -520,7 +525,7 @@ export default class WdioMochaTestFramework extends TestFramework { async getTestData(instance: TestFrameworkInstance, test: Frameworks.Test) { const framework = TestFramework.getState(instance, TestFrameworkConstants.KEY_TEST_FRAMEWORK_NAME) const fullTitle = getUniqueIdentifier(test, framework) - const filename = test.file // || this._suiteFile + const filename = test.file || this._suiteFile const scopes = getMochaTestHierarchy(test) const testData: Record = { @@ -714,7 +719,8 @@ export default class WdioMochaTestFramework extends TestFramework { } if (hookState === HookState.PRE) { - const filename = test.file + // root-level hooks belong to mocha's root suite, which has no file + const filename = test.file || this._suiteFile const hook: Record = { key, [TestFrameworkConstants.KEY_HOOK_ID]: TestFramework.getState(instance, TestFrameworkConstants.KEY_HOOK_ID) || '', diff --git a/packages/browserstack-service/src/service.ts b/packages/browserstack-service/src/service.ts index 3a1c76d7..c754770b 100644 --- a/packages/browserstack-service/src/service.ts +++ b/packages/browserstack-service/src/service.ts @@ -46,11 +46,13 @@ import { HookState } from './cli/states/hookState.js' import { AutomationFrameworkConstants } from './cli/frameworks/constants/automationFrameworkConstants.js' import TestFramework from './cli/frameworks/testFramework.js' import WdioCucumberTestFramework from './cli/frameworks/wdioCucumberTestFramework.js' +import WdioMochaTestFramework from './cli/frameworks/wdioMochaTestFramework.js' import { TestFrameworkState } from './cli/states/testFrameworkState.js' import { TestFrameworkConstants } from './cli/frameworks/constants/testFrameworkConstants.js' import AccessibilityModule from './cli/modules/accessibilityModule.js' import util from 'node:util' +import { fileURLToPath } from 'node:url' export default class BrowserstackService implements Services.ServiceInstance { private _sessionBaseUrl = 'https://api.browserstack.com/automate/sessions' @@ -217,6 +219,9 @@ export default class BrowserstackService implements Services.ServiceInstance { this._browser = browser ? browser : globalThis.browser PerformanceTester.browser = this._browser + // a root-level mocha `before` runs ahead of the first beforeSuite + this._setCliSuiteFile(specs?.[0]) + UploadAttachmentModule.installNoopFallback(this._browser) // Healing Support: @@ -430,6 +435,7 @@ export default class BrowserstackService implements Services.ServiceInstance { this._suiteTitle = suite.title this._insightsHandler?.setSuiteFile(suite.file) this._accessibilityHandler?.setSuiteFile(suite.file) + this._setCliSuiteFile(suite.file) if (suite.title && suite.title !== 'Jasmine__TopLevel__Suite') { if (!BrowserstackCLI.getInstance().isRunning() || this._config.framework !== 'mocha'){ @@ -1271,6 +1277,21 @@ export default class BrowserstackService implements Services.ServiceInstance { }) } + /** The file the CLI mocha framework gives root-level hooks, which mocha leaves without one. */ + private _setCliSuiteFile(file?: string) { + const cli = BrowserstackCLI.getInstance() + const cliFramework = cli.isRunning() ? cli.getTestFramework() : null + if (!file || !(cliFramework instanceof WdioMochaTestFramework)) { + return + } + try { + // a runner spec is a file URL; MochaAdapter converts it the same way for suite.file + cliFramework.setSuiteFile(file.startsWith('file://') ? fileURLToPath(file) : file) + } catch (err) { + BStackLogger.debug(`Could not resolve the suite file from ${file}: ${err}`) + } + } + private async _setSessionName(suiteTitle: string | undefined, test?: Frameworks.Test) { if (!this._options.setSessionName || !suiteTitle) { return diff --git a/packages/browserstack-service/tests/cli/wdioMochaTestFramework.test.ts b/packages/browserstack-service/tests/cli/wdioMochaTestFramework.test.ts index 608cdf2b..a127eb5b 100644 --- a/packages/browserstack-service/tests/cli/wdioMochaTestFramework.test.ts +++ b/packages/browserstack-service/tests/cli/wdioMochaTestFramework.test.ts @@ -82,6 +82,66 @@ describe('SDK-7233 — test_file_path must be the absolute spec path', () => { }) }) +describe('root-level mocha hooks fall back to the suite file, as on the non-CLI flow', () => { + const ROOT_HOOK = { title: '"before each" hook: wrappedHook for "t1"' } as Frameworks.Test + let framework: WdioMochaTestFramework + + beforeEach(() => { + framework = Object.create(WdioMochaTestFramework.prototype) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + const startHook = async (test: Frameworks.Test) => { + const data = new Map([ + [TestFrameworkConstants.KEY_HOOKS_STARTED, new Map()], + [TestFrameworkConstants.KEY_HOOKS_FINISHED, new Map()], + [TestFrameworkConstants.KEY_HOOK_ID, 'hook-1'], + ]) + const instance = { + getAllData: () => data, + updateMultipleEntries: (entries: Record) => Object.entries(entries).forEach(([k, v]) => data.set(k, v)), + } as unknown as TestFrameworkInstance + await framework.trackHookEvents(instance, TestFrameworkState.BEFORE_EACH, HookState.PRE, { test }) + return (data.get(TestFrameworkConstants.KEY_HOOKS_STARTED) as Map[]>).get('BEFORE_EACH')![0] + } + + it('sends the current suite file for a hook mocha gives no file', async () => { + // a beforeEach/afterEach declared outside any describe hangs off mocha's root suite, which + // has no file; without a path the binary threw, dropped the hook events, and the test's + // reference to the never-sent hook timed the build out + framework.setSuiteFile(SPEC) + + const hook = await startHook(ROOT_HOOK) + + expect(hook[TestFrameworkConstants.KEY_HOOK_ID]).toBe('hook-1') + expect(hook[TestFrameworkConstants.KEY_TEST_FILE_PATH]).toBe(SPEC) + expect(hook[TestFrameworkConstants.KEY_TEST_LOCATION]).toBe(path.relative(process.cwd(), SPEC)) + }) + + it('keeps the file mocha gives a hook declared inside a describe', async () => { + framework.setSuiteFile('/Users/someone/projects/arenaclub/test/specs/other.test.ts') + + const hook = await startHook({ ...ROOT_HOOK, file: SPEC } as Frameworks.Test) + + expect(hook[TestFrameworkConstants.KEY_TEST_FILE_PATH]).toBe(SPEC) + }) + + it('applies the same fallback to test data', async () => { + vi.spyOn(TestFramework, 'getState').mockReturnValue('WebdriverIO-mocha') + vi.spyOn(util, 'getUniqueIdentifier').mockReturnValue('t1') + vi.spyOn(util, 'getMochaTestHierarchy').mockReturnValue([]) + framework.setSuiteFile(SPEC) + + // @ts-expect-error — partial TestFrameworkInstance / Frameworks.Test are enough here + const data = await framework.getTestData({}, { title: 't1', body: '' }) + + expect(data[TestFrameworkConstants.KEY_TEST_FILE_PATH]).toBe(SPEC) + }) +}) + describe('SDK-4177 — loadLogEntries must not relabel a log that carries its own kind', () => { let entries: unknown[] let instance: any diff --git a/packages/browserstack-service/tests/service.test.ts b/packages/browserstack-service/tests/service.test.ts index 4f5040b2..384f05f3 100644 --- a/packages/browserstack-service/tests/service.test.ts +++ b/packages/browserstack-service/tests/service.test.ts @@ -1,4 +1,5 @@ import path from 'node:path' +import { pathToFileURL } from 'node:url' import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' import type { Frameworks } from '@wdio/types' @@ -8,6 +9,7 @@ import BrowserstackService from '../src/service.js' import * as utils from '../src/util.js' import InsightsHandler from '../src/insights-handler.js' import { BrowserstackCLI } from '../src/cli/index.js' +import WdioMochaTestFramework from '../src/cli/frameworks/wdioMochaTestFramework.js' import AccessibilityModule from '../src/cli/modules/accessibilityModule.js' import * as bstackLogger from '../src/bstackLogger.js' import * as skipReporter from '../src/cli/skipReporter.js' @@ -928,6 +930,43 @@ describe('beforeScenario', () => { }) describe('beforeSuite', () => { + describe('suite file for the CLI mocha framework', () => { + const SPEC = path.resolve('/specs/root hooks.spec.ts') + let framework: WdioMochaTestFramework + + beforeEach(() => { + framework = Object.create(WdioMochaTestFramework.prototype) as WdioMochaTestFramework + vi.spyOn(BrowserstackCLI, 'getInstance').mockReturnValue({ + isRunning: () => true, + getTestFramework: () => framework, + getAutomationFramework: () => ({ trackEvent: vi.fn() }), + } as any) + }) + + afterEach(() => { + vi.mocked(BrowserstackCLI.getInstance).mockRestore() + }) + + it('hands the suite file to the CLI mocha framework, as the non-CLI flow does to its insights handler', async () => { + await service.beforeSuite({ title: 'foobar', file: '/specs/foobar.spec.ts' } as any) + + expect(framework['_suiteFile']).toBe('/specs/foobar.spec.ts') + }) + + it('seeds it from the worker spec in before(), as a root-level `before` runs ahead of the first beforeSuite', async () => { + await service.before(service['_config'] as any, [pathToFileURL(SPEC).href], browser) + + expect(framework['_suiteFile']).toBe(SPEC) + }) + + it('keeps the spec file when a suite comes without one', async () => { + await service.before(service['_config'] as any, [pathToFileURL(SPEC).href], browser) + await service.beforeSuite({ title: 'root-level tests' } as any) + + expect(framework['_suiteFile']).toBe(SPEC) + }) + }) + it('should send request to set the session name as suite name for Mocha tests', async () => { await service.before(service['_config'] as any, [], browser) expect(service['_suiteTitle']).toBeUndefined()