-
Notifications
You must be signed in to change notification settings - Fork 7
feat(browserstack-service): test-level custom metadata hook handling (beforeEach/afterEach) for WDIO+Mocha #60
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 |
|---|---|---|
|
|
@@ -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 | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The last test's If the worker is terminated between the final Suggest also invoking Is |
||
|
|
||
| /** | ||
| * Create a new TestHubModule | ||
| */ | ||
|
|
@@ -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) | ||
|
|
@@ -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 | ||
|
|
@@ -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 | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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[] | ||
|
|
@@ -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 { | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Example —
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 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
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in 86b1235 — Live-verified: a named |
||
| 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 | ||
| } | ||
There was a problem hiding this comment.
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
setCustomTagsoutside any test, or a config where the CLI test-framework tracker was never wired), the tag is silently merged intopendingTestLevelTags. It then either flushes onto an unrelated next test (misattribution) or, if noTEST/PREever fires, accumulates and is never sent — with no diagnostic.Consider buffering only when a genuine pre-test window is open (
isPreTestWindow), and keeping adebug/warnfor the truly-unresolvable call so it stays diagnosable.