-
Notifications
You must be signed in to change notification settings - Fork 9
fix(cli): queue skip reports until the drain so a mid-run skip is not lost (SDK-7493) #194
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
01a43a0
8923993
facc04e
f97c969
105d018
52da8be
7ad0b4c
d13a549
a3956a7
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 a skipped test staying stuck on "In Progress" in Test Hub when it sits between two running tests — for example a `it.skip()` in the middle of a spec. Completes the fix shipped in 9.35.3, which only covered skips at the end of a spec. Skipped tests are now reported when the run finishes, so they appear grouped at the end of the build rather than in source order. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -38,8 +38,23 @@ export default class TestHubModule extends BaseModule { | |
| * 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(). | ||
| * | ||
| * SDK-7493: keyed by test uuid, NOT a single slot. wdio does not await `onTestSkip`, so a | ||
| * skip report runs detached and can interleave with a live test — its events land between | ||
| * the live test's own, on the same per-worker tracked-instance context. With one slot the | ||
| * second stash silently REPLACED the first (the old guard only flushed when the instance | ||
| * OBJECT differed, and an interleave can present the same object), so one test's | ||
| * TestRunFinished was never sent and TRA left it rendering "In Progress" until the ~60-min | ||
| * idle reap. A map cannot evict: every deferred finish is delivered, each under its own uuid. | ||
| * | ||
| * NOTE ON REDUNDANCY: SDK-7493's queue-and-drain (skipReporter) already stops a skip report | ||
| * running while a test is in flight, so on the normal path that collision can no longer be | ||
| * triggered and this map is not the primary fix. It is retained deliberately as | ||
| * defence-in-depth for a reporting path with a real incident history (SDK-7265, SDK-7493, | ||
| * ~60-min reaps): if any future caller reintroduces an interleave, the worst case degrades | ||
| * to a late send rather than a silently lost TestRunFinished. | ||
| */ | ||
| private pendingTestFinish: { args: Record<string, unknown> } | null = null | ||
| private pendingTestFinishes: Map<string, { args: Record<string, unknown>, uuid: string }> = new Map() | ||
|
|
||
| /** | ||
| * Create a new TestHubModule | ||
|
|
@@ -84,7 +99,7 @@ export default class TestHubModule extends BaseModule { | |
| // 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))) { | ||
| if (this.pendingTestFinishes.size > 0 && (testState === TestFrameworkState.INIT_TEST || (testState === TestFrameworkState.TEST && hookState === HookState.PRE))) { | ||
| this.flushPendingTestFinishEvent() | ||
| } | ||
| if (testState === TestFrameworkState.LOG) { | ||
|
|
@@ -119,13 +134,16 @@ export default class TestHubModule extends BaseModule { | |
| 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') | ||
| // SDK-7493: key the stash by the test's uuid, captured NOW. Two different tests | ||
| // can present the same tracked instance when a detached skip report interleaves | ||
| // with a live test, so keying on the instance object silently dropped one of the | ||
| // two finishes. Re-stashing the SAME uuid (e.g. the LOG_REPORT/POST recovery | ||
| // re-entry below) correctly replaces only that test's own entry. | ||
| const deferUuid = String( | ||
| 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})`) | ||
| } else { | ||
| this.sendTestFrameworkEvent(args) | ||
| } | ||
|
|
@@ -140,34 +158,45 @@ export default class TestHubModule extends BaseModule { | |
| * next test's boundary and from service.after() at worker end. | ||
| */ | ||
| flushPendingTestFinishEvent(): Promise<void> | undefined { | ||
| if (!this.pendingTestFinish) { | ||
| if (this.pendingTestFinishes.size === 0) { | ||
| return undefined | ||
| } | ||
| const { args } = this.pendingTestFinish | ||
| this.pendingTestFinish = null | ||
| this.logger.debug('flushPendingTestFinishEvent: sending deferred TEST/POST event') | ||
| // Drain every pending finish, not just the newest. Take and clear the whole batch up | ||
| // front so a concurrent stash (the detached skip chain) starts a fresh entry rather | ||
| // than being swallowed by this in-flight drain. | ||
| const batch = [...this.pendingTestFinishes.values()] | ||
| this.pendingTestFinishes.clear() | ||
| this.logger.debug(`flushPendingTestFinishEvent: sending ${batch.length} deferred TEST/POST event(s)`) | ||
|
|
||
| // SDK-7265: this is the only send of a mocha test's TestRunFinished, and the worker's last | ||
| // test relies on this single flush from service.after(). A dropped send orphans the test → | ||
| // Test Hub reaps it at its ~60-min idle timeout → the passing build is stamped `timeout`. | ||
| // Retry with backoff. `args` is captured locally and the shared slot is only cleared (never | ||
| // written back), so concurrent flushes can't clobber one another. | ||
| // Retry with backoff. Each entry is captured locally, so concurrent flushes can't clobber | ||
| // one another. | ||
| const maxAttempts = 3 | ||
| const attempt = (n: number): Promise<void> => | ||
| this.sendTestFrameworkEvent(args, { testFrameworkState: 'TEST', testHookState: 'POST' }).then((sent) => { | ||
| if (sent) { | ||
| return | ||
| } | ||
| this.logger.debug(`flushPendingTestFinishEvent: attempt ${n}/${maxAttempts} failed`) | ||
| if (n >= maxAttempts) { | ||
| this.logger.error('flushPendingTestFinishEvent: deferred TEST/POST send failed after all retries') | ||
| return | ||
| } | ||
| return new Promise<void>((resolve) => setTimeout(resolve, 200 * n)).then(() => attempt(n + 1)) | ||
| }) | ||
| return attempt(1) | ||
| const sendOne = ({ args, uuid }: { args: Record<string, unknown>, uuid: string }): Promise<void> => { | ||
| // SDK-7493: pin the uuid captured at DEFER time. The payload is otherwise serialized | ||
| // from the instance's live data at send time, and an interleaved skip report can have | ||
| // rewritten the uuid on that instance since — which would close the wrong test_run and | ||
| // leave this one open forever. | ||
| const attempt = (n: number): Promise<void> => | ||
| this.sendTestFrameworkEvent(args, { testFrameworkState: 'TEST', testHookState: 'POST', uuid }).then((sent) => { | ||
| if (sent) { | ||
| return | ||
| } | ||
| this.logger.debug(`flushPendingTestFinishEvent: uuid=${uuid} attempt ${n}/${maxAttempts} failed`) | ||
| if (n >= maxAttempts) { | ||
| this.logger.error(`flushPendingTestFinishEvent: deferred TEST/POST send failed after all retries (uuid=${uuid})`) | ||
| return | ||
| } | ||
| return new Promise<void>((resolve) => setTimeout(resolve, 200 * n)).then(() => attempt(n + 1)) | ||
| }) | ||
| return attempt(1) | ||
| } | ||
| return Promise.all(batch.map(sendOne)).then(() => undefined) | ||
| } | ||
|
|
||
| async sendTestFrameworkEvent(args: Record<string, unknown>, stateOverride?: { testFrameworkState: string, testHookState: string }): Promise<boolean> { | ||
| async sendTestFrameworkEvent(args: Record<string, unknown>, stateOverride?: { testFrameworkState: string, testHookState: string, uuid?: string }): Promise<boolean> { | ||
| try { | ||
| const testArgs = args as { test: Frameworks.Test, instance: TestFrameworkInstance } | ||
| const instance = testArgs.instance as TestFrameworkInstance | ||
|
|
@@ -182,11 +211,24 @@ export default class TestHubModule extends BaseModule { | |
|
|
||
| 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 | ||
| const uuid = TestFramework.getState(instance, TestFrameworkConstants.KEY_TEST_UUID) || instance.getRef() | ||
| // SDK-7493: a deferred flush pins the uuid captured when the finish was stashed; | ||
| // reading it live here can pick up another test's uuid if an interleaved skip | ||
| // report rewrote it on this instance in the meantime. | ||
| const uuid = stateOverride?.uuid || TestFramework.getState(instance, TestFrameworkConstants.KEY_TEST_UUID) || instance.getRef() | ||
|
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 — [CORRECTNESS] Pinned uuid covers the top-level field but not the uuid embedded in
|
||
| // Nested values such as test_hooks_started/test_hooks_finished are JS Maps, which | ||
| // JSON.stringify would serialise to `{}` and strip the hook data. Convert any Map to | ||
| // a plain object so the binary receives populated hook maps. | ||
| const eventJson = Buffer.from(JSON.stringify(Object.fromEntries(testData), (_key, value) => value instanceof Map ? Object.fromEntries(value) : value)) | ||
| // SDK-7493: the pinned uuid must go INSIDE event_json too, not just the top-level | ||
| // field. The binary routes a mocha test_run on the uuid it parses out of this blob — | ||
| // `webdriverio/index.js` does `const event = JSON.parse(eventJson)` and the mocha | ||
| // handler builds the test run with `uuid: event.test_uuid` — so a stale `test_uuid` | ||
| // here would close the wrong run and leave the deferred one open, which is the very | ||
| // failure the pin exists to prevent. Overriding a copy keeps the instance untouched. | ||
| const eventData = Object.fromEntries(testData) | ||
| if (stateOverride?.uuid) { | ||
| eventData[TestFrameworkConstants.KEY_TEST_UUID] = stateOverride.uuid | ||
| } | ||
| const eventJson = Buffer.from(JSON.stringify(eventData, (_key, value) => value instanceof Map ? Object.fromEntries(value) : value)) | ||
| const executionContext = { hash: trackedContext.getId(), threadId: trackedContext.getThreadId().toString(), processId: trackedContext.getProcessId().toString() } | ||
| const payload: Omit<TestFrameworkEventRequest, 'binSessionId'> = { | ||
| platformIndex, | ||
|
|
||
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.
💡 Suggestion — [MAINTAINABILITY] Map + pinned uuid guard a trigger the queue already removes (wdio anti-pattern #26)
Problem
The PR is candid that the uuid-keyed map and the pinned-uuid flush are "defence in depth (neither fixes this alone)" — the actual fix is the queue-and-drain, which guarantees no skip runs while a test is in flight. With the interleave gone, the eviction / live-uuid-rewrite that the map and the pin defend against can no longer be triggered on the normal path. This is the shape the repo's own review knowledge calls out in anti-pattern #26 ("belt-and-suspenders that catches errors that can't fire"): a defense for a failure mode the primary fix already eliminates can read as load-bearing to a future maintainer and set a false invariant.
The mitigating factor — and why this is only a Suggestion — is that the code does follow #26's author rule: each hardening carries a comment stating the concrete interleave scenario it protects against, and the reporting path has a real incident history (SDK-7265, SDK-7493, ~60-min reaps) that makes conservative redundancy defensible.
Suggested Fix
Keep the hardening, but consider one line making the redundancy explicit — e.g. "With SDK-7493's drain in place this cannot be triggered on the normal path; retained as defence-in-depth for the reporting path's incident history." That preserves the intent without letting a future reader treat the map/pin as the primary fix.
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.
Valid — fixed in
facc04e. Added the line you suggested, on the map's doc block:That is the distinction worth preserving: the map does not stop the interleave, it stops an interleave from becoming silent data loss. Given this path has now produced two separate customer-visible incidents, I would rather a future regression surface as a late event than as a test stuck "In Progress" for an hour.
Relatedly, per the reply on the
eventJsoncomment, I have not extended the pin into the serialized payload — that genuinely would be a guard for something the queue eliminates.