From 9d6b28712c9f32fed87a1126c3dc966679a9ba3a Mon Sep 17 00:00:00 2001 From: rounak bhatia Date: Mon, 26 Oct 2026 19:57:18 +0530 Subject: [PATCH 1/4] fix(cli): give root-level mocha hooks the suite file, as the non-CLI flow does (v8) Mocha attaches a beforeEach/afterEach declared outside any describe to its root suite, which has no file, so the CLI flow sent these hooks with no path. The binary threw on the missing path and dropped their HookRunStarted/Finished events while the test still referenced the hook id, leaving a never-closed hook that timed the build out. The non-CLI flow falls back to the current suite file (set in beforeSuite); the CLI mocha framework now does the same for hooks and test data. Co-Authored-By: Claude Opus 5.5 --- .../cli/frameworks/wdioMochaTestFramework.ts | 10 +++- packages/browserstack-service/src/service.ts | 5 ++ .../frameworks/wdioMochaTestFramework.test.ts | 54 +++++++++++++++++++ .../tests/service.test.ts | 14 +++++ 4 files changed, 81 insertions(+), 2 deletions(-) diff --git a/packages/browserstack-service/src/cli/frameworks/wdioMochaTestFramework.ts b/packages/browserstack-service/src/cli/frameworks/wdioMochaTestFramework.ts index b5706ee2..c9d93cc3 100644 --- a/packages/browserstack-service/src/cli/frameworks/wdioMochaTestFramework.ts +++ b/packages/browserstack-service/src/cli/frameworks/wdioMochaTestFramework.ts @@ -16,6 +16,7 @@ import { TEST_ANALYTICS_ID } from '../../constants.js' export default class WdioMochaTestFramework extends TestFramework { static KEY_HOOK_LAST_STARTED = 'test_hook_last_started' static KEY_HOOK_LAST_FINISHED = 'test_hook_last_finished' + private _suiteFile?: string /** * Constructor for the TestFramework @@ -27,6 +28,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 @@ -190,7 +195,7 @@ export default class WdioMochaTestFramework extends TestFramework { const framework = TestFramework.getState(instance, TestFrameworkConstants.KEY_TEST_FRAMEWORK_NAME) const fullTitle = getUniqueIdentifier(test, framework) const gitConfig = await getGitMetaData() - const filename = test.file // || this._suiteFile + const filename = test.file || this._suiteFile const scopes = getMochaTestHierarchy(test) @@ -388,7 +393,8 @@ export default class WdioMochaTestFramework extends TestFramework { if (hookState === HookState.PRE) { const gitConfig = await getGitMetaData() - 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 1a93ffd1..9b7e539c 100644 --- a/packages/browserstack-service/src/service.ts +++ b/packages/browserstack-service/src/service.ts @@ -47,6 +47,7 @@ import util from 'node:util' import APIUtils from './cli/apiUtils.js' import { CLIUtils } from './cli/cliUtils.js' import WdioCucumberTestFramework from './cli/frameworks/wdioCucumberTestFramework.js' +import WdioMochaTestFramework from './cli/frameworks/wdioMochaTestFramework.js' import type TestHubModule from './cli/modules/testHubModule.js' export default class BrowserstackService implements Services.ServiceInstance { @@ -379,6 +380,10 @@ export default class BrowserstackService implements Services.ServiceInstance { this._suiteTitle = suite.title this._insightsHandler?.setSuiteFile(suite.file) this._accessibilityHandler?.setSuiteFile(suite.file) + const cliFramework = BrowserstackCLI.getInstance().isRunning() ? BrowserstackCLI.getInstance().getTestFramework() : null + if (cliFramework instanceof WdioMochaTestFramework) { + cliFramework.setSuiteFile(suite.file) + } if (suite.title && suite.title !== 'Jasmine__TopLevel__Suite') { if (!BrowserstackCLI.getInstance().isRunning() || this._config.framework !== 'mocha'){ diff --git a/packages/browserstack-service/tests/cli/frameworks/wdioMochaTestFramework.test.ts b/packages/browserstack-service/tests/cli/frameworks/wdioMochaTestFramework.test.ts index 8276c50b..e9e812cf 100644 --- a/packages/browserstack-service/tests/cli/frameworks/wdioMochaTestFramework.test.ts +++ b/packages/browserstack-service/tests/cli/frameworks/wdioMochaTestFramework.test.ts @@ -163,6 +163,60 @@ describe('WdioMochaTestFramework', () => { }) }) + describe('root-level mocha hooks fall back to the suite file, as on the non-CLI flow', () => { + const SPEC = '/proj/test/specs/home.spec.ts' + const ROOT_HOOK = { title: '"before each" hook: wrappedHook for "t1"' } + + const startHook = async (test: Record) => { + const data = new Map([ + ['test_hooks_started', new Map()], + ['test_hooks_finished', new Map()], + ['hook_id', 'hook-1'], + ]) + const TestFramework = await import('../../../src/cli/frameworks/testFramework.js') + vi.mocked(TestFramework.default.getState).mockImplementation((_instance, key) => data.get(key)) + const util = await import('../../../src/util.js') + vi.mocked(util.getGitMetaData).mockResolvedValue({ root: '/proj' } as any) + const instance = { updateMultipleEntries: vi.fn() } + + await framework.trackHookEvents(instance as any, TestFrameworkState.BEFORE_EACH, HookState.PRE, { test }) + return (data.get('test_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.hook_id).toBe('hook-1') + expect(hook.test_file_path).toBe('test/specs/home.spec.ts') + expect(hook.test_location).toBeDefined() + }) + + it('keeps the file mocha gives a hook declared inside a describe', async () => { + framework.setSuiteFile('/proj/test/specs/other.spec.ts') + + const hook = await startHook({ ...ROOT_HOOK, file: SPEC }) + + expect(hook.test_file_path).toBe('test/specs/home.spec.ts') + }) + + it('applies the same fallback to test data', async () => { + const TestFramework = await import('../../../src/cli/frameworks/testFramework.js') + vi.mocked(TestFramework.default.getState).mockReturnValue('mocha') + const util = await import('../../../src/util.js') + vi.mocked(util.getGitMetaData).mockResolvedValue({ root: '/proj' } as any) + framework.setSuiteFile(SPEC) + + const result = await framework.getTestData({} as any, { title: 't1', body: '' } as any) + + expect(result).toHaveProperty('test_file_path', 'test/specs/home.spec.ts') + }) + }) + describe('loadTestResult', () => { it('should update instance with passed result', () => { const mockInstance = { diff --git a/packages/browserstack-service/tests/service.test.ts b/packages/browserstack-service/tests/service.test.ts index 34ba6c98..dec38223 100644 --- a/packages/browserstack-service/tests/service.test.ts +++ b/packages/browserstack-service/tests/service.test.ts @@ -10,6 +10,7 @@ import InsightsHandler from '../src/insights-handler.js' import * as bstackLogger from '../src/bstackLogger.js' import { BrowserstackCLI } from '../src/cli/index.js' import WdioCucumberTestFramework from '../src/cli/frameworks/wdioCucumberTestFramework.js' +import WdioMochaTestFramework from '../src/cli/frameworks/wdioMochaTestFramework.js' import { TestFrameworkState } from '../src/cli/states/testFrameworkState.js' import { HookState } from '../src/cli/states/hookState.js' import TestFramework from '../src/cli/frameworks/testFramework.js' @@ -635,6 +636,19 @@ describe('beforeScenario', () => { }) describe('beforeSuite', () => { + it('hands the suite file to the CLI mocha framework, as the non-CLI flow does to its insights handler', async () => { + const framework = Object.create(WdioMochaTestFramework.prototype) as WdioMochaTestFramework + const getInstanceSpy = vi.spyOn(BrowserstackCLI, 'getInstance').mockReturnValue({ + isRunning: () => true, + getTestFramework: () => framework, + } as any) + + await service.beforeSuite({ title: 'foobar', file: '/specs/foobar.spec.ts' } as any) + + expect(framework['_suiteFile']).toBe('/specs/foobar.spec.ts') + getInstanceSpy.mockRestore() + }) + 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() From 6fbcf414ce43c798d6b3fb6c5d779bba76ed2833 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:38:14 +0000 Subject: [PATCH 2/4] chore(changeset): auto-generate from PR template (patch) --- .changeset/pr-309.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/pr-309.md diff --git a/.changeset/pr-309.md b/.changeset/pr-309.md new file mode 100644 index 00000000..72c8534f --- /dev/null +++ b/.changeset/pr-309.md @@ -0,0 +1,5 @@ +--- +"@wdio/browserstack-service": patch +--- + +- Fixed builds being reported as timed out on Test Reporting & Analytics when a Mocha `beforeEach` / `afterEach` hook is declared outside any `describe` block. From f86990c1a52364ecfb9ee90199410383558ea250 Mon Sep 17 00:00:00 2001 From: rounak bhatia Date: Mon, 26 Oct 2026 23:28:39 +0530 Subject: [PATCH 3/4] fix(cli): give a root-level mocha before() the spec file too wdio registers its beforeSuite as a root beforeAll only after the spec files load, so a before() declared outside any describe runs ahead of it. The suite file the CLI mocha framework falls back to was still unset at that point and the hook went out with no path, which the binary throws on. Seed the fallback from the worker's spec in before(), converted from the file URL the runner hands over the same way MochaAdapter does for suite.file. beforeSuite still updates it, but no longer clears it when a suite has no file. Co-Authored-By: Claude Opus 5.5 --- packages/browserstack-service/src/service.ts | 24 +++++++++-- .../tests/service.test.ts | 43 +++++++++++++++---- 2 files changed, 54 insertions(+), 13 deletions(-) diff --git a/packages/browserstack-service/src/service.ts b/packages/browserstack-service/src/service.ts index 9b7e539c..e6c1d816 100644 --- a/packages/browserstack-service/src/service.ts +++ b/packages/browserstack-service/src/service.ts @@ -44,6 +44,7 @@ import { AutomationFrameworkConstants } from './cli/frameworks/constants/automat import AutomationFramework from './cli/frameworks/automationFramework.js' import type AutomationFrameworkInstance from './cli/instances/automationFrameworkInstance.js' import util from 'node:util' +import { fileURLToPath } from 'node:url' import APIUtils from './cli/apiUtils.js' import { CLIUtils } from './cli/cliUtils.js' import WdioCucumberTestFramework from './cli/frameworks/wdioCucumberTestFramework.js' @@ -196,6 +197,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]) + // Healing Support: if (!shouldAddServiceVersion(this._config, this._options.testObservability, caps as any)) { try { @@ -380,10 +384,7 @@ export default class BrowserstackService implements Services.ServiceInstance { this._suiteTitle = suite.title this._insightsHandler?.setSuiteFile(suite.file) this._accessibilityHandler?.setSuiteFile(suite.file) - const cliFramework = BrowserstackCLI.getInstance().isRunning() ? BrowserstackCLI.getInstance().getTestFramework() : null - if (cliFramework instanceof WdioMochaTestFramework) { - cliFramework.setSuiteFile(suite.file) - } + this._setCliSuiteFile(suite.file) if (suite.title && suite.title !== 'Jasmine__TopLevel__Suite') { if (!BrowserstackCLI.getInstance().isRunning() || this._config.framework !== 'mocha'){ @@ -1086,6 +1087,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/service.test.ts b/packages/browserstack-service/tests/service.test.ts index dec38223..b84e43c2 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 got from 'got' @@ -636,17 +637,41 @@ describe('beforeScenario', () => { }) describe('beforeSuite', () => { - it('hands the suite file to the CLI mocha framework, as the non-CLI flow does to its insights handler', async () => { - const framework = Object.create(WdioMochaTestFramework.prototype) as WdioMochaTestFramework - const getInstanceSpy = vi.spyOn(BrowserstackCLI, 'getInstance').mockReturnValue({ - isRunning: () => true, - getTestFramework: () => framework, - } as any) + 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() + }) - await service.beforeSuite({ title: 'foobar', file: '/specs/foobar.spec.ts' } as any) + 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') - getInstanceSpy.mockRestore() + 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 () => { From a0e05739a8151c5f529aa10f556a53b1e630ee15 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 26 Oct 2026 18:00:32 +0000 Subject: [PATCH 4/4] chore(changeset): auto-generate from PR template (patch) --- .changeset/pr-309.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/pr-309.md b/.changeset/pr-309.md index 72c8534f..9df4adf0 100644 --- a/.changeset/pr-309.md +++ b/.changeset/pr-309.md @@ -2,4 +2,4 @@ "@wdio/browserstack-service": patch --- -- Fixed builds being reported as timed out on Test Reporting & Analytics when a Mocha `beforeEach` / `afterEach` hook is declared outside any `describe` block. +- Fixed builds being reported as timed out on Test Reporting & Analytics when a Mocha `before` / `beforeEach` / `afterEach` hook is declared outside any `describe` block.