Skip to content

fix(cli): record a timed-out mocha test's failure when mocha reports it (SDK-7843) - #272

Open
AakashHotchandani wants to merge 9 commits into
mainfrom
fix/SDK-7843-record-timeout-at-source
Open

AakashHotchandani wants to merge 9 commits into
mainfrom
fix/SDK-7843-record-timeout-at-source

Conversation

@AakashHotchandani

@AakashHotchandani AakashHotchandani commented Oct 7, 2026 •

Copy link
Copy Markdown
Collaborator

What is this about?

On the CLI flow, a mocha test that hits mocha's timeout is reported as if it hadn't failed:

  • App Automate / Automate dashboard: its session is marked passed.
  • Test Reporting: the test (and any bail-skipped tests after it) has no TestRunFinished, so Test Hub reaps the build as timeout about 60 minutes later.

A customer hit this (SDK-7843, group 2109715, iOS + Android App Automate, mocha with bail, 300 s / 600 s timeouts). I counted their sessions that ran past the mocha timeout and were then retried, which means the attempt failed:

period SDK marked failed marked passed (wrong)
Sep 24 – 30 ≤ 9.39.2 110 0
Oct 1 – 6 9.39.3 46 78

Example: build #753, session 3915be364994ad61431860dd123e258df0c0d666 ("bottom nav", 352 s). The terminal shows Timeout of 300000ms exceeded, yet the dashboard shows passed. Their own SDK log has Session status updated … status: 'passed' 139 ms before the test's failure reached the SDK.

Why it happens

wdio runs a test's afterTest hook inside the test's runnable, after the body. When the test times out, mocha fails it, and emits fail to reporters, while the body and its afterTest are still pending. With bail, or when it's the worker's last test, wdio then runs after() before that afterTest. Measured on a real session with stock 9.39.2:

time event
05:30:13.467 mocha fails the test; reporter onTestFail (with the timeout error)
05:30:13.470 wdio after(result=1): mocha's own failure count is 1
05:30:13.472 AutomateModule.onAfterExecute marks the session passed. Its results come only from afterTest (TEST/POST), and there is none yet; an empty map means passed.
05:30:14.654 the timed-out test's afterTest finally fires, too late for both the session status and the TestRunFinished flush

Why the classic flow never had this: it sets the session status from after(result), which is mocha's own count. The CLI flow drops result.

Why it surfaced now: #261 (4eb12b3) made updateURLSForGRR tolerate a bin-session config without apis. Up to 9.39.2 that config crashed the CLI bootstrap for these App Automate accounts, so they silently ran the classic flow. Since 9.39.3 the CLI boots and they hit this path.

The fix: send the failure through the existing CLI events when mocha reports it

service.ts and reporter.ts only feed the existing trackEvent calls; the product modules are unchanged. TestHubModule (Test Reporting) and AutomateModule (session status) already handle a failed TEST/POST correctly. The only problem was that it arrived after after().

  • reporter.ts onTestFail: on the CLI flow with mocha, it sends LOG_REPORT/POST + TEST/POST through framework.trackEvent, with mocha's own result (passed: false, the timeout error, its duration). This is the same pattern cli/skipReporter already uses from the reporter.
  • WdioMochaTestFramework (CLI layer) tracks each mocha test attempt from TEST/PRE:
    • Once only: a test's finish is reported once, whichever comes first (the reporter's fail or afterTest); the late duplicate is dropped.
    • Own test run: the finish is reported against the instance that attempt started on, even when the next test holds the tracked-instance slot. This replaces the service's _cliTestUuids pin.
    • Mocha's verdict kept: when a late afterTest says passed but mocha already failed the test, the failure is reported.
    • Bail cascade moved here from service.ts. It runs as part of the failed finish, so it lands before the flush, and it uses the runnable captured at TEST/PRE.
    • settleTestFinishes() (a no-op on the base TestFramework) waits for finishes wdio doesn't await. With no reporter registered, it also finishes tests mocha already failed, from mocha's runnable.
  • service.ts:
    • beforeTest adds bail to its existing TEST/PRE call.
    • after() calls settleTestFinishes() before the deferred-finish flush and EXECUTE/POST.
    • _cliTestUuids and the bail cascade are removed, so service.ts is 87 lines shorter than on main.

Unchanged:

  • Normal failures: wdio runs afterTest before mocha emits fail, so the reporter's events are dropped as already reported.
  • The classic flow and non-mocha frameworks: onTestFail returns immediately.

Also in this PR — the resolveInstance ERROR (same change as #264): console output from wdio's before hook (the customer's [SelfHealer] Installed …) reaches trackEvent(LOG, POST) before mocha's first hook, when no test or hook instance exists. resolveInstance then printed resolveInstance: unable to resolve/create instance for TestFrameworkState.LOG HookState.POST on every worker. WdioMochaTestFramework.trackEvent now drops a LOG with no tracked instance at debug level, as the classic flow does. New test: tests/cli/wdioMochaTestFramework.preTestLog.test.ts (3 cases). Verified on real sessions under #264: stock 9.39.2 build 2owxolsli9… prints the ERROR, patched build h5a3ibnc2o… is clean.

Review follow-ups:

  • 7c9e8c0 / 092e69b: no-reporter fallback, per-attempt retry keys, uuid hand-back, mocha's verdict on a late afterTest, bail cascade from the captured runnable.
  • 26a5872 (platformisation review): the logic moved out of service.ts / reporter.ts into the CLI framework. The events go through the existing trackEvent calls, and cli/earlyTestFinish.ts is gone. Behaviour is the same as above; it was re-verified on real sessions (below).

Verification

Real sessions, with mochaOpts.timeout exceeded by a waitFor… and Test Reporting on. Every build was read back from /ext/v1/builds/<uuid>/testRuns, and every session from the Automate / App Automate session API:

product build SDK case session status Test Reporting
Automate (chrome/Win11) c7bsborr… stock 9.39.2 timeout, last test, bail ca3982e2… passed ❌ build passed ❌ (timed-out test never closed)
Automate jygwt2vd… this PR timeout, last test, bail d534ff4b… failed, reason = mocha timeout passed 1, failed 1, unknown 0
Automate qyccjc2i… this PR timeout mid-spec, bail 815df7b4… failed passed 1, failed 1, skipped 1, unknown 0
Automate mjhq2iqi… this PR timeout mid-spec, no bail (next test runs) 4f261091… failed passed 2, failed 1, unknown 0
App Automate (Galaxy S24) issawcvh… stock 9.39.3 timeout, bail c7627749… passed ❌ failed 1, unknown 1 ❌
App Automate (Galaxy S24) wlmwmp17… this PR timeout, bail 17e6fe50… failed, reason = mocha timeout failed 1, skipped 1, unknown 0

Re-verified after the 26a5872 rework (same repro; every build read back from /ext/v1):

product build case session status Test Reporting
Automate rqss9pq5… timeout, last test, bail b48b64fb… failed, reason = mocha timeout passed 1, failed 1, in progress 0
Automate dgttbcxh… timeout mid-spec, no bail b86c33b1… failed passed 2, failed 1
Automate okv7vi3w… timeout mid-spec, bail 4b0af703… failed passed 1, failed 1, skipped 1
App Automate (Galaxy S24) agg8icwu… timeout, bail 9fe3304a… failed, reason = mocha timeout failed 1, skipped 1, in progress 0

In every run the late afterTest logs was already reported, dropping … from afterTest. No run prints the resolveInstance ERROR.

Tests:

  • tests/cli/wdioMochaTestFramework.timedOutTest.test.ts (7, real framework):
    • timed-out test reported from fail, late afterTest dropped;
    • normal failure reported from afterTest, the fail after it dropped;
    • late afterTest closes its own test run while the next test holds the slot;
    • mocha's failure kept over a late passed;
    • no-reporter settle;
    • retried attempts each on their own test run;
    • a hook's fail dropped.
  • tests/cli/wdioMochaTestFramework.bailCascade.test.ts (9): the SDK-7063 cascade cases moved from service.test.ts, plus the cascade from the captured runnable when mocha has moved on to a hook.
  • tests/reporter.onTestFail.test.ts (4): the events and identity the reporter sends.
  • tests/service.timedOutTestFinish.test.ts (2): after() settles before the flush and EXECUTE/POST; afterTest no longer touches the tracked slot.
  • service.test.ts: beforeTest passes mocha's bail (not wdio's) to the framework.
  • Mutation-checked: removing the dedupe, own-instance resolution, pending wait, mocha-verdict check, settle fallback, the settle call in after(), the retry key or the cascade each fails a test.

Full suite: 78 failed / 1324 passed with this PR vs 78 / 1305 on main: the same pre-existing failures, compared by name, mostly network-dependent service.test.ts cases. tsc -p tsconfig.prod.json --noEmit and eslint are clean.

Follow-ups:

Related Jira task/s

SDK-7843

Release (mandatory for every PR — required for the ready-for-review label)

Version bump: (required — tick exactly one)

  • minor (backwards-compatible feature)
  • patch (bug fix or other small change)

Release notes type: (optional)

  • New Feature
  • Bug Fix
  • Other Improvement

Release notes (customer-facing): (optional but encouraged)

  • Fixed mocha tests that fail by exceeding their timeout being marked "passed" on the Automate / App Automate dashboard, and their builds ending as "timeout" in Test Reporting about an hour after the run. Such tests are now reported as failed, with the timeout error as the reason.
  • Removed the resolveInstance: unable to resolve/create instance ... LOG POST error printed when a wdio before hook writes to the console.

Release notes (internal): (required — engineer-facing; what actually changed / why)

  • Reporter onTestFail (CLI, mocha) sends LOG_REPORT/POST + TEST/POST via framework.trackEvent with mocha's result. WdioMochaTestFramework tracks attempts from TEST/PRE: it reports each finish once, against the attempt's own instance (replacing _cliTestUuids), keeps mocha's failure over a late passed, and runs the bail cascade (moved from service.ts). settleTestFinishes() runs from after() before the flush and EXECUTE/POST. TestHubModule / AutomateModule are unchanged.
  • Root cause: wdio runs afterTest inside the test's runnable, so for a mocha timeout under bail (or on a worker's last test) after() runs first. AutomateModule.onAfterExecute then marks the session from results that only afterTest supplies (empty → passed), and the test's TestRunFinished is stranded (build reaped as timeout). The classic flow used after(result) and was correct.
  • Surfaced by fix/SDK-7138-wdio-upload-attachment and security/chalk-mal-2025-46969 #261 (4eb12b3): App Automate accounts whose bin-session config has no apis now boot the CLI instead of silently falling back to the classic flow.
  • Also (as fix(cli): drop a log written before the first mocha hook instead of logging an ERROR (SDK-7843) #264): WdioMochaTestFramework.trackEvent drops a LOG event with no tracked instance at debug level instead of logging the resolveInstance ERROR.

Checklist

  • Ready to review
  • Has it been tested locally?

PR Validations

Run Tests: Comment RUN_TESTS to trigger sanity tests.

🤖 Generated with Claude Code

…it (SDK-7843)

wdio runs a test's afterTest inside the test's runnable, after the body.
When a test hits mocha's timeout, mocha fails it while the body and its
afterTest are still pending. With `bail`, or when it is the worker's last
test, wdio then runs after() BEFORE that afterTest. On the CLI flow
everything after() does then runs without the failure:
- AutomateModule.onAfterExecute marks the session `passed`, because its
  results only come from afterTest.
- The test's TestRunFinished is stranded, so Test Hub reaps the build as
  `timeout` about 60 min later.
The classic flow never had this, because it reads the session status from
after(result), which is mocha's own failure count.

Since 9.39.3 the CLI also boots for App Automate accounts whose
bin-session config has no `apis` (#261), so those accounts now hit it.
One customer had 78 timed-out sessions marked passed in 6 days, against
0 the week before.

Fix at the source: the service registers a finisher per mocha test in
beforeTest (cli/earlyTestFinish.ts). The reporter's onTestFail, which
mocha drives at the moment it fails the test, reports the failure through
it with mocha's own result. after() awaits those finishes before its
deferred-finish flush and EXECUTE/POST. The late afterTest then sees the
test was already reported and stands down. A normal failure is still
reported by afterTest, which runs before mocha's `fail`, so the reporter
is a no-op for it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Oct 7, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration
  • Configuration used: Central YAML (base), Organization UI (inherited), Workspace UI (inherited)
  • Review profile: ASSERTIVE
  • Plan: Enterprise
  • Run ID: 0ba465ca-e59f-4d6f-86f6-43f64d00604f

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Oct 7, 2026

Copy link
Copy Markdown
Contributor

🔴 SDK PR Review gate is red. Pending:

  • The SDK PR Review Agent has not been run on the current head commit yet — run the SDK PR Review Agent (its verdict is advisory; this gate only requires that it ran on the latest commit).

It turns green once the SDK PR Review Agent has run on the current head commit (any verdict — the gate only requires that the review ran). A native reviewer approval is separately required by branch protection before merge.

…ogging an ERROR (SDK-7843)

Console output from wdio's `before` hook reaches trackEvent(LOG, POST) before mocha's first
hook, when no test or hook instance exists, so resolveInstance printed
"resolveInstance: unable to resolve/create instance ... LOG POST" on every worker. Drop it at
debug level, as the classic path does. Same change as #264.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 7, 2026

Copy link
Copy Markdown
Contributor

🔴 SDK PR Review gate is red. Pending:

  • The SDK PR Review Agent has not been run on the current head commit yet — run the SDK PR Review Agent (its verdict is advisory; this gate only requires that it ran on the latest commit).

It turns green once the SDK PR Review Agent has run on the current head commit (any verdict — the gate only requires that the review ran). A native reviewer approval is separately required by branch protection before merge.

@AakashHotchandani AakashHotchandani left a comment

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.

Automated SDK PR Review

Verdict: ⚠️ Fix 1 issue — The timed-out-test fix only works when TestReporter is registered. Customers on the CLI flow with mocha and Test Reporting, Accessibility and Percy all off still get passed sessions on a mocha timeout. Add a fallback that doesn't depend on the reporter, or scope the release note. The 5 suggestions don't block the merge.

Summary: 0 critical · 1 warning · 5 suggestions across 9 files reviewed.

Intent: On the CLI flow, a mocha test that hits mocha's timeout (with bail, or as the worker's last test) had its session marked passed and got no TestRunFinished, because wdio runs afterTest inside the test runnable and after() ran before the late afterTest. The PR records the failure at the moment mocha reports it: beforeTest registers a per-test finisher (cli/earlyTestFinish.ts), the reporter's onTestFail runs it with mocha's own result, afterTest stands down for a test already reported, and after() awaits those finishes before the deferred-finish flush and EXECUTE/POST. The CLI finish moves unchanged into finishCliTest(). It also carries #264's change: a LOG event with no tracked instance is dropped at debug level instead of logging the resolveInstance ERROR. Surfaced by #261, which let the CLI boot for App Automate accounts that used to fall back to the classic flow.
Risk: Medium. It changes when a mocha test's finish is reported on the CLI flow, for Automate and App Automate session status and for Test Reporting. The classic flow and non-mocha frameworks are untouched.
0 critical · 1 warning · 5 suggestions | Files reviewed: 9

Rovo enrichment unavailable — review based on local SDK docs only.

The verdict hinges on one coverage gap. The event-order fix is correct against the @wdio/utils testFnWrapper, @wdio/mocha-framework and mocha Runner source, but it only engages when TestReporter is registered.

Not anchored inline:

  • Cross-framework (#23): Jasmine on the CLI flow wraps afterTest in the same testFnWrapper, so a spec timeout under Jasmine may have the same after()-before-afterTest ordering. This is not verified and is out of scope here; Cucumber's afterScenario runs outside the step, so it is unlikely to be affected. The classic flow is untouched: onTestFail returns when the CLI isn't running, and every service.ts change is inside isRunning().
  • v8 port #273: it intentionally diverges. There is no uuid restore, no bail cascade and no flush ordering, because those don't exist on v8. Its changeset accordingly claims only the session-status fix. It copies the same unreachable try/catch (Suggestion 2) and has the same reporter-registration dependency (Warning 1); please check v8's reporter gate.
  • #5 (error boundaries): each reporter-started finish has a .catch, so a rejection can't break after(). The wait is not time-bounded, which matches the existing unbounded drainSkipReports / flush / EXECUTE/POST awaits in after().
  • Rovo enrichment unavailable — review based on local SDK docs only.

See inline comments below for full Problem and Suggested Fix detail on each finding.

Generated by Automated SDK PR review.

* marks the session and closes the test with it. A normal failure is already reported by
* `afterTest` before mocha's `fail`, so this is a no-op for it.
*/
onTestFail(testStats: TestStats) {

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.

⚠️ Warning — [CORRECTNESS] The fix depends on TestReporter, which is not registered when Test Reporting, Accessibility and Percy are all off

Problem

The only thing that reports a timed-out test before after() is TestReporter.onTestFail. The service adds that reporter only when shouldProcessEventForTesthub('') is true (service.ts:126-128). That needs LTS, or one of BROWSERSTACK_OBSERVABILITY, BROWSERSTACK_ACCESSIBILITY or BROWSERSTACK_PERCY set to 'true' (testHub/utils.ts:47-63).

The session-status side doesn't have that gate. The CLI boots for every supported framework that isn't multiremote (launcher.ts:357-361). AutomateModule is always loaded (cli/index.ts:162), and it marks the session in onAfterExecute from the TEST/POST results.

So an Automate or App Automate customer on the CLI flow with mocha and Test Reporting off hits the original bug unchanged. A timeout under bail, or on the last test, still gives an empty results map at EXECUTE/POST, so the session is marked passed. The same happens when Test Reporting is switched off at runtime after a failed build-start (handleErrorForObservability sets BROWSERSTACK_OBSERVABILITY='false').

The changeset and the customer-facing release note say the Automate / App Automate dashboard status is fixed, with no condition. All six verification runs had Test Reporting on, so none of them covers this configuration.

Suggested Fix

Option A (recommended): a reporter-independent fallback in after(). In beforeTest, capture the live mocha runnable (context.test) alongside the finisher. In after(), after awaitCliTestFinishesOnFailure() and before the flush, finish every finisher that is still registered and whose runnable has state === 'failed', using { passed: false, error: runnable.err }. mocha's Runner#fail sets test.state = 'failed' before it emits fail (lib/runner.js:440). This covers Test-Reporting-off workers. When the reporter is present it is a no-op, because it already claimed the finisher.

Option B: register the reporter whenever the CLI flow runs mocha, even when shouldProcessEventForTesthub('') is false. You would then have to confirm that every other TestReporter handler is gated by needToSendData / shouldProcessEventForTesthub, so that nothing new goes to Test Hub. That is a larger surface to audit.

With either option, add a test where the reporter is absent (testObservability: false, no a11y/percy env) and a timed-out test still reaches EXECUTE/POST as failed. If you defer this, scope the release note to "with Test Reporting enabled".

Confidence: 🟢 Objectively verifiable: the registration gate (service.ts:126, testHub/utils.ts:47-63) and the unconditional AutomateModule load (cli/index.ts:162) are both in the head commit.

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.

Valid, fixed in 7c9e8c0 (Option A). beforeTest now registers the live mocha runnable (test.ctx.test, read before any await) alongside the finisher. In after(), awaitCliTestFinishesOnFailure() first finishes every finisher still registered whose runnable has state === 'failed' (set by Runner#fail before it emits fail), with passed: false, mocha's duration, and Timeout of <ms>ms exceeded. when timedOut is set. It then awaits all finishes, before the flush and EXECUTE/POST. When the reporter is registered it already claimed the finisher, so this is a no-op. New test: no reporter, the runnable is failed after beforeTest, and after(1) still sends TEST/POST passed=false before the flush and EXECUTE/POST. Mutation-checked: dropping the fallback fails it. Same change in #273 (ab144e5), which had the same reporter gate (service.ts:95).

* test timed out), in which case afterTest must not report it again. A test that was never
* registered still belongs to afterTest.
*/
export function claimCliTestFinish(identifier: string): boolean {

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.

💡 Suggestion — [CORRECTNESS] Finishers are keyed per test, not per attempt; a mocha retry can hand the next attempt's finisher to the previous attempt's late afterTest

Problem

With this.retries(n), a timed-out attempt that will be retried makes mocha emit retry, not fail (lib/runner.js:813-819), so the reporter does nothing. mocha starts the next attempt straight away. Its beforeTest overwrites the finisher, and _cliTestUuids, under the same ${parent} - ${title} key.

When the first attempt's body finally resolves, its late afterTest calls claimCliTestFinish(id). That deletes the second attempt's finisher and returns true. finishCliTest then closes the second attempt's test_run (it restores the second attempt's uuid) with the first attempt's result.

If the last attempt then times out too, onTestFail finds no finisher, and the original bug comes back for that run. Most of this was already true before the PR, but this PR's guarantee doesn't hold under retries.

Key the hand-off per attempt: append the attempt to the id. On the service side that is the snapshot's _currentRetry (the {...context.test} copy carries it). On the reporter side it is testStats.retries, which WDIOReporter increments on test:retry.

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.

Valid, fixed in 7c9e8c0. The hand-off and the _cliTestUuids snapshot are now keyed per attempt with cliTestAttemptKey(id, attempt). The service takes the attempt from _currentRetry on the {...context.test} copy (the same copy afterTest gets as originalTest), and the reporter takes it from TestStats.retries, which WDIOReporter sets at test:start from its retry count and resets at test:end. Both are 0 for the first attempt, which keeps the plain identity. A retried attempt's late afterTest now claims and closes its own attempt, with its own uuid, and the next attempt stays claimable by onTestFail. Tests: unit (separate keys), reporter (retries: 1 uses the attempt key), service (attempt 0's late afterTest, then attempt 1 timing out → two TEST/POSTs, uuid-1 then uuid-2). Mutation-checked.

// deferred into that stash) and before EXECUTE/POST, which marks the session status
// from the results recorded so far.
try {
await awaitCliTestFinishesOnFailure()

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.

💡 Suggestion — [ANTI-PATTERN] try/catch around awaitCliTestFinishesOnFailure() cannot fire (#26)

Problem

finishCliTestOnFailure stores finisher(result).catch(...) in inFlight, so every promise in it already resolves, and Promise.all over them cannot reject. Nothing else in the helper can throw. The try/catch and its Exception awaiting failed-test finishes log are unreachable. They suggest a failure mode that doesn't exist (sdk-anti-patterns #26).

Drop the wrapper, or keep the single error boundary inside earlyTestFinish.ts, where it already is. #273 copies the same wrapper.

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.

Valid, removed in 7c9e8c0 (and in #273, ab144e5). The comment now says why it can't reject: every finish carries its own .catch.

}
}
await BrowserstackCLI.getInstance().getTestFramework()!.trackEvent(TestFrameworkState.LOG_REPORT, HookState.POST, { test, result: results })
await BrowserstackCLI.getInstance().getTestFramework()!.trackEvent(TestFrameworkState.TEST, HookState.POST, { test, result: results, suiteTitle: this._suiteTitle })

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.

💡 Suggestion — [CORRECTNESS] The uuid is restored before LOG_REPORT's await, but TEST/POST reads it after; on the reporter path mocha keeps running in between

Problem

finishCliTest writes the timed-out test's uuid onto the single tracked instance and then awaits LOG_REPORT/POST. Only after that does TEST/POST read the uuid back from the instance.

When the finish starts from onTestFail, mocha doesn't wait for it. It goes straight on to the afterEach / after all hooks (wdio beforeHook/afterHook → CLI hook events) and, without bail, to the next test's INIT_TEST, which overwrites that slot. Any of these can land inside the LOG_REPORT await.

The old late-afterTest path had the same window, and the no-bail real run (mjhq2iqi…) came out right. Still, the new path starts the finish exactly when mocha moves on, which makes the overlap more likely. Re-apply the resolved uuid right before TEST/POST (or pass it explicitly) to close the window.

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.

Valid, fixed in 7c9e8c0. finishCliTest pins the resolved uuid before LOG_REPORT/POST and again right before TEST/POST. If the second pin replaced another test's uuid (the next test's INIT_TEST ran during the LOG_REPORT await), it hands the slot back to that uuid once TEST/POST returns, so the running test's later events don't pick up the old uuid. New test with a real slot: the finish starts on failure, the next test's beforeTest mints uuid-2 during LOG_REPORT, the TEST/POST still carries uuid-1, and the slot ends at uuid-2. Mutation-checked for both the re-pin and the hand-back. Not applicable to #273: v8 has no uuid snapshot/restore, so this PR doesn't change how v8 reads the uuid.

// arrives before mocha's first hook, so there is no test or hook to attach it to yet and
// resolveInstance cannot create one for LOG. The classic path drops such a log silently; do the same instead of
// printing an ERROR on every worker (SDK-7843).
if (testFrameworkState === TestFrameworkState.LOG && !TestFramework.getTrackedInstance()) {

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.

💡 Suggestion — [PROCESS] The pre-test LOG change duplicates open PR #264 (and #267 is still open)

Problem

This hunk, its test file and its release-note bullet match #264 (still OPEN against main). The change itself is sound: it only short-circuits LOG when getTrackedInstance() is null, and the TEST/hook paths still log the ERROR, which a test covers.

If both PRs merge, the changelog gets the resolveInstance bullet twice, and whichever merges second conflicts. Close #264 as folded into this PR, or drop the hunk here. #267 (the rejected late-finish approach, which this PR replaces) is also still OPEN; close it so it isn't merged by mistake. The v8 pair (#265 / #273) has the same overlap.

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.

Intentional: the change was folded in here (and into #273) on purpose, so one 9.x release fixes both symptoms the SDK-7843 customer reported. #264/#265 are superseded and #267 (the rejected late-finish approach) is replaced by this PR; they'll be closed so they can't be merged by mistake or duplicate the changelog bullet.

vi.restoreAllMocks()
})

it('reports the failure, then marks the session, then ignores the late afterTest', async () => {

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.

💡 Suggestion — [TESTING] No retry case, and the timeout+bail service test never exercises the bail cascade

Problem

The new tests cover reporter-first finishing, a normal failure staying with afterTest, an unregistered test, after() waiting for an in-flight finish, and a rejecting finish. The body also reports that two of them were mutation-checked.

Gaps remain:

  • Bail cascade: mochaOpts.bail is on, but timedOutTest.ctx.test has no parent, so reportBailSkippedTests returns early. The test never shows that the cascade (skipped tests' TestRunFinished) runs from the reporter path and finishes before the flush.
  • Retries: no case for mocha retries (see Suggestion 1).
  • No reporter: no case for a worker without the reporter (see Warning 1).

Give ctx.test.parent a root suite with one unreached test, and assert that its skipped TEST/POST lands before flushPendingTestFinishEvent.

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.

Valid, added in 7c9e8c0. Bail cascade: ctx.test.parent is a suite under a root with one unreached test, and the test asserts the skipped TEST/POST lands after the failed one and before flushPendingTestFinishEvent. Retries: see the retry thread. No reporter: see the reporter-gate thread. Plus the uuid re-pin case. 23 SDK-7843 tests in all on main. The full suite has the same 78 failures as main (pre-existing), with 1328 passing. tsc and eslint are clean.

- Finish a test mocha already failed from after() when no reporter claimed it, using mocha's
  own runnable, so the fix holds with Test Reporting, Accessibility and Percy all off (the
  reporter is only registered when Test Hub events are on).
- Key the finish hand-off and the uuid snapshot per retry attempt, so a retried attempt's late
  afterTest closes that attempt and not the next one.
- Pin the test's uuid again right before TEST/POST, since mocha moves on while a finish started
  on failure awaits LOG_REPORT, then hand the slot back to the test now running.
- Drop the try/catch around awaitCliTestFinishesOnFailure(); every finish catches its own error.
- Tests: no reporter, retries, bail cascade from the reporter path, uuid re-pin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 7, 2026

Copy link
Copy Markdown
Contributor

🔴 SDK PR Review gate is red. Pending:

  • The SDK PR Review Agent has not been run on the current head commit yet — run the SDK PR Review Agent (its verdict is advisory; this gate only requires that it ran on the latest commit).

It turns green once the SDK PR Review Agent has run on the current head commit (any verdict — the gate only requires that the review ran). A native reviewer approval is separately required by branch protection before merge.

AakashHotchandani added a commit that referenced this pull request Oct 7, 2026
- Finish a test mocha already failed from after() when no reporter claimed it, using mocha's
  own runnable, so the fix holds with Test Reporting, Accessibility and Percy all off (the
  reporter is only registered when Test Hub events are on).
- Key the finish hand-off per retry attempt, so a retried attempt's late afterTest closes that
  attempt and not the next one.
- Drop the try/catch around awaitCliTestFinishesOnFailure(); every finish catches its own error.
- Tests: no reporter, retries. Same changes as #272.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@AakashHotchandani AakashHotchandani left a comment

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.

Automated SDK PR Review

Verdict: ✅ Good to go — W1 and S1–S3, S5 are resolved, and S4 is an accepted fold-in. Two non-blocking suggestions: (1) src/service.ts is 🟡: the no-reporter fallback finishes a mid-spec timed-out test on the currently tracked instance, which can mis-attribute Test Reporting data when TestHubModule is loaded without the reporter. (2) Refresh the stale verification counts in the PR body and add a real run with Test Reporting off. Close #264, #265 and #267 before merging.

Summary: 0 critical · 0 warnings · 2 suggestions across 9 files reviewed.

This is a re-review at 7c9e8c09. The no-reporter fallback (runnable.state === 'failed' → finish in after()) holds against the wdio v9.32.0 hookArgsFn ({ ...context.test } keeps ctx; mocha's Context#runnable sets ctx.test to the live Test) and mocha 10.8.2's Runner#fail (state = 'failed' before fail is emitted). It is a no-op when the reporter has already claimed the attempt. Retry keys match on both sides.

Prior findings status

# Prior finding Status Evidence
W1 ⚠️ Fix depended on TestReporter RESOLVED Option A: const runnable = test.ctx?.test (read before any await) → registerCliTestFinisher(key, fn, runnable); awaitCliTestFinishesOnFailure() finishes unclaimed entries with runnable?.state === 'failed' before the flush and EXECUTE/POST. New no-reporter test.
S1 Per-test, not per-attempt keys RESOLVED cliTestAttemptKey(id, test._currentRetry) (service) and cliTestAttemptKey(id, testStats.retries ?? 0) (reporter) give the same number (@wdio/reporter test:start/test:retry/test:end).
S2 Unreachable try/catch (#26) RESOLVED Bare await awaitCliTestFinishesOnFailure(), with the comment "Every finish catches its own error, so this cannot reject."
S3 uuid read after the LOG_REPORT await RESOLVED Local resolvedUuid; pinUuid() before LOG_REPORT and before TEST/POST, then the slot is handed back. The residual is in Suggestion 1.
S4 Overlap with #264 / #267 DECLINED-ACCEPTABLE Folded in on purpose. #264, #265 and #267 are still OPEN; close them before merging.
S5 Test gaps RESOLVED Bail cascade with a real parent chain, retry (uuid-1 then uuid-2), no-reporter and uuid re-pin cases added.
— #23 Jasmine note / #5 unbounded wait Unchanged, acceptable Out of scope; consistent with the existing after() awaits.
— #273 divergence Addressed ab144e5 carries the fallback, the attempt keys and the cleanup.

Not anchored inline:

  • [PROCESS] PR description: the verification is stale and has no real run for the two new paths. The "Review follow-up" bullets are current, but the Verification section still says "Tests (11 new)" and "78 failed / 1319 passed". The follow-up reports 23 tests and 1328 passed. The fix section still says the fix relies on the reporter, which "already runs in every worker when Test Reporting is on". All six real sessions ran with Test Reporting on, and there is no real run with Test Reporting, Accessibility and Percy all off, or with mochaOpts.retries. The release note now claims the fix without conditions. That is right by construction, because the fallback covers the no-reporter worker, but that path is unit-tested only. Add one App Automate or Automate run with testObservability: false (expect the session to be failed with the timeout reason), and update the stale counts.
  • Rovo enrichment unavailable — review based on local SDK docs only.

See inline comments below for full Problem and Suggested Fix detail on each finding.

Generated by Automated SDK PR review.

Comment on lines +710 to +711
pinUuid()
await BrowserstackCLI.getInstance().getTestFramework()!.trackEvent(TestFrameworkState.LOG_REPORT, HookState.POST, { test, result: results })

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.

💡 Suggestion — [CORRECTNESS] The no-reporter fallback finishes a mid-spec timed-out test on whichever test instance is tracked at after()

Problem

The fallback in awaitCliTestFinishesOnFailure() runs finishCliTest() from after(). finishCliTest() re-pins only the uuid; LOG_REPORT/POST and TEST/POST still act on TestFramework.getTrackedInstance().

For a bail or last-test timeout that is the timed-out test's own instance, because after each / after all reuse it (resolveInstance only mints a new instance at a BEFORE_* / INIT_TEST boundary). The new tests cover that shape. A mid-spec timeout without bail, whose body is still pending when the worker ends, is different. Take test A timing out, then B…Z running, with A's body still hung at after():

  • The tracked instance is Z's. LOG_REPORT/POST runs loadTestResult() on Z's instance and overwrites Z's result with A's failure.
  • TEST/POST stashes A's finish (uuid A) with args.instance = Z's instance. Z's own finish is still in pendingTestFinishes, and sendTestFrameworkEvent() reads the data fresh from the instance at flush time. So the flush in after() sends Z's TestRunFinished with A's failure, and A's with Z's name and data.
  • The first pinUuid()'s return value is dropped, so Z's instance keeps uuid A afterwards. That is harmless here, because the stash carries its uuid explicitly.

The session status is unaffected and correct, because AutomateModule.onAfterTest reads args.result, not the instance. The impact is limited to Test Reporting data, and only when TestHubModule is loaded while TestReporter is not, for example after the Test Reporting flag is turned off at runtime by handleErrorForObservability. Before this PR, A was never finished at all, so this is a trade of a missing event for a mis-attributed one in a narrow configuration. The re-pin added for S3 has the same limit: if an INIT_TEST ever minted a new instance inside the LOG_REPORT await, only the uuid would be restored. In practice that await is microtask-only today, so the reporter path does not hit this.

Suggested Fix

Capture the test's own TestFrameworkInstance in beforeTest, right after INIT_TEST (next to the uuid snapshot). In finishCliTest(), when the tracked instance is not that one, swap it in with TestFramework.setTrackedInstance(ctx, own) around LOG_REPORT/POST → TEST/POST, then restore the previous one. That replaces the uuid-only pin. If that is too invasive for a patch, narrow the fallback: call the finisher only when the tracked instance still carries this test's uuid, and otherwise record the result for the session status alone. Add one service test where a second test's beforeTest runs between the timed-out test and after(), with no reporter.

Confidence: 🟡 The data flow is traceable in wdioMochaTestFramework.resolveInstance / loadTestResult and testHubModule.sendTestFrameworkEvent, but whether TestHubModule loads without the reporter depends on the binary's testhub response, which can't be verified from the SDK alone.

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.

Valid. This is resolved by the rework in 26a5872 (refined in f51407e), which takes the first approach you suggested: report against the test's own instance.

  • No more tracked-instance slot. finishCliTest(), the uuid pin and earlyTestFinish.ts are gone. WdioMochaTestFramework now captures each attempt's own TestFrameworkInstance at TEST/PRE, in openTestAttempt.
  • Every finish uses that instance. That covers afterTest, the reporter's fail, and the no-reporter settleTestFinishes(). They all go through resolveTestAttempt, and trackTestEvent then uses attempt.instance, not getTrackedInstance().
  • Your Z/A scenario can't happen now. loadTestResult and the deferred TestRunFinished both read A's own instance, so Z's instance and result are never touched.

Test: without a reporter, settle finishes a test mocha already failed, from its runnable in tests/cli/wdioMochaTestFramework.timedOutTest.test.ts. A second test starts after the timed-out one, so it holds the tracked slot at settle time. The finish still closes the timed-out test's own run, with its timeout error. Related: closes a late afterTest against its own test run while the next test holds the slot asserts that the running test keeps its own uuid.

Real sessions: the no-bail run dgttbcxh… shows 2 passed, 1 failed, 0 in progress. The bail-mid runs (okv7vi3w…, hmqkve23…) show 1 passed, 1 failed, 1 skipped, 0 in progress.

…ade (SDK-7843)

- With no reporter, a timed-out test whose body finishes late gets an afterTest saying only
  whether the body threw. If mocha already failed that attempt, claimCliTestFinish now reports
  mocha's failure and afterTest stands down, instead of reporting it passed.
- A finish started on failure runs after mocha moved on, so the suite's shared ctx.test points at
  a hook (which inherits the suite's retries) or the next test. Pass the runnable captured in
  beforeTest to hasRetryPending and the bail cascade, so the cascade is not skipped.
- Hand the slot back after TEST/POST also when the first uuid pin displaced another test.
- Wait for in-flight finishes in the uuid test instead of a fixed sleep.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 7, 2026

Copy link
Copy Markdown
Contributor

🔴 SDK PR Review gate is red. Pending:

  • The SDK PR Review Agent has not been run on the current head commit yet — run the SDK PR Review Agent (its verdict is advisory; this gate only requires that it ran on the latest commit).

It turns green once the SDK PR Review Agent has run on the current head commit (any verdict — the gate only requires that the review ran). A native reviewer approval is separately required by branch protection before merge.

AakashHotchandani added a commit that referenced this pull request Oct 7, 2026
With no reporter, a timed-out test whose body finishes late gets an afterTest saying only whether
the body threw. If mocha already failed that attempt, claimCliTestFinish now reports mocha's
failure and afterTest stands down, instead of reporting it passed. Same change as #272.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@AakashHotchandani

Copy link
Copy Markdown
Collaborator Author

RUN_TESTS

@minionhelperappqa

Copy link
Copy Markdown

[SDK Wdio Test] TRA build state: passed | Stability 100% — verdict: success. Passed: 52, Failed: 0, Aggregate: 52. TRA: https://observability.browserstack.com/builds/1hf56s9jthrtrmtvgwhv9vck6rfkke7qgmxidpmv

…K-7843)

Review: keep service.ts / reporter.ts to feeding the existing CLI events and
leave product logic to the CLI layer, instead of a side-channel util.

- reporter onTestFail sends LOG_REPORT/POST + TEST/POST through
  framework.trackEvent (as skipReporter does), with mocha's result.
  TestHubModule and AutomateModule already handle a failed TEST/POST; the
  only problem was that it arrived after after().
- WdioMochaTestFramework tracks each mocha test attempt from TEST/PRE:
  the finish is reported once, against the instance the attempt started
  on (replaces the service's _cliTestUuids pin), keeps mocha's failure when
  a late afterTest says passed, and runs the bail cascade (moved from
  service.ts, using the runnable captured at TEST/PRE).
- settleTestFinishes() (TestFramework, no-op by default) waits for finishes
  wdio does not await and finishes tests mocha already failed when no
  reporter is registered; after() calls it before the flush and EXECUTE/POST.
- beforeTest passes mocha's bail through its TEST/PRE call.
- cli/earlyTestFinish.ts removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
AakashHotchandani added a commit that referenced this pull request Oct 8, 2026
…K-7843)

v8 port of the #272 review rework: service.ts / reporter.ts only feed the
existing CLI events; product logic stays in the CLI layer.

- reporter onTestFail sends LOG_REPORT/POST + TEST/POST through
  framework.trackEvent with mocha's result; AutomateModule already marks the
  session from a failed TEST/POST, it just arrived after after().
- WdioMochaTestFramework tracks each mocha test attempt from TEST/PRE and
  reports its finish once, against the instance it started on, keeping
  mocha's failure when a late afterTest says passed.
- settleTestFinishes() (TestFramework, no-op by default); after() calls it
  before EXECUTE/POST.
- cli/earlyTestFinish.ts removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 8, 2026

Copy link
Copy Markdown
Contributor

🔴 SDK PR Review gate is red. Pending:

  • The SDK PR Review Agent has not been run on the current head commit yet — run the SDK PR Review Agent (its verdict is advisory; this gate only requires that it ran on the latest commit).

It turns green once the SDK PR Review Agent has run on the current head commit (any verdict — the gate only requires that the review ran). A native reviewer approval is separately required by branch protection before merge.

@AakashHotchandani AakashHotchandani left a comment

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.

Automated SDK PR Review

Verdict: ⚠️ Fix 1 issue before merge. finishedAttempts is never cleared, so a second test with the same immediate-parent and title in a worker has all of its finishes dropped.

Summary: 1 critical · 0 warnings · 0 suggestions. Re-review at 26a58726 (route the timed-out test finish through trackEvent).

The refactor addresses the earlier findings on this PR:

  • finishes now run on the attempt's own instance (wdioMochaTestFramework.ts:146) instead of whichever instance is tracked;
  • attempts are keyed per retry (attemptKey, :91-95);
  • the no-reporter path is settled in after() before the flush.

The one new problem comes from the de-dupe set this refactor introduces. Neither base nor the previous head has it.

The v8 port #273 (ede6baaa) carries the same finishedAttempts set, so the same fix applies there. #264, #265 and #267 are still open; per the earlier reply they are superseded by this PR.

See the inline comment for the full Problem and Suggested Fix.

Generated by Automated SDK PR review.

const key = WdioMochaTestFramework.attemptKey(args.test as Frameworks.Test)
const source = args.fromMochaFail ? 'fail' : 'afterTest'
const attempt = this.openAttempts.get(key)
if (this.finishedAttempts.has(key) || (attempt?.finishingFrom && attempt.finishingFrom !== source)) {

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.

🔴 Critical — [CORRECTNESS] finishedAttempts is never cleared, so a same-named test later in the worker loses every finish

Problem

Every LOG_REPORT/POST and TEST/POST of a mocha test is de-duplicated through resolveTestAttempt. The key is WdioMochaTestFramework.attemptKey(test) (:91-95), which is getUniqueIdentifier(test, 'mocha') plus (retry n). For mocha, getUniqueIdentifier is ${test.parent} - ${test.title} (util.ts:1109-1119): the immediate parent's title, not the full path and not the file.

When TEST/POST is processed, the key is added to finishedAttempts (:315). Nothing ever removes it: openTestAttempt (:273-283) only sets openAttempts. So once a test has finished, any later test in the same worker that produces the same key is caught by the first clause here:

if (this.finishedAttempts.has(key) || …) { … return null }

trackEvent then returns without running hooks (:140-143). Here is a concrete spec:

describe('login', () => { describe('valid', () => { it('works', …) }) })
describe('signup', () => { describe('valid', () => { it('works', …) }) })

Both tests key to valid - works. The second test's TEST/PRE still opens an attempt, but its afterTest finish, the reporter's onTestFail finish and the settleTestFinishes() finish are all dropped.

As a result:

  • the test never gets its TestRunFinished, so it stays In Progress on the dashboard until the idle reap;
  • AutomateModule never records its result, so a failure in that test is missing from the session verdict.

Neither base nor 092e69b5 has this set, so it is a regression introduced by this commit.

Suggested Fix

Key the attempt on something unique within the worker, rather than only clearing the set. Clearing finishedAttempts on TEST/PRE alone would let a late fail for the first valid - works land on the second one.

Both callers already pass fullTitle and file:

  • the hook path passes the wdio test object;
  • the reporter path builds { title, parent, fullTitle, file, _currentRetry } at reporter.ts:172.

So key on those:

static attemptKey(test: Frameworks.Test): string {
    const retry = (test as { _currentRetry?: number })._currentRetry
    const identifier = test.fullTitle
        ? `${test.file ?? ''}::${test.fullTitle}`
        : getUniqueIdentifier(test, 'mocha')
    return retry ? `${identifier} (retry ${retry})` : identifier
}

The afterTest↔reporter hand-off only works if both sides produce the identical string. Please confirm that the hook-side test.fullTitle and the reporter's TestStats.fullTitle agree for nested describes, and pin it with a test that drives both paths for one test.

Also add a test with two same-named tests under different outer describes, asserting both get a TEST/POST. Keep finishedAttempts.delete(key) in openTestAttempt as a backstop if you like, but it is not the fix on its own.

Confidence: 🟢 Objectively traced:

  • key construction at :91-95 → util.ts:1119;
  • add at :315, with no delete anywhere in the file;
  • drop at :298 → return null → trackEvent returns at :141;
  • base d5eb5ede has no finishedAttempts.

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.

Valid. Fixed in f51407e (and in #273, 359575a), but not with the suggested key.

Why not file::fullTitle: the two sides can't produce the same string.

  • wdio's afterTest/beforeTest get { ...context.test, parent: context.test?.parent?.title } (@wdio/mocha-framework hookArgsFn). The spread copies only the runnable's own properties, and mocha's fullTitle() is a prototype method, so test.fullTitle is undefined there.
  • The reporter's TestStats has fullTitle (A.B.title), but its constructor sets no file.

So the late afterTest and the reporter's fail would key differently, and the de-duplication this PR exists for would break.

There was also a second problem behind this one. Two same-key attempts can be open at once: a timed-out test's afterTest can arrive after the next same-named test's TEST/PRE. The by-key openAttempts entry then gets overwritten, so clearing the set alone wouldn't fix it.

The fix:

  • Finished is tracked per attempt (attempt.finished). There's no global key set any more.
  • afterTest resolves by the test's body. fn is an own property, so it's on both the beforeTest copy and wdio's afterTest identity snapshot. With _currentRetry it identifies the attempt exactly, even when keys collide.
  • The reporter's fail has only the title and parent. It resolves to the latest attempt with that key. That resolution now happens before any await in trackTestEvent, and mocha only starts the next test via setImmediate, so this is always the test mocha just failed.
  • The resolved attempt is pinned to the event's test object (a WeakMap). That source's TEST/POST reuses it, so it can't land on a later same-named test.

Tests (in wdioMochaTestFramework.timedOutTest.test.ts, on both branches):

  1. Your case: login > valid > works and signup > valid > works both get a TEST/POST.
  2. A same-named test starts while the first test's afterTest is still pending. The first test's late afterTest arrives, then the second test fails. Both close against their own test runs, with the right verdicts.
  3. The reporter's TEST/POST stays on its test when a same-named test starts between its LOG_REPORT and TEST/POST.

Mutation checks: each test fails with its piece removed. Restoring the never-cleared key set fails 1 and 2, a key-only lookup fails 2, and dropping the pin fails 3.

Suites:

  • main: same 78 failures as main before this change, 1327 passed. tsc and eslint are clean.
  • v8: 1136/1136.

Real sessions with your spec, where the second works fails:

Before After
WDIO 9 session 27d948c4 passed 43dde01e failed (its assertion)
WDIO 9 Test Reporting 1 passed, 1 in progress 1 passed, 1 failed
WDIO 8 session 7e1d8f0e passed e2eb13f3 failed
WDIO 8 Test Reporting 1 passed, 1 in progress 1 passed, 1 failed

The timeout+bail repro still behaves as before on both:

  • WDIO 9: session 122b36d4 failed; Test Reporting 1 passed, 1 failed, 1 skipped.
  • WDIO 8: session c2241847 failed; Test Reporting 1 passed, 1 failed.

Neither shows a resolveInstance error.

…name (SDK-7843)

A finished attempt's key went into `finishedAttempts` and was never cleared.
The key is `${parent} - ${title}` with only the immediate parent, so a later
test with the same key in a worker (e.g. login > valid > works and
signup > valid > works) had every finish dropped: it stayed In Progress in
Test Reporting and its failure never reached the session status. Two
same-key attempts open at once (a timed-out test's late afterTest) also
overwrote each other's entry.

Track finished per attempt. Resolve wdio's afterTest by the test's body
(`fn`, on both the beforeTest and afterTest copies of the mocha test) plus
retry, and the reporter's `fail`, which has only title and parent, by the
latest attempt with that key, resolved before any await so it is the test
mocha just failed. Pin the resolved attempt to the event's `test` object so
the same source's TEST/POST cannot land on a later same-named test.

`file::fullTitle` (review suggestion) cannot key both sides: wdio's hook
copy `{ ...context.test }` drops mocha's prototype `fullTitle()`, and the
reporter's TestStats has no `file`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 9, 2026

Copy link
Copy Markdown
Contributor

🔴 SDK PR Review gate is red. Pending:

  • The SDK PR Review Agent has not been run on the current head commit yet — run the SDK PR Review Agent (its verdict is advisory; this gate only requires that it ran on the latest commit).

It turns green once the SDK PR Review Agent has run on the current head commit (any verdict — the gate only requires that the review ran). A native reviewer approval is separately required by branch protection before merge.

@AakashHotchandani AakashHotchandani left a comment

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.

Automated SDK PR Review

Verdict: ⚠️ Fix 1 issue before merge. When the reporter's fail reports a failure, the bail cascade now runs inside a finish that wdio does not await. That breaks the "immediate is only for awaited callers" invariant that skipReporter.ts relies on.

Summary: 0 critical · 1 warning · 0 suggestions. Re-review at f51407ea.

Earlier findings: all resolved at this head.

  • The never-cleared attempt-key set from the previous review is fixed: attempt.finished is now per attempt, afterTest resolves through fn + _currentRetry, and the reporter's TEST/POST is pinned. The three new tests in timedOutTest.test.ts cover it.
  • Your reason for not using the suggested file::fullTitle key holds. In webdriverio's hookArgsFn, the {...context.test} spread drops the prototype method fullTitle(), and TestStats sets no file.

Still open from before: #264, #265 and #267 are open. Close them before merging, as planned.

Edge case worth a thought (not a finding): a test body shared across a loop, it(name, sharedFn) with the same immediate parent and title, would share an attemptsByFn entry.

See the inline comment for the full Problem and Suggested Fix.

Generated by Automated SDK PR review.

args.instance = instance
await this.runHooks(instance, testFrameworkState, hookState, args)
if (attempt && testFrameworkState === TestFrameworkState.TEST) {
await this.reportBailSkippedTests(attempt, args.result as Frameworks.TestResult)

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.

⚠️ Warning — [CONCURRENCY] The bail cascade now runs inside the un-awaited fail finish, so its immediate skip reports can interleave with mocha's after-hooks

Problem

A finish that comes from the reporter's fail is detached on purpose. trackEvent adds it to pendingFinishes and returns (:133-141), and wdio never awaits it. That finish now ends with the bail cascade:

await this.runHooks(instance, testFrameworkState, hookState, args)
if (attempt && testFrameworkState === TestFrameworkState.TEST) {
    await this.reportBailSkippedTests(attempt, args.result as Frameworks.TestResult)
}

reportBailSkippedTests (:270-290) does not check the source; the comment at :267-268 says it runs on the fail path deliberately. It calls reportSuiteSkipped, which reports each dropped test with { immediate: true }.

skipReporter.ts justifies immediate with one invariant, at :130-135 and again at :160-163: every caller is awaited by wdio, "so nothing else can claim the tracked slot underneath them". That no longer holds for this path. Each immediate skip report starts with INIT_TEST/PRE, which repoints the tracked instance. mocha fires fail when the test fails, then still runs that test's afterEach and the suite's after hooks under bail. Those hook events (and logs sent during them) resolve through the tracked instance, so they can attach to a bail-skipped test's run instead of the failed test's.

The session verdict is not affected, because settleTestFinishes() awaits pendingFinishes before the session is marked. The exposure is misattributed hook runs and logs on the dashboard, in exactly the timed-out-test + bail flow this PR targets.

Suggested Fix

This is offered as an approach rather than a patch. The ordering depends on mocha's runtime, and I haven't run it.

  • Option 1 (preferred): on the fail path, don't cascade inside the detached finish. Record that the attempt owes a bail cascade, and run it from settleTestFinishes(), which after() awaits before the flush and EXECUTE/POST. Bail has already aborted the spec, so moving these skips to end of spec loses nothing. The afterTest path can keep cascading inline, because wdio awaits it.
  • Option 2: queue the fail-path cascade through the deferred (non-immediate) branch of reportSkippedTest. This needs an option passed through reportSuiteSkipped, including its recursive call at skipReporter.ts:185.

Either way, update the invariant comments in skipReporter.ts (:130-135, :160-163), which still say every cascade caller is awaited. Add a test where the reporter's fail arrives with bail set and an afterEach hook event follows. Assert that the hook event lands on the failed test's instance, not on a skipped one.

Confidence: 🟢 for the structure: the detached finish at :133-141 → the cascade at :231-232 → reportSuiteSkipped(..., immediate), with no source gate at :270-290, against the invariant stated at skipReporter.ts:130-135. 🟡 for the runtime interleave: it depends on mocha running after-hooks after fail under bail, which I checked at mocha 10.8.2 because the lockfile does not pin mocha.

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.

Valid, thanks. Fixed in 2d7bba7 with Option 1.

  • When the reporter's fail reports the failure, the finish no longer runs the bail cascade. It records the cascade in owedBailCascades, and settleTestFinishes() runs it once pendingFinishes has drained. after() awaits that before the flush and EXECUTE/POST, so the skips still land before the session status is marked. The afterTest path still cascades inline, because wdio awaits it.
  • The invariant comments in skipReporter.ts (reportSkippedTest and reportSuiteSkipped) now name after() as the awaited caller for this case.
  • New test in wdioMochaTestFramework.bailCascade.test.ts: fail with bail set, then an afterEach PRE. It asserts the tracked instance is still the failed test's, that nothing is skipped yet, and that A3 and B1 are reported after settleTestFinishes(). It fails with the cascade put back inline. The existing fail-path cascade test now calls settleTestFinishes() too.
  • Real run (timed-out test with afterEach, bail, a sibling describe): the timed-out test's AFTER_EACH PRE/POST stayed on its own test run, and both skips were sent after the hook. Test Reporting yqinf1n3: failed, P1 F1 S2. Automate build cf220be2: failed.
  • Suites: same 78 pre-existing failures, 1328 passed. tsc and eslint are clean.

v8 (#273) needs no change, because its CLI framework has no bail cascade.

On the shared-body edge case: two tests would need the same parent, the same title and the same fn, and the first would have to finish late while the second runs. I've left it as is.

…ttle (SDK-7843)

wdio does not await the reporter's `fail`, and mocha still runs the failed
test's after-hooks after it. Running the bail cascade inside that finish let
its immediate skip reports claim the tracked slot while those hooks ran, so
their events could attach to a skipped test. Record the cascade on that path
and run it from settleTestFinishes(), which after() awaits before the flush
and EXECUTE/POST. The afterTest path still cascades inline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 9, 2026

Copy link
Copy Markdown
Contributor

🔴 SDK PR Review gate is red. Pending:

  • The SDK PR Review Agent has not been run on the current head commit yet — run the SDK PR Review Agent (its verdict is advisory; this gate only requires that it ran on the latest commit).

It turns green once the SDK PR Review Agent has run on the current head commit (any verdict — the gate only requires that the review ran). A native reviewer approval is separately required by branch protection before merge.

@AakashHotchandani AakashHotchandani left a comment

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.

SDK PR Review — integrity violation

The review gate could not certify this run on 2d7bba79299d, so no review decision was submitted. This is not an approval and not a verdict on the code — the review must be re-run.

Gate reasons:

  • G6 — 5 kb proposal(s) but no proposal PR recorded (kb-pr.txt) — run pr-kb-proposal.sh

— SDK PR Review Agent

@AakashHotchandani

Copy link
Copy Markdown
Collaborator Author

⚠️ Needs human review

See the SDK PR Review Agent's report from your local run.

Process integrity

The review gate could NOT certify this run — the verdict is held at ⚠️ pending regardless of findings:

  • G6 — 5 kb proposal(s) but no proposal PR recorded (kb-pr.txt) — run pr-kb-proposal.sh

Change map (generated deterministically from the diff)

graph LR
  subgraph nwdio_service["wdio-service"]
    npackages_browserstack_service_src_cli_frameworks_wdioMochaTestFramework_ts["⚠ wdioMochaTestFramework.ts<br/>~286 lines"]
    npackages_browserstack_service_tests_cli_wdioMochaTestFramework_timedOutTest_test_ts["wdioMochaTestFramework.timedOutTest.test.ts<br/>~205 lines"]
    npackages_browserstack_service_tests_cli_wdioMochaTestFramework_bailCascade_test_ts["wdioMochaTestFramework.bailCascade.test.ts<br/>~191 lines"]
    npackages_browserstack_service_tests_service_test_ts["⚠ service.test.ts<br/>~145 lines"]
    npackages_browserstack_service_src_service_ts["⚠ service.ts<br/>~113 lines"]
    npackages_browserstack_service_tests_service_timedOutTestFinish_test_ts["⚠ service.timedOutTestFinish.test.ts<br/>~81 lines"]
    npackages_browserstack_service_tests_reporter_onTestFail_test_ts["⚠ reporter.onTestFail.test.ts<br/>~75 lines"]
    npackages_browserstack_service_tests_cli_wdioMochaTestFramework_preTestLog_test_ts["⚠ wdioMochaTestFramework.preTestLog.test.ts<br/>~56 lines"]
    npackages_browserstack_service_src_reporter_ts["⚠ reporter.ts<br/>~33 lines"]
    npackages_browserstack_service_src_cli_frameworks_testFramework_ts["testFramework.ts<br/>~7 lines"]
    n_changeset_pr_272_md["pr-272.md<br/>~6 lines"]
    npackages_browserstack_service_src_cli_skipReporter_ts["⚠ skipReporter.ts<br/>~6 lines"]
    npackages_browserstack_service_tests_service_afterSkipOrdering_test_ts["⚠ service.afterSkipOrdering.test.ts<br/>~1 lines"]
  end
Loading

↻ This verdict comment is the review anchor — it's updated in place on each run (the gate posts its status separately).

— SDK PR Review Agent

@github-actions

github-actions Bot commented Oct 9, 2026

Copy link
Copy Markdown
Contributor

🟢 SDK PR Review gate is green — the SDK PR Review Agent has run on the current head commit (verdict: pending).

This gate confirms a review ran on the latest commit. The verdict itself is advisory — read the findings and use your judgement; it does not block merge. A native GitHub reviewer approval is still separately required by branch protection before this PR can merge.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant