-
Notifications
You must be signed in to change notification settings - Fork 10
fix(cli): send a timed-out mocha test's finish that lands after the worker's final flush (SDK-7843) #267
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?
fix(cli): send a timed-out mocha test's finish that lands after the worker's final flush (SDK-7843) #267
Changes from all commits
29ef83d
1a76bfc
6b0453a
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,5 @@ | ||
| --- | ||
| "@wdio/browserstack-service": patch | ||
| --- | ||
|
|
||
| - Fixed builds ending as "timeout" in Test Reporting, about an hour after the run, when a mocha test failed by exceeding its timeout (especially with `bail`). The timed-out test is now reported as failed, and the build closes when the run ends. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -27,6 +27,16 @@ export default class TestHubModule extends BaseModule { | |
| name: string | ||
| static MODULE_NAME = 'TestHubModule' | ||
|
|
||
| /** | ||
| * SDK-7843: how long service.after() waits for late test finishes to ARRIVE (see | ||
| * awaitLateTestFinishes). It does not bound the sends themselves, which keep their own | ||
| * retry/backoff in flushPendingTestFinishEvent. | ||
| */ | ||
| static readonly LATE_TEST_FINISH_WAIT_MS = 5000 | ||
|
|
||
| /** Failure reason on a TestRunFinished synthesised for a test that never reported one. */ | ||
| static readonly INCOMPLETE_TEST_REASON = 'Test did not finish before the session ended (incomplete).' | ||
|
|
||
| /** | ||
| * 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 | ||
|
|
@@ -56,6 +66,32 @@ export default class TestHubModule extends BaseModule { | |
| */ | ||
| private pendingTestFinishes: Map<string, { args: Record<string, unknown>, uuid: string }> = new Map() | ||
|
|
||
| /** | ||
| * SDK-7843: set by service.after() once it has run the worker's final flush. A mocha test | ||
| * that hits its timeout keeps running after mocha moves on, so its `afterTest` (and so its | ||
| * TEST/POST) can land AFTER that flush — with `bail` it routinely does. Deferring it then | ||
| * strands it: there is no next test and no later flush, the TestRunFinished is never sent, | ||
| * and Test Hub reaps the build as `timeout` ~60 min later. Past this point a finish is sent | ||
| * immediately instead. | ||
| */ | ||
| private workerEnding = false | ||
|
|
||
| /** | ||
| * Tests whose TEST/PRE was seen but whose TEST/POST has not arrived yet, keyed by uuid, with | ||
| * the PRE `args` so a finish can be synthesised for one that never reports (SDK-7843). The | ||
| * stored `args.instance` is that test's own object, see pendingTestFinishes. | ||
| */ | ||
| private openTests: Map<string, Record<string, unknown>> = new Map() | ||
|
|
||
| /** Late work (an afterTest that started after workerEnding, with its bail cascade). */ | ||
| private lateWork: Set<Promise<unknown>> = new Set() | ||
|
|
||
| /** Finishes sent after workerEnding, which service.after() awaits before teardown. */ | ||
| private lateFinishSends: Set<Promise<unknown>> = new Set() | ||
|
|
||
| /** Tests closed with a synthetic finish; a straggling real TEST/POST for them is dropped. */ | ||
| private syntheticallyClosed: Set<string> = new Set() | ||
|
|
||
| /** | ||
| * Create a new TestHubModule | ||
| */ | ||
|
|
@@ -131,6 +167,19 @@ export default class TestHubModule extends BaseModule { | |
|
|
||
| if (testState === TestFrameworkState.TEST || CLIUtils.matchHookRegex(testState.toString().split('.')[1])) { | ||
| const frameworkName = String(TestFramework.getState(instance, TestFrameworkConstants.KEY_TEST_FRAMEWORK_NAME) || '') | ||
| const testUuid = TestFramework.getState(instance, TestFrameworkConstants.KEY_TEST_UUID) | ||
| if (testUuid && testState === TestFrameworkState.TEST) { | ||
| if (hookState === HookState.PRE) { | ||
| this.openTests.set(String(testUuid), args) | ||
| } else if (hookState === HookState.POST) { | ||
| if (this.syntheticallyClosed.has(String(testUuid))) { | ||
| // Already closed at teardown; a second finish would contradict it. | ||
| this.logger.debug(`onAllTestEvents: dropping TEST/POST for a test already closed as incomplete (uuid=${testUuid})`) | ||
| return | ||
| } | ||
| this.openTests.delete(String(testUuid)) | ||
| } | ||
| } | ||
| 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). | ||
|
|
@@ -143,13 +192,88 @@ export default class TestHubModule extends BaseModule { | |
| TestFramework.getState(instance, TestFrameworkConstants.KEY_TEST_UUID) || instance.getRef() | ||
| ) | ||
| this.pendingTestFinishes.set(deferUuid, { args, uuid: deferUuid }) | ||
| this.logger.debug(`onAllTestEvents: deferred TEST/POST send past the after-each hook window (uuid=${deferUuid}, pending=${this.pendingTestFinishes.size})`) | ||
| if (this.workerEnding) { | ||
| // The worker's final flush already ran; nothing would ever send this stash. | ||
| this.logger.debug(`onAllTestEvents: TEST/POST arrived after the worker's final flush, sending now (uuid=${deferUuid})`) | ||
| const send = this.flushPendingTestFinishEvent() | ||
| if (send) { | ||
| this.trackIn(this.lateFinishSends, send) | ||
| } | ||
| } else { | ||
| this.logger.debug(`onAllTestEvents: deferred TEST/POST send past the after-each hook window (uuid=${deferUuid}, pending=${this.pendingTestFinishes.size})`) | ||
| } | ||
| } else { | ||
| this.sendTestFrameworkEvent(args) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Worker end, step 1 (start of service.after()): flush the deferred finish of the worker's | ||
| * last test and switch to sending any later TEST/POST immediately (SDK-7843). | ||
| */ | ||
| finishWorker(): Promise<void> | undefined { | ||
| this.workerEnding = true | ||
| return this.flushPendingTestFinishEvent() | ||
| } | ||
|
|
||
| /** | ||
| * Register work that has to finish before the worker tears down. Only tracked once the | ||
| * worker is ending: service.afterTest passes its whole CLI branch, so a late afterTest's | ||
| * bail cascade (skip reports after the timed-out test's own TEST/POST) is waited for too. | ||
| */ | ||
| trackLateWork<T>(work: Promise<T>): Promise<T> { | ||
| if (this.workerEnding) { | ||
| this.trackIn(this.lateWork, work) | ||
| } | ||
| return work | ||
| } | ||
|
|
||
| /** | ||
| * Worker end, step 2 (end of service.after(), before teardown). A test that timed out is | ||
| * still running when after() starts; its afterTest (TEST/POST, then any bail cascade) lands | ||
| * while after() is in progress. Wait, up to the bound, until every started test has | ||
| * reported, late afterTest work has settled and late sends have landed. Any test still open | ||
| * after that is closed with a synthetic failed finish, as the classic path's | ||
| * sweepUnfinished() does, so the build is not left to Test Hub's ~60-min idle reap. | ||
| */ | ||
| async awaitLateTestFinishes(waitMs: number = TestHubModule.LATE_TEST_FINISH_WAIT_MS): Promise<void> { | ||
| const deadline = Date.now() + waitMs | ||
| while ((this.openTests.size > 0 || this.lateWork.size > 0 || this.lateFinishSends.size > 0) && Date.now() < deadline) { | ||
| await new Promise((resolve) => setTimeout(resolve, 50)) | ||
| } | ||
| if (this.openTests.size > 0) { | ||
| this.logger.debug(`awaitLateTestFinishes: no TEST/POST within ${waitMs}ms for ${[...this.openTests.keys()].join(', ')}; closing as incomplete`) | ||
| this.closeOpenTestsAsIncomplete() | ||
| } | ||
| await Promise.all([...this.lateFinishSends]) | ||
|
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.
|
||
| } | ||
|
|
||
| private closeOpenTestsAsIncomplete() { | ||
| const reason = TestHubModule.INCOMPLETE_TEST_REASON | ||
| for (const [uuid, args] of this.openTests) { | ||
| const instance = args.instance as TestFrameworkInstance | ||
| instance.updateMultipleEntries({ | ||
| [TestFrameworkConstants.KEY_TEST_RESULT]: 'failed', | ||
| [TestFrameworkConstants.KEY_TEST_FAILURE]: [{ backtrace: [reason] }], | ||
| [TestFrameworkConstants.KEY_TEST_FAILURE_REASON]: reason, | ||
| [TestFrameworkConstants.KEY_TEST_FAILURE_TYPE]: 'UnhandledError', | ||
| [TestFrameworkConstants.KEY_TEST_ENDED_AT]: new Date().toISOString() | ||
| }) | ||
| this.syntheticallyClosed.add(uuid) | ||
| this.trackIn(this.lateFinishSends, this.sendTestFrameworkEvent(args, { testFrameworkState: 'TEST', testHookState: 'POST', uuid })) | ||
|
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. 💡 Suggestion — [RESILIENCE] The synthetic close is a single send with no retry, and an exception on one test stops the rest from being closedProblem
Separately, the loop has no per-item boundary. If Suggested FixReuse the existing retry path, and isolate each entry: for (const [uuid, args] of this.openTests) {
try {
(args.instance as TestFrameworkInstance).updateMultipleEntries({ /* same fields */ })
this.syntheticallyClosed.add(uuid)
this.pendingTestFinishes.set(uuid, { args, uuid })
} catch (err) {
this.logger.debug(`closeOpenTestsAsIncomplete: ${uuid}: ${util.format(err)}`)
}
}
this.openTests.clear()
const send = this.flushPendingTestFinishEvent() // pinned uuid + 3-attempt retry
if (send) { this.trackIn(this.lateFinishSends, send) } |
||
| } | ||
| this.openTests.clear() | ||
| } | ||
|
|
||
| private trackIn(set: Set<Promise<unknown>>, work: Promise<unknown>) { | ||
| set.add(work) | ||
| const untrack = () => { | ||
| set.delete(work) | ||
| } | ||
| work.then(untrack, untrack) | ||
| } | ||
|
|
||
| /** | ||
| * 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 | ||
|
|
||
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.
afterTestthat starts beforefinishWorker()is not tracked, so its bail cascade can still outliveawaitLateTestFinishesProblem
trackLateWork()adds theafterTestwork onlyif (this.workerEnding).service.afterTestcalls it synchronously when it is entered (service.ts:682), so the gate is checked when the timed-out body settles, not when the cascade runs.If the body settles in the window between the end of the mocha run and
finishWorker(),afterTestis entered withworkerEnding === falseand is never tracked. That window includes the rest of wdio's runner work before theafterhooks, andafter()itself up to theawait drainSkipReports(). This case then plays out as follows:finishWorker(), or sent immediately and tracked inlateFinishSends.reportBailSkippedTeststhen runs untracked. Between the timed-out test's POST and each skipped test's TEST/PRE,openTestsandlateFinishSendscan both be empty.openTestswhen the synthetic close runs, so it ends in the same ~60-mintimeoutreap.When the body settles relative to
after()is outside the SDK's control. That is the PR's own premise: the body is "still running when after() starts". The customer timings (late POST 139–150 ms after the flush, with LOG_REPORT/POST in front of it) do not rule out an entry before the flush. The testdoes not track work registered before the worker is ending(testHubModule.lateFinish.test.ts:171) asserts this gap as intended behaviour.Suggested Fix
Track unconditionally.
trackInalready removes each promise when it settles. wdio awaits every normalafterTestinside the test wrapper, so whenafter()runs, the only unsettled entries areafterTests of tests mocha abandoned, which are exactly the ones to wait for. The 5 s deadline still bounds the wait.Change the case at
:171to register work beforefinishWorker(), release it 120 ms intoawaitLateTestFinishes(), and emit a bail-skipped PRE/POST from it. Assert that the skipped test's POST is sent. Keep a separate check that settled pre-ending work returns at once.Confidence: 🟡 The untracked path is visible in the code and pinned by the unit case at
:171. The author's live run measured the cascade gap at 1.27 s. Whether wdio can actually enter a timed-out test'safterTestbeforeafter()reachesfinishWorker()has not been reproduced: the live run's body settled after the flush.