From 2a3c1e191bdfdf86bf232badfb58c128192cc21c 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 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 ++ .../tests/cli/wdioMochaTestFramework.test.ts | 60 +++++++++++++++++++ .../tests/service.test.ts | 14 +++++ 4 files changed, 87 insertions(+), 2 deletions(-) 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..3a9d4707 100644 --- a/packages/browserstack-service/src/service.ts +++ b/packages/browserstack-service/src/service.ts @@ -46,6 +46,7 @@ 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' @@ -430,6 +431,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/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..e5f801f4 100644 --- a/packages/browserstack-service/tests/service.test.ts +++ b/packages/browserstack-service/tests/service.test.ts @@ -8,6 +8,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 +929,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 319772cd58e7d4a2cfc097e53795971698454e1e 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:37:46 +0000 Subject: [PATCH 2/4] chore(changeset): auto-generate from PR template (patch) --- .changeset/pr-308.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/pr-308.md diff --git a/.changeset/pr-308.md b/.changeset/pr-308.md new file mode 100644 index 00000000..72c8534f --- /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 `beforeEach` / `afterEach` hook is declared outside any `describe` block. From d90b9537dba3038ea8b5e3ce5d65b42dfe7f08d2 Mon Sep 17 00:00:00 2001 From: rounak bhatia Date: Mon, 26 Oct 2026 23:28:34 +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 3a9d4707..c754770b 100644 --- a/packages/browserstack-service/src/service.ts +++ b/packages/browserstack-service/src/service.ts @@ -52,6 +52,7 @@ import { TestFrameworkConstants } from './cli/frameworks/constants/testFramework 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' @@ -218,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: @@ -431,10 +435,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'){ @@ -1276,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/service.test.ts b/packages/browserstack-service/tests/service.test.ts index e5f801f4..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' @@ -929,17 +930,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 - await service.beforeSuite({ title: 'foobar', file: '/specs/foobar.spec.ts' } as any) + beforeEach(() => { + framework = Object.create(WdioMochaTestFramework.prototype) as WdioMochaTestFramework + vi.spyOn(BrowserstackCLI, 'getInstance').mockReturnValue({ + isRunning: () => true, + getTestFramework: () => framework, + getAutomationFramework: () => ({ trackEvent: vi.fn() }), + } as any) + }) - expect(framework['_suiteFile']).toBe('/specs/foobar.spec.ts') - getInstanceSpy.mockRestore() + 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 () => { From 3593be8ce0385e98e52f4027ac19a52766263573 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:29 +0000 Subject: [PATCH 4/4] chore(changeset): auto-generate from PR template (patch) --- .changeset/pr-308.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/pr-308.md b/.changeset/pr-308.md index 72c8534f..9df4adf0 100644 --- a/.changeset/pr-308.md +++ b/.changeset/pr-308.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.