Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/test-level-custom-metadata-hooks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@wdio/browserstack-service": minor
---

Custom tags (`browser.setCustomTags`) set inside Mocha `beforeEach` / `afterEach` hooks now reliably land on the intended test's custom metadata. WDIO's Mocha runner fires user hooks outside the SDK's per-test tracking span — `beforeEach` runs before the test instance is tracked and `afterEach` runs after it is finished — so tags set in those hooks previously either attached to the wrong test or were dropped from the payload.

Tags set in `beforeEach` are now buffered and flushed onto the test at its start, and the test-finished event is deferred past the `afterEach` window so late tags still make the payload. Values set across `beforeEach`, the test body, and `afterEach` union and dedupe onto the test (parity with Java `@Before` / `@After`).
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,11 @@ import type AutomationFrameworkInstance from '../instances/automationFrameworkIn
import type TestFrameworkInstance from '../instances/testFrameworkInstance.js'
import { AutomationFrameworkState } from '../states/automationFrameworkState.js'
import { HookState } from '../states/hookState.js'
import { TestFrameworkState } from '../states/testFrameworkState.js'
import { TestFrameworkConstants } from '../frameworks/constants/testFrameworkConstants.js'
import { CLIUtils } from '../cliUtils.js'
import WdioMochaTestFramework from '../frameworks/wdioMochaTestFramework.js'
import { mergeIntoTags, parseCommaSeparatedValues } from '../../customTags.js'
import { mergeIntoTags, parseCommaSeparatedValues, getCurrentMochaHookWindow } from '../../customTags.js'
import type { CustomMetadata } from '../../customTags.js'

/**
Expand All @@ -33,10 +34,20 @@ export default class CustomTagsModule extends BaseModule {
name: string
static MODULE_NAME = 'CustomTagsModule'

/**
* Tags set before a per-test context exists — e.g. from a `beforeEach` hook, which
* WDIO runs BEFORE `beforeTest` tracks the test instance. Buffered here and flushed
* into the test at test start (onBeforeTest), then reset per-test, so hook-set tags
* land on the test (parity with Java @Before). Process-local (one module per worker)
* and Mocha runs tests serially, so a plain object is safe.
*/
private pendingTestLevelTags: CustomMetadata = {}

constructor() {
super()
this.name = 'CustomTagsModule'
AutomationFramework.registerObserver(AutomationFrameworkState.CREATE, HookState.POST, this.onBeforeExecute.bind(this))
TestFramework.registerObserver(TestFrameworkState.TEST, HookState.PRE, this.onBeforeTest.bind(this))
}

getModuleName() {
Expand Down Expand Up @@ -64,18 +75,29 @@ export default class CustomTagsModule extends BaseModule {
return
}

const testInstance: TestFrameworkInstance = TestFramework.getTrackedInstance()
if (!testInstance) {
this.logger.warn('setCustomTags called outside of a resolvable test context; ignoring')
return
}

const values = parseCommaSeparatedValues(value)
if (values.length === 0) {
this.logger.warn(`setCustomTags: no usable values parsed from "${value}"; ignoring call`)
return
}

// Route by the open Mocha hook window (recorded by the service layer —
// the CLI framework never sees Mocha's beforeEach/afterEach):
// - before-each / before-all → the tag belongs to the UPCOMING test.
// The tracked instance is absent (first test) or still the PREVIOUS
// test here, so buffer and flush at test start (onBeforeTest).
// - after-each / after-all / in-test → the CURRENT tracked test. Its
// TestRunFinished send is deferred past the after-each window
// (TestHubModule), so late merges still make the payload.
const hookWindow = getCurrentMochaHookWindow()
const isPreTestWindow = hookWindow === 'before_each' || hookWindow === 'before_all'
const testInstance: TestFrameworkInstance = TestFramework.getTrackedInstance()
if (!testInstance || isPreTestWindow) {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This branch replaced the previous this.logger.warn('setCustomTags called outside of a resolvable test context; ignoring') + return.

Now, when there is no tracked instance and no pre-test hook window (a stray setCustomTags outside any test, or a config where the CLI test-framework tracker was never wired), the tag is silently merged into pendingTestLevelTags. It then either flushes onto an unrelated next test (misattribution) or, if no TEST/PRE ever fires, accumulates and is never sent — with no diagnostic.

Consider buffering only when a genuine pre-test window is open (isPreTestWindow), and keeping a debug/warn for the truly-unresolvable call so it stays diagnosable.

mergeIntoTags(this.pendingTestLevelTags, key, values)
this.logger.debug(`setCustomTags: buffered pre-test tag key=${key} values=${JSON.stringify(values)} (window=${hookWindow ?? 'none'}; will flush at test start)`)
return
}

// Merge into the per-test instance map (test-scoped accumulator).
const existing = (TestFramework.getState(testInstance, TestFrameworkConstants.KEY_CUSTOM_TAGS) as CustomMetadata) || {}
mergeIntoTags(existing, key, values)
Expand All @@ -97,6 +119,39 @@ export default class CustomTagsModule extends BaseModule {
}
}

/**
* At each test start (TEST/PRE — after beforeTest tracks the instance), flush any
* tags buffered before the test had a context (e.g. set in a `beforeEach` hook — see
* the setCustomTags closure) into the tracked instance's custom_metadata via the SAME
* merge path as explicit setCustomTags, then reset the buffer for the next test — so
* hook-set tags union onto the test (parity with Java @Before).
*/
async onBeforeTest(args: Record<string, unknown>) {
try {
// Prefer the tracked instance (the SAME one the explicit setCustomTags closure
// reads/writes) so buffered and programmatic tags all union.
const testInstance = TestFramework.getTrackedInstance() || (args.instance as TestFrameworkInstance)
if (!testInstance) {
return
}

// Flush tags buffered before this test had a context (e.g. from beforeEach).
if (Object.keys(this.pendingTestLevelTags).length > 0) {
const buffered = (TestFramework.getState(testInstance, TestFrameworkConstants.KEY_CUSTOM_TAGS) as CustomMetadata) || {}
for (const [k, entry] of Object.entries(this.pendingTestLevelTags)) {
mergeIntoTags(buffered, k, entry.values)
}
testInstance.updateMultipleEntries({
[TestFrameworkConstants.KEY_CUSTOM_TAGS]: buffered
})
this.logger.debug(`setCustomTags: flushed buffered pre-test tags ${JSON.stringify(Object.keys(this.pendingTestLevelTags))} into test`)
this.pendingTestLevelTags = {}
}
} catch (error) {
this.logger.debug(`setCustomTags: error flushing buffered pre-test tags: ${error}`)
}
}

/**
* If the test framework is currently inside a hook, merge custom_metadata onto
* the last-started hook record so the binary receives hook.custom_metadata.
Expand Down
59 changes: 55 additions & 4 deletions packages/browserstack-service/src/cli/modules/testHubModule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,20 @@ export default class TestHubModule extends BaseModule {
name: string
static MODULE_NAME = 'TestHubModule'

/**
* Mocha-only: the TEST/POST (TestRunFinished) send deferred past the after-each hook
* window. WDIO fires `afterTest` (which triggers TEST/POST) BEFORE the user's
* `afterEach` hooks run, so custom tags set in `afterEach` would otherwise miss the
* event. The payload (`eventJson`) is serialized from the instance's live data map at
* SEND time, so deferring the send picks up those late merges; uuid / started_at /
* ended_at are already stamped in the data and do not drift. The stashed
* `args.instance` is the finished test's OWN object — INIT_TEST replaces the tracked
* slot with a fresh instance for the next test — so it stays valid across tests.
* Flushed at the next test's first event (INIT_TEST / TEST PRE) or, for the worker's
* last test, from service.after() via flushPendingTestFinishEvent().
*/
private pendingTestFinish: { args: Record<string, unknown> } | null = null

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The last test's TestRunFinished is now flushed only from service.after()flushPendingTestFinishEvent is called from exactly two sites: onAllTestEvents at the next-test boundary, and service.after() (L556). Before this change the last test's TEST/POST was sent eagerly during afterTest.

If the worker is terminated between the final afterTest and after() (SIGINT/SIGTERM/CI timeout/crash), that finish is never sent and the test shows as unfinished/running on the dashboard. exitHandler.ts sends buildStop on exit but does not flush pendingTestFinish. This is the loss class flagged in the shared anti-patterns doc under "Swallowing or Hanging on Shutdown & Event-Queue Failures".

Suggest also invoking flushPendingTestFinishEvent() (bounded, best-effort) from the worker exit / onWorkerEnd / exit-handler path. It already no-ops when nothing is pending, so it stays idempotent.

Is after() guaranteed to run per worker on SIGINT in your target WDIO versions? If not, the last-test finish needs a second flush site.


/**
* Create a new TestHubModule
*/
Expand Down Expand Up @@ -66,6 +80,13 @@ export default class TestHubModule extends BaseModule {
const testState = instance.getCurrentTestState()
const hookState = instance.getCurrentHookState()
const keyTestDeferred = TestFramework.getState(instance, TestFrameworkConstants.KEY_TEST_DEFERRED)

// A NEW test is starting (INIT_TEST minted a fresh instance) — the previous test's
// after-each hook window is definitively over, so flush its deferred finish first
// (payload build is synchronous, so gRPC send order is preserved).
if (this.pendingTestFinish && (testState === TestFrameworkState.INIT_TEST || (testState === TestFrameworkState.TEST && hookState === HookState.PRE))) {
this.flushPendingTestFinishEvent()
}
if (testState === TestFrameworkState.LOG) {
this.logger.debug(`onAllTestEvents: TestFrameworkState.LOG - ${testState}`)
const logEntries = WdioMochaTestFramework.getLogEntries(instance, testState, hookState)
Expand Down Expand Up @@ -94,11 +115,41 @@ export default class TestHubModule extends BaseModule {
}

if (testState === TestFrameworkState.TEST || CLIUtils.matchHookRegex(testState.toString().split('.')[1])) {
this.sendTestFrameworkEvent(args)
const frameworkName = String(TestFramework.getState(instance, TestFrameworkConstants.KEY_TEST_FRAMEWORK_NAME) || '')
if (testState === TestFrameworkState.TEST && hookState === HookState.POST && frameworkName.toLowerCase().includes('mocha')) {
// Defer the TestRunFinished send past the Mocha after-each hook window so
// custom tags set in `afterEach` still make the payload (see field docs).
// If a previous finish is somehow still pending for a DIFFERENT test, flush
// it first; a re-stash for the same instance just replaces the stash.
if (this.pendingTestFinish && (this.pendingTestFinish.args.instance as TestFrameworkInstance) !== instance) {
this.flushPendingTestFinishEvent()
}
this.pendingTestFinish = { args }
this.logger.debug('onAllTestEvents: deferred TEST/POST send past the after-each hook window')
} else {
this.sendTestFrameworkEvent(args)
}
}
}

async sendTestFrameworkEvent(args: Record<string, unknown>) {
/**
* Send a deferred TEST/POST (TestRunFinished) event, if one is pending. The instance's
* CURRENT state may have moved on (e.g. LOG events during afterEach), so the send uses
* an explicit TEST/POST state override; the payload data itself is read fresh from the
* instance so late custom-tag merges are included. Called from onAllTestEvents at the
* next test's boundary and from service.after() at worker end.
*/
flushPendingTestFinishEvent(): Promise<void> | undefined {
if (!this.pendingTestFinish) {
return undefined
}
const { args } = this.pendingTestFinish
this.pendingTestFinish = null
this.logger.debug('flushPendingTestFinishEvent: sending deferred TEST/POST event')
return this.sendTestFrameworkEvent(args, { testFrameworkState: 'TEST', testHookState: 'POST' })
}

async sendTestFrameworkEvent(args: Record<string, unknown>, stateOverride?: { testFrameworkState: string, testHookState: string }) {
try {
const testArgs = args as { test: Frameworks.Test, instance: TestFrameworkInstance }
const instance = testArgs.instance as TestFrameworkInstance
Expand All @@ -108,8 +159,8 @@ export default class TestHubModule extends BaseModule {
const testFrameworkVersion = testData.get(TestFrameworkConstants.KEY_TEST_FRAMEWORK_VERSION) || ''
const startedAt = testData.get(TestFrameworkConstants.KEY_TEST_STARTED_AT) || ''
const endedAt = testData.get(TestFrameworkConstants.KEY_TEST_ENDED_AT) || ''
const testFrameworkState = instance.getCurrentTestState().toString().split('.')[1]
const testHookState = instance.getCurrentHookState().toString().split('.')[1]
const testFrameworkState = stateOverride?.testFrameworkState || instance.getCurrentTestState().toString().split('.')[1]
const testHookState = stateOverride?.testHookState || instance.getCurrentHookState().toString().split('.')[1]

this.logger.debug(`sendTestFrameworkEvent for testState: ${testFrameworkState} hookState: ${testHookState}`)
const platformIndex = process.env.WDIO_WORKER_ID ? parseInt(process.env.WDIO_WORKER_ID.split('-')[0]) : 0
Expand Down
60 changes: 60 additions & 0 deletions packages/browserstack-service/src/customTags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
* node-agent quote-aware tokenizer (helper.js `parseCommaSeparatedValues`).
*/

import { getHookType } from './util.js'

export interface CustomMetadataEntry {
field_type: 'multi_dropdown'
values: string[]
Expand Down Expand Up @@ -131,3 +133,61 @@ export class CustomTagAccumulator {
}
}
}

/* --------------------------------------------------------------------------
* Mocha hook-window tracking.
*
* WDIO's Mocha runner fires user hooks OUTSIDE the SDK's test-tracking span:
* `beforeEach` runs before `beforeTest` (which creates/tracks the per-test
* instance), and `afterEach` runs after `afterTest` (which finishes the test).
* The CLI framework never sees these hooks, so a `setCustomTags` call made
* inside one cannot be routed by instance state alone — during the NEXT test's
* `beforeEach` the tracked instance is still the PREVIOUS test.
*
* The service layer (which does see WDIO's beforeHook/afterHook callbacks)
* records which Mocha hook window is currently open; the CLI CustomTagsModule
* reads it to route tags: before-each / before-all → buffer for the upcoming
* test; after-each / after-all → the current (tracked) test, whose
* TestRunFinished send is deferred past the hook window (see TestHubModule).
* Process-local; Mocha runs tests serially within a worker.
* ------------------------------------------------------------------------ */

export type MochaHookWindow = 'before_each' | 'after_each' | 'before_all' | 'after_all' | null

let currentMochaHookWindow: MochaHookWindow = null

/**
* Classify a Mocha hook title (e.g. '"before each" hook: setup') into a window type.
* Derives from getHookType so this and the tracked hook-state — both computed from the
* same hook title in service.beforeHook — can never disagree. getHookType anchors on
* Mocha's generated '"before each"' prefix (startsWith), so a hook with a custom name
* that merely contains another window's phrase (e.g. afterEach('reset before each run'))
* is NOT misrouted, unlike a substring match.
*/
export function classifyMochaHookTitle(title: string | undefined | null): MochaHookWindow {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

classifyMochaHookTitle decides the routing for every hook-set tag (buffer-for-next-test vs. merge-into-current-test), but it matches with substring .includes() and checks before each first. Any hook whose custom description contains another window's phrase is misclassified.

Example — afterEach('cleanup before each iteration', ...) produces title "after each" hook: cleanup before each iteration:

  • classifyMochaHookTitlebefore_eachisPreTestWindow = true → the tag is buffered and flushed onto the next test (cross-test misattribution).
  • getHookType in util.ts (running on the same title one line later at service.ts L411) → AFTER_EACH (correct), because it anchors with .startsWith('"before each"').

So the hook is recorded to the binary as after-each while its custom tag lands on the next test. Descriptions like "runs before each assertion" are natural English, so this is reachable.

This also duplicates getHookType. Deriving from it fixes the substring bug and guarantees the window and the tracked hook-state can never disagree:

export function classifyMochaHookTitle(title?: string | null): MochaHookWindow {
    if (!title) return null
    switch (getHookType(title)) {
        case 'BEFORE_EACH': return 'before_each'
        case 'AFTER_EACH':  return 'after_each'
        case 'BEFORE_ALL':  return 'before_all'
        case 'AFTER_ALL':   return 'after_all'
        default:            return null
    }
}

Was the case-insensitive includes deliberate (e.g. to tolerate a title shape getHookType's startsWith would miss)? If so, both classifiers should be fixed together, since they consume the identical (test).title in the same beforeHook.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 86b1235classifyMochaHookTitle now derives the window from getHookType (anchored startsWith), so a custom hook name that merely contains another window's phrase can no longer be misrouted, and the two stop duplicating the matching logic (they consume the same title, so they can never disagree).

Live-verified: a named afterEach('reset state before each next iteration') now routes to the current test (merged key=edgeHookTag, window AFTER_EACH) with zero before_each buffering, and the existing body+afterEach union is unchanged.

if (!title) {
return null
}
switch (getHookType(title)) {
case 'BEFORE_EACH':
return 'before_each'
case 'AFTER_EACH':
return 'after_each'
case 'BEFORE_ALL':
return 'before_all'
case 'AFTER_ALL':
return 'after_all'
default:
return null
}
}

/** Set by the service's beforeHook (mocha); cleared by afterHook. */
export function setCurrentMochaHookWindow(window: MochaHookWindow): void {
currentMochaHookWindow = window
}

/** Read by the CLI CustomTagsModule to route setCustomTags calls made inside hooks. */
export function getCurrentMochaHookWindow(): MochaHookWindow {
return currentMochaHookWindow
}
20 changes: 20 additions & 0 deletions packages/browserstack-service/src/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import { DEFAULT_OPTIONS, NOT_ALLOWED_KEYS_IN_CAPS, PERF_MEASUREMENT_ENV } from
import CrashReporter from './crash-reporter.js'
import AccessibilityHandler from './accessibility-handler.js'
import CustomTagsHandler from './custom-tags-handler.js'
import { classifyMochaHookTitle, setCurrentMochaHookWindow } from './customTags.js'
import type TestHubModule from './cli/modules/testHubModule.js'
import { BStackLogger } from './bstackLogger.js'
import PercyHandler from './Percy/Percy-Handler.js'
import Listener from './testOps/listener.js'
Expand Down Expand Up @@ -389,6 +391,11 @@ export default class BrowserstackService implements Services.ServiceInstance {
if (this._config.framework !== 'cucumber') {
this._currentTest = test as Frameworks.Test // not update currentTest when this is called for cucumber step
}
// Record which Mocha hook window is open so custom-tag calls made inside user
// hooks route to the right test (the CLI framework never sees these hooks).
if (this._config.framework === 'mocha') {
setCurrentMochaHookWindow(classifyMochaHookTitle((test as Frameworks.Test).title))
}

// CLI flow: route hook lifecycle to the binary via the TestFramework tracker (gRPC),
// mirroring beforeTest/afterTest. Without this, hook events fall through to the legacy
Expand All @@ -414,6 +421,10 @@ export default class BrowserstackService implements Services.ServiceInstance {

@PerformanceTester.Measure(PERFORMANCE_SDK_EVENTS.EVENTS.SDK_HOOK, { hookType: 'afterHook' })
async afterHook(test: Frameworks.Test | CucumberHook, context: unknown, result: Frameworks.TestResult) {
// The Mocha hook window is closed — clear the tracker (see beforeHook).
if (this._config.framework === 'mocha') {
setCurrentMochaHookWindow(null)
}
// Track hook failures separately
if (result && !result.passed) {
const hookError = (result.error && result.error.message) || 'Hook failed'
Expand Down Expand Up @@ -537,6 +548,15 @@ export default class BrowserstackService implements Services.ServiceInstance {
}

if (BrowserstackCLI.getInstance().isRunning()) {
// Flush a test-finish event deferred past the after-each hook window — the last
// test of the worker has no next-test boundary to trigger the flush. Must run
// before worker teardown so the event isn't dropped.
try {
const testHubModule = BrowserstackCLI.getInstance().modules.TestHubModule as TestHubModule | undefined
await testHubModule?.flushPendingTestFinishEvent()
} catch (flushErr) {
BStackLogger.debug(`Exception flushing deferred test finish in after(): ${util.format(flushErr)}`)
}
await BrowserstackCLI.getInstance().getAutomationFramework()!.trackEvent(AutomationFrameworkState.EXECUTE, HookState.POST, {})
}

Expand Down
Loading